From 48f02e7d44f6b706cd4379b69e03744228ffc3d8 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Sat, 11 Apr 2026 22:00:25 +0530 Subject: [PATCH] decouple scenario, add structure to memories --- demo.json | 116 ++++++++++++ engine.py | 435 +++++++++++++++++++++++++++++++++++++++++++++ logging_setup.py | 12 ++ main.py | 136 ++------------ pyproject.toml | 1 + scenario_loader.py | 109 ++++++++++++ uv.lock | 2 + 7 files changed, 689 insertions(+), 122 deletions(-) create mode 100644 demo.json create mode 100644 engine.py create mode 100644 logging_setup.py create mode 100644 scenario_loader.py diff --git a/demo.json b/demo.json new file mode 100644 index 0000000..1447d32 --- /dev/null +++ b/demo.json @@ -0,0 +1,116 @@ +{ + "scenario": { + "id": "demo", + "title": "Barnaby and Sybil", + "description": "A small village gate and a shadowed alley conversation.", + "player_id": "player", + "world_time": "1999-05-14 20:20", + "location": "Village" + }, + "entities": [ + { + "id": "barnaby", + "name": "Barnaby", + "traits": [ + "Grumbling", + "Duty-bound" + ], + "stats": { + "Str": 15 + }, + "voice_sample": "'Move along. I've got a gate to watch and no time for your prattle. Speak quick or get lost.'", + "current_mood": "Neutral", + "memories": [ + { + "content": "I saw the Merchant enter the Blue Tavern. He looked happy.", + "event_type": "observation", + "timestamp": "1999-05-14 08:00", + "location": "Village", + "entities": [ + "merchant" + ] + }, + { + "content": "Past Conversation Summary: 'Arthur.' Another name. Honestly, the sheer volume of introductions is exhausting enough without having to catalogue every passing face in this miserable little town square. The bard? Never heard it mentioned before today; I suppose my duties keep me away from such frivolous nonsense anyway. As for that merchant... yes, he entered Blue Tavern and appeared rather pleased with himself doing so. Looked happy was the best description available without resorting to outright exaggeration of his mood swings if one could call them that in a single afternoon's watch duty!Move along now; I have gates needing watching here, not endless little inquiries into local gossip or questionable entertainers.'", + "event_type": "reflection", + "timestamp": "1999-05-14 15:00", + "location": "Village", + "entities": [ + "player" + ] + }, + { + "content": "Past Conversation Summary: The merchant. Of course, it was the blasted merchants and their trivial movements that consumed his attention now? Honestly... I've got gates to watch; they don\u2019t care if some peddler arrived at midday or midnight! And he expects me\u2014*expects* nothing less than a detailed report on local commerce just because *he*'s back. It was hardly necessary, this whole recounting of who saw whom and when the blasted ale would be bought next time to placate him into silence. Move along with it; I have actual duties requiring attention elsewhere!", + "event_type": "reflection", + "timestamp": "1999-05-14 18:20", + "location": "Village", + "entities": [ + "player" + ] + } + ] + }, + { + "id": "player", + "name": "Arthur", + "traits": [ + "Curious" + ], + "stats": {}, + "voice_sample": "Voice: 'Direct and concise.'", + "current_mood": "Neutral", + "memories": [] + }, + { + "id": "sybil", + "name": "Sybil", + "traits": [ + "Mysterious", + "Gloomy" + ], + "stats": { + "Mag": 20 + }, + "voice_sample": "'The air is heavy today... like the smell of wet earth. What brings you to this shadow?'", + "current_mood": "Neutral", + "memories": [ + { + "content": "I smelled bitter almonds (poison) in the Bard's bag.", + "event_type": "observation", + "timestamp": "1999-05-14 12:00", + "location": "Village", + "entities": [ + "bard" + ] + }, + { + "content": "Past Conversation Summary:What brings you to this shadow?A brittle exchange, nothing more than a series of hollow echoes bouncing off these walls between us. They press for substance where I offer only mist; they demand an accounting that simply does not exist in my current state. To repeat 'Nothing' feels less like evasion and more... accurate enough tonight. The persistence behind the questions is almost tiresome a bright, insistent little flame trying to illuminate a space best left shrouded by twilight.", + "event_type": "reflection", + "timestamp": "1999-05-14 18:20", + "location": "Village", + "entities": [ + "player" + ] + }, + { + "content": "Past Conversation Summary:He merely watched, those eyes holding a depth I could not fathom 2014or perhaps would rather leave unfathomed. His question was so direct in its lack: *What do you seek?* It felt less like an inquiry and more like\u2026 expectation. A quiet demand for some hidden truth to surface into the gloom between us now that he has spoken again.", + "event_type": "reflection", + "timestamp": "1999-05-14 18:20", + "location": "Village", + "entities": [ + "player" + ] + }, + { + "content": "Past Conversation Summary: His impatience hangs around him, a thin film over everything else here that I find utterly tiresome to breathe in. He expects some pronouncement from me; he seems so certain his words will elicit something\u2014a flinch, perhaps? No. They simply settle with nothingness beneath them. The way the shadows deepen... it is more honest than anything spoken aloud between us today.", + "event_type": "reflection", + "timestamp": "1999-05-14 20:20", + "location": "Village", + "entities": [ + "player" + ] + } + ] + } + ] +} diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..47212b1 --- /dev/null +++ b/engine.py @@ -0,0 +1,435 @@ +from datetime import datetime, timedelta +import logging +import multiprocessing + +from langchain_community.chat_models import ChatLlamaCpp +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain_community.vectorstores import FAISS +from langchain_core.messages import HumanMessage, SystemMessage + +DEFAULT_MODEL_PATH = "/home/sortedcord/.cache/huggingface/hub/models--ggml-org--gemma-4-E4B-it-GGUF/snapshots/6b352c53e1d2e4bb974d9f8cafcf85887c224219/gemma-4-e4b-it-Q4_K_M.gguf" + +logger = logging.getLogger(__name__) + +embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") + +llm = ChatLlamaCpp( + temperature=0.2, + model_path=DEFAULT_MODEL_PATH, + n_ctx=4096, + n_gpu_layers=8, + max_tokens=512, + n_threads=multiprocessing.cpu_count() - 1, + repeat_penalty=1.5, +) + + +def _format_prompt(messages): + formatted = [] + for message in messages: + formatted.append(f"{message.__class__.__name__}:\n{message.content}") + return "\n\n".join(formatted) + + +def _normalize_llm_output(text: str) -> str: + return text.replace("\r", "").replace("\n", "").strip() + + +def _time_of_day_label(hour: int, *, for_today: bool) -> str: + if 5 <= hour < 12: + return "morning" + if 12 <= hour < 17: + return "afternoon" + return "tonight" if for_today else "night" + + +def describe_relative_time( + timestamp_str: str, + reference_time: datetime, + *, + prefer_day_part_for_today: bool = False, +) -> str: + try: + timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M") + except ValueError: + return "a long time ago" + + delta = reference_time - timestamp + seconds = delta.total_seconds() + if seconds < 0: + return "just now" + + if not prefer_day_part_for_today: + if seconds < 120: + return "just now" + if seconds < 15 * 60: + return "a few minutes ago" + if seconds < 90 * 60: + return "an hour ago" + if seconds < 3 * 60 * 60: + return "a couple hours ago" + + day_diff = (reference_time.date() - timestamp.date()).days + if day_diff == 0: + return f"today {_time_of_day_label(timestamp.hour, for_today=True)}" + if day_diff == 1: + return f"yesterday {_time_of_day_label(timestamp.hour, for_today=False)}" + if day_diff == 2: + return "2 days ago" + if day_diff == 3: + return "3 days ago" + if day_diff <= 6: + return "a couple days ago" + if day_diff <= 10: + return "a week ago" + if day_diff <= 20: + return "a couple weeks ago" + if day_diff <= 45: + return "a month ago" + if day_diff <= 75: + return "a couple months ago" + if day_diff <= 420: + return "a year ago" + return "a long time ago" + + +class WorldClock: + def __init__(self, start_year=1999, month=5, day=14, hour=18, minute=0): + # We use a standard datetime object for easy math + self.current_time = datetime(start_year, month, day, hour, minute) + + def advance_time(self, minutes=0, hours=0, days=0): + self.current_time += timedelta(minutes=minutes, hours=hours, days=days) + + def get_time_str(self): + # 1999-05-14 18:00 + return self.current_time.strftime("%Y-%m-%d %H:%M") + + def get_vibe(self): + """Helper to tell the LLM the 'feel' of the time.""" + hour = self.current_time.hour + if 5 <= hour < 12: + return "Morning" + if 12 <= hour < 17: + return "Afternoon" + if 17 <= hour < 21: + return "Evening" + return "Night" + + @classmethod + def from_time_str(cls, time_str: str | None): + if not time_str: + return cls() + parsed = datetime.strptime(time_str, "%Y-%m-%d %H:%M") + return cls( + start_year=parsed.year, + month=parsed.month, + day=parsed.day, + hour=parsed.hour, + minute=parsed.minute, + ) + + +class MemoryEntry: + def __init__(self, content, event_type, timestamp_str, location, entities): + self.content = content + self.event_type = event_type # 'dialogue', 'observation', 'reflection' + self.timestamp = timestamp_str + self.location = location + self.entities = entities + + def __repr__(self): + return f"[{self.timestamp}] ({self.location}): {self.content}" + + def to_dict(self): + return { + "content": self.content, + "event_type": self.event_type, + "timestamp": self.timestamp, + "location": self.location, + "entities": list(self.entities), + } + + def to_vector_text(self): + entities = ", ".join(self.entities) if self.entities else "Unknown" + return ( + f"{self.content}\n" + f"Time: {self.timestamp}\n" + f"Location: {self.location}\n" + f"Entities: {entities}\n" + f"Type: {self.event_type}" + ) + + def to_relative_string(self, reference_time: datetime): + time_label = describe_relative_time(self.timestamp, reference_time) + return f"[{time_label}] ({self.location}): {self.content}" + + +class EntityMemory: + def __init__(self): + self.vector_store = None + self.entries = [] + + def save(self, entry: MemoryEntry): + self.entries.append(entry) + entry_text = entry.to_vector_text() + if self.vector_store is None: + self.vector_store = FAISS.from_texts( + [entry_text], + embeddings, + metadatas=[{"entry_index": len(self.entries) - 1}], + ) + else: + self.vector_store.add_texts( + [entry_text], + metadatas=[{"entry_index": len(self.entries) - 1}], + ) + + def retrieve(self, query: str, k=2, reference_time: datetime | None = None): + if self.vector_store is None: + return "No long-term memories relevant." + docs = self.vector_store.similarity_search(query, k=k) + memories = [] + for doc in docs: + entry_index = doc.metadata.get("entry_index") + if entry_index is None: + memories.append(doc.page_content) + continue + entry = self.entries[entry_index] + if reference_time is None: + memories.append(repr(entry)) + else: + memories.append(entry.to_relative_string(reference_time)) + return "\n".join(memories) + + def dump_entries(self): + return list(self.entries) + + +class Entity: + def __init__( + self, + name, + traits, + stats, + voice_sample, + current_mood="Neutral", + entity_id=None, + ): + self.name = name + self.traits = traits + self.stats = stats + self.current_mood = current_mood + self.memory = EntityMemory() + # TIER 1: The Short-Term Buffer (Verbatim) + self.chat_buffer = [] + self.voice_sample = voice_sample + self.entity_id = entity_id + + def perceive(self, entry: MemoryEntry): + self.memory.save(entry) + + def reflect_and_summarize(self, world_clock: WorldClock, location: str): + """Converts Tier 1 (Buffer) into Tier 2 (Long-term Subjective Memory).""" + if not self.chat_buffer: + return + + dialogue_text = "\n".join( + [f"{m['role_name']}: {m['content']}" for m in self.chat_buffer] + ) + + # The Subjective Filter Prompt + summary_prompt = [ + SystemMessage( + content=f""" +You are the private inner thoughts of {self.name}. +Traits: {", ".join(self.traits)}. +Mood: {self.current_mood}. +Voice Reference: {self.voice_sample} + +Think about what just happened. +- No META-TALK, Do not use 'player', 'interaction', 'entity', or 'dialogue' +- BE SUBJECTIVE. If you hated the talk or loved it, then express that. +- USE YOUR VOICE. Match the style of your Voice Reference +- Focus only on facts learned or feelings toward the person""" + ), + HumanMessage( + content=f""" +What just happened? Context:\n{dialogue_text}""" + ), + ] + + logger.info("LLM prompt (reflection):\n%s", _format_prompt(summary_prompt)) + summary = _normalize_llm_output(llm.invoke(summary_prompt).content) + logger.info("SYSTEM: %s reflected on the talk: '%s'", self.name, summary) + + chat_entities = sorted( + { + m["role_id"] + for m in self.chat_buffer + if m.get("role_id") and m.get("role_id") != self.entity_id + } + ) + reflection = MemoryEntry( + content=f"Past Conversation Summary: {summary}", + event_type="reflection", + timestamp_str=world_clock.get_time_str(), + location=location, + entities=chat_entities, + ) + self.perceive(reflection) + self.chat_buffer = [] # Clear buffer after archiving + + +class Player(Entity): + pass + + +def ask_entity( + entity: Entity, + player: Entity, + player_query: str, + world_clock: WorldClock, + location: str, +): + facts = entity.memory.retrieve( + player_query, + reference_time=world_clock.current_time, + ) + + recent_context = "\n".join( + [f"{m['role_name']}: {m['content']}" for m in entity.chat_buffer[-5:]] + ) + + world_time_label = describe_relative_time( + world_clock.get_time_str(), + world_clock.current_time, + prefer_day_part_for_today=True, + ) + + prompt = [ + SystemMessage(content=f"WORLD TIME: {world_time_label}"), + SystemMessage( + content=f""" +### ROLE +You are {entity.name}. Persona: {", ".join(entity.traits)}. +Current Mood: {entity.current_mood}. +Vibe Time: {world_clock.get_vibe()}. +Location: {location}. + +### WRITING STYLE RULES +1. NO META-TALK. Never mention "memory," "records," "claims," or "narratives." +2. ACT, DON'T EXPLAIN. If you don't know something, just say "Never heard of it" or "I wasn't there." Do not explain WHY you don't know. + +### KNOWLEDGE +MEMORIES: {facts} +RECENT CHAT: {recent_context} + """ + ), + HumanMessage(content=f"{player.name} speaks to you: {player_query}"), + ] + + logger.info("LLM prompt (dialogue):\n%s", _format_prompt(prompt)) + response = _normalize_llm_output(llm.invoke(prompt).content) + + entity.chat_buffer.append( + { + "role_id": player.entity_id, + "role_name": player.name, + "content": player_query, + } + ) + entity.chat_buffer.append( + { + "role_id": entity.entity_id, + "role_name": entity.name, + "content": response, + } + ) + + player.chat_buffer.append( + { + "role_id": player.entity_id, + "role_name": player.name, + "content": player_query, + } + ) + player.chat_buffer.append( + { + "role_id": entity.entity_id, + "role_name": entity.name, + "content": response, + } + ) + + logger.info("[%s]: %s", entity.name.upper(), response) + + +def _build_name_lookup(entities): + name_lookup = {} + for entity_key, entity in entities.items(): + name_lookup[entity_key.lower()] = entity_key + name_lookup[entity.name.lower()] = entity_key + return name_lookup + + +def start_game(entities, player_id=None, world_time=None, location="Unknown"): + player = None + if player_id: + player = entities.get(player_id) + if player is None: + raise ValueError(f"Player entity '{player_id}' not found in scenario.") + else: + player = Player( + name="Player", + traits=["Curious"], + stats={}, + voice_sample="Voice: 'Direct and concise.'", + entity_id="player", + ) + + available_entities = { + entity_id: entity + for entity_id, entity in entities.items() + if entity_id != player_id + } + + world_clock = WorldClock.from_time_str(world_time) + current_entity = None + name_lookup = _build_name_lookup(available_entities) + entity_names = "/".join( + [entity.name for entity in available_entities.values()] + ["Exit"] + ) + logger.info("--- WORLD INITIALIZED ---") + logger.info("World initialized with %s active entities.", len(available_entities)) + logger.info("Current location: %s", location) + logger.info( + "World time: %s (%s)", world_clock.get_time_str(), world_clock.get_vibe() + ) + + while True: + target_name = ( + input(f"\nWho do you want to talk to? ({entity_names}): ").lower().strip() + ) + + if target_name in ["exit", "quit"]: + if current_entity: + current_entity.reflect_and_summarize(world_clock, location) + break + + target_key = name_lookup.get(target_name) + if target_key is None: + logger.warning("Target not found.") + continue + + new_entity = available_entities[target_key] + if current_entity and current_entity != new_entity: + logger.info( + "You leave %s and approach %s.", current_entity.name, new_entity.name + ) + current_entity.reflect_and_summarize(world_clock, location) + + current_entity = new_entity + + user_msg = input(f"You to {current_entity.name}: ") + ask_entity(current_entity, player, user_msg, world_clock, location) diff --git a/logging_setup.py b/logging_setup.py new file mode 100644 index 0000000..8a3b5bf --- /dev/null +++ b/logging_setup.py @@ -0,0 +1,12 @@ +import logging + +from rich.logging import RichHandler + + +def configure_logging(level="INFO"): + logging.basicConfig( + level=level, + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True)], + ) diff --git a/main.py b/main.py index 959ee7c..9d67e66 100644 --- a/main.py +++ b/main.py @@ -1,127 +1,19 @@ -import multiprocessing -import sys -from langchain_community.chat_models import ChatLlamaCpp -from langchain_community.embeddings import HuggingFaceEmbeddings -from langchain_community.vectorstores import FAISS -from langchain_core.messages import SystemMessage, HumanMessage +from pathlib import Path -# --- 1. GLOBAL SETUP (Loads once) --- -local_model = "/home/sortedcord/.cache/huggingface/hub/models--ggml-org--gemma-4-E4B-it-GGUF/snapshots/6b352c53e1d2e4bb974d9f8cafcf85887c224219/gemma-4-e4b-it-Q4_K_M.gguf" +from engine import start_game +from logging_setup import configure_logging +from scenario_loader import load_scenario, save_scenario -print("--- Initializing Models (Please wait...) ---") -embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") - -llm = ChatLlamaCpp( - temperature=0.2, - model_path=local_model, - n_ctx=4096, - n_gpu_layers=8, - max_tokens=256, - n_threads=multiprocessing.cpu_count() - 1, - repeat_penalty=1.5, -) - - -# --- 2. THE ARCHITECTURE --- -class EntityMemory: - def __init__(self): - self.vector_store = None - - def save(self, text: str): - if self.vector_store is None: - self.vector_store = FAISS.from_texts([text], embeddings) - else: - self.vector_store.add_texts([text]) - - def retrieve(self, query: str, k=2): - if self.vector_store is None: - return "I have no memory of this." - docs = self.vector_store.similarity_search(query, k=k) - return " ".join([d.page_content for d in docs]) - - -class NPC: - def __init__(self, name, traits, stats): - self.name = name - self.traits = traits - self.stats = stats - self.current_mood = "Neutral" - self.current_activity = "Waiting" - self.memory = EntityMemory() - - def perceive(self, observation: str): - self.memory.save(observation) - - def get_context(self, query: str): - subjective_facts = self.memory.retrieve(query) - internal_state = ( - f"Mood: {self.current_mood}. Activity: {self.current_activity}." - ) - return subjective_facts, internal_state - - -# --- 3. THE INTERACTION HANDLER --- -def ask_npc(npc: NPC, player_query: str): - facts, state = npc.get_context(player_query) - prompt = [ - SystemMessage( - content=f""" - Role: You are {npc.name}. - Persona Traits: {", ".join(npc.traits)}. - INTERNAL STATE: {state} - STRICT RULES: - 1. You ONLY know what is in your 'MEMORIES'. - 2. Answer in character, reflecting your traits and current mood. - MEMORIES: {facts} - """ - ), - HumanMessage(content=player_query), - ] - response = llm.invoke(prompt) - print(f"\n[{npc.name.upper()}] says: {response.content.strip()}") - - -# --- 4. DATA INITIALIZATION --- -barnaby = NPC("Barnaby", ["Grumbling", "Duty-bound"], {"Str": 15}) -sybil = NPC("Sybil", ["Mysterious", "Gloomy"], {"Mag": 20}) - -barnaby.perceive("I saw the Merchant enter the Blue Tavern at sunset.") -barnaby.perceive("The Bard was tuning his instrument near the fireplace.") -sybil.perceive("I smelled bitter almonds (poison) coming from the Bard's bag.") -sybil.current_mood = "Deeply troubled" - -npcs = {"barnaby": barnaby, "sybil": sybil} - - -# --- 5. THE EVENT LOOP --- -def start_game(): - print("\n==========================================") - print("WORLD INITIALIZED. TYPE 'exit' TO QUIT.") - print("==========================================\n") - - while True: - # Choose target - target = ( - input("\nWho do you want to talk to? (Barnaby/Sybil): ").lower().strip() - ) - - if target in ["exit", "quit"]: - print("Exiting simulation...") - break - - if target not in npcs: - print(f"I don't see anyone named '{target}' here.") - continue - - # Get query - user_msg = input(f"What do you say to {target.capitalize()}?: ") - - if user_msg.lower().strip() in ["exit", "quit"]: - break - - # Execute - ask_npc(npcs[target], user_msg) +SCENARIO_PATH = Path(__file__).with_name("demo.json") if __name__ == "__main__": - start_game() + configure_logging() + scenario = load_scenario(SCENARIO_PATH) + start_game( + scenario.entities, + scenario.player_id, + world_time=scenario.metadata.get("world_time"), + location=scenario.metadata.get("location", "Unknown"), + ) + save_scenario(SCENARIO_PATH, scenario) diff --git a/pyproject.toml b/pyproject.toml index b2f404c..b6381ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,5 +9,6 @@ dependencies = [ "langchain-community>=0.4.1", "langchain[llms]>=1.2.15", "llama-cpp-python>=0.3.20", + "rich>=13.9.4", "sentence-transformers>=5.4.0", ] diff --git a/scenario_loader.py b/scenario_loader.py new file mode 100644 index 0000000..d4505c2 --- /dev/null +++ b/scenario_loader.py @@ -0,0 +1,109 @@ +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from engine import Entity, MemoryEntry, Player + +logger = logging.getLogger(__name__) + +@dataclass +class Scenario: + metadata: dict + entities: dict + player_id: str | None = None + + +def load_scenario(path: Path) -> Scenario: + logger.info("Loading scenario from %s", path) + payload = json.loads(path.read_text()) + metadata = payload.get("scenario", {}) + player_id = metadata.get("player_id") + world_time = metadata.get("world_time", "Unknown") + location = metadata.get("location", "Unknown") + raw_entities = payload.get("entities", []) + if not raw_entities: + raise ValueError(f"No entities found in scenario: {path}") + + entities = {} + for raw in raw_entities: + entity_id = (raw.get("id") or raw["name"]).strip().lower() + if entity_id in entities: + raise ValueError(f"Duplicate Entity id '{entity_id}' in {path}") + + entity_class = Player if player_id and entity_id == player_id else Entity + entity = entity_class( + name=raw["name"], + traits=list(raw["traits"]), + stats=dict(raw["stats"]), + voice_sample=raw["voice_sample"], + current_mood=raw.get("current_mood", "Neutral"), + entity_id=entity_id, + ) + for memory in raw.get("memories", []): + if isinstance(memory, str): + entry = MemoryEntry( + content=memory, + event_type="observation", + timestamp_str=world_time, + location=location, + entities=[], + ) + else: + entry = MemoryEntry( + content=memory["content"], + event_type=memory["event_type"], + timestamp_str=memory["timestamp"], + location=memory["location"], + entities=[ + normalized + for entity_ref in memory.get("entities", []) + if (normalized := str(entity_ref).strip().lower()) + and normalized != entity_id + ], + ) + entity.perceive(entry) + + entities[entity_id] = entity + + logger.info("Loaded %s entities from scenario.", len(entities)) + return Scenario(metadata=metadata, entities=entities, player_id=player_id) + + +def dump_scenario(scenario: Scenario) -> dict: + entity_payloads = [] + for entity_id in sorted(scenario.entities.keys()): + entity = scenario.entities[entity_id] + entity_payloads.append( + { + "id": entity_id, + "name": entity.name, + "traits": list(entity.traits), + "stats": dict(entity.stats), + "voice_sample": entity.voice_sample, + "current_mood": entity.current_mood, + "memories": [ + entry.to_dict() for entry in entity.memory.dump_entries() + ], + } + ) + + metadata = dict(scenario.metadata) + if scenario.player_id and metadata.get("player_id") != scenario.player_id: + metadata["player_id"] = scenario.player_id + + return { + "scenario": metadata, + "entities": entity_payloads, + } + + +def dumps_scenario(scenario: Scenario) -> str: + return json.dumps(dump_scenario(scenario), indent=2) + + +def save_scenario(path: Path, scenario: Scenario) -> str: + dumped = dumps_scenario(scenario) + path.write_text(f"{dumped}\n") + logger.info("Saved scenario to %s", path) + return dumped diff --git a/uv.lock b/uv.lock index 0d86f43..3484b3a 100644 --- a/uv.lock +++ b/uv.lock @@ -760,6 +760,7 @@ dependencies = [ { name = "langchain" }, { name = "langchain-community" }, { name = "llama-cpp-python" }, + { name = "rich" }, { name = "sentence-transformers" }, ] @@ -769,6 +770,7 @@ requires-dist = [ { name = "langchain", extras = ["llms"], specifier = ">=1.2.15" }, { name = "langchain-community", specifier = ">=0.4.1" }, { name = "llama-cpp-python", specifier = ">=0.3.20" }, + { name = "rich", specifier = ">=13.9.4" }, { name = "sentence-transformers", specifier = ">=5.4.0" }, ]