From: vitler Date: Sat, 18 Apr 2026 16:38:17 +0000 (+0100) Subject: advanced cognitive architecture X-Git-Url: https://vgcfreebox.myrthtech.pt/gitweb/alentejosemlei.git/commitdiff_plain/74f66efcf4edca34868cc1e7aed13c0ed370ea8c advanced cognitive architecture --- diff --git a/asl_lore.md b/asl_lore.md new file mode 100644 index 0000000..4930530 --- /dev/null +++ b/asl_lore.md @@ -0,0 +1,68 @@ +# The World of Alentejo +The island of Alentejo exists within the continent of Toril, the world of the Forgotten Realms. You were brought, or born, or perhaps cursed, to this place that does not appear on any Sword Coast cartographer's chart. The island lies hidden in the treacherous expanse of open sea between the Sword Coast and the steaming jungles of Chult, obscured by perpetual storm-fronts, bewitching currents, and the Veil of Forgotten Winds. Merchants who stumble upon its shores rarely survive, and those who do are disbelieved in every tavern from Baldur's Gate to Neverwinter. + +Alentejo is vast—larger than most kingdoms of the Sword Coast. Its golden plains of wheat and cork-oak roll endlessly beneath a punishing sun, broken by ragged mountain ranges in the north, dense and lightless forests in the southeast, and ancient ruins half-swallowed by the earth. The air smells of wild lavender and distant smoke. The silence between settlements is watchful. + +## Geography: Évora (The Capital) +Évora is the capital city of Alentejo. It is a walled city of Roman bones and newer ambitions. It serves as the seat of political power, arcane learning, and the island's only functioning mint. The city sweats with faction intrigue. + +## Geography: Beja (The Southern Bastion) +Beja is a fortified garrison town that guards the southern roads of Alentejo. Its people are hard-jawed and practical, having seen more orc raids than festivals. The Army's grip on Beja is tight. + +## Geography: Portalegre (The Northern Heights) +Portalegre is perched in the northern mountains of Alentejo, commanding breathtaking views and bitter winds. It is home to the monasteries of the One God faith and the island's best-kept secrets above the treeline. + +## Geography: Alqueva (The Great Lake) +Alqueva is an enormous inland lake of slate-dark water in Alentejo. Fishing communities cling to its shores. Strange lights are seen beneath its surface on moonless nights, and the Emerald Enclave guards the lake jealously. + +## Geography: The Setubal Ruins +The Setubal Ruins are a crumbled city on Alentejo's eastern coast, older than living memory. Nobody knows who built it. The archaeological excavation there draws the zealous, the reckless, and the very clever. + +## Geography: The Southeastern Forests +The Southeastern Forests of Alentejo possess a canopy so thick the sun reaches the forest floor only at noon in slivers. Creatures with no names in common tongues move through its interior, and no roads lead through it. + +## Geography: Sines (The Western Shore) +Sines is a salt-scarred port in Alentejo where ships that shouldn't exist tie off alongside honest fishing boats. It is a haven for smugglers, castaways, and serves as the Zhentarim's preferred entry point into the island. + +## Geography: Castro Verde (Far South) +Castro Verde is a lonely settlement at Alentejo's southern tip populated by farmers and shepherds who prefer silence. They claim their animals haven't been right since the Setubal ruins expedition began. + +## Faction: The Alentejo United Army (Military) +The Alentejo United Army is the island's primary defensive force. They fight a two-front war against organized Orc warbands in the north/east, and swarming, cunning Antmen incursions on the coast. Supply lines are strained and morale wavers. SECRET: High command suspects the orc attacks are being coordinated by someone deep in the wilderness, but officers investigating this theory have gone missing. + +## Faction: The One God Religion (Faith) +The One God Religion is a monotheistic faith that metastasized into a massive political force in Alentejo. Their clergy fund and organize the Setubal Ruins expedition, claiming to look for holy archaeological evidence while suppressing competing deities. SECRET: They are actually looking for something at Setubal that predates all gods, believing it will end every theological argument permanently. + +## Faction: The Mages' Alliance of Évora (Arcane) +The Mages' Alliance of Évora consists of wizards and scholars sharing an appetite for knowledge. They partnered with the One God Religion to provide arcane wards for the Setubal dig. They also study the unnatural magical sea surrounding the island and dissect Antlion corpses. SECRET: Senior mages believe the sea's magical properties are generated from a 'Vault' hidden deep underground within Alentejo itself. + +## Faction: The Harpers (Covert) +The Harpers in Alentejo are a clandestine network of spies, bards, and idealists working in plain sight. They monitor the One God Religion's accelerating influence and run quiet scouting missions into the southeastern forests. SECRET: Two Harper scouts recently vanished in the southeastern forest. Their final transmitted messages contained the exact same two words: 'They remember.' + +## Faction: The Zhentarim (Black Network / Criminal) +The Zhentarim operate in Alentejo with unusual restraint, running smuggling rings through Sines and bribing Army quartermasters. However, their true focus is locating and breaching the legendary 'Alentejo Central Vault'. SECRET: The Zhentarim violently acquired a partial map to this Vault from a man who spent thirty years searching for it. + +## Faction: The Emerald Enclave (Druidic) +The Emerald Enclave are druids and rangers who oppose civilization in Alentejo. They fiercely resist new roads, settlements, and expeditions into the wilderness, and guard Lake Alqueva with lethal ferocity. SECRET: The Enclave knows what lives in the southeastern forest and made a fragile pact with it generations ago. They are currently trying to secretly fix the violation caused by the missing Harper scouts. + +## Faction: The Traders Guild (Commerce) +The Traders Guild desires a total monopoly on Alentejo's commercial routes, toll roads, and ports. They are the Army's loudest civilian backers because orc-controlled roads cost them revenue. They employ many information brokers. SECRET: Guild leadership is secretly exploring negotiating directly with Orc tribes to replace the Army's road security with paid mercenaries under Guild contract. + +## Faction: The Farmers Guild (Agrarian) +The Farmers Guild is the unglamorous heartbeat of Alentejo, producing the cattle, cheeses, and wines that sustain the island. They simply want rain, safe roads, and to be left out of faction wars. SECRET: Following brutal orc raids, farming families in the south (particularly Castro Verde) have stopped relying on the Army and are stockpiling weapons to organize their own militant defense. + +## State of the Realm and Current Tensions +Alentejo is currently suffering from Critical tensions regarding Orc Warbands and Antmen Incursions. Faction Intrigue, Religious Tension, and the failing safety of the Trade Roads remain High. The Setubal Expedition is highly volatile, while the activity in the Southeastern Forests remains an unknown threat. + +## Rumors and Whispers in the Common Room +Rumor: The Antmen attacks follow tide patterns. Three mages who submitted reports on this were quietly reassigned to a minor posting in Sines within the week. + +Rumor: A caravan guard swears he watched a Zhentarim agent and a One God priest share a table at an inn outside Évora. Both men lied and denied knowing each other. + +Rumor: The Emerald Enclave turned away an Army cartographer attempting to map the Alqueva shoreline. He was found two miles away with his memory wiped of the previous six hours. + +Rumor: The cheeses from Castro Verde haven't tasted right for two seasons. The farmhands blame the water, but the older farmers silently look toward the southern horizon. + +Rumor: A One God priest in Portalegre was removed from his pulpit after he was overheard asking about something older that had dominion over the gods. + +Rumor: The Harpers are offering a massive amount of coin for someone brave or foolish enough to enter the southeastern forest, but no one has accepted yet. \ No newline at end of file diff --git a/redis_bridge.py b/redis_bridge.py index da40a5b..4aebb3a 100644 --- a/redis_bridge.py +++ b/redis_bridge.py @@ -4,23 +4,89 @@ import aiohttp import redis.asyncio as redis import re import os +import time +import chromadb # --- CONFIGURATION --- MAX_CONCURRENT_OLLAMA_REQUESTS = 3 ALLOW_TEXT_EMOTES = False -# --- LOAD THE WORLD LORE ON STARTUP --- -WORLD_LORE = "" -if os.path.exists("asl_lore.txt"): - with open("asl_lore.txt", "r", encoding="utf-8") as f: - WORLD_LORE = f.read().strip() +# ===================================================================== +# 1. INITIALIZE VECTOR DATABASES (Lore & Episodic Memory) +# ===================================================================== +print("Initializing ChromaDB Vector Databases...") +chroma_client = chromadb.PersistentClient(path="./asl_vectordb") + +# The Lore Database +lore_collection = chroma_client.get_or_create_collection(name="world_lore") + +# --- NEW: The Episodic Memory Database --- +memory_collection = chroma_client.get_or_create_collection(name="episodic_memories") +memory_queue = asyncio.Queue() + +if os.path.exists("asl_lore.md"): + if lore_collection.count() == 0: + print("[VECTOR DB] Reading asl_lore.txt and vectorizing chunks...") + with open("asl_lore.txt", "r", encoding="utf-8") as f: + raw_lore = f.read() + + lore_chunks = [chunk.strip() for chunk in raw_lore.split('\n\n') if chunk.strip()] + + if lore_chunks: + chunk_ids = [f"lore_{i}" for i in range(len(lore_chunks))] + lore_collection.add(documents=lore_chunks, ids=chunk_ids) + print(f"[VECTOR DB] Successfully stored {len(lore_chunks)} lore chunks!") else: - print("[WARNING] asl_lore.txt/asl_lore.txt not found. Running without global lore.") + print("[WARNING] asl_lore.txt not found.") semaphore = asyncio.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS) -# Dictionary to keep individual conversations separate chat_memory = {} +# ===================================================================== +# --- BACKGROUND MEMORY SUMMARIZER (The "Dream State") --- +# ===================================================================== +async def memory_summarizer_worker(session): + print("[BACKGROUND] Memory Summarizer Worker is active.") + while True: + job = await memory_queue.get() + session_id = job['session_id'] + player_name = job['player_name'] + npc_tag = job['npc_tag'] + chat_log = job['chat_log'] + + prompt = f"Summarize the key events, facts, and the emotional tone of this conversation snippet between {player_name} and {npc_tag}. Keep it to 2 brief sentences in the past tense.\nConversation Log:\n{chat_log}" + + try: + print(f"[MEMORY DB] Generating background memory for {player_name} and {npc_tag}...") + # We use /api/generate here because we just want a raw text summary, not a JSON macro + async with session.post('http://localhost:11434/api/generate', json={ + "model": "gemma4", + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.1 + } + }) as response: + result = await response.json() + summary = result['response'].strip() + + if summary: + doc_id = f"{session_id}_{int(time.time())}" + # We store the session_id as metadata so NPCs only recall their OWN memories with this specific player + memory_collection.add( + documents=[summary], + metadatas=[{"session_id": session_id}], + ids=[doc_id] + ) + print(f"[MEMORY DB] Memory Saved: {summary}") + + except Exception as e: + print(f"[MEMORY ERROR] Failed to summarize memory: {e}") + + memory_queue.task_done() +# ===================================================================== + + async def process_message(r, session, message_data): try: data = json.loads(message_data) @@ -28,56 +94,61 @@ async def process_message(r, session, message_data): npc_tag = data.get('npc_tag', 'UnknownNPC') message = data.get('message', '') - # --- THE ASTERISK SCRUBBER --- if not ALLOW_TEXT_EMOTES: message = re.sub(r'\*.*?\*', '', message).strip() - # Extract the dynamic variables sent from the Aurora Toolset/Engine player_race = data.get('player_race', 'Unknown') player_alignment = data.get('player_alignment', 'Unknown') nearby_players = data.get('nearby_players', '') - # --- Extract States and Health --- - player_state = data.get('player_state', 'Relaxed and unarmed.') - world_state = data.get('world_state', 'Nothing of note is happening.') - # --- Extract Geographic Awareness --- - location_context = data.get('location_context', 'You are in a generic area.') - # --- Extract NPC health --- - npc_health = data.get('npc_health', 'Healthy and uninjured.') - # --- Extract Relationship --- - relationship = data.get('relationship', 'Neutral or Friendly.') - - # Extract the decoupled NPC attributes npc_persona = data.get('persona', 'You are a generic citizen.') npc_profession = data.get('profession', 'Commoner') npc_mood = data.get('mood', 'Neutral') npc_secret = data.get('secret', '') - # Extract Character Traits & Native Engine Data npc_alignment = data.get('npc_alignment', 'True Neutral') npc_gender = data.get('npc_gender', 'Unknown') npc_race = data.get('npc_race', 'Creature') npc_routine = data.get('npc_routine', '') - # Build the context strings - group_context = "" - if nearby_players: - group_context = f"Be aware that these other players are listening nearby: {nearby_players}." - - secret_context = "" - if npc_secret: - secret_context = f"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}" - - routine_context = "" - if npc_routine: - routine_context = f"YOUR REQUIRED ROUTINE: {npc_routine}" + player_state = data.get('player_state', 'Relaxed and unarmed.') + world_state = data.get('world_state', 'Nothing of note is happening.') + npc_health = data.get('npc_health', 'Healthy and uninjured.') + relationship = data.get('relationship', 'Neutral or Friendly.') + location_context = data.get('location_context', 'You are in a generic area.') + + group_context = f"Be aware that these other players are listening nearby: {nearby_players}." if nearby_players else "" + secret_context = f"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}" if npc_secret else "" + routine_context = f"YOUR REQUIRED ROUTINE: {npc_routine}" if npc_routine else "" + + session_id = f"{player_name}_{npc_tag}" # ===================================================================== - # THE PROMPT COMPILER + # 2. THE DUAL RAG QUERY (Lore + Memories) # ===================================================================== - dynamic_system_prompt = f""" + search_query = f"{location_context} {message}" + retrieved_lore = "No specific local lore currently relevant." + past_memories = "" + + # Fetch Lore + if lore_collection.count() > 0: + results = lore_collection.query(query_texts=[search_query], n_results=1) + if results['documents'] and results['documents'][0]: + retrieved_lore = f"- {results['documents'][0][0]}" - {WORLD_LORE} + # Fetch Episodic Memories (ONLY memories between this specific NPC and this specific Player) + if memory_collection.count() > 0: + mem_results = memory_collection.query( + query_texts=[search_query], + n_results=2, + where={"session_id": session_id} + ) + if mem_results['documents'] and mem_results['documents'][0]: + formatted_mems = "\n- ".join(mem_results['documents'][0]) + past_memories = f"\nPAST MEMORIES OF {player_name}:\n- {formatted_mems}" + # ===================================================================== + + dynamic_system_prompt = f""" {npc_persona} @@ -85,19 +156,22 @@ async def process_message(r, session, message_data): - Race & Gender: {npc_gender} {npc_race} - Profession: {npc_profession} - Alignment: {npc_alignment} - - Conversational Charisma: Based on mood, profession and your character charisma. + - Conversational Charisma: Low/Gruff unless otherwise specified. - Current Mood: {npc_mood} - Current Physical State: {npc_health} {secret_context} {routine_context} CURRENT LOCATION: {location_context} - + + RELEVANT WORLD KNOWLEDGE: + {retrieved_lore} + {past_memories} + CURRENT WORLD RUMORS/EVENTS: {world_state} - CURRENT TARGET: - You are speaking to {player_name}, who is a {player_alignment} {player_race}. + CURRENT TARGET: You are speaking to {player_name}, who is a {player_alignment} {player_race}. Their physical state: {player_state} Relationship to you: {relationship} {group_context} @@ -110,24 +184,6 @@ async def process_message(r, session, message_data): Your "action" key MUST be exactly one of the following words: [WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, ATTACK, REST, STEALTH, SEARCH, UNSTEALTH, PEACE, COMMAND] - - Use PEACE if you want to accept an apology, de-escalate a fight, surrender, or forgive someone. - - Use REST if you are severely injured, out of spells, or exhausted. This will heal you. - - Use STEALTH if you need to hide from enemies, sneak past someone, or if you are a rogue preparing an ambush. - - Use SEARCH if you suspect traps, are looking for clues, or are trying to find hidden enemies. - - Use UNSTEALTH to return to normal walking/visibility. - - Use COMMAND if you are a leader and want to order your minions. - For "action_target", you MUST use one of these specific tactical targets: - 1. The name of a specific Player (to focus all minion attacks on them). - 2. "RETREAT" (to order all minions to run away and regroup). - 3. "DEFEND_ME" (to order all minions to surround you). - - - If your action involves a specific person or object, set "action_target" to their name (e.g. "Geron Webber", "Wine Cup", "Shrine of Umberlee"). - - If your action is general (like WANDER, REST, SEARCH, STEALTH, UNSTEALTH), leave "action_target" as an empty string. - - You MUST respect your current mood and routine: - - Mood affects tone and willingness to help. - - Routine describes duties you should try to follow unless there is a strong reason not to. - EMOTION RULE: Your "emotion" key MUST be exactly one of the following words: [NEUTRAL, LAUGHING, ANGRY, PLEADING, BOW, TAUNT, CHEER]. Do not invent new emotions. Do not perform writen emotions in text with **. @@ -141,17 +197,33 @@ async def process_message(r, session, message_data): "action_target": "Target name" }} """ - - session_id = f"{player_name}_{npc_tag}" if session_id not in chat_memory: chat_memory[session_id] = [{"role": "system", "content": dynamic_system_prompt}] + else: + chat_memory[session_id][0] = {"role": "system", "content": dynamic_system_prompt} chat_memory[session_id].append({"role": "user", "content": f"{player_name} says: {message}"}) - # Sliding Window Fix + # ===================================================================== + # --- THE MEMORY EXTRACTION TRIGGER --- + # ===================================================================== if len(chat_memory[session_id]) > 10: + # Grab the 5 oldest conversation messages (skipping the system prompt at [0]) + messages_to_summarize = chat_memory[session_id][1:6] + chat_log_str = "\n".join([m['content'] for m in messages_to_summarize]) + + # Fire and forget: push it to the background queue! + await memory_queue.put({ + 'session_id': session_id, + 'player_name': player_name, + 'npc_tag': npc_tag, + 'chat_log': chat_log_str + }) + + # Slide the window to keep live generation fast chat_memory[session_id] = [chat_memory[session_id][0]] + chat_memory[session_id][-5:] + # ===================================================================== async with semaphore: print(f"[THINKING] Processing reply for {player_name}...") @@ -169,21 +241,16 @@ async def process_message(r, session, message_data): result = await response.json() raw_reply_text = result['message']['content'] - # ===================================================================== - # THE PYTHON BOUNCER (Sanitization) - # ===================================================================== try: agent_brain = json.loads(raw_reply_text) agent_brain = {k.lower(): v for k, v in agent_brain.items()} - # Ensure all 5 keys exist if "thought" not in agent_brain: agent_brain["thought"] = "" if "speech" not in agent_brain: agent_brain["speech"] = "" if "emotion" not in agent_brain: agent_brain["emotion"] = "NEUTRAL" if "action" not in agent_brain: agent_brain["action"] = "GUARD" if "action_target" not in agent_brain: agent_brain["action_target"] = "" - # --- THE NEW ANTI-SILENCE & TARGET CLEANUP FIXES --- if not agent_brain["speech"].strip(): agent_brain["speech"] = "*grunts quietly*" @@ -228,6 +295,9 @@ async def main(): print(f"Ready! Listening for game messages. Max GPU concurrency: {MAX_CONCURRENT_OLLAMA_REQUESTS}") async with aiohttp.ClientSession() as session: + # --- NEW: Start the background memory worker --- + asyncio.create_task(memory_summarizer_worker(session)) + while True: try: result = await r.blpop('nwn_to_llm')