Files

603 lines
21 KiB
Python

"""SQLite backend plugin implementation with namespace, key, tags, and TTL support."""
import json
import sqlite3
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..plugin_interface import Capability
class SQLiteBackendPlugin:
"""SQLite backend plugin implementation.
Supports:
- Namespace-scoped memory storage
- Key-based exact retrieval and wildcard matching
- Tags for filtering and categorization
- TTL (time-to-live) with ISO date expiry
- Append mode for key-based content concatenation
- Version history per key
- Export/import
"""
# Sentinel for in-memory database (useful for testing)
IN_MEMORY = ":memory:"
def __init__(self, db_path=None):
"""Initialize the SQLite backend plugin.
Args:
db_path: Path to SQLite DB file, or ":memory:" for in-memory.
Defaults to ~/.hanzo/memory.db.
"""
if db_path == self.IN_MEMORY:
self._db_path = self.IN_MEMORY
else:
self._db_path = db_path or Path.home() / ".hanzo" / "memory.db"
self._client = None
self._conn: Optional[sqlite3.Connection] = None
self._initialized = False
@property
def name(self) -> str:
return "sqlite"
@property
def capabilities(self) -> List[Capability]:
return [
Capability.PERSISTENCE,
Capability.EMBEDDINGS,
Capability.MARKDOWN_IMPORT,
Capability.STRUCTURED_QUERY,
Capability.VECTOR_SEARCH,
]
async def initialize(self) -> None:
"""Initialize the backend."""
if not self._initialized:
if self._db_path == self.IN_MEMORY:
# Pure in-memory — skip hanzo_memory client, use direct sqlite3
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
self._conn.row_factory = sqlite3.Row
else:
# Use hanzo_memory SQLiteMemoryClient if available
try:
from hanzo_memory.db.sqlite_client import SQLiteMemoryClient
self._client = SQLiteMemoryClient(db_path=self._db_path)
self._conn = self._client.conn
except Exception:
db_str = str(self._db_path)
self._conn = sqlite3.connect(db_str, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._ensure_schema()
self._initialized = True
def _ensure_schema(self):
"""Ensure the memories table has all required columns."""
conn = self._conn
if not conn:
return
# Create the memories table if it doesn't exist (standalone mode)
conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
memory_id TEXT UNIQUE NOT NULL,
project_id TEXT NOT NULL DEFAULT 'default',
user_id TEXT NOT NULL DEFAULT 'default',
content TEXT NOT NULL,
memory_type TEXT DEFAULT 'general',
importance REAL DEFAULT 0.5,
context TEXT,
metadata TEXT,
source TEXT,
embedding BLOB,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Add namespace column if missing
self._add_column_if_missing("memories", "namespace", "TEXT DEFAULT 'default'")
# Add key column if missing
self._add_column_if_missing("memories", "key", "TEXT")
# Add tags column (JSON array as TEXT)
self._add_column_if_missing("memories", "tags", "TEXT")
# Add ttl column (ISO date string)
self._add_column_if_missing("memories", "ttl", "TEXT")
# Create indexes for new columns
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_memories_ns_key ON memories(namespace, key);"
)
conn.commit()
def _add_column_if_missing(self, table: str, column: str, col_type: str):
"""Add a column to a table if it doesn't already exist."""
conn = self._conn
if not conn:
return
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
conn.commit()
except sqlite3.OperationalError:
# Column already exists
pass
async def shutdown(self) -> None:
"""Shutdown the backend."""
if self._client:
self._client.close()
self._client = None
elif self._conn:
self._conn.close()
self._conn = None
self._initialized = False
# ------------------------------------------------------------------ #
# Core CRUD (backward-compatible signatures + new params)
# ------------------------------------------------------------------ #
async def store_memory(
self,
content: str,
metadata: Dict[str, Any],
user_id: str = "default",
project_id: str = "default",
namespace: str = "default",
key: Optional[str] = None,
tags: Optional[List[str]] = None,
ttl: Optional[str] = None,
append: bool = False,
) -> str:
"""Store a memory and return its ID."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
# Inject namespace/key/tags into metadata for backward compat
metadata = dict(metadata)
metadata["namespace"] = namespace
if key:
metadata["key"] = key
if tags:
metadata["tags"] = tags
if ttl:
metadata["ttl"] = ttl
# Handle append mode: find existing entry with same key+namespace and concatenate
if append and key:
existing = self._find_by_key(key, namespace)
if existing:
new_content = existing["content"] + content
self._conn.execute(
"UPDATE memories SET content = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE memory_id = ?",
(new_content, json.dumps(metadata), existing["memory_id"]),
)
self._conn.commit()
return existing["memory_id"]
memory_id = str(uuid.uuid4())
tags_json = json.dumps(tags or [])
metadata_json = json.dumps(metadata)
self._conn.execute(
"""
INSERT INTO memories
(id, memory_id, user_id, project_id, content, importance, context,
metadata, source, embedding, namespace, key, tags, ttl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
memory_id,
user_id,
project_id,
content,
metadata.get("importance", 0.5),
json.dumps({}),
metadata_json,
metadata.get("source", ""),
None, # embedding
namespace,
key,
tags_json,
ttl,
),
)
self._conn.commit()
return memory_id
async def retrieve_memory(
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
namespace: Optional[str] = None,
metadata_filter: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Retrieve memories based on query."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
where_parts = ["user_id = ?", "project_id = ?"]
params: list = [user_id, project_id]
if namespace:
where_parts.append("namespace = ?")
params.append(namespace)
if metadata_filter:
ns = metadata_filter.get("namespace")
if ns:
where_parts.append("namespace = ?")
params.append(ns)
agent = metadata_filter.get("agent")
if agent:
where_parts.append("json_extract(metadata, '$.agent') = ?")
params.append(agent)
mtype = metadata_filter.get("type")
if mtype:
where_parts.append("json_extract(metadata, '$.type') = ?")
params.append(mtype)
# Content search via LIKE if query provided
if query:
where_parts.append("content LIKE ?")
params.append(f"%{query}%")
# Filter out expired TTL entries
where_parts.append("(ttl IS NULL OR ttl = '' OR ttl > ?)")
params.append(datetime.now(timezone.utc).isoformat())
where_clause = " AND ".join(where_parts)
params.append(limit)
cursor = self._conn.execute(
f"SELECT * FROM memories WHERE {where_clause} ORDER BY created_at DESC LIMIT ?",
params,
)
return [self._row_to_dict(row) for row in cursor.fetchall()]
async def search_memory(
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
namespace: Optional[str] = None,
metadata_filter: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Search memories based on query with scoring."""
return await self.retrieve_memory(
query, user_id, project_id, limit, namespace, metadata_filter
)
async def delete_memory(
self,
memory_id: str,
user_id: str = "default",
project_id: str = "default",
) -> bool:
"""Delete a memory by ID."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
cursor = self._conn.execute(
"DELETE FROM memories WHERE memory_id = ?",
(memory_id,),
)
self._conn.commit()
return cursor.rowcount > 0
# ------------------------------------------------------------------ #
# New operations for TypeScript parity
# ------------------------------------------------------------------ #
async def get_by_key(
self,
key: str,
namespace: str = "default",
):
"""Get memory by exact key or wildcard within namespace.
If key contains '*', returns a list of matches.
Otherwise returns a single dict or None.
"""
if not self._conn:
raise RuntimeError("Plugin not initialized")
now = datetime.now(timezone.utc).isoformat()
if "*" in key:
# Wildcard: convert glob to SQL LIKE
like_pattern = key.replace("*", "%")
cursor = self._conn.execute(
"""SELECT * FROM memories
WHERE key LIKE ? AND namespace = ?
AND (ttl IS NULL OR ttl = '' OR ttl > ?)
ORDER BY created_at DESC""",
(like_pattern, namespace, now),
)
rows = cursor.fetchall()
return [self._row_to_dict(r) for r in rows] if rows else []
else:
row_dict = self._find_by_key(key, namespace)
if row_dict and self._is_expired(row_dict):
return None
return row_dict
async def list_memories(
self,
namespace: Optional[str] = None,
tag: Optional[str] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""List all memories with optional filters."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
where_parts: list = []
params: list = []
if namespace:
where_parts.append("namespace = ?")
params.append(namespace)
if tag:
# tags is a JSON array stored as TEXT; use json_each to filter
where_parts.append(
"EXISTS (SELECT 1 FROM json_each(tags) WHERE json_each.value = ?)"
)
params.append(tag)
# Filter expired
now = datetime.now(timezone.utc).isoformat()
where_parts.append("(ttl IS NULL OR ttl = '' OR ttl > ?)")
params.append(now)
where_clause = " AND ".join(where_parts) if where_parts else "1=1"
params.append(limit)
cursor = self._conn.execute(
f"SELECT * FROM memories WHERE {where_clause} ORDER BY created_at DESC LIMIT ?",
params,
)
return [self._row_to_dict(row) for row in cursor.fetchall()]
async def namespaces(self) -> Dict[str, int]:
"""Return all namespaces with their memory counts (excludes expired)."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
now = datetime.now(timezone.utc).isoformat()
cursor = self._conn.execute(
"""SELECT namespace, COUNT(*) as cnt FROM memories
WHERE (ttl IS NULL OR ttl = '' OR ttl > ?)
GROUP BY namespace""",
(now,),
)
return {row[0] or "default": row[1] for row in cursor.fetchall()}
async def stats(self) -> Dict[str, Any]:
"""Return count, namespaces, size info."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
ns = await self.namespaces()
total = sum(ns.values())
return {
"count": total,
"namespaces": ns,
}
async def clear(self, namespace: Optional[str] = None) -> int:
"""Clear all or namespace-specific memories. Returns count deleted."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
if namespace:
cursor = self._conn.execute(
"DELETE FROM memories WHERE namespace = ?", (namespace,)
)
else:
cursor = self._conn.execute("DELETE FROM memories")
self._conn.commit()
return cursor.rowcount
async def tag_memory(self, memory_id: str, tag: str) -> bool:
"""Add a tag to a memory."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
cursor = self._conn.execute(
"SELECT tags, metadata FROM memories WHERE memory_id = ?", (memory_id,)
)
row = cursor.fetchone()
if not row:
return False
tags = json.loads(row[0]) if row[0] else []
if tag not in tags:
tags.append(tag)
# Also update tags in metadata
metadata = json.loads(row[1]) if row[1] else {}
metadata["tags"] = tags
self._conn.execute(
"UPDATE memories SET tags = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE memory_id = ?",
(json.dumps(tags), json.dumps(metadata), memory_id),
)
self._conn.commit()
return True
async def untag_memory(self, memory_id: str, tag: str) -> bool:
"""Remove a tag from a memory."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
cursor = self._conn.execute(
"SELECT tags, metadata FROM memories WHERE memory_id = ?", (memory_id,)
)
row = cursor.fetchone()
if not row:
return False
tags = json.loads(row[0]) if row[0] else []
if tag in tags:
tags.remove(tag)
else:
return False
metadata = json.loads(row[1]) if row[1] else {}
metadata["tags"] = tags
self._conn.execute(
"UPDATE memories SET tags = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE memory_id = ?",
(json.dumps(tags), json.dumps(metadata), memory_id),
)
self._conn.commit()
return True
async def history(
self, key: str, namespace: str = "default"
) -> List[Dict[str, Any]]:
"""Show all versions/entries for a key within a namespace, ordered by creation time."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
cursor = self._conn.execute(
"SELECT * FROM memories WHERE key = ? AND namespace = ? ORDER BY created_at ASC",
(key, namespace),
)
return [self._row_to_dict(row) for row in cursor.fetchall()]
async def export_memories(
self, namespace: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Export memories as a list of dicts."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
if namespace:
cursor = self._conn.execute(
"SELECT * FROM memories WHERE namespace = ? ORDER BY created_at ASC",
(namespace,),
)
else:
cursor = self._conn.execute(
"SELECT * FROM memories ORDER BY created_at ASC"
)
return [self._row_to_dict(row) for row in cursor.fetchall()]
async def import_memories(self, data: List[Dict[str, Any]]) -> int:
"""Import memories from exported data. Returns count imported."""
if not self._conn:
raise RuntimeError("Plugin not initialized")
count = 0
for entry in data:
memory_id = entry.get("memory_id") or entry.get("id") or str(uuid.uuid4())
metadata = entry.get("metadata", {})
namespace = metadata.get("namespace", entry.get("namespace", "default"))
key = metadata.get("key", entry.get("key"))
tags = metadata.get("tags", entry.get("tags", []))
ttl = metadata.get("ttl", entry.get("ttl"))
self._conn.execute(
"""
INSERT INTO memories
(id, memory_id, user_id, project_id, content, importance, context,
metadata, source, embedding, namespace, key, tags, ttl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
memory_id,
entry.get("user_id", "default"),
entry.get("project_id", "default"),
entry.get("content", ""),
entry.get("importance", 0.5),
json.dumps(entry.get("context", {})),
json.dumps(metadata),
entry.get("source", ""),
None,
namespace,
key,
json.dumps(tags if isinstance(tags, list) else []),
ttl,
),
)
count += 1
self._conn.commit()
return count
# ------------------------------------------------------------------ #
# Internal helpers
# ------------------------------------------------------------------ #
def _find_by_key(self, key: str, namespace: str) -> Optional[Dict[str, Any]]:
"""Find a single memory by exact key + namespace."""
if not self._conn:
return None
cursor = self._conn.execute(
"SELECT * FROM memories WHERE key = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1",
(key, namespace),
)
row = cursor.fetchone()
return self._row_to_dict(row) if row else None
def _is_expired(self, memory: Dict[str, Any]) -> bool:
"""Check if a memory's TTL has expired."""
ttl = memory.get("ttl") or (memory.get("metadata", {}).get("ttl"))
if not ttl:
return False
try:
expiry = datetime.fromisoformat(ttl)
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) > expiry
except (ValueError, TypeError):
return False
def _row_to_dict(self, row) -> Dict[str, Any]:
"""Convert a sqlite3.Row to a dict with parsed JSON fields."""
if row is None:
return {}
# sqlite3.Row supports both index and key access
d = dict(row) if hasattr(row, "keys") else {}
if not d:
# Fallback for tuple rows
return {}
# Parse JSON fields
for field in ("metadata", "context"):
if field in d and isinstance(d[field], str):
try:
d[field] = json.loads(d[field])
except (json.JSONDecodeError, TypeError):
d[field] = {}
if "tags" in d and isinstance(d["tags"], str):
try:
d["tags"] = json.loads(d["tags"])
except (json.JSONDecodeError, TypeError):
d["tags"] = []
return d