Files
Timmy-time-dashboard/src/brain/memory.py
Trip T b2f12ca97c
All checks were successful
Tests / lint (pull_request) Successful in 3s
Tests / test (pull_request) Successful in 30s
feat: consolidate memory into unified memory.db with 4-type model
Consolidates 3 separate memory databases (semantic_memory.db, swarm.db
memory_entries, brain.db) into a single data/memory.db with facts,
chunks, and episodes tables.

Key changes:
- Add unified schema (timmy/memory/unified.py) with 3 core tables
- Redirect vector_store.py and semantic_memory.py to memory.db
- Add thought distillation: every Nth thought extracts lasting facts
- Enrich agent context with known facts in system prompt
- Add memory_forget tool for removing outdated memories
- Unify embeddings: vector_store delegates to semantic_memory.embed_text
- Bridge spark events to unified event log
- Add pruning for thoughts and events with configurable retention
- Add data migration script (timmy/memory_migrate.py)
- Deprecate brain.memory in favor of unified system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:23:18 -04:00

678 lines
24 KiB
Python

"""Unified memory interface (DEPRECATED).
New code should use ``timmy.memory.unified`` and the tools in
``timmy.semantic_memory`` (memory_write, memory_read, memory_search,
memory_forget). This module is retained for backward compatibility
and the loop-QA self-test probes.
One API, two backends:
- **Local SQLite** (default) — works immediately, no setup
- **Distributed rqlite** — same API, replicated across Tailscale devices
Every module that needs to store or recall memory uses this interface.
Usage:
from brain.memory import UnifiedMemory
memory = UnifiedMemory() # auto-detects backend
# Store
await memory.remember("User prefers dark mode", tags=["preference"])
# Recall
results = await memory.recall("what does the user prefer?")
# Facts
await memory.store_fact("user_preference", "Prefers dark mode")
facts = await memory.get_facts("user_preference")
# Context for prompt
context = await memory.get_context("current user question")
"""
from __future__ import annotations
import json
import logging
import sqlite3
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Default paths
_PROJECT_ROOT = Path(__file__).parent.parent.parent
_DEFAULT_DB_PATH = _PROJECT_ROOT / "data" / "brain.db"
# Schema version for migrations
_SCHEMA_VERSION = 1
def _get_db_path() -> Path:
"""Get the brain database path from env or default."""
from config import settings
if settings.brain_db_path:
return Path(settings.brain_db_path)
return _DEFAULT_DB_PATH
class UnifiedMemory:
"""Unified memory interface.
Provides a single API for all memory operations. Defaults to local
SQLite. When rqlite is available (detected via RQLITE_URL env var),
delegates to BrainClient for distributed operation.
"""
def __init__(
self,
db_path: Path | None = None,
source: str = "default",
use_rqlite: bool | None = None,
):
self.db_path = db_path or _get_db_path()
self.source = source
self._embedder = None
self._rqlite_client = None
# Auto-detect: use rqlite if RQLITE_URL is set, otherwise local SQLite
if use_rqlite is None:
from config import settings as _settings
use_rqlite = bool(_settings.rqlite_url)
self._use_rqlite = use_rqlite
if not self._use_rqlite:
self._init_local_db()
# ──────────────────────────────────────────────────────────────────────
# Local SQLite Setup
# ──────────────────────────────────────────────────────────────────────
def _init_local_db(self) -> None:
"""Initialize local SQLite database with schema."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.db_path))
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.executescript(_LOCAL_SCHEMA)
conn.commit()
logger.info("Brain local DB initialized at %s (WAL mode)", self.db_path)
finally:
conn.close()
def _get_conn(self) -> sqlite3.Connection:
"""Get a SQLite connection with WAL mode and busy timeout."""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _get_embedder(self):
"""Lazy-load the embedding model."""
if self._embedder is None:
from config import settings as _settings
if _settings.timmy_skip_embeddings:
return None
try:
from brain.embeddings import LocalEmbedder
self._embedder = LocalEmbedder()
except ImportError:
logger.warning("sentence-transformers not available — semantic search disabled")
self._embedder = None
return self._embedder
# ──────────────────────────────────────────────────────────────────────
# rqlite Delegation
# ──────────────────────────────────────────────────────────────────────
def _get_rqlite_client(self):
"""Lazy-load the rqlite BrainClient."""
if self._rqlite_client is None:
from brain.client import BrainClient
self._rqlite_client = BrainClient()
return self._rqlite_client
# ──────────────────────────────────────────────────────────────────────
# Core Memory Operations
# ──────────────────────────────────────────────────────────────────────
async def remember(
self,
content: str,
tags: list[str] | None = None,
source: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Store a memory.
Args:
content: Text content to remember.
tags: Optional list of tags for categorization.
source: Source identifier (defaults to self.source).
metadata: Additional JSON-serializable metadata.
Returns:
Dict with 'id' and 'status'.
"""
if self._use_rqlite:
client = self._get_rqlite_client()
return await client.remember(content, tags, source or self.source, metadata)
return self.remember_sync(content, tags, source, metadata)
def remember_sync(
self,
content: str,
tags: list[str] | None = None,
source: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Store a memory (synchronous, local SQLite only).
Args:
content: Text content to remember.
tags: Optional list of tags.
source: Source identifier.
metadata: Additional metadata.
Returns:
Dict with 'id' and 'status'.
"""
now = datetime.now(UTC).isoformat()
embedding_bytes = None
embedder = self._get_embedder()
if embedder is not None:
try:
embedding_bytes = embedder.encode_single(content)
except Exception as e:
logger.warning("Embedding failed, storing without vector: %s", e)
conn = self._get_conn()
try:
cursor = conn.execute(
"""INSERT INTO memories (content, embedding, source, tags, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(
content,
embedding_bytes,
source or self.source,
json.dumps(tags or []),
json.dumps(metadata or {}),
now,
),
)
conn.commit()
memory_id = cursor.lastrowid
logger.debug("Stored memory %s: %s", memory_id, content[:50])
return {"id": memory_id, "status": "stored"}
finally:
conn.close()
async def recall(
self,
query: str,
limit: int = 5,
sources: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Semantic search for memories.
If embeddings are available, uses cosine similarity.
Falls back to keyword search if no embedder.
Args:
query: Search query text.
limit: Max results to return.
sources: Filter by source(s).
Returns:
List of memory dicts with 'content', 'source', 'score'.
"""
if self._use_rqlite:
client = self._get_rqlite_client()
return await client.recall(query, limit, sources)
return self.recall_sync(query, limit, sources)
def recall_sync(
self,
query: str,
limit: int = 5,
sources: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Semantic search (synchronous, local SQLite).
Uses numpy dot product for cosine similarity when embeddings
are available. Falls back to LIKE-based keyword search.
"""
embedder = self._get_embedder()
if embedder is not None:
return self._recall_semantic(query, limit, sources, embedder)
return self._recall_keyword(query, limit, sources)
def _recall_semantic(
self,
query: str,
limit: int,
sources: list[str] | None,
embedder,
) -> list[dict[str, Any]]:
"""Vector similarity search over local SQLite."""
import numpy as np
try:
query_vec = embedder.encode(query)
if len(query_vec.shape) > 1:
query_vec = query_vec[0]
except Exception as e:
logger.warning("Query embedding failed, falling back to keyword: %s", e)
return self._recall_keyword(query, limit, sources)
conn = self._get_conn()
try:
sql = "SELECT id, content, embedding, source, tags, metadata, created_at FROM memories WHERE embedding IS NOT NULL"
params: list = []
if sources:
placeholders = ",".join(["?"] * len(sources))
sql += f" AND source IN ({placeholders})"
params.extend(sources)
rows = conn.execute(sql, params).fetchall()
# Compute similarities
scored = []
for row in rows:
try:
stored_vec = np.frombuffer(row["embedding"], dtype=np.float32)
score = float(np.dot(query_vec, stored_vec))
scored.append((score, row))
except Exception:
continue
# Sort by similarity (highest first)
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, row in scored[:limit]:
results.append(
{
"id": row["id"],
"content": row["content"],
"source": row["source"],
"tags": json.loads(row["tags"]) if row["tags"] else [],
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
"score": score,
"created_at": row["created_at"],
}
)
return results
finally:
conn.close()
def _recall_keyword(
self,
query: str,
limit: int,
sources: list[str] | None,
) -> list[dict[str, Any]]:
"""Keyword-based fallback search."""
conn = self._get_conn()
try:
sql = "SELECT id, content, source, tags, metadata, created_at FROM memories WHERE content LIKE ?"
params: list = [f"%{query}%"]
if sources:
placeholders = ",".join(["?"] * len(sources))
sql += f" AND source IN ({placeholders})"
params.extend(sources)
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
return [
{
"id": row["id"],
"content": row["content"],
"source": row["source"],
"tags": json.loads(row["tags"]) if row["tags"] else [],
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
"score": 0.5, # Keyword match gets a neutral score
"created_at": row["created_at"],
}
for row in rows
]
finally:
conn.close()
# ──────────────────────────────────────────────────────────────────────
# Fact Storage (Long-Term Memory)
# ──────────────────────────────────────────────────────────────────────
async def store_fact(
self,
category: str,
content: str,
confidence: float = 0.8,
source: str = "extracted",
) -> dict[str, Any]:
"""Store a long-term fact.
Args:
category: Fact category (user_preference, user_fact, learned_pattern).
content: The fact text.
confidence: Confidence score 0.0-1.0.
source: Where this fact came from.
Returns:
Dict with 'id' and 'status'.
"""
return self.store_fact_sync(category, content, confidence, source)
def store_fact_sync(
self,
category: str,
content: str,
confidence: float = 0.8,
source: str = "extracted",
) -> dict[str, Any]:
"""Store a long-term fact (synchronous)."""
fact_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
conn = self._get_conn()
try:
conn.execute(
"""INSERT INTO facts (id, category, content, confidence, source, created_at, last_accessed, access_count)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
(fact_id, category, content, confidence, source, now, now),
)
conn.commit()
logger.debug("Stored fact [%s]: %s", category, content[:50])
return {"id": fact_id, "status": "stored"}
finally:
conn.close()
async def get_facts(
self,
category: str | None = None,
query: str | None = None,
limit: int = 10,
) -> list[dict[str, Any]]:
"""Retrieve facts from long-term memory.
Args:
category: Filter by category.
query: Keyword search within facts.
limit: Max results.
Returns:
List of fact dicts.
"""
return self.get_facts_sync(category, query, limit)
def get_facts_sync(
self,
category: str | None = None,
query: str | None = None,
limit: int = 10,
) -> list[dict[str, Any]]:
"""Retrieve facts (synchronous)."""
conn = self._get_conn()
try:
conditions = []
params: list = []
if category:
conditions.append("category = ?")
params.append(category)
if query:
conditions.append("content LIKE ?")
params.append(f"%{query}%")
where = " AND ".join(conditions) if conditions else "1=1"
sql = f"""SELECT id, category, content, confidence, source, created_at, last_accessed, access_count
FROM facts WHERE {where}
ORDER BY confidence DESC, last_accessed DESC
LIMIT ?"""
params.append(limit)
rows = conn.execute(sql, params).fetchall()
# Update access counts
for row in rows:
conn.execute(
"UPDATE facts SET access_count = access_count + 1, last_accessed = ? WHERE id = ?",
(datetime.now(UTC).isoformat(), row["id"]),
)
conn.commit()
return [
{
"id": row["id"],
"category": row["category"],
"content": row["content"],
"confidence": row["confidence"],
"source": row["source"],
"created_at": row["created_at"],
"access_count": row["access_count"],
}
for row in rows
]
finally:
conn.close()
# ──────────────────────────────────────────────────────────────────────
# Recent Memories
# ──────────────────────────────────────────────────────────────────────
async def get_recent(
self,
hours: int = 24,
limit: int = 20,
sources: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Get recent memories by time."""
if self._use_rqlite:
client = self._get_rqlite_client()
return await client.get_recent(hours, limit, sources)
return self.get_recent_sync(hours, limit, sources)
def get_recent_sync(
self,
hours: int = 24,
limit: int = 20,
sources: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Get recent memories (synchronous)."""
conn = self._get_conn()
try:
sql = """SELECT id, content, source, tags, metadata, created_at
FROM memories
WHERE created_at > datetime('now', ?)"""
params: list = [f"-{hours} hours"]
if sources:
placeholders = ",".join(["?"] * len(sources))
sql += f" AND source IN ({placeholders})"
params.extend(sources)
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
return [
{
"id": row["id"],
"content": row["content"],
"source": row["source"],
"tags": json.loads(row["tags"]) if row["tags"] else [],
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
"created_at": row["created_at"],
}
for row in rows
]
finally:
conn.close()
# ──────────────────────────────────────────────────────────────────────
# Identity
# ──────────────────────────────────────────────────────────────────────
def get_identity(self) -> str:
"""Return empty string — identity system removed."""
return ""
def get_identity_for_prompt(self) -> str:
"""Return empty string — identity system removed."""
return ""
# ──────────────────────────────────────────────────────────────────────
# Context Building
# ──────────────────────────────────────────────────────────────────────
async def get_context(self, query: str) -> str:
"""Build formatted context for system prompt.
Combines identity + recent memories + relevant memories.
Args:
query: Current user query for relevance matching.
Returns:
Formatted context string for prompt injection.
"""
parts = []
# Recent activity
recent = await self.get_recent(hours=24, limit=5)
if recent:
lines = ["## Recent Activity"]
for m in recent:
lines.append(f"- {m['content'][:100]}")
parts.append("\n".join(lines))
# Relevant memories
relevant = await self.recall(query, limit=5)
if relevant:
lines = ["## Relevant Memories"]
for r in relevant:
score = r.get("score", 0)
lines.append(f"- [{score:.2f}] {r['content'][:100]}")
parts.append("\n".join(lines))
return "\n\n---\n\n".join(parts)
# ──────────────────────────────────────────────────────────────────────
# Stats
# ──────────────────────────────────────────────────────────────────────
def get_stats(self) -> dict[str, Any]:
"""Get memory statistics.
Returns:
Dict with memory_count, fact_count, db_size_bytes, etc.
"""
conn = self._get_conn()
try:
memory_count = conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
fact_count = conn.execute("SELECT COUNT(*) FROM facts").fetchone()[0]
embedded_count = conn.execute(
"SELECT COUNT(*) FROM memories WHERE embedding IS NOT NULL"
).fetchone()[0]
db_size = self.db_path.stat().st_size if self.db_path.exists() else 0
return {
"memory_count": memory_count,
"fact_count": fact_count,
"embedded_count": embedded_count,
"db_size_bytes": db_size,
"backend": "rqlite" if self._use_rqlite else "local_sqlite",
"db_path": str(self.db_path),
}
finally:
conn.close()
# ──────────────────────────────────────────────────────────────────────────
# Module-level convenience
# ──────────────────────────────────────────────────────────────────────────
_default_memory: UnifiedMemory | None = None
def get_memory(source: str = "agent") -> UnifiedMemory:
"""Get the singleton UnifiedMemory instance.
Args:
source: Source identifier for this caller.
Returns:
UnifiedMemory instance.
"""
global _default_memory
if _default_memory is None:
_default_memory = UnifiedMemory(source=source)
return _default_memory
# ──────────────────────────────────────────────────────────────────────────
# Local SQLite Schema
# ──────────────────────────────────────────────────────────────────────────
_LOCAL_SCHEMA = """
-- Unified memory table (replaces vector_store, semantic_memory, etc.)
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
embedding BLOB,
source TEXT DEFAULT 'agent',
tags TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
created_at TEXT NOT NULL
);
-- Long-term facts (replaces memory_layers LongTermMemory)
CREATE TABLE IF NOT EXISTS facts (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
content TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
source TEXT DEFAULT 'extracted',
created_at TEXT NOT NULL,
last_accessed TEXT NOT NULL,
access_count INTEGER DEFAULT 0
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence);
-- Schema version
CREATE TABLE IF NOT EXISTS brain_schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT
);
INSERT OR REPLACE INTO brain_schema_version (version, applied_at)
VALUES (1, datetime('now'));
"""