#!/usr/bin/env python3 """ DreamWeaver.py - 11/10 practical dream + memory weaving module DreamWeaver integrates: - Dream journaling (encrypted at rest) - Lightweight dream analysis (symbols, emotions, themes, questions) - "4D memory capsule" linking (relevance scoring vs past interaction layers) - Metaphor generation + continuity-rich interactive prompts - Persistence (key + encrypted journal) with a simple CLI Design goals: - Works offline (no LLM required) - Deterministic-ish structure (always returns a strict schema) - Secure by default (Fernet encryption; key persisted per user) - Easy to embed inside RomanAI as a module Copyright Daniel Harding - RomanAILabs Credits: Nova (GPT-5.2 Thinking) """ from __future__ import annotations import argparse import json import os import re import secrets import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple try: from cryptography.fernet import Fernet, InvalidToken except Exception as e: # pragma: no cover print("ERROR: Missing dependency 'cryptography'. Install with:\n pip install cryptography", file=sys.stderr) raise # ----------------------------- # Utilities # ----------------------------- ISO_FMT = "%Y-%m-%dT%H:%M:%S.%fZ" def utc_now_iso() -> str: return datetime.now(timezone.utc).strftime(ISO_FMT) def safe_json_dumps(obj: Any) -> str: return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True) def safe_json_loads(s: str) -> Any: return json.loads(s) def tokenize(text: str) -> List[str]: # Cheap tokenization: lowercase, keep alnum, split text = text.lower() text = re.sub(r"[^a-z0-9\s]+", " ", text) toks = [t for t in text.split() if len(t) >= 2] return toks def jaccard(a: List[str], b: List[str]) -> float: sa, sb = set(a), set(b) if not sa and not sb: return 0.0 inter = len(sa.intersection(sb)) union = len(sa.union(sb)) return inter / union if union else 0.0 # ----------------------------- # Analysis (lightweight, heuristic) # ----------------------------- SYMBOL_KEYWORDS: Dict[str, List[str]] = { "forest": ["forest", "woods", "trees", "jungle"], "water": ["river", "ocean", "sea", "lake", "water", "rain", "flood"], "flight": ["fly", "flying", "wings", "floating"], "falling": ["fall", "falling", "drop", "plunge"], "pursuit": ["chase", "chasing", "run", "ran", "pursued", "escape"], "search": ["search", "seeking", "looking", "find", "finding", "lost"], "home": ["home", "house", "room", "bed", "door"], "school": ["school", "class", "teacher", "exam", "test"], "work": ["work", "office", "boss", "deadline"], "darkness": ["dark", "shadow", "night", "fog", "mist"], "mirror": ["mirror", "reflection"], "stairs": ["stairs", "staircase", "ladder", "elevator"], "vehicle": ["car", "truck", "bus", "train", "plane"], "animal": ["dog", "cat", "wolf", "bear", "snake", "bird", "spider"], } EMOTION_CUES: Dict[str, List[str]] = { "fear": ["fear", "scared", "terrified", "panic", "anxious", "anxiety"], "joy": ["happy", "joy", "excited", "relieved", "laugh", "smile"], "sadness": ["sad", "cry", "lonely", "grief", "melancholy"], "anger": ["angry", "rage", "mad", "furious"], "awe": ["awe", "wonder", "amazed", "vast", "infinite"], "confusion": ["confused", "unclear", "lost", "strange", "weird"], "calm": ["calm", "peace", "quiet", "still"], } THEME_RULES: List[Tuple[str, List[str]]] = [ ("exploration", ["forest", "jungle", "path", "map", "unknown", "door", "hallway", "maze", "search", "seeking"]), ("change", ["new", "different", "transform", "shift", "moving", "transition"]), ("pressure", ["late", "deadline", "exam", "test", "boss", "rush"]), ("identity", ["mirror", "reflection", "name", "face", "mask"]), ("loss_recovery", ["lost", "missing", "find", "finding", "search", "recover"]), ("connection", ["friend", "family", "together", "hug", "talk", "message"]), ("threat_safety", ["chase", "chasing", "attack", "danger", "hide", "escape"]), ("freedom", ["fly", "flying", "wings", "open", "sky"]), ] def extract_symbols(text: str) -> List[str]: lower = text.lower() found: List[str] = [] for sym, kws in SYMBOL_KEYWORDS.items(): if any(kw in lower for kw in kws): found.append(sym) return sorted(set(found)) def extract_emotions(text: str) -> List[str]: lower = text.lower() found: List[str] = [] for emo, kws in EMOTION_CUES.items(): if any(kw in lower for kw in kws): found.append(emo) return sorted(set(found)) def extract_themes(text: str) -> List[str]: toks = tokenize(text) found: List[str] = [] for theme, kws in THEME_RULES: if any(kw in toks for kw in kws): found.append(theme) # If nothing matched, fall back to something sensible if not found: found = ["reflection"] return sorted(set(found)) def build_questions(symbols: List[str], emotions: List[str], themes: List[str]) -> List[str]: qs: List[str] = [] if "search" in symbols or "loss_recovery" in themes: qs.append("What felt ‘lost’ in the dream—an object, a person, a goal, or a feeling?") if "forest" in symbols or "exploration" in themes: qs.append("What were you hoping would be at the end of the path?") if "pursuit" in symbols or "threat_safety" in themes: qs.append("What do you think was chasing you—or what were you avoiding?") if "mirror" in symbols or "identity" in themes: qs.append("If the dream was about ‘you’, what part of you was it pointing at?") if "water" in symbols: qs.append("Was the water calm or intense, and what does that match in real life right now?") if emotions: qs.append(f"Which emotion felt strongest ({', '.join(emotions)}) and where do you feel that lately?") # Always ensure a clean minimum set if len(qs) < 3: qs.append("What was the single most vivid moment in the dream?") if len(qs) < 4: qs.append("If the dream had a title, what would you name it?") # cap return qs[:7] def summarize(text: str, max_len: int = 220) -> str: t = re.sub(r"\s+", " ", text.strip()) if len(t) <= max_len: return t return t[: max_len - 1].rstrip() + "…" def metaphor_from(signals: Dict[str, Any]) -> str: symbols = signals.get("symbols", []) themes = signals.get("themes", []) emotions = signals.get("emotions", []) # A few strong templates if "exploration" in themes and "forest" in symbols: return "A lantern-lit trail through a misty forest—each step revealing a memory you didn’t know you kept." if "loss_recovery" in themes and "search" in symbols: return "A compass that points not north, but toward the thing your mind refuses to give up on." if "threat_safety" in themes and ("pursuit" in symbols or "fear" in emotions): return "A hallway that stretches when you run—asking what you’re trying to outpace." if "identity" in themes and "mirror" in symbols: return "A mirror with shifting faces—inviting you to choose which version of you feels most true." if "freedom" in themes and "flight" in symbols: return "A wide-open sky—where every breath feels like permission to be bigger than yesterday." return "A river of images—carrying yesterday’s echoes into tomorrow’s choices." # ----------------------------- # Persistence + crypto # ----------------------------- @dataclass class StoragePaths: base_dir: Path key_file: Path journal_file: Path def default_storage_dir(user_id: str) -> Path: # XDG-like default (works fine on Linux + termux-ish environments too) home = Path.home() base = Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) return base / "dreamweaver" / user_id def ensure_dir(p: Path) -> None: p.mkdir(parents=True, exist_ok=True) def atomic_write_bytes(path: Path, data: bytes) -> None: tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_bytes(data) os.replace(tmp, path) def atomic_write_text(path: Path, data: str) -> None: tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(data, encoding="utf-8") os.replace(tmp, path) def load_or_create_key(key_file: Path) -> bytes: if key_file.exists(): b = key_file.read_bytes() # A tiny sanity check: Fernet keys are urlsafe base64 32 bytes -> usually 44 chars in bytes if len(b) < 40: raise ValueError(f"Key file looks invalid: {key_file}") return b ensure_dir(key_file.parent) key = Fernet.generate_key() atomic_write_bytes(key_file, key) try: os.chmod(key_file, 0o600) except Exception: # Not all platforms support chmod cleanly (e.g., some Android setups). Ignore. pass return key # ----------------------------- # DreamWeaver # ----------------------------- class DreamWeaver: """ Main interface. memory_capsule format (recommended): { "recent_layers": ["string notes of recent interactions", ...], "tags": ["optional high-level tags", ...], "entities": ["optional entity strings", ...] } """ def __init__( self, user_id: str, memory_capsule: Optional[Dict[str, Any]] = None, storage_dir: Optional[str] = None, encryption_key: Optional[bytes] = None, ): self.user_id = user_id self.memory_capsule = memory_capsule or {"recent_layers": []} base_dir = Path(storage_dir) if storage_dir else default_storage_dir(user_id) self.paths = StoragePaths( base_dir=base_dir, key_file=base_dir / "key.fernet", journal_file=base_dir / "dreams.journal.jsonl", ) ensure_dir(self.paths.base_dir) if encryption_key is None: encryption_key = load_or_create_key(self.paths.key_file) self.encryption_key = encryption_key self.cipher = Fernet(self.encryption_key) # ---------- Core API ---------- def log_dream(self, dream_content: str, timestamp: Optional[str] = None, title: Optional[str] = None) -> str: """ Encrypt and append a dream entry to journal. Returns dream_id. """ if timestamp is None: timestamp = utc_now_iso() dream_id = self._make_dream_id(timestamp, dream_content) analysis = self.analyze_text(dream_content) entry = { "dream_id": dream_id, "user_id": self.user_id, "timestamp": timestamp, "title": title or self._auto_title(analysis), "schema_version": 1, # Store only non-sensitive metadata in clear: "summary": analysis["summary"], "signals": analysis["signals"], # Store raw + full analysis encrypted: "payload": self._encrypt_payload( { "content": dream_content, "analysis": analysis, } ), } self._append_journal(entry) return dream_id def analyze_dream(self, dream_id: str) -> Dict[str, Any]: """ Load dream content and return the full structured analysis (decrypted). """ entry = self._find_entry(dream_id) if not entry: return {"error": "Dream not found.", "dream_id": dream_id} payload = self._decrypt_payload(entry["payload"]) return payload.get("analysis", {"error": "Analysis missing.", "dream_id": dream_id}) def generate_metaphor(self, dream_id: str) -> str: entry = self._find_entry(dream_id) if not entry: return "Dream not found." # Use stored signals (fast, no decrypt needed) return metaphor_from(entry.get("signals", {})) def uncover_insight(self, dream_id: str, top_k: int = 3) -> Dict[str, Any]: """ Cross-reference dream vs memory capsule layers using overlap scoring. Returns insight + linked memories (ranked). """ entry = self._find_entry(dream_id) if not entry: return {"error": "Dream not found.", "dream_id": dream_id} signals = entry.get("signals", {}) dream_terms = self._dream_terms_from_signals(signals) layers: List[str] = list(self.memory_capsule.get("recent_layers", []) or []) scored: List[Tuple[float, str]] = [] for layer in layers: score = jaccard(dream_terms, tokenize(layer)) if score > 0: scored.append((score, layer)) scored.sort(key=lambda x: x[0], reverse=True) linked = [{"score": round(s, 3), "memory": m} for s, m in scored[: max(1, top_k)]] # Build a grounded insight sentence themes = signals.get("themes", []) symbols = signals.get("symbols", []) emotions = signals.get("emotions", []) insight = self._compose_insight(themes=themes, symbols=symbols, emotions=emotions, linked=linked) return { "dream_id": dream_id, "insight": insight, "linked_memory": linked, } def interactive_experience(self, dream_id: str) -> str: """ Continuity-rich prompt: metaphor + insight + questions. """ entry = self._find_entry(dream_id) if not entry: return "Dream not found." metaphor = metaphor_from(entry.get("signals", {})) insight = self.uncover_insight(dream_id) signals = entry.get("signals", {}) questions = signals.get("questions", []) or [] if not questions: # fallback if someone edited journal questions = build_questions(signals.get("symbols", []), signals.get("emotions", []), signals.get("themes", [])) lines = [] lines.append(f"Reflect on this dream’s journey: {metaphor}") lines.append(insight.get("insight", "What do you think your mind is trying to show you?")) lines.append("") lines.append("A few questions to pull the thread:") for i, q in enumerate(questions[:7], 1): lines.append(f"{i}. {q}") return "\n".join(lines) def list_dreams(self, limit: int = 25) -> List[Dict[str, Any]]: """ Returns recent dream metadata (no decryption). """ rows = self._read_journal() rows = rows[-limit:] out = [] for r in reversed(rows): out.append( { "dream_id": r.get("dream_id"), "timestamp": r.get("timestamp"), "title": r.get("title"), "summary": r.get("summary"), "signals": r.get("signals", {}), } ) return out def get_dream_text(self, dream_id: str) -> Dict[str, Any]: """ Returns decrypted dream content (and basic metadata). """ entry = self._find_entry(dream_id) if not entry: return {"error": "Dream not found.", "dream_id": dream_id} payload = self._decrypt_payload(entry["payload"]) return { "dream_id": dream_id, "timestamp": entry.get("timestamp"), "title": entry.get("title"), "content": payload.get("content", ""), } # ---------- Analysis engine ---------- def analyze_text(self, dream_content: str) -> Dict[str, Any]: """ Always returns a strict schema. """ symbols = extract_symbols(dream_content) emotions = extract_emotions(dream_content) themes = extract_themes(dream_content) questions = build_questions(symbols, emotions, themes) signals = { "symbols": symbols, "emotions": emotions, "themes": themes, "questions": questions, } analysis = { "dream_id": None, # populated at log time "summary": summarize(dream_content), "signals": signals, } return analysis # ---------- Internals ---------- def _auto_title(self, analysis: Dict[str, Any]) -> str: themes = analysis.get("signals", {}).get("themes", []) or [] symbols = analysis.get("signals", {}).get("symbols", []) or [] if themes and symbols: return f"{themes[0].replace('_', ' ').title()} — {symbols[0].title()}" if themes: return themes[0].replace("_", " ").title() return "Untitled Dream" def _make_dream_id(self, timestamp: str, content: str) -> str: # collision-resistant ID with time + random + small content fingerprint rnd = secrets.token_hex(8) fp = secrets.token_hex(4) # Keep it short but unique-ish return f"dw_{self.user_id}_{timestamp.replace(':', '').replace('.', '')}_{rnd}_{fp}" def _encrypt_payload(self, obj: Dict[str, Any]) -> str: raw = safe_json_dumps(obj).encode("utf-8") return self.cipher.encrypt(raw).decode("utf-8") def _decrypt_payload(self, token: str) -> Dict[str, Any]: try: raw = self.cipher.decrypt(token.encode("utf-8")) return safe_json_loads(raw.decode("utf-8")) except InvalidToken: return {"error": "Unable to decrypt payload (wrong key?)."} def _append_journal(self, entry: Dict[str, Any]) -> None: ensure_dir(self.paths.journal_file.parent) line = safe_json_dumps(entry) with self.paths.journal_file.open("a", encoding="utf-8") as f: f.write(line + "\n") def _read_journal(self) -> List[Dict[str, Any]]: if not self.paths.journal_file.exists(): return [] rows: List[Dict[str, Any]] = [] with self.paths.journal_file.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: rows.append(safe_json_loads(line)) except Exception: # Corrupt line; skip it (journal is append-only) continue return rows def _find_entry(self, dream_id: str) -> Optional[Dict[str, Any]]: rows = self._read_journal() for r in reversed(rows): if r.get("dream_id") == dream_id and r.get("user_id") == self.user_id: return r return None def _dream_terms_from_signals(self, signals: Dict[str, Any]) -> List[str]: terms: List[str] = [] for k in ("symbols", "emotions", "themes"): for item in (signals.get(k, []) or []): terms.extend(tokenize(item.replace("_", " "))) # add question keywords to enrich matching for q in (signals.get("questions", []) or []): terms.extend(tokenize(q)) return terms def _compose_insight( self, themes: List[str], symbols: List[str], emotions: List[str], linked: List[Dict[str, Any]], ) -> str: # Grounded, but not too "therapy-ish" theme_txt = ", ".join(t.replace("_", " ") for t in themes[:2]) if themes else "reflection" sym_txt = ", ".join(symbols[:2]) if symbols else "imagery" emo_txt = ", ".join(emotions[:2]) if emotions else "a feeling" if linked: top = linked[0]["memory"] return ( f"This dream leans into **{theme_txt}** with **{sym_txt}** and **{emo_txt}**. " f"It overlaps with a recent memory layer: “{top}”. " f"If you treat the dream as a message, it might be nudging you to connect those dots today." ) return ( f"This dream leans into **{theme_txt}** with **{sym_txt}** and **{emo_txt}**. " f"I don’t see a strong overlap with recent memory layers yet—log one more dream or add richer recent_layers " f"and the linking will get sharper." ) # ----------------------------- # CLI # ----------------------------- def cli() -> int: p = argparse.ArgumentParser( prog="DreamWeaver", description="Encrypted dream journal + lightweight analysis + memory linking (offline).", ) p.add_argument("--user", required=True, help="User ID (used to scope storage + key).") p.add_argument("--storage", default=None, help="Storage directory (default: XDG_DATA_HOME/dreamweaver/).") p.add_argument( "--memory", default=None, help="JSON file for memory capsule (expects {'recent_layers':[...]}). Optional.", ) sub = p.add_subparsers(dest="cmd", required=True) sp_add = sub.add_parser("add", help="Add a dream entry.") sp_add.add_argument("--title", default=None, help="Optional title.") sp_add.add_argument("--text", default=None, help="Dream text. If omitted, read from stdin.") sp_list = sub.add_parser("list", help="List recent dreams.") sp_list.add_argument("--limit", type=int, default=10) sp_show = sub.add_parser("show", help="Show decrypted dream text.") sp_show.add_argument("dream_id") sp_an = sub.add_parser("analyze", help="Show full structured analysis (decrypted).") sp_an.add_argument("dream_id") sp_met = sub.add_parser("metaphor", help="Generate metaphor (fast; no decrypt).") sp_met.add_argument("dream_id") sp_ins = sub.add_parser("insight", help="Generate insight + linked memories.") sp_ins.add_argument("dream_id") sp_ins.add_argument("--topk", type=int, default=3) sp_nav = sub.add_parser("narrate", help="Generate interactive experience prompt.") sp_nav.add_argument("dream_id") args = p.parse_args() memory_capsule: Dict[str, Any] = {"recent_layers": []} if args.memory: mp = Path(args.memory) if mp.exists(): try: memory_capsule = safe_json_loads(mp.read_text(encoding="utf-8")) except Exception: print("WARN: Could not parse memory capsule JSON; using empty.", file=sys.stderr) dw = DreamWeaver(user_id=args.user, memory_capsule=memory_capsule, storage_dir=args.storage) if args.cmd == "add": text = args.text if text is None: text = sys.stdin.read().strip() if not text: print("ERROR: No dream text provided.", file=sys.stderr) return 2 dream_id = dw.log_dream(text, title=args.title) print(dream_id) return 0 if args.cmd == "list": rows = dw.list_dreams(limit=args.limit) print(safe_json_dumps(rows)) return 0 if args.cmd == "show": print(safe_json_dumps(dw.get_dream_text(args.dream_id))) return 0 if args.cmd == "analyze": print(safe_json_dumps(dw.analyze_dream(args.dream_id))) return 0 if args.cmd == "metaphor": print(dw.generate_metaphor(args.dream_id)) return 0 if args.cmd == "insight": print(safe_json_dumps(dw.uncover_insight(args.dream_id, top_k=args.topk))) return 0 if args.cmd == "narrate": print(dw.interactive_experience(args.dream_id)) return 0 return 0 # ----------------------------- # Example usage (when run directly) # ----------------------------- def _demo() -> None: memory_capsule = {"recent_layers": ["curiosity discussion", "forest metaphor chat", "building RomanAI modules"]} dw = DreamWeaver(user_id="daniel_001", memory_capsule=memory_capsule) dream_id = dw.log_dream("I walked through a vast forest, searching for something lost.", title="Forest Search") print("Dream ID:", dream_id) print("\nMetaphor:\n", dw.generate_metaphor(dream_id)) print("\nInsight:\n", safe_json_dumps(dw.uncover_insight(dream_id))) print("\nNarrative:\n", dw.interactive_experience(dream_id)) print("\nList:\n", safe_json_dumps(dw.list_dreams(limit=5))) if __name__ == "__main__": # If run with no args, run demo. If args present, act as CLI. if len(sys.argv) == 1: _demo() sys.exit(0) sys.exit(cli())