From b8e0f4539f503a90ff53b6f2dcfb5d841cfa1e23 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone <8633216+AlexanderWhitestone@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:20:38 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20Discord=20memory=20bug=20=E2=80=94=20add?= =?UTF-8?q?=20session=20continuity=20+=206=20memory=20system=20fixes=20(#1?= =?UTF-8?q?47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord created a new agent per message with no conversation history, causing Timmy to lose context between messages (the "yes" bug). Now uses a singleton agent with per-channel/thread session_id, matching the dashboard's session.py pattern. Also applies _clean_response() to strip hallucinated tool-call JSON from Discord output. Additional fixes: - get_system_context() no longer clears the handoff file (was destroying session context on every agent creation) - Orchestrator uses HotMemory.read() to auto-create MEMORY.md if missing - vector_store DB_PATH anchored to __file__ instead of relative CWD - brain/schema.py: removed invalid .load dot-commands from INIT_SQL - tools_intro: fixed wrong table name 'vectors' → 'chunks' in tier3 check Co-authored-by: Trip T Co-authored-by: Claude Opus 4.6 --- src/brain/schema.py | 6 +-- .../chat_bridge/vendors/discord.py | 40 ++++++++++++++++--- src/timmy/agents/timmy.py | 9 ++--- src/timmy/memory/vector_store.py | 2 +- src/timmy/memory_system.py | 24 ++++++++++- src/timmy/tools_intro/__init__.py | 4 +- tests/timmy/test_agents_timmy.py | 13 +++++- 7 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/brain/schema.py b/src/brain/schema.py index 8175dff..d504ae9 100644 --- a/src/brain/schema.py +++ b/src/brain/schema.py @@ -7,9 +7,9 @@ SQL to initialize rqlite with memories and tasks tables. SCHEMA_VERSION = 1 INIT_SQL = """ --- Enable SQLite extensions -.load vector0 -.load vec0 +-- Note: sqlite-vec extensions must be loaded programmatically +-- via conn.load_extension("vector0") / conn.load_extension("vec0") +-- before executing this schema. Dot-commands are CLI-only. -- Memories table with vector search CREATE TABLE IF NOT EXISTS memories ( diff --git a/src/integrations/chat_bridge/vendors/discord.py b/src/integrations/chat_bridge/vendors/discord.py index 54059b9..de155d7 100644 --- a/src/integrations/chat_bridge/vendors/discord.py +++ b/src/integrations/chat_bridge/vendors/discord.py @@ -32,6 +32,25 @@ logger = logging.getLogger(__name__) _STATE_FILE = Path(__file__).parent.parent.parent.parent / "discord_state.json" +# Module-level agent singleton — reused across all Discord messages. +# Mirrors the pattern from timmy.session._agent. +_discord_agent = None + + +def _get_discord_agent(): + """Lazy-initialize the Discord agent singleton.""" + global _discord_agent + if _discord_agent is None: + from timmy.agent import create_timmy + + try: + _discord_agent = create_timmy() + logger.info("Discord: Timmy agent initialized (singleton)") + except Exception as exc: + logger.error("Discord: Failed to create Timmy agent: %s", exc) + raise + return _discord_agent + class DiscordVendor(ChatPlatform): """Discord integration with native thread conversations. @@ -330,17 +349,28 @@ class DiscordVendor(ChatPlatform): thread = await self._get_or_create_thread(message) target = thread or message.channel - # Run Timmy agent - try: - from timmy.agent import create_timmy + # Derive session_id for per-conversation history via Agno's SQLite + if thread: + session_id = f"discord_{thread.id}" + else: + session_id = f"discord_{message.channel.id}" - agent = create_timmy() - run = await asyncio.to_thread(agent.run, content, stream=False) + # Run Timmy agent (singleton, with session continuity) + try: + agent = _get_discord_agent() + run = await asyncio.to_thread( + agent.run, content, stream=False, session_id=session_id + ) response = run.content if hasattr(run, "content") else str(run) except Exception as exc: logger.error("Timmy error in Discord handler: %s", exc) response = f"Timmy is offline: {exc}" + # Strip hallucinated tool-call JSON and chain-of-thought narration + from timmy.session import _clean_response + + response = _clean_response(response) + # Discord has a 2000 character limit for chunk in _chunk_message(response, 2000): await target.send(chunk) diff --git a/src/timmy/agents/timmy.py b/src/timmy/agents/timmy.py index 9ef2de2..2189a78 100644 --- a/src/timmy/agents/timmy.py +++ b/src/timmy/agents/timmy.py @@ -82,13 +82,10 @@ def build_timmy_context_sync() -> dict[str, Any]: logger.warning("Could not load agents for context: %s", exc) ctx["agents"] = [] - # 3. Read hot memory + # 3. Read hot memory (via HotMemory to auto-create if missing) try: - memory_path = Path(settings.repo_root) / "MEMORY.md" - if memory_path.exists(): - ctx["memory"] = memory_path.read_text()[:2000] # First 2000 chars - else: - ctx["memory"] = "(MEMORY.md not found)" + from timmy.memory_system import memory_system + ctx["memory"] = memory_system.hot.read()[:2000] except Exception as exc: logger.warning("Could not load memory for context: %s", exc) ctx["memory"] = "(Memory unavailable)" diff --git a/src/timmy/memory/vector_store.py b/src/timmy/memory/vector_store.py index dbcadfb..43562ad 100644 --- a/src/timmy/memory/vector_store.py +++ b/src/timmy/memory/vector_store.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -DB_PATH = Path("data/swarm.db") +DB_PATH = Path(__file__).parent.parent.parent.parent / "data" / "swarm.db" # Simple embedding function using sentence-transformers if available, # otherwise fall back to keyword-based "pseudo-embeddings" diff --git a/src/timmy/memory_system.py b/src/timmy/memory_system.py index 9cfe029..bbc7bff 100644 --- a/src/timmy/memory_system.py +++ b/src/timmy/memory_system.py @@ -431,8 +431,28 @@ class MemorySystem: return "\n".join(summary_parts) if summary_parts else "" def get_system_context(self) -> str: - """Get full context for system prompt injection.""" - return self.start_session() + """Get full context for system prompt injection. + + Unlike start_session(), this does NOT clear the handoff. + Safe to call multiple times without data loss. + """ + context_parts = [] + + # 1. Hot memory + hot_content = self.hot.read() + context_parts.append("## Hot Memory\n" + hot_content) + + # 2. Last session handoff (read-only, do NOT clear) + handoff_content = self.handoff.read_handoff() + if handoff_content: + context_parts.append("## Previous Session\n" + handoff_content) + + # 3. User profile (key fields only) + profile = self._load_user_profile_summary() + if profile: + context_parts.append("## User Context\n" + profile) + + return "\n\n---\n\n".join(context_parts) # Module-level singleton diff --git a/src/timmy/tools_intro/__init__.py b/src/timmy/tools_intro/__init__.py index 19e511d..07ec012 100644 --- a/src/timmy/tools_intro/__init__.py +++ b/src/timmy/tools_intro/__init__.py @@ -164,10 +164,10 @@ def get_memory_status() -> dict[str, Any]: if sem_db.exists(): conn = sqlite3.connect(str(sem_db)) row = conn.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vectors'" + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='chunks'" ).fetchone() if row and row[0]: - count = conn.execute("SELECT COUNT(*) FROM vectors").fetchone() + count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone() tier3_info["available"] = True tier3_info["vector_count"] = count[0] if count else 0 conn.close() diff --git a/tests/timmy/test_agents_timmy.py b/tests/timmy/test_agents_timmy.py index 5f63877..ffae023 100644 --- a/tests/timmy/test_agents_timmy.py +++ b/tests/timmy/test_agents_timmy.py @@ -63,8 +63,17 @@ class TestBuildContext: memory_file.write_text("# Important memories\nRemember this.") mock_settings.repo_root = str(tmp_path) - ctx = build_timmy_context_sync() - assert "Important memories" in ctx["memory"] + # Patch HotMemory path so it reads from tmp_path + from timmy.memory_system import memory_system + original_path = memory_system.hot.path + memory_system.hot.path = memory_file + memory_system.hot._content = None # Clear cache + try: + ctx = build_timmy_context_sync() + assert "Important memories" in ctx["memory"] + finally: + memory_system.hot.path = original_path + memory_system.hot._content = None class TestFormatPrompt: