diff --git a/VENV_SETUP.md.gz b/VENV_SETUP.md.gz
new file mode 100644
index 0000000..5e094fe
Binary files /dev/null and b/VENV_SETUP.md.gz differ
diff --git a/conosci i dale mud _ vorrei fare un brainstorming.md b/conosci i dale mud _ vorrei fare un brainstorming.md
new file mode 100644
index 0000000..bd57da7
--- /dev/null
+++ b/conosci i dale mud _ vorrei fare un brainstorming.md
@@ -0,0 +1,128 @@
+
+
+# conosci i dale mud ? vorrei fare un brainstorming per vedere come andrebbe cvreato un app che simula interagendo con un dale mud nella simulazione di un personaggio giocante ai dale mud dalla scelta del personaggio e delle caratteristiche , poi al gioco vero oe proprio, leggendo le istruzioni a schermo e le descrizioni delle varie arre e interagendo con i personaggi del dale mud e giocando e facendo il automapping, fino a far crescere il personaggio facendo exp, e quest, e quindi una simulazione in toto del giocateore di da dalemud.
+
+Sì, conosco bene i **MUD** (Multi-User Dungeon) - quei giochi di ruolo testuali multiplayer via Telnet che hanno ispirato gli MMORPG moderni. L'idea di creare un'app che simuli un "giocatore AI" di MUD è affascinante, un'intersezione tra **agente AI autonomo** e **retro-gaming**. Ti propongo un brainstorming strutturato per l'architettura di questa app.
+
+## Architettura generale
+
+L'app dovrebbe essere composta da diversi moduli specializzati che comunicano tra loro, simulando il "cervello" di un giocatore umano:
+
+### Modulo 1: Parser \& Interpret (l'occhio sullo schermo)
+
+Questo modulo deve interpretare il testo grezzo che arriva dal MUD e trasformarlo in una struttura dati comprensibile per l'AI.
+
+- **Telnet Client**: Connessione al server MUD (handshake, gestione colori ANSI, MCCP per compressione)[^1][^2]
+- **NLP Layer**: Estrazione di informazioni chiave dalle descrizioni (stanza corrente, NPC presenti, oggetti, direzioni disponibili, stato del personaggio HP/MP/EXP)
+- **Stato di gioco**: Modello dati che rappresenta la "memoria a breve termine" del giocatore (dove sono, cosa vedo, che mob c'è)
+
+
+### Modulo 2: Character Creator (la creazione del personaggio)
+
+Simulation della fase di rolling iniziale.
+
+- **Roller automatico**: Simulare il "roll" delle statistiche (STR, INT, WIS, DEX, CON, CHA) ottimizzando per la classe scelta[^3]
+- **Scelta classe/razza**: Logica di decisione basata su preferenze (meele vs caster, tank vs dps)
+- **Allocazione skill**: Decisione su come spendere i punti iniziali
+
+
+### Modulo 3: Auto-Mapper (la memoria spaziale)
+
+Una componente critica per la navigazione autonoma.
+
+- **Grafo delle stanze**: Ogni stanza è un nodo, le direzioni (n/s/e/w/u/d) sono gli archi
+- **Room ID detection**: Identificare univocamente ogni stanza (anche se la descrizione è simile, usando esiti dei comandi LOOK + coordinate relative)[^2]
+- **Mapping esplorativo**: Esplorare sistematicamente aree sconosciute, marcare zone già visitate, rilevare loop
+- **Pathfinding**: Calcolo del percorso più breve tra punto A e punto B (utile per tornare da un healer o raggiungere un quest-giver)
+
+
+### Modulo 4: Decision Engine (il cervello strategico)
+
+Il core dell'intelligenza artificiale del giocatore.
+
+- **Goal Manager**: Gestione degli obiettivi a breve e lungo termine (leveling, quest, grinding, looting)
+- **Combat AI**: Decisioni in combattimento (quando attaccare, quando fuggire, quando curarsi, quale mob scegliere in base al livello)
+- **Social AI**: Interazione con altri giocatori (commercio, gruppi, comunicazione)
+- **Economy AI**: Gestione dell'inventario, vendita loot, acquisto equipaggiamento, bilancio del gold
+
+
+### Modulo 5: Quest System (il diary delle quest)
+
+Gestione delle missioni in stile MUD classico.
+
+- **Quest tracker**: Parsing delle descrizioni quest ("trova l'anello nel cimitero", "uccidi 5 goblin")
+- **Obiettivi**: Scomporre la quest in task atomici (naviga verso zona X → trova mob Y → combatti → loota → ritorna)
+- **Prioritizzazione**: Valutare EXP/time ratio delle quest disponibili
+
+
+### Modulo 6: Progression Optimizer (il min-maxer)
+
+La logica di crescita del personaggio.
+
+- **Grinding spot finder**: Identificare zone dove il rapporto EXP/ora è ottimale per il livello corrente
+- **Equip optimizer**: Confrontare gear basato su stat bonus
+- **Skill rotation**: Scegliere quali abilità usare in combattimento in base a mana cost / damage / cooldown
+
+
+## Flusso di gioco simulato
+
+```
+1. CONNESSIONE → Telnet al server, autenticazione
+2. CREAZIONE → Roll stats, scegli classe, setup iniziale
+3. EXPLORING → Inizia mappando la città di partenza, memorizza shop/inn/quest-giver
+4. COMBATTIMENTO → Trova mob appropriato al livello, combatte, accumula EXP
+5. QUESTING → Accetta quest, pianifica esecuzione, completa, riscatta reward
+6. LOOP → Torna a 4-5 fino al level up, poi espandi aree esplorabili
+```
+
+
+## Stack tecnico suggerito
+
+- **Backend**: Python con `asyncio` per la gestione Telnet non-bloccante
+- **Parser**: Regex + NLP (spaCy/LLM) per interpretare le descrizioni
+- **Grafo**: NetworkX per l'auto-mapping
+- **AI Decision**: Può essere una serie di regole (expert system) oppure integrare un LLM locale (Llama via Ollama) per decisioni più "intuitive"
+- **Storage**: SQLite per mappare le stanze e SQLite/JSON per il progresso del personaggio
+
+
+## Sfide interessanti
+
+- **Ambiguità testuali**: I MUD hanno descrizioni in prosa, non dati strutturati. Estrarre "questo è un mob ostile" vs "questo è un NPC friendly" richiede parsing intelligente.
+- **Anti-cheat**: Molti MUD hanno sistemi di rilevamento bot. Il tuo "giocatore simulato" deve mimare comportamenti umani (tempi di reazione variabili, pause, errori di battitura occasionali).
+- **Memoria a lungo termine**: Dopo settimane di gioco, il grafo del mondo diventa enorme. Serve una buona strategia di storage/indexing.
+
+Vuoi approfondire un modulo specifico? Ad esempio, il sistema di auto-mapping è probabilmente il più complesso da implementare in modo robusto. Oppure preferisci discutere l'architettura di decisione (rule-based vs LLM-based)?
+[^10][^11][^12][^13][^14][^15][^4][^5][^6][^7][^8][^9]
+
+
⁂
+
+[^1]: https://www.reddit.com/r/MUD/comments/ke91kh/can_you_play_a_mud_over_just_sshtelnet/
+
+[^2]: https://www.reddit.com/r/MUD/comments/15mt7pu/telnet_protocol/
+
+[^3]: http://www.aardmud.org/creation.html
+
+[^4]: https://en.wikipedia.org/wiki/Multi-user_dungeon
+
+[^5]: https://www.livinginternet.com/d/di_major.htm
+
+[^6]: https://www.landley.net/history/mirror/games/mud-history.html
+
+[^7]: https://www.oreateai.com/blog/the-origin-and-cultural-impact-of-the-mud-game-the-ancestor-of-mmorpgs/dcbad95e5ff1728227e53e52772ade9a
+
+[^8]: https://if50.substack.com/p/1980-mud
+
+[^9]: https://www.reddit.com/r/MUD/comments/1jktmhd/creating_a_mud/
+
+[^10]: https://www.gamedev.net/forums/topic/401912-making-a-telnet-based-game/
+
+[^11]: https://www.reddit.com/r/MUD/comments/ew4gfe/history_of_muds_question/
+
+[^12]: https://www.reddit.com/r/rpg/comments/1s0yxg/best_character_creation_layout_mechanics/
+
+[^13]: https://www.linnaean.org/~lpb/muddex/mudline.html
+
+[^14]: https://www.linnaean.org/~lpb/muddex/essay/
+
+[^15]: https://akurosia.github.io/DevFFXIVPocketGuide/dalamud/
+
diff --git a/memvid.txt b/memvid.txt
new file mode 100644
index 0000000..d29eb14
--- /dev/null
+++ b/memvid.txt
@@ -0,0 +1,117 @@
+in questo vorrei proporre:
+Interessante! Questo approccio esiste già ed è chiamato Memvid - una libreria Python open-source che implementa esattamente il flusso che descrivi. Vediamo se ha senso per il tuo automapper MUD.
+
+C'è già: Memvid
+Memvid è un progetto GitHub (8.1k stars) che codifica chunk di testo in QR code, li salva come frame in un file MP4 e usa un indice FAISS per la ricerca semantica. Il flusso è identico al tuo:
+
+text
+Text → chunk → embed → QR code → Frame MP4 (H.264/H.265)
+Query → embed → FAISS cosine similarity → frame seek → decode QR → return text
+Benefici confermati
+Vantaggio Dettaglio Fonte
+Compressione 50-100x I codec video moderni comprimono pattern ripetitivi (i QR) meglio di qualsiasi algoritmo custom
+
+Sub-100ms retrieval Seek diretto al frame tramite indice, O(1), poi decodifica QR
+Zero infrastruttura Solo Python + MP4, niente cluster DB, niente Docker
+
+Portabilità totale Copia memoria.mp4 su un altro sistema, funziona ovunque
+
+Offline-first Dopo l'encoding, tutto funziona senza internet
+
+Benchmark reali
+100MB di testo → 1-2MB video
+
+
+Ricerca su 1M chunk in <100ms (MacBook Pro 2021)
+
+
+RAM costante: ~500MB indipendentemente dalla dimensione
+
+
+Il problema per il tuo use case
+L'automapper di un MUD ha una caratteristica critica: aggiornamenti continui mentre esplori. E qui Memvid mostra i limiti:
+
+
+Write Amplification
+I file MP4 sono append-only. Se stai esplorando una nuova stanza nel MUD e vuoi aggiungerla alla mappa, non puoi semplicemente "inserire" un frame nel mezzo. Devi:
+
+Decodificare tutto il video esistente
+
+Aggiungere il nuovo chunk
+
+Re-encodare l'intero MP4
+
+Questo è killer per un'esperienza real-time dove ogni nuova stanza dovrebbe essere memorizzata immediatamente.
+
+Sync tra indice e video
+L'indice FAISS è un JSON separato che mappa embedding → numero frame. Se il video viene rigenerato, l'indice deve essere rigenerato. In uno scenario MUD con esplorazione continua, staresti re-encodando ogni volta che muovi il personaggio in una stanza nuova.
+
+Soluzione ibrida suggerita
+Per l'automapper MUD, ti propongo un archivio a due livelli:
+
+Layer 1: Memoria attiva (SQLite/grafico in-memory)
+Durante l'esplorazione: usa SQLite o un grafo NetworkX in RAM
+
+Ogni nuova stanza viene inserita immediatamente
+
+Navigazione, pathfinding, ricerca in tempo reale
+
+Layer 2: Memoria storica (Memvid)
+Periodicamente (alla fine sessione, o ogni X stanze): "congela" lo stato esplorato
+
+Dump del grafo → Memvid MP4
+
+Questo diventa l'archivio storico della mappa del mondo MUD
+
+Per ricerche semantiche ("dove era quell'NPC goblin?"), query su Memvid
+
+Vantaggio di questo ibrido
+Real-time: SQLite per l'esplorazione attiva, nessun lag
+
+Archivio: Memvid per storage storico compresso e portatile
+
+Ricerca semantica: "Trova tutte le stanze con descrizione 'foresta oscura'" usando l'embedding dei testi delle stanze salvate in Memvid
+
+Altra alternativa: "Delta encoding" in arrivo
+Memvid v2 promette delta encoding per aggiornamenti incrementali senza re-encoding totale, ma per ora è in sviluppo. Nel frattempo, il pattern "SQLite attivo + Memvid storico" è il più pragmatico.
+
+
+Conclusione
+Il metodo QR-in-MP4 è valido e funziona (Memvid lo dimostra), ma per un automapper MUD serve un ibrido: grafo attivo in SQLite per l'esplorazione real-time, con periodico "snapshot" in Memvid per archiviazione compressa e ricerca semantica storica. Vuoi che approfondiamo l'architettura di questo ibrido?
+### Modulo 6: Progression Optimizer (il min-maxer)
+
+La logica di crescita del personaggio.
+
+- **Grinding spot finder**: Identificare zone dove il rapporto EXP/ora è ottimale per il livello corrente
+- **Equip optimizer**: Confrontare gear basato su stat bonus
+- **Skill rotation**: Scegliere quali abilità usare in combattimento in base a mana cost / damage / cooldown
+
+
+## Flusso di gioco simulato
+
+```
+1. CONNESSIONE → Telnet al server, autenticazione
+2. CREAZIONE → Roll stats, scegli classe, setup iniziale
+3. EXPLORING → Inizia mappando la città di partenza, memorizza shop/inn/quest-giver
+4. COMBATTIMENTO → Trova mob appropriato al livello, combatte, accumula EXP
+5. QUESTING → Accetta quest, pianifica esecuzione, completa, riscatta reward
+6. LOOP → Torna a 4-5 fino al level up, poi espandi aree esplorabili
+```
+
+
+## Stack tecnico suggerito
+
+- **Backend**: Python con `asyncio` per la gestione Telnet non-bloccante
+- **Parser**: Regex + NLP (spaCy/LLM) per interpretare le descrizioni
+- **Grafo**: NetworkX per l'auto-mapping
+- **AI Decision**: Può essere una serie di regole (expert system) oppure integrare un LLM locale (Llama via Ollama) per decisioni più "intuitive"
+- **Storage**: SQLite per mappare le stanze e SQLite/JSON per il progresso del personaggio
+
+
+## Sfide interessanti
+
+- **Ambiguità testuali**: I MUD hanno descrizioni in prosa, non dati strutturati. Estrarre "questo è un mob ostile" vs "questo è un NPC friendly" richiede parsing intelligente.
+- **Anti-cheat**: Molti MUD hanno sistemi di rilevamento bot. Il tuo "giocatore simulato" deve mimare comportamenti umani (tempi di reazione variabili, pause, errori di battitura occasionali).
+- **Memoria a lungo termine**: Dopo settimane di gioco, il grafo del mondo diventa enorme. Serve una buona strategia di storage/indexing.
+
+Vuoi approfondire un modulo specifico? Ad esempio, il sistema di auto-mapping è probabilmente il più complesso da implementare in modo robusto. Oppure preferisci discutere l'architettura di decisione (rule-based vs LLM-based)?
\ No newline at end of file
diff --git a/src/config/settings.py b/src/config/settings.py
index 62fd177..94432d7 100644
--- a/src/config/settings.py
+++ b/src/config/settings.py
@@ -38,6 +38,8 @@ class LLMProviderConfig(BaseModel):
"You must respond ONLY with the requested format. "
"Be concise and precise. No explanations unless asked."
)
+ memory_db_path: str = "data/llm_memory.db"
+ """Path to SQLite database for LLM long-term memory."""
class AIConfig(BaseModel):
diff --git a/src/main.py b/src/main.py
index bf12e58..7f25f41 100644
--- a/src/main.py
+++ b/src/main.py
@@ -76,7 +76,7 @@ async def main_loop(config):
max_retries=config.ai.llm.max_retries,
system_prompt=config.ai.llm.system_prompt,
)
- llm_brain = LLMBrain(llm_config)
+ llm_brain = LLMBrain(llm_config, memory_db_path=config.ai.llm.memory_db_path)
social_ai = SocialAI(
client=llm_brain.client,
character_name=config.character.name,
@@ -207,14 +207,30 @@ async def main_loop(config):
if llm_brain.is_available:
social_event = social_ai.detect_social_event(block)
if social_event:
+ speaker = social_event["speaker"]
+ message = social_event["message"]
+ event_type = social_event["type"]
logger.info(
"Social: %s from %s: %s",
- social_event["type"],
- social_event["speaker"],
- social_event["message"][:100],
+ event_type,
+ speaker,
+ message[:100],
)
- reply_cmd = await social_ai.respond(social_event)
- if reply_cmd:
+ # Use LLMBrain with memory for contextual replies
+ reply_text = await llm_brain.social_reply(
+ message=message,
+ sender=speaker,
+ character_class=config.character.preferred_class,
+ character_name=config.character.name,
+ )
+ if reply_text:
+ # Format as MUD command
+ if event_type == "tell":
+ reply_cmd = f"tell {speaker} {reply_text}"
+ elif event_type == "shout":
+ reply_cmd = f"shout {reply_text}"
+ else:
+ reply_cmd = f"say {reply_text}"
await human_behavior.delay()
await telnet.send(reply_cmd)
logger.info("Social reply: %s", reply_cmd)
diff --git a/src/modules/llm/__init__.py b/src/modules/llm/__init__.py
index 700d526..6bb0f71 100644
--- a/src/modules/llm/__init__.py
+++ b/src/modules/llm/__init__.py
@@ -12,6 +12,7 @@ All LLM features gracefully degrade when Ollama is unavailable.
from .client import LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES
from .brain import LLMBrain
from .social_ai import SocialAI
+from .memory import LLMMemory, MemoryEntry
__all__ = [
"LLMClient",
@@ -19,6 +20,8 @@ __all__ = [
"LLMResponse",
"LLMBrain",
"SocialAI",
+ "LLMMemory",
+ "MemoryEntry",
"render_template",
"PROMPT_TEMPLATES",
]
diff --git a/src/modules/llm/brain.py b/src/modules/llm/brain.py
index 4605cc1..92fa16c 100644
--- a/src/modules/llm/brain.py
+++ b/src/modules/llm/brain.py
@@ -20,6 +20,7 @@ from dataclasses import dataclass
from typing import Dict, List, Optional, TYPE_CHECKING
from .client import LLMClient, LLMConfig, LLMResponse, render_template
+from .memory import LLMMemory
if TYPE_CHECKING:
from ...core.game_state import GameState
@@ -48,19 +49,25 @@ class LLMBrain:
parsed = await brain.parse_ambiguous("Something strange happens...")
"""
- def __init__(self, config: Optional[LLMConfig] = None) -> None:
+ def __init__(
+ self,
+ config: Optional[LLMConfig] = None,
+ memory_db_path: str = "data/llm_memory.db",
+ ) -> None:
self.client = LLMClient(config)
+ self.memory = LLMMemory(memory_db_path)
self._available = False
self._query_count = 0
self._total_tokens = 0
self._total_latency_ms = 0.0
async def initialize(self) -> bool:
- """Check LLM availability. Returns ``True`` if the server is reachable."""
+ """Check LLM availability and initialize memory. Returns ``True`` if server reachable."""
+ await self.memory.initialize()
self._available = await self.client.is_available()
if self._available:
logger.info(
- "LLMBrain online: model=%s, url=%s",
+ "LLMBrain online: model=%s, url=%s, memory_initialized",
self.client.config.model,
self.client.config.base_url,
)
@@ -111,22 +118,32 @@ class LLMBrain:
async def social_reply(
self,
message: str,
+ sender: str = "unknown",
character_class: str = "adventurer",
character_name: str = "Player",
) -> Optional[str]:
"""Generate an in-character reply to another player's message.
+ Uses conversation history to maintain context.
Returns the reply string, or ``None`` on failure.
"""
if not self._available:
return None
- resp = await self.client.ask(
- "social_reply",
+ # Get context from memory
+ context = await self.memory.build_context_prompt(sender, max_tokens=200)
+
+ # Enhanced prompt template with context
+ prompt = render_template(
+ "social_reply_with_context",
+ context=context,
+ sender=sender,
message=message,
character_class=character_class,
character_name=character_name,
)
+
+ resp = await self.client.generate(prompt)
self._record(resp)
if not resp.success or not resp.text:
@@ -137,6 +154,10 @@ class LLMBrain:
# Limit length for MUD output
if len(reply) > 200:
reply = reply[:197] + "..."
+
+ # Record the conversation
+ await self.memory.add_conversation(sender, message, reply, importance=0.6)
+
return reply
# ------------------------------------------------------------------
diff --git a/src/modules/llm/client.py b/src/modules/llm/client.py
index 1ba9fc6..b541f77 100644
--- a/src/modules/llm/client.py
+++ b/src/modules/llm/client.py
@@ -134,6 +134,15 @@ PROMPT_TEMPLATES: Dict[str, str] = {
"Reply with JSON: {{\"hostile\": true/false, \"confidence\": 0.0-1.0, \"reason\": \"...\"}}\n"
"JSON:"
),
+
+ "social_reply_with_context": (
+ "{context}"
+ "You are {character_name}, a {character_class} in a MUD game. "
+ "Another player ({sender}) said: \"{message}\"\n"
+ "Write a short in-character reply (max 1 sentence). "
+ "Consider your previous interactions shown above. "
+ "Reply:"
+ ),
}
diff --git a/src/modules/llm/memory.py b/src/modules/llm/memory.py
new file mode 100644
index 0000000..cb9ad45
--- /dev/null
+++ b/src/modules/llm/memory.py
@@ -0,0 +1,471 @@
+"""Long-term memory system for LLM context persistence.
+
+Stores conversation history, important events, and player relationships
+across sessions. Integrates with both active (SQLite) and historical
+(Memvid) storage for persistence.
+
+Usage::
+
+ from ..config.settings import get_settings
+ from .memory import LLMMemory
+
+ memory = LLMMemory(settings.ai.llm.memory_db_path)
+ await memory.initialize()
+
+ # Record a conversation
+ await memory.add_conversation("Alice", "Hey, want to group up?", "Sure!")
+
+ # Recall recent context for a prompt
+ context = await memory.get_conversation_context("Alice", limit=5)
+
+ # Record an important event
+ await memory.add_event("gained_level", {"level": 10, "class": "warrior"})
+
+ # Search memories
+ memories = await memory.search_memories("dragon", limit=10)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class MemoryEntry:
+ """A single memory entry."""
+ id: int
+ timestamp: datetime
+ category: str # "conversation", "event", "npc_interaction", "quest"
+ key: str # player name, npc name, event type, etc.
+ data: Dict
+ importance: float # 0.0-1.0, for prioritization
+
+
+class LLMMemory:
+ """Long-term memory manager for LLM context.
+
+ Maintains:
+ - Conversation history with other players
+ - NPC interactions and reputations
+ - Important game events
+ - Quest progress memories
+ """
+
+ def __init__(self, db_path: str = "data/llm_memory.db") -> None:
+ self.db_path = Path(db_path)
+ self._conn: Optional[sqlite3.Connection] = None
+
+ async def initialize(self) -> None:
+ """Initialize the memory database."""
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
+ self._conn.row_factory = sqlite3.Row
+ await self._create_tables()
+ logger.info("LLMMemory initialized: %s", self.db_path)
+
+ async def _create_tables(self) -> None:
+ """Create the memory tables."""
+ self._conn.executescript("""
+ CREATE TABLE IF NOT EXISTS memories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ category TEXT NOT NULL,
+ key TEXT NOT NULL,
+ data TEXT NOT NULL,
+ importance REAL DEFAULT 0.5,
+ summary TEXT
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_memories_category
+ ON memories(category);
+ CREATE INDEX IF NOT EXISTS idx_memories_key
+ ON memories(key);
+ CREATE INDEX IF NOT EXISTS idx_memories_timestamp
+ ON memories(timestamp);
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
+ USING fts5(summary, data, content=memories, content_rowid=id);
+
+ CREATE TABLE IF NOT EXISTS npc_reputations (
+ npc_name TEXT PRIMARY KEY,
+ reputation_score REAL DEFAULT 0.0,
+ interactions_count INTEGER DEFAULT 0,
+ last_interaction TEXT,
+ notes TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS player_relationships (
+ player_name TEXT PRIMARY KEY,
+ relationship_score REAL DEFAULT 0.0,
+ first_met TEXT,
+ last_interaction TEXT,
+ notes TEXT,
+ conversation_count INTEGER DEFAULT 0
+ );
+ """)
+ self._conn.commit()
+
+ async def add_conversation(
+ self,
+ player_name: str,
+ message: str,
+ reply: str,
+ importance: float = 0.5,
+ ) -> None:
+ """Record a conversation with another player."""
+ if not self._conn:
+ return
+
+ now = datetime.utcnow().isoformat()
+ summary = f"Conversation with {player_name}: {message[:50]}..."
+
+ data = {
+ "player": player_name,
+ "message": message,
+ "reply": reply,
+ "direction": "received" if message else "sent",
+ }
+
+ cursor = self._conn.execute(
+ """INSERT INTO memories (timestamp, category, key, data, importance, summary)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (now, "conversation", player_name, json.dumps(data), importance, summary)
+ )
+
+ # Update FTS index
+ self._conn.execute(
+ "INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
+ (cursor.lastrowid, summary, json.dumps(data))
+ )
+
+ # Update player relationship
+ self._conn.execute(
+ """INSERT INTO player_relationships (player_name, last_interaction, conversation_count)
+ VALUES (?, ?, 1)
+ ON CONFLICT(player_name) DO UPDATE SET
+ last_interaction = excluded.last_interaction,
+ conversation_count = conversation_count + 1,
+ first_met = COALESCE(first_met, excluded.last_interaction)""",
+ (player_name, now)
+ )
+
+ self._conn.commit()
+
+ async def get_conversation_context(
+ self,
+ player_name: str,
+ limit: int = 5,
+ ) -> List[Dict]:
+ """Get recent conversation history with a player."""
+ if not self._conn:
+ return []
+
+ cursor = self._conn.execute(
+ """SELECT timestamp, data, importance FROM memories
+ WHERE category = 'conversation' AND key = ?
+ ORDER BY timestamp DESC
+ LIMIT ?""",
+ (player_name, limit)
+ )
+
+ results = []
+ for row in cursor:
+ data = json.loads(row["data"])
+ results.append({
+ "timestamp": row["timestamp"],
+ "message": data.get("message", ""),
+ "reply": data.get("reply", ""),
+ "importance": row["importance"],
+ })
+
+ return list(reversed(results)) # Oldest first
+
+ async def add_npc_interaction(
+ self,
+ npc_name: str,
+ interaction_type: str, # "talk", "combat", "trade", "quest"
+ details: Dict,
+ reputation_delta: float = 0.0,
+ ) -> None:
+ """Record an interaction with an NPC."""
+ if not self._conn:
+ return
+
+ now = datetime.utcnow().isoformat()
+ summary = f"{interaction_type} with {npc_name}: {str(details)[:50]}"
+
+ data = {
+ "npc": npc_name,
+ "type": interaction_type,
+ "details": details,
+ }
+
+ cursor = self._conn.execute(
+ """INSERT INTO memories (timestamp, category, key, data, importance, summary)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (now, "npc_interaction", npc_name, json.dumps(data), 0.6, summary)
+ )
+
+ self._conn.execute(
+ "INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
+ (cursor.lastrowid, summary, json.dumps(data))
+ )
+
+ # Update NPC reputation
+ self._conn.execute(
+ """INSERT INTO npc_reputations (npc_name, reputation_score, interactions_count, last_interaction)
+ VALUES (?, ?, 1, ?)
+ ON CONFLICT(npc_name) DO UPDATE SET
+ reputation_score = reputation_score + ?,
+ interactions_count = interactions_count + 1,
+ last_interaction = excluded.last_interaction""",
+ (npc_name, reputation_delta, now, reputation_delta)
+ )
+
+ self._conn.commit()
+
+ async def get_npc_reputation(self, npc_name: str) -> Optional[Dict]:
+ """Get reputation info for an NPC."""
+ if not self._conn:
+ return None
+
+ cursor = self._conn.execute(
+ """SELECT * FROM npc_reputations WHERE npc_name = ?""",
+ (npc_name,)
+ )
+ row = cursor.fetchone()
+
+ if row:
+ return {
+ "npc_name": row["npc_name"],
+ "reputation_score": row["reputation_score"],
+ "interactions_count": row["interactions_count"],
+ "last_interaction": row["last_interaction"],
+ "notes": row["notes"],
+ }
+ return None
+
+ async def add_event(
+ self,
+ event_type: str,
+ details: Dict,
+ importance: float = 0.7,
+ ) -> None:
+ """Record an important game event."""
+ if not self._conn:
+ return
+
+ now = datetime.utcnow().isoformat()
+ summary = f"{event_type}: {str(details)[:50]}"
+
+ data = {
+ "type": event_type,
+ "details": details,
+ }
+
+ cursor = self._conn.execute(
+ """INSERT INTO memories (timestamp, category, key, data, importance, summary)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (now, "event", event_type, json.dumps(data), importance, summary)
+ )
+
+ self._conn.execute(
+ "INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
+ (cursor.lastrowid, summary, json.dumps(data))
+ )
+
+ self._conn.commit()
+
+ async def search_memories(
+ self,
+ query: str,
+ category: Optional[str] = None,
+ limit: int = 10,
+ ) -> List[MemoryEntry]:
+ """Search memories using full-text search."""
+ if not self._conn:
+ return []
+
+ if category:
+ cursor = self._conn.execute(
+ """SELECT m.* FROM memories m
+ JOIN memories_fts fts ON m.id = fts.rowid
+ WHERE memories_fts MATCH ? AND m.category = ?
+ ORDER BY rank, m.importance DESC
+ LIMIT ?""",
+ (query, category, limit)
+ )
+ else:
+ cursor = self._conn.execute(
+ """SELECT m.* FROM memories m
+ JOIN memories_fts fts ON m.id = fts.rowid
+ WHERE memories_fts MATCH ?
+ ORDER BY rank, m.importance DESC
+ LIMIT ?""",
+ (query, limit)
+ )
+
+ results = []
+ for row in cursor:
+ results.append(MemoryEntry(
+ id=row["id"],
+ timestamp=datetime.fromisoformat(row["timestamp"]),
+ category=row["category"],
+ key=row["key"],
+ data=json.loads(row["data"]),
+ importance=row["importance"],
+ ))
+
+ return results
+
+ async def get_important_memories(
+ self,
+ min_importance: float = 0.7,
+ limit: int = 20,
+ ) -> List[MemoryEntry]:
+ """Get the most important recent memories."""
+ if not self._conn:
+ return []
+
+ cursor = self._conn.execute(
+ """SELECT * FROM memories
+ WHERE importance >= ?
+ ORDER BY timestamp DESC
+ LIMIT ?""",
+ (min_importance, limit)
+ )
+
+ results = []
+ for row in cursor:
+ results.append(MemoryEntry(
+ id=row["id"],
+ timestamp=datetime.fromisoformat(row["timestamp"]),
+ category=row["category"],
+ key=row["key"],
+ data=json.loads(row["data"]),
+ importance=row["importance"],
+ ))
+
+ return results
+
+ async def build_context_prompt(
+ self,
+ current_player: Optional[str] = None,
+ max_tokens: int = 500,
+ ) -> str:
+ """Build a context prompt from recent important memories.
+
+ This creates a summary of relevant memories to prepend to LLM prompts.
+ """
+ memories = await self.get_important_memories(limit=10)
+
+ if current_player:
+ # Add conversation context with current player
+ conversations = await self.get_conversation_context(current_player, limit=3)
+ for conv in conversations:
+ memories.append(MemoryEntry(
+ id=0,
+ timestamp=datetime.fromisoformat(conv["timestamp"]),
+ category="conversation",
+ key=current_player,
+ data=conv,
+ importance=conv.get("importance", 0.5),
+ ))
+
+ # Sort by importance and recency
+ memories.sort(key=lambda m: (m.importance, m.timestamp), reverse=True)
+
+ context_parts = []
+ token_count = 0
+
+ for mem in memories[:10]: # Max 10 memories
+ if mem.category == "conversation":
+ part = f"- Talked with {mem.key}: {mem.data.get('message', '')[:50]}\n"
+ elif mem.category == "event":
+ part = f"- {mem.data.get('type', 'Event')}: {str(mem.data.get('details', {}))[:50]}\n"
+ elif mem.category == "npc_interaction":
+ part = f"- Met {mem.key} ({mem.data.get('type', 'unknown')})\n"
+ else:
+ part = f"- {mem.category}: {mem.key}\n"
+
+ # Rough token estimate: 4 chars ≈ 1 token
+ estimated_tokens = len(part) // 4
+ if token_count + estimated_tokens > max_tokens:
+ break
+
+ context_parts.append(part)
+ token_count += estimated_tokens
+
+ if not context_parts:
+ return ""
+
+ return "Previous memories:\n" + "".join(context_parts) + "\n"
+
+ async def export_to_json(self, filepath: str) -> None:
+ """Export all memories to JSON for backup/synchronization."""
+ if not self._conn:
+ return
+
+ cursor = self._conn.execute("SELECT * FROM memories ORDER BY timestamp")
+ memories = []
+ for row in cursor:
+ memories.append({
+ "id": row["id"],
+ "timestamp": row["timestamp"],
+ "category": row["category"],
+ "key": row["key"],
+ "data": json.loads(row["data"]),
+ "importance": row["importance"],
+ "summary": row["summary"],
+ })
+
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
+ with open(filepath, 'w') as f:
+ json.dump(memories, f, indent=2)
+
+ logger.info("Exported %d memories to %s", len(memories), filepath)
+
+ async def import_from_json(self, filepath: str) -> int:
+ """Import memories from JSON backup."""
+ if not self._conn or not Path(filepath).exists():
+ return 0
+
+ with open(filepath) as f:
+ memories = json.load(f)
+
+ count = 0
+ for mem in memories:
+ try:
+ self._conn.execute(
+ """INSERT OR IGNORE INTO memories
+ (timestamp, category, key, data, importance, summary)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (
+ mem["timestamp"],
+ mem["category"],
+ mem["key"],
+ json.dumps(mem["data"]),
+ mem["importance"],
+ mem.get("summary", ""),
+ )
+ )
+ count += 1
+ except Exception as e:
+ logger.warning("Failed to import memory %s: %s", mem.get("id"), e)
+
+ self._conn.commit()
+ logger.info("Imported %d memories from %s", count, filepath)
+ return count
+
+ async def close(self) -> None:
+ """Close the database connection."""
+ if self._conn:
+ self._conn.close()
+ self._conn = None
diff --git a/tests/modules/llm/test_memory.py b/tests/modules/llm/test_memory.py
new file mode 100644
index 0000000..21d2fc6
--- /dev/null
+++ b/tests/modules/llm/test_memory.py
@@ -0,0 +1,198 @@
+"""Tests for LLMMemory system"""
+
+import pytest
+import asyncio
+from pathlib import Path
+from datetime import datetime
+
+from src.modules.llm.memory import LLMMemory, MemoryEntry
+
+
+@pytest.fixture
+async def memory(tmp_path):
+ """Create a temporary memory instance for testing."""
+ db_path = tmp_path / "test_memory.db"
+ mem = LLMMemory(str(db_path))
+ await mem.initialize()
+ yield mem
+ await mem.close()
+
+
+class TestLLMMemory:
+ """Test suite for LLMMemory."""
+
+ @pytest.mark.asyncio
+ async def test_initialization(self, tmp_path):
+ """Test that memory initializes correctly."""
+ db_path = tmp_path / "test_init.db"
+ mem = LLMMemory(str(db_path))
+ await mem.initialize()
+ assert db_path.exists()
+ await mem.close()
+
+ @pytest.mark.asyncio
+ async def test_add_conversation(self, memory):
+ """Test adding conversations."""
+ await memory.add_conversation(
+ "Alice", "Hey there!", "Hello! How are you?",
+ importance=0.7
+ )
+
+ context = await memory.get_conversation_context("Alice", limit=5)
+ assert len(context) == 1
+ assert context[0]["message"] == "Hey there!"
+ assert context[0]["reply"] == "Hello! How are you?"
+ assert context[0]["importance"] == 0.7
+
+ @pytest.mark.asyncio
+ async def test_multiple_conversations(self, memory):
+ """Test multiple conversations with same player."""
+ await memory.add_conversation("Bob", "Msg 1", "Reply 1")
+ await memory.add_conversation("Bob", "Msg 2", "Reply 2")
+ await memory.add_conversation("Bob", "Msg 3", "Reply 3")
+
+ context = await memory.get_conversation_context("Bob", limit=2)
+ assert len(context) == 2
+ # Should be in chronological order
+ assert context[0]["message"] == "Msg 2"
+ assert context[1]["message"] == "Msg 3"
+
+ @pytest.mark.asyncio
+ async def test_npc_interaction(self, memory):
+ """Test recording NPC interactions."""
+ await memory.add_npc_interaction(
+ "Guard", "talk", {"topic": "directions"}, reputation_delta=0.2
+ )
+
+ rep = await memory.get_npc_reputation("Guard")
+ assert rep is not None
+ assert rep["reputation_score"] == 0.2
+ assert rep["interactions_count"] == 1
+
+ @pytest.mark.asyncio
+ async def test_npc_reputation_accumulation(self, memory):
+ """Test that NPC reputation accumulates."""
+ await memory.add_npc_interaction("Dragon", "combat", {}, reputation_delta=-0.5)
+ await memory.add_npc_interaction("Dragon", "combat", {}, reputation_delta=-0.3)
+
+ rep = await memory.get_npc_reputation("Dragon")
+ assert rep["reputation_score"] == -0.8
+ assert rep["interactions_count"] == 2
+
+ @pytest.mark.asyncio
+ async def test_add_event(self, memory):
+ """Test recording important events."""
+ await memory.add_event(
+ "level_up", {"level": 10, "class": "warrior"}, importance=0.9
+ )
+
+ memories = await memory.get_important_memories(min_importance=0.8)
+ assert len(memories) == 1
+ assert memories[0].key == "level_up"
+ assert memories[0].data["details"]["level"] == 10
+
+ @pytest.mark.asyncio
+ async def test_search_memories(self, memory):
+ """Test full-text search."""
+ await memory.add_conversation("Charlie", "Want to fight the dragon?", "Sure!")
+ await memory.add_conversation("Dave", "Let's explore the forest", "Okay")
+ await memory.add_event("found_item", {"item": "dragon scale"})
+
+ results = await memory.search_memories("dragon")
+ assert len(results) >= 2 # Should find conversation and event
+
+ dragon_conversation = [r for r in results if r.category == "conversation"]
+ assert len(dragon_conversation) == 1
+
+ @pytest.mark.asyncio
+ async def test_search_with_category(self, memory):
+ """Test searching with category filter."""
+ await memory.add_conversation("Eve", "Hello", "Hi")
+ await memory.add_event("combat", {"enemy": "goblin"})
+
+ # Search only events
+ results = await memory.search_memories("goblin", category="event")
+ assert len(results) == 1
+ assert results[0].category == "event"
+
+ @pytest.mark.asyncio
+ async def test_build_context_prompt(self, memory):
+ """Test building context prompt."""
+ await memory.add_conversation("Frank", "Hello!", "Hey!", importance=0.8)
+ await memory.add_event("quest_complete", {"quest": "tutorial"}, importance=0.9)
+
+ context = await memory.build_context_prompt("Frank", max_tokens=500)
+ assert "Previous memories:" in context
+ assert "Frank" in context
+
+ @pytest.mark.asyncio
+ async def test_export_import_json(self, memory, tmp_path):
+ """Test exporting and importing memories."""
+ # Add some data
+ await memory.add_conversation("Grace", "Test", "Reply")
+ await memory.add_event("test_event", {"test": True})
+
+ # Export
+ export_path = tmp_path / "memories_export.json"
+ await memory.export_to_json(str(export_path))
+ assert export_path.exists()
+
+ # Create new memory and import
+ new_mem = LLMMemory(str(tmp_path / "new_memory.db"))
+ await new_mem.initialize()
+ count = await new_mem.import_from_json(str(export_path))
+
+ assert count == 2
+
+ # Verify data
+ context = await new_mem.get_conversation_context("Grace")
+ assert len(context) == 1
+
+ await new_mem.close()
+
+ @pytest.mark.asyncio
+ async def test_player_relationship_tracking(self, memory):
+ """Test that player relationships are tracked."""
+ await memory.add_conversation("Henry", "Hey!", "Hello!")
+ await memory.add_conversation("Henry", "How are you?", "Good!")
+
+ # Check that relationship was created
+ # Note: We don't have a direct getter, but we can infer from conversation count
+ context = await memory.get_conversation_context("Henry")
+ assert len(context) == 2
+
+ @pytest.mark.asyncio
+ async def test_empty_memory_operations(self, memory):
+ """Test operations on empty memory."""
+ context = await memory.get_conversation_context("Nobody", limit=5)
+ assert context == []
+
+ rep = await memory.get_npc_reputation("UnknownNPC")
+ assert rep is None
+
+ results = await memory.search_memories("nonexistent")
+ assert results == []
+
+ prompt = await memory.build_context_prompt("Nobody")
+ assert prompt == ""
+
+
+class TestMemoryEntry:
+ """Test MemoryEntry dataclass."""
+
+ def test_memory_entry_creation(self):
+ """Test creating a MemoryEntry."""
+ entry = MemoryEntry(
+ id=1,
+ timestamp=datetime.now(),
+ category="test",
+ key="test_key",
+ data={"value": 123},
+ importance=0.5
+ )
+
+ assert entry.id == 1
+ assert entry.category == "test"
+ assert entry.key == "test_key"
+ assert entry.data["value"] == 123
+ assert entry.importance == 0.5
diff --git a/tests/test_llm.py b/tests/test_llm.py
index 353c1f3..482a373 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -172,10 +172,11 @@ class TestLLMBrain:
async def test_social_reply_with_mock(self):
brain = LLMBrain()
brain._available = True
- brain.client.ask = AsyncMock(
+ # Mock generate since social_reply now uses it with context
+ brain.client.generate = AsyncMock(
return_value=_make_mock_response("Greetings, fellow adventurer!")
)
- result = await brain.social_reply("Hello!")
+ result = await brain.social_reply("Hello!", sender="TestPlayer")
assert result is not None
assert "adventurer" in result.lower()