forked from Rockachopa/Timmy-time-dashboard
fix: Discord memory bug — add session continuity + 6 memory system fixes (#147)
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 <trip@local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
4bc53a43f9
commit
b8e0f4539f
@@ -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 (
|
||||
|
||||
40
src/integrations/chat_bridge/vendors/discord.py
vendored
40
src/integrations/chat_bridge/vendors/discord.py
vendored
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user