mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
603 lines
21 KiB
Python
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
|