feat: add local SQLite cache with background sync and HA deployment
- Add SyncEngine for background sync between local SQLite cache and remote API with pending_ops queue for offline resilience - Refactor MCP server to support three modes: SQLite-only, hybrid (local cache + sync, new default), and HTTP-only (legacy) - Add GET /api/memories/sync endpoint for incremental sync - Change DELETE to soft delete (set deleted_at) for sync support - Add deleted_at IS NULL filters to all read queries - Scale API deployment to 2 replicas with pod anti-affinity, PDB, and startup probe for high availability - Add migration 003 for deleted_at column and updated_at index - Add comprehensive tests for sync engine and API sync endpoint
This commit is contained in:
parent
fe55ac634b
commit
cd80a67dfa
7 changed files with 1133 additions and 110 deletions
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
|
||||
from claude_memory.api.auth import AuthUser, get_current_user
|
||||
from claude_memory.api.database import close_pool, get_pool, init_pool
|
||||
from claude_memory.api.models import MemoryRecall, MemoryResponse, MemoryStore, SecretResponse
|
||||
from claude_memory.api.models import MemoryRecall, MemoryResponse, MemoryStore, SecretResponse, SyncResponse
|
||||
from claude_memory.api.vault_service import (
|
||||
delete_secret,
|
||||
get_secret,
|
||||
|
|
@ -58,6 +59,58 @@ async def health():
|
|||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/memories/sync", response_model=SyncResponse)
|
||||
async def sync_memories(
|
||||
since: Optional[str] = None,
|
||||
user: AuthUser = Depends(get_current_user),
|
||||
):
|
||||
pool = await get_pool()
|
||||
server_time = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
if since:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, content, category, tags, expanded_keywords, importance,
|
||||
is_sensitive, created_at, updated_at, deleted_at
|
||||
FROM memories
|
||||
WHERE user_id = $1 AND updated_at > $2::timestamptz
|
||||
ORDER BY updated_at ASC
|
||||
""",
|
||||
user.user_id,
|
||||
since,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, content, category, tags, expanded_keywords, importance,
|
||||
is_sensitive, created_at, updated_at, deleted_at
|
||||
FROM memories
|
||||
WHERE user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at ASC
|
||||
""",
|
||||
user.user_id,
|
||||
)
|
||||
|
||||
memories = []
|
||||
for row in rows:
|
||||
mem = {
|
||||
"id": row["id"],
|
||||
"content": row["content"],
|
||||
"category": row["category"],
|
||||
"tags": row["tags"],
|
||||
"expanded_keywords": row["expanded_keywords"],
|
||||
"importance": row["importance"],
|
||||
"is_sensitive": row["is_sensitive"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
"updated_at": row["updated_at"].isoformat(),
|
||||
"deleted_at": row["deleted_at"].isoformat() if row["deleted_at"] else None,
|
||||
}
|
||||
memories.append(mem)
|
||||
|
||||
return SyncResponse(memories=memories, server_time=server_time)
|
||||
|
||||
|
||||
@app.post("/api/memories", response_model=MemoryResponse)
|
||||
async def store_memory(body: MemoryStore, user: AuthUser = Depends(get_current_user)):
|
||||
pool = await get_pool()
|
||||
|
|
@ -117,6 +170,7 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
|
|||
created_at, updated_at
|
||||
FROM memories, plainto_tsquery('english', $2) query
|
||||
WHERE user_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND (search_vector @@ query OR $2 = '')
|
||||
{category_filter}
|
||||
ORDER BY {order_clause}
|
||||
|
|
@ -158,14 +212,14 @@ async def list_memories(
|
|||
if category:
|
||||
query = """
|
||||
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
|
||||
FROM memories WHERE user_id = $1 AND category = $2
|
||||
FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2
|
||||
ORDER BY importance DESC LIMIT $3
|
||||
"""
|
||||
params: list = [user.user_id, category, limit]
|
||||
else:
|
||||
query = """
|
||||
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
|
||||
FROM memories WHERE user_id = $1
|
||||
FROM memories WHERE user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY importance DESC LIMIT $2
|
||||
"""
|
||||
params = [user.user_id, limit]
|
||||
|
|
@ -200,7 +254,7 @@ async def delete_memory(memory_id: int, user: AuthUser = Depends(get_current_use
|
|||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, vault_path, substr(content, 1, 50) AS preview FROM memories WHERE id = $1 AND user_id = $2",
|
||||
"SELECT id, vault_path, substr(content, 1, 50) AS preview FROM memories WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
memory_id,
|
||||
user.user_id,
|
||||
)
|
||||
|
|
@ -211,7 +265,7 @@ async def delete_memory(memory_id: int, user: AuthUser = Depends(get_current_use
|
|||
await delete_secret(user.user_id, row["vault_path"])
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM memories WHERE id = $1 AND user_id = $2",
|
||||
"UPDATE memories SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2",
|
||||
memory_id,
|
||||
user.user_id,
|
||||
)
|
||||
|
|
@ -227,7 +281,7 @@ async def get_memory_secret(memory_id: int, user: AuthUser = Depends(get_current
|
|||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, content, is_sensitive, vault_path, encrypted_content
|
||||
FROM memories WHERE id = $1 AND user_id = $2
|
||||
FROM memories WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||
""",
|
||||
memory_id,
|
||||
user.user_id,
|
||||
|
|
@ -263,7 +317,7 @@ async def migrate_secrets(user: AuthUser = Depends(get_current_user)):
|
|||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, content FROM memories
|
||||
WHERE user_id = $1 AND is_sensitive = FALSE
|
||||
WHERE user_id = $1 AND is_sensitive = FALSE AND deleted_at IS NULL
|
||||
""",
|
||||
user.user_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -30,3 +30,8 @@ class SecretResponse(BaseModel):
|
|||
id: int
|
||||
content: str
|
||||
source: str # "vault", "encrypted", "plaintext"
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
memories: list[dict[str, Any]]
|
||||
server_time: str
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
"""
|
||||
Claude Memory MCP Server — standalone memory server with multi-user support.
|
||||
|
||||
Supports two modes:
|
||||
1. HTTP API mode: connects to a shared PostgreSQL-backed API server
|
||||
2. SQLite fallback: local file-based storage when no API key is configured
|
||||
Supports three modes:
|
||||
1. SQLite-only: local file-based storage when no API key is configured
|
||||
2. Hybrid (default when API key set): local SQLite cache + background sync
|
||||
3. HTTP-only (legacy): direct HTTP to API, no local cache (MEMORY_SYNC_DISABLE=1)
|
||||
|
||||
Uses only stdlib (urllib) — no pip install required.
|
||||
"""
|
||||
|
|
@ -21,14 +22,17 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
SERVER_NAME = "claude-memory"
|
||||
SERVER_VERSION = "1.0.0"
|
||||
SERVER_VERSION = "1.1.0"
|
||||
|
||||
# API configuration — support both MEMORY_* (primary) and CLAUDE_MEMORY_* (fallback) env vars
|
||||
API_BASE_URL = os.environ.get("MEMORY_API_URL") or os.environ.get("CLAUDE_MEMORY_API_URL", "http://localhost:8080")
|
||||
API_KEY = os.environ.get("MEMORY_API_KEY") or os.environ.get("CLAUDE_MEMORY_API_KEY", "")
|
||||
|
||||
# Fallback to SQLite if API is not configured
|
||||
SQLITE_FALLBACK = not API_KEY
|
||||
# Mode detection
|
||||
SYNC_DISABLED = os.environ.get("MEMORY_SYNC_DISABLE", "") == "1"
|
||||
HYBRID_MODE = bool(API_KEY) and not SYNC_DISABLED
|
||||
HTTP_ONLY = bool(API_KEY) and SYNC_DISABLED
|
||||
SQLITE_ONLY = not API_KEY
|
||||
|
||||
|
||||
def _api_request(method: str, path: str, body: dict | None = None) -> dict:
|
||||
|
|
@ -54,23 +58,29 @@ def _api_request(method: str, path: str, body: dict | None = None) -> dict:
|
|||
raise RuntimeError(f"API connection error: {e.reason}") from e
|
||||
|
||||
|
||||
# ─── SQLite fallback (local storage when API not configured) ─────────────────
|
||||
# ─── SQLite initialization ────────────────────────────────────────────────────
|
||||
|
||||
def _get_db_path(db_path: str | None = None) -> str:
|
||||
"""Resolve the SQLite database path."""
|
||||
if db_path is not None:
|
||||
return db_path
|
||||
|
||||
memory_home = os.path.expandvars(
|
||||
os.path.expanduser(os.environ.get("MEMORY_HOME", "~/.claude/claude-memory"))
|
||||
)
|
||||
db_path = os.environ.get(
|
||||
"MEMORY_DB",
|
||||
os.path.join(memory_home, "memory", "memory.db"),
|
||||
)
|
||||
return os.path.expandvars(os.path.expanduser(db_path))
|
||||
|
||||
|
||||
def _init_sqlite(db_path: str | None = None):
|
||||
"""Initialize SQLite database as fallback."""
|
||||
"""Initialize SQLite database."""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
if db_path is None:
|
||||
memory_home = os.path.expandvars(
|
||||
os.path.expanduser(os.environ.get("MEMORY_HOME", "~/.claude/claude-memory"))
|
||||
)
|
||||
db_path = os.environ.get(
|
||||
"MEMORY_DB",
|
||||
os.path.join(memory_home, "memory", "memory.db"),
|
||||
)
|
||||
db_path = os.path.expandvars(os.path.expanduser(db_path))
|
||||
|
||||
db_path = _get_db_path(db_path)
|
||||
Path(os.path.dirname(db_path)).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(db_path, timeout=30.0)
|
||||
|
|
@ -91,6 +101,15 @@ def _init_sqlite(db_path: str | None = None):
|
|||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# Add server_id column if missing (for hybrid mode sync)
|
||||
cursor.execute("PRAGMA table_info(memories)")
|
||||
columns = {row["name"] for row in cursor.fetchall()}
|
||||
if "server_id" not in columns:
|
||||
cursor.execute("ALTER TABLE memories ADD COLUMN server_id INTEGER")
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_server_id ON memories(server_id)"
|
||||
)
|
||||
|
||||
cursor.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
content, category, tags, expanded_keywords,
|
||||
|
|
@ -118,7 +137,7 @@ def _init_sqlite(db_path: str | None = None):
|
|||
END
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
return conn, db_path
|
||||
|
||||
|
||||
# ─── Tool definitions ────────────────────────────────────────────────────────
|
||||
|
|
@ -229,10 +248,27 @@ class MemoryServer:
|
|||
|
||||
def __init__(self, sqlite_db_path: str | None = None) -> None:
|
||||
self.sqlite_conn = None
|
||||
if SQLITE_FALLBACK:
|
||||
self.sqlite_conn = _init_sqlite(sqlite_db_path)
|
||||
self.sync_engine = None
|
||||
|
||||
# ── HTTP-backed methods ──────────────────────────────────────────
|
||||
if SQLITE_ONLY or HYBRID_MODE:
|
||||
self.sqlite_conn, resolved_path = _init_sqlite(sqlite_db_path)
|
||||
|
||||
if HYBRID_MODE:
|
||||
from claude_memory.sync import SyncEngine
|
||||
sync_interval = int(os.environ.get("MEMORY_SYNC_INTERVAL", "60"))
|
||||
self.sync_engine = SyncEngine(
|
||||
db_path=resolved_path,
|
||||
api_base_url=API_BASE_URL,
|
||||
api_key=API_KEY,
|
||||
sync_interval=sync_interval,
|
||||
)
|
||||
self.sync_engine.start()
|
||||
|
||||
def __del__(self) -> None:
|
||||
if self.sync_engine:
|
||||
self.sync_engine.stop()
|
||||
|
||||
# ── Tool methods ────────────────────────────────────────────────
|
||||
|
||||
def memory_store(self, args: dict[str, Any]) -> str:
|
||||
content = args.get("content")
|
||||
|
|
@ -244,18 +280,28 @@ class MemoryServer:
|
|||
expanded_keywords = args.get("expanded_keywords", "")
|
||||
force_sensitive = bool(args.get("force_sensitive", False))
|
||||
|
||||
if SQLITE_FALLBACK:
|
||||
return self._sqlite_store(content, category, tags, importance, expanded_keywords, force_sensitive)
|
||||
if HTTP_ONLY:
|
||||
result = _api_request("POST", "/api/memories", {
|
||||
"content": content,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"expanded_keywords": expanded_keywords,
|
||||
"importance": importance,
|
||||
"force_sensitive": force_sensitive,
|
||||
})
|
||||
return f"Stored memory #{result['id']} in category '{result['category']}' with importance {result['importance']:.1f}"
|
||||
|
||||
result = _api_request("POST", "/api/memories", {
|
||||
"content": content,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"expanded_keywords": expanded_keywords,
|
||||
"importance": importance,
|
||||
"force_sensitive": force_sensitive,
|
||||
})
|
||||
return f"Stored memory #{result['id']} in category '{result['category']}' with importance {result['importance']:.1f}"
|
||||
# SQLite-only or Hybrid: write to local SQLite first
|
||||
result_text = self._sqlite_store(content, category, tags, importance, expanded_keywords, force_sensitive)
|
||||
|
||||
if HYBRID_MODE and self.sync_engine:
|
||||
# Extract local_id from result text
|
||||
local_id = int(result_text.split("#")[1].split(" ")[0])
|
||||
self.sync_engine.try_sync_store(
|
||||
local_id, content, category, tags, expanded_keywords, importance, force_sensitive
|
||||
)
|
||||
|
||||
return result_text
|
||||
|
||||
def memory_recall(self, args: dict[str, Any]) -> str:
|
||||
context = args.get("context")
|
||||
|
|
@ -266,80 +312,102 @@ class MemoryServer:
|
|||
sort_by = args.get("sort_by", "importance")
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
if SQLITE_FALLBACK:
|
||||
return self._sqlite_recall(context, expanded_query, category, sort_by, limit)
|
||||
if HTTP_ONLY:
|
||||
result = _api_request("POST", "/api/memories/recall", {
|
||||
"context": context,
|
||||
"expanded_query": expanded_query,
|
||||
"category": category,
|
||||
"sort_by": sort_by,
|
||||
"limit": limit,
|
||||
})
|
||||
rows = result.get("memories", [])
|
||||
if not rows:
|
||||
filter_desc = f" in category '{category}'" if category else ""
|
||||
return f"No memories found matching: {context}{filter_desc}"
|
||||
|
||||
result = _api_request("POST", "/api/memories/recall", {
|
||||
"context": context,
|
||||
"expanded_query": expanded_query,
|
||||
"category": category,
|
||||
"sort_by": sort_by,
|
||||
"limit": limit,
|
||||
})
|
||||
rows = result.get("memories", [])
|
||||
if not rows:
|
||||
filter_desc = f" in category '{category}'" if category else ""
|
||||
return f"No memories found matching: {context}{filter_desc}"
|
||||
sort_desc = "by relevance" if sort_by == "relevance" else "by importance"
|
||||
filter_desc = f" in '{category}'" if category else ""
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
f"#{row['id']} [{row['category']}] (importance: {row['importance']:.1f}) {row['content']}"
|
||||
f"\n Tags: {row.get('tags') or 'none'} | Stored: {row['created_at']}"
|
||||
)
|
||||
return f"Found {len(rows)} memories{filter_desc} ({sort_desc}):\n\n" + "\n\n".join(results)
|
||||
|
||||
sort_desc = "by relevance" if sort_by == "relevance" else "by importance"
|
||||
filter_desc = f" in '{category}'" if category else ""
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
f"#{row['id']} [{row['category']}] (importance: {row['importance']:.1f}) {row['content']}"
|
||||
f"\n Tags: {row.get('tags') or 'none'} | Stored: {row['created_at']}"
|
||||
)
|
||||
return f"Found {len(rows)} memories{filter_desc} ({sort_desc}):\n\n" + "\n\n".join(results)
|
||||
# SQLite-only or Hybrid: always read from local cache
|
||||
return self._sqlite_recall(context, expanded_query, category, sort_by, limit)
|
||||
|
||||
def memory_list(self, args: dict[str, Any]) -> str:
|
||||
category = args.get("category")
|
||||
limit = args.get("limit", 20)
|
||||
|
||||
if SQLITE_FALLBACK:
|
||||
return self._sqlite_list(category, limit)
|
||||
if HTTP_ONLY:
|
||||
params = f"?limit={limit}"
|
||||
if category:
|
||||
params += f"&category={category}"
|
||||
result = _api_request("GET", f"/api/memories{params}")
|
||||
rows = result.get("memories", [])
|
||||
if not rows:
|
||||
return f"No memories in category '{category}'" if category else "No memories stored yet"
|
||||
|
||||
params = f"?limit={limit}"
|
||||
if category:
|
||||
params += f"&category={category}"
|
||||
result = _api_request("GET", f"/api/memories{params}")
|
||||
rows = result.get("memories", [])
|
||||
if not rows:
|
||||
return f"No memories in category '{category}'" if category else "No memories stored yet"
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
f"#{row['id']} [{row['category']}] {row['content']}"
|
||||
f"\n Importance: {row['importance']:.1f} | Tags: {row.get('tags') or 'none'} | Stored: {row['created_at']}"
|
||||
)
|
||||
header = "Recent memories"
|
||||
if category:
|
||||
header += f" in '{category}'"
|
||||
return header + f" ({len(rows)} shown):\n\n" + "\n\n".join(results)
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
f"#{row['id']} [{row['category']}] {row['content']}"
|
||||
f"\n Importance: {row['importance']:.1f} | Tags: {row.get('tags') or 'none'} | Stored: {row['created_at']}"
|
||||
)
|
||||
header = "Recent memories"
|
||||
if category:
|
||||
header += f" in '{category}'"
|
||||
return header + f" ({len(rows)} shown):\n\n" + "\n\n".join(results)
|
||||
# SQLite-only or Hybrid: always read from local cache
|
||||
return self._sqlite_list(category, limit)
|
||||
|
||||
def memory_delete(self, args: dict[str, Any]) -> str:
|
||||
memory_id = args.get("id")
|
||||
if memory_id is None:
|
||||
raise ValueError("id is required")
|
||||
|
||||
if SQLITE_FALLBACK:
|
||||
return self._sqlite_delete(memory_id)
|
||||
if HTTP_ONLY:
|
||||
result = _api_request("DELETE", f"/api/memories/{memory_id}")
|
||||
return f"Deleted memory #{result['deleted']}: {result['preview']}..."
|
||||
|
||||
result = _api_request("DELETE", f"/api/memories/{memory_id}")
|
||||
return f"Deleted memory #{result['deleted']}: {result['preview']}..."
|
||||
# SQLite-only or Hybrid: delete from local SQLite
|
||||
# In hybrid mode, also try to sync delete to server
|
||||
if HYBRID_MODE and self.sync_engine:
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("SELECT server_id FROM memories WHERE id = ?", (memory_id,))
|
||||
row = cursor.fetchone()
|
||||
server_id = row["server_id"] if row and row["server_id"] else None
|
||||
|
||||
result_text = self._sqlite_delete(memory_id)
|
||||
|
||||
if HYBRID_MODE and self.sync_engine and server_id:
|
||||
self.sync_engine.try_sync_delete(server_id)
|
||||
|
||||
return result_text
|
||||
|
||||
def secret_get(self, args: dict[str, Any]) -> str:
|
||||
memory_id = args.get("id")
|
||||
if memory_id is None:
|
||||
raise ValueError("id is required")
|
||||
|
||||
if SQLITE_FALLBACK:
|
||||
return self._sqlite_secret_get(memory_id)
|
||||
if HTTP_ONLY or HYBRID_MODE:
|
||||
# Secrets should be fetched from API when available
|
||||
try:
|
||||
result = _api_request("POST", f"/api/memories/{memory_id}/secret")
|
||||
return f"#{result['id']} [{result['category']}] {result['content']}"
|
||||
except Exception:
|
||||
if HYBRID_MODE:
|
||||
# Fall back to local SQLite
|
||||
return self._sqlite_secret_get(memory_id)
|
||||
raise
|
||||
|
||||
result = _api_request("POST", f"/api/memories/{memory_id}/secret")
|
||||
return f"#{result['id']} [{result['category']}] {result['content']}"
|
||||
return self._sqlite_secret_get(memory_id)
|
||||
|
||||
# ── SQLite fallback methods ──────────────────────────────────────
|
||||
# ── SQLite methods ──────────────────────────────────────────────
|
||||
|
||||
def _sqlite_store(self, content, category, tags, importance, expanded_keywords, force_sensitive=False):
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -520,25 +588,29 @@ class MemoryServer:
|
|||
return response
|
||||
|
||||
def run(self) -> None:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
print(
|
||||
json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32700, "message": f"Parse error: {e}"},
|
||||
}),
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
response = self.process_message(message)
|
||||
if response is not None:
|
||||
print(json.dumps(response), flush=True)
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
print(
|
||||
json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": -32700, "message": f"Parse error: {e}"},
|
||||
}),
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
response = self.process_message(message)
|
||||
if response is not None:
|
||||
print(json.dumps(response), flush=True)
|
||||
finally:
|
||||
if self.sync_engine:
|
||||
self.sync_engine.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
334
src/claude_memory/sync.py
Normal file
334
src/claude_memory/sync.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""Background sync between local SQLite cache and remote API.
|
||||
|
||||
Uses only stdlib — no pip install required.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncEngine:
|
||||
"""Background sync between local SQLite cache and remote API."""
|
||||
|
||||
def __init__(self, db_path: str, api_base_url: str, api_key: str, sync_interval: int = 60):
|
||||
self.db_path = db_path
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.sync_interval = sync_interval
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._last_sync_success = False
|
||||
|
||||
# Own connection for thread safety
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(db_path, timeout=30.0, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=30000")
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self._init_sync_tables()
|
||||
|
||||
def _init_sync_tables(self) -> None:
|
||||
"""Create sync-specific tables if they don't exist."""
|
||||
with self._lock:
|
||||
self._conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS pending_ops (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
op_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
# Add server_id column to memories if missing
|
||||
cursor = self._conn.execute("PRAGMA table_info(memories)")
|
||||
columns = {row["name"] for row in cursor.fetchall()}
|
||||
if "server_id" not in columns:
|
||||
self._conn.execute("ALTER TABLE memories ADD COLUMN server_id INTEGER")
|
||||
self._conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_server_id ON memories(server_id)"
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
@property
|
||||
def last_sync_ts(self) -> str | None:
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT value FROM sync_meta WHERE key = 'last_sync_ts'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
@last_sync_ts.setter
|
||||
def last_sync_ts(self, value: str) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync_ts', ?)",
|
||||
(value,),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
@property
|
||||
def api_available(self) -> bool:
|
||||
return self._last_sync_success
|
||||
|
||||
def start(self) -> None:
|
||||
"""Run initial sync (blocking), then start background thread."""
|
||||
try:
|
||||
self._sync_once()
|
||||
self._last_sync_success = True
|
||||
except Exception:
|
||||
logger.warning("Initial sync failed, starting in offline mode")
|
||||
self._last_sync_success = False
|
||||
|
||||
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal background thread to stop and wait."""
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._conn.close()
|
||||
|
||||
def _sync_loop(self) -> None:
|
||||
"""Periodic sync loop running in background thread."""
|
||||
while not self._stop_event.is_set():
|
||||
self._stop_event.wait(self.sync_interval)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
self._sync_once()
|
||||
self._last_sync_success = True
|
||||
except Exception as e:
|
||||
logger.warning("Sync cycle failed: %s", e)
|
||||
self._last_sync_success = False
|
||||
|
||||
def _sync_once(self) -> None:
|
||||
"""Push pending ops, then pull remote changes."""
|
||||
self._push_pending_ops()
|
||||
self._pull_changes()
|
||||
|
||||
def _api_request(self, method: str, path: str, body: dict | None = None) -> dict:
|
||||
"""Make an HTTP request to the memory API."""
|
||||
url = f"{self.api_base_url}{path}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def _push_pending_ops(self) -> None:
|
||||
"""Push queued operations to the API server."""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT id, op_type, payload FROM pending_ops ORDER BY id"
|
||||
)
|
||||
ops = cursor.fetchall()
|
||||
|
||||
for op in ops:
|
||||
op_id = op["id"]
|
||||
op_type = op["op_type"]
|
||||
payload = json.loads(op["payload"])
|
||||
|
||||
try:
|
||||
if op_type == "store":
|
||||
result = self._api_request("POST", "/api/memories", payload)
|
||||
server_id = result.get("id")
|
||||
if server_id and payload.get("local_id"):
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE memories SET server_id = ? WHERE id = ?",
|
||||
(server_id, payload["local_id"]),
|
||||
)
|
||||
self._conn.commit()
|
||||
elif op_type == "delete":
|
||||
server_id = payload.get("server_id")
|
||||
if server_id:
|
||||
try:
|
||||
self._api_request("DELETE", f"/api/memories/{server_id}")
|
||||
except RuntimeError as e:
|
||||
if "404" in str(e):
|
||||
pass # Already deleted on server
|
||||
else:
|
||||
raise
|
||||
|
||||
# Remove from pending queue on success
|
||||
with self._lock:
|
||||
self._conn.execute("DELETE FROM pending_ops WHERE id = ?", (op_id,))
|
||||
self._conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to push op %d (%s): %s", op_id, op_type, e)
|
||||
raise # Propagate to mark sync as failed
|
||||
|
||||
def _pull_changes(self) -> None:
|
||||
"""Pull changes from server since last sync."""
|
||||
params = ""
|
||||
ts = self.last_sync_ts
|
||||
if ts:
|
||||
params = f"?since={ts}"
|
||||
|
||||
result = self._api_request("GET", f"/api/memories/sync{params}")
|
||||
memories = result.get("memories", [])
|
||||
server_time = result.get("server_time")
|
||||
|
||||
with self._lock:
|
||||
for mem in memories:
|
||||
server_id = mem["id"]
|
||||
deleted_at = mem.get("deleted_at")
|
||||
|
||||
if deleted_at:
|
||||
# Remove from local cache
|
||||
self._conn.execute(
|
||||
"DELETE FROM memories WHERE server_id = ?", (server_id,)
|
||||
)
|
||||
else:
|
||||
# Upsert by server_id (server wins)
|
||||
existing = self._conn.execute(
|
||||
"SELECT id FROM memories WHERE server_id = ?", (server_id,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
self._conn.execute(
|
||||
"""UPDATE memories SET content = ?, category = ?, tags = ?,
|
||||
expanded_keywords = ?, importance = ?, is_sensitive = ?,
|
||||
updated_at = ? WHERE server_id = ?""",
|
||||
(
|
||||
mem["content"],
|
||||
mem["category"],
|
||||
mem.get("tags", ""),
|
||||
mem.get("expanded_keywords", ""),
|
||||
mem["importance"],
|
||||
1 if mem.get("is_sensitive") else 0,
|
||||
mem.get("updated_at", datetime.now(timezone.utc).isoformat()),
|
||||
server_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO memories
|
||||
(content, category, tags, expanded_keywords, importance,
|
||||
is_sensitive, created_at, updated_at, server_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
mem["content"],
|
||||
mem["category"],
|
||||
mem.get("tags", ""),
|
||||
mem.get("expanded_keywords", ""),
|
||||
mem["importance"],
|
||||
1 if mem.get("is_sensitive") else 0,
|
||||
mem.get("created_at", datetime.now(timezone.utc).isoformat()),
|
||||
mem.get("updated_at", datetime.now(timezone.utc).isoformat()),
|
||||
server_id,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
if server_time:
|
||||
self.last_sync_ts = server_time
|
||||
|
||||
def enqueue_store(
|
||||
self,
|
||||
local_id: int,
|
||||
content: str,
|
||||
category: str,
|
||||
tags: str,
|
||||
expanded_keywords: str,
|
||||
importance: float,
|
||||
force_sensitive: bool = False,
|
||||
) -> None:
|
||||
"""Queue a store operation for later sync."""
|
||||
payload = {
|
||||
"local_id": local_id,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"expanded_keywords": expanded_keywords,
|
||||
"importance": importance,
|
||||
"force_sensitive": force_sensitive,
|
||||
}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO pending_ops (op_type, payload, created_at) VALUES (?, ?, ?)",
|
||||
("store", json.dumps(payload), now),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def enqueue_delete(self, server_id: int) -> None:
|
||||
"""Queue a delete operation for later sync."""
|
||||
payload = {"server_id": server_id}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO pending_ops (op_type, payload, created_at) VALUES (?, ?, ?)",
|
||||
("delete", json.dumps(payload), now),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def try_sync_store(
|
||||
self,
|
||||
local_id: int,
|
||||
content: str,
|
||||
category: str,
|
||||
tags: str,
|
||||
expanded_keywords: str,
|
||||
importance: float,
|
||||
force_sensitive: bool = False,
|
||||
) -> int | None:
|
||||
"""Try to sync a store immediately. Returns server_id or None if failed."""
|
||||
try:
|
||||
result = self._api_request("POST", "/api/memories", {
|
||||
"content": content,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"expanded_keywords": expanded_keywords,
|
||||
"importance": importance,
|
||||
"force_sensitive": force_sensitive,
|
||||
})
|
||||
server_id = result.get("id")
|
||||
if server_id:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE memories SET server_id = ? WHERE id = ?",
|
||||
(server_id, local_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
return server_id
|
||||
except Exception:
|
||||
self.enqueue_store(
|
||||
local_id, content, category, tags, expanded_keywords, importance, force_sensitive
|
||||
)
|
||||
return None
|
||||
|
||||
def try_sync_delete(self, server_id: int) -> bool:
|
||||
"""Try to sync a delete immediately. Returns True if successful."""
|
||||
try:
|
||||
self._api_request("DELETE", f"/api/memories/{server_id}")
|
||||
return True
|
||||
except Exception:
|
||||
self.enqueue_delete(server_id)
|
||||
return False
|
||||
Loading…
Add table
Add a link
Reference in a new issue