Extracted from private infra repo into standalone open-source project. Three operating modes: - Local: SQLite + FTS5 (zero dependencies) - Server: PostgreSQL via HTTP API with multi-user auth - Full: PostgreSQL + HashiCorp Vault for secret management Features: - MCP stdio server with 5 tools (store/recall/list/delete/secret_get) - FastAPI HTTP API with multi-user Bearer token auth (API_KEYS JSON map) - Regex-based credential detection with auto-redaction - AES-256-GCM encryption fallback for non-Vault deployments - Vault KV v2 client (stdlib urllib, K8s SA auto-auth) - Per-user data isolation (all queries scoped by user_id) - Secret migration endpoint for existing plain-text credentials - Backward-compatible env var aliases (CLAUDE_MEMORY_API_URL) Infrastructure: - Docker + docker-compose (API + PostgreSQL + optional Vault) - Woodpecker CI (test → build → push → kubectl deploy) - GitHub Actions CI (Python 3.11/3.12/3.13) + Release (GHCR + PyPI) - Helm chart + raw Kubernetes manifests 96 tests passing across 6 test files.
337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""Claude Memory API -- shared persistent memory with PostgreSQL full-text search."""
|
|
|
|
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.vault_service import (
|
|
delete_secret,
|
|
get_secret,
|
|
is_vault_configured,
|
|
store_secret,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await init_pool()
|
|
yield
|
|
await close_pool()
|
|
|
|
|
|
app = FastAPI(title="Claude Memory API", lifespan=lifespan)
|
|
|
|
|
|
def _detect_sensitive(content: str) -> bool:
|
|
"""Check if content contains credentials using the credential detector."""
|
|
try:
|
|
from claude_memory.credential_detector import detect_credentials
|
|
|
|
findings = detect_credentials(content)
|
|
return len(findings) > 0
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def _redact_content(content: str) -> str:
|
|
"""Redact sensitive content for storage in the main DB."""
|
|
try:
|
|
from claude_memory.credential_detector import detect_credentials, redact_credentials
|
|
|
|
creds = detect_credentials(content)
|
|
if creds:
|
|
return redact_credentials(content, creds)
|
|
return content
|
|
except ImportError:
|
|
return "[REDACTED]"
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/api/memories", response_model=MemoryResponse)
|
|
async def store_memory(body: MemoryStore, user: AuthUser = Depends(get_current_user)):
|
|
pool = await get_pool()
|
|
is_sensitive = body.force_sensitive or _detect_sensitive(body.content)
|
|
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO memories (user_id, content, category, tags, expanded_keywords, importance, is_sensitive)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, category, importance
|
|
""",
|
|
user.user_id,
|
|
body.content if not is_sensitive else _redact_content(body.content),
|
|
body.category,
|
|
body.tags,
|
|
body.expanded_keywords,
|
|
body.importance,
|
|
is_sensitive,
|
|
)
|
|
memory_id = row["id"]
|
|
|
|
if is_sensitive and is_vault_configured():
|
|
vault_path = await store_secret(user.user_id, memory_id, body.content)
|
|
await conn.execute(
|
|
"UPDATE memories SET vault_path = $1 WHERE id = $2",
|
|
vault_path,
|
|
memory_id,
|
|
)
|
|
|
|
return MemoryResponse(id=row["id"], category=row["category"], importance=row["importance"])
|
|
|
|
|
|
@app.post("/api/memories/recall")
|
|
async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_current_user)):
|
|
pool = await get_pool()
|
|
|
|
query_text = f"{body.context} {body.expanded_query}".strip()
|
|
|
|
order_clause = "ts_rank(search_vector, query) DESC"
|
|
if body.sort_by == "importance":
|
|
order_clause = "importance DESC, ts_rank(search_vector, query) DESC"
|
|
elif body.sort_by == "recency":
|
|
order_clause = "created_at DESC"
|
|
|
|
category_filter = ""
|
|
params: list = [user.user_id, query_text, body.limit]
|
|
if body.category:
|
|
category_filter = "AND category = $4"
|
|
params.append(body.category)
|
|
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT id, content, category, tags, importance, is_sensitive,
|
|
ts_rank(search_vector, query) AS rank,
|
|
created_at, updated_at
|
|
FROM memories, plainto_tsquery('english', $2) query
|
|
WHERE user_id = $1
|
|
AND (search_vector @@ query OR $2 = '')
|
|
{category_filter}
|
|
ORDER BY {order_clause}
|
|
LIMIT $3
|
|
""",
|
|
*params,
|
|
)
|
|
|
|
results = []
|
|
for row in rows:
|
|
content = row["content"]
|
|
if row["is_sensitive"]:
|
|
content = f"[SENSITIVE - use secret_get(id={row['id']})]"
|
|
results.append(
|
|
{
|
|
"id": row["id"],
|
|
"content": content,
|
|
"category": row["category"],
|
|
"tags": row["tags"],
|
|
"importance": row["importance"],
|
|
"is_sensitive": row["is_sensitive"],
|
|
"rank": float(row["rank"]),
|
|
"created_at": row["created_at"].isoformat(),
|
|
"updated_at": row["updated_at"].isoformat(),
|
|
}
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
@app.get("/api/memories")
|
|
async def list_memories(
|
|
category: Optional[str] = None,
|
|
limit: int = 50,
|
|
user: AuthUser = Depends(get_current_user),
|
|
):
|
|
pool = await get_pool()
|
|
|
|
if category:
|
|
query = """
|
|
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
|
|
FROM memories WHERE user_id = $1 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
|
|
ORDER BY importance DESC LIMIT $2
|
|
"""
|
|
params = [user.user_id, limit]
|
|
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(query, *params)
|
|
|
|
results = []
|
|
for row in rows:
|
|
content = row["content"]
|
|
if row["is_sensitive"]:
|
|
content = f"[SENSITIVE - use secret_get(id={row['id']})]"
|
|
results.append(
|
|
{
|
|
"id": row["id"],
|
|
"content": content,
|
|
"category": row["category"],
|
|
"tags": row["tags"],
|
|
"importance": row["importance"],
|
|
"is_sensitive": row["is_sensitive"],
|
|
"created_at": row["created_at"].isoformat(),
|
|
"updated_at": row["updated_at"].isoformat(),
|
|
}
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
@app.delete("/api/memories/{memory_id}")
|
|
async def delete_memory(memory_id: int, user: AuthUser = Depends(get_current_user)):
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT id, vault_path FROM memories WHERE id = $1 AND user_id = $2",
|
|
memory_id,
|
|
user.user_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
|
|
if row["vault_path"]:
|
|
await delete_secret(user.user_id, row["vault_path"])
|
|
|
|
await conn.execute(
|
|
"DELETE FROM memories WHERE id = $1 AND user_id = $2",
|
|
memory_id,
|
|
user.user_id,
|
|
)
|
|
|
|
return {"deleted": memory_id}
|
|
|
|
|
|
@app.post("/api/memories/{memory_id}/secret", response_model=SecretResponse)
|
|
async def get_memory_secret(memory_id: int, user: AuthUser = Depends(get_current_user)):
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, content, is_sensitive, vault_path, encrypted_content
|
|
FROM memories WHERE id = $1 AND user_id = $2
|
|
""",
|
|
memory_id,
|
|
user.user_id,
|
|
)
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
|
|
if not row["is_sensitive"]:
|
|
return SecretResponse(id=row["id"], content=row["content"], source="plaintext")
|
|
|
|
if row["vault_path"]:
|
|
secret = await get_secret(user.user_id, row["vault_path"])
|
|
if secret:
|
|
return SecretResponse(id=row["id"], content=secret, source="vault")
|
|
|
|
if row["encrypted_content"]:
|
|
return SecretResponse(
|
|
id=row["id"],
|
|
content="[ENCRYPTED - decryption not available]",
|
|
source="encrypted",
|
|
)
|
|
|
|
return SecretResponse(id=row["id"], content=row["content"], source="plaintext")
|
|
|
|
|
|
@app.post("/api/memories/migrate-secrets")
|
|
async def migrate_secrets(user: AuthUser = Depends(get_current_user)):
|
|
pool = await get_pool()
|
|
migrated = 0
|
|
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id, content FROM memories
|
|
WHERE user_id = $1 AND is_sensitive = FALSE
|
|
""",
|
|
user.user_id,
|
|
)
|
|
|
|
for row in rows:
|
|
if _detect_sensitive(row["content"]):
|
|
original_content = row["content"]
|
|
redacted = _redact_content(original_content)
|
|
|
|
vault_path = None
|
|
if is_vault_configured():
|
|
vault_path = await store_secret(user.user_id, row["id"], original_content)
|
|
|
|
await conn.execute(
|
|
"""
|
|
UPDATE memories
|
|
SET is_sensitive = TRUE, content = $1, vault_path = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $3 AND user_id = $4
|
|
""",
|
|
redacted,
|
|
vault_path,
|
|
row["id"],
|
|
user.user_id,
|
|
)
|
|
migrated += 1
|
|
|
|
return {"migrated": migrated}
|
|
|
|
|
|
@app.post("/api/memories/import")
|
|
async def import_memories(
|
|
memories: list[MemoryStore], user: AuthUser = Depends(get_current_user)
|
|
):
|
|
pool = await get_pool()
|
|
imported = []
|
|
|
|
async with pool.acquire() as conn:
|
|
for mem in memories:
|
|
is_sensitive = mem.force_sensitive or _detect_sensitive(mem.content)
|
|
content = mem.content if not is_sensitive else _redact_content(mem.content)
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO memories (user_id, content, category, tags, expanded_keywords, importance, is_sensitive)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, category, importance
|
|
""",
|
|
user.user_id,
|
|
content,
|
|
mem.category,
|
|
mem.tags,
|
|
mem.expanded_keywords,
|
|
mem.importance,
|
|
is_sensitive,
|
|
)
|
|
|
|
if is_sensitive and is_vault_configured():
|
|
vault_path = await store_secret(user.user_id, row["id"], mem.content)
|
|
await conn.execute(
|
|
"UPDATE memories SET vault_path = $1 WHERE id = $2",
|
|
vault_path,
|
|
row["id"],
|
|
)
|
|
|
|
imported.append(
|
|
MemoryResponse(id=row["id"], category=row["category"], importance=row["importance"])
|
|
)
|
|
|
|
return imported
|