f4f77c5196
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
7.8 KiB
Python
Executable File
209 lines
7.8 KiB
Python
Executable File
"""Dedicated memory curator service — run alongside Synapse on port 8001.
|
|
|
|
Endpoints:
|
|
GET / health check
|
|
GET /memories list all memory items (optional ?section= filter)
|
|
POST /memories direct write — no LLM, saves immediately
|
|
PATCH /memories/{id} update a memory item
|
|
DELETE /memories/{id} delete a memory item
|
|
POST /memories/extract LLM-curated: evaluate a conversation exchange and
|
|
optionally save a new permanent fact
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import math
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, Body
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel
|
|
|
|
from .store import store, MemoryItem
|
|
from .extractor import extract_memory
|
|
from ..ollama_manager import get_ollama_manager
|
|
|
|
app = FastAPI(title="Nexus Memory Service", version="1.0")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def _warm_curator():
|
|
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
|
|
extraction isn't a cold load that blows the timeout. Runs in the background
|
|
so it never delays startup. keep_alive then holds it warm between messages."""
|
|
async def _bg():
|
|
try:
|
|
mgr = get_ollama_manager()
|
|
# Ollama is started by the Synapse backend (a separate process), so
|
|
# at our startup it usually isn't reachable yet. Wait for it before
|
|
# warming instead of failing with "All connection attempts failed" —
|
|
# which leaves the curator cold and makes the first extraction slow.
|
|
for _ in range(60): # up to ~2 min
|
|
if await asyncio.to_thread(mgr.is_running):
|
|
break
|
|
await asyncio.sleep(2)
|
|
else:
|
|
return
|
|
settings = store.get_settings()
|
|
model = settings.get("memory_model") or await mgr.select_best_model()
|
|
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
|
await mgr.warm(model, num_gpu=num_gpu)
|
|
except Exception:
|
|
pass
|
|
asyncio.create_task(_bg())
|
|
|
|
|
|
@app.get("/")
|
|
async def health():
|
|
return {"status": "ok", "count": len(store.all())}
|
|
|
|
|
|
@app.get("/memories")
|
|
async def list_memories(section: Optional[str] = None):
|
|
items = store.all()
|
|
if section:
|
|
items = [i for i in items if i.section.lower() == section.lower()]
|
|
return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}
|
|
|
|
|
|
@app.post("/memories")
|
|
async def add_memory(payload: Dict[str, Any] = Body(...)):
|
|
text = (payload.get("text") or "").strip()
|
|
if not text:
|
|
raise HTTPException(status_code=400, detail="Missing 'text'")
|
|
item = MemoryItem(
|
|
id=str(uuid.uuid4()),
|
|
section=(payload.get("section") or "General").strip(),
|
|
text=text,
|
|
tags=payload.get("tags", []),
|
|
)
|
|
store.add(item)
|
|
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
|
|
|
|
|
|
@app.patch("/memories/{item_id}")
|
|
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
|
|
existing = store.get(item_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
updated = MemoryItem(
|
|
id=item_id,
|
|
section=(payload.get("section") or existing.section).strip(),
|
|
text=(payload.get("text") or existing.text).strip(),
|
|
tags=payload.get("tags", existing.tags),
|
|
)
|
|
store.update(updated)
|
|
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
|
|
|
|
|
|
@app.delete("/memories/{item_id}")
|
|
async def delete_memory(item_id: str):
|
|
if not store.get(item_id):
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
store.delete(item_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
def _cosine(a: list, b: list) -> float:
|
|
dot = sum(x * y for x, y in zip(a, b))
|
|
na = math.sqrt(sum(x * x for x in a))
|
|
nb = math.sqrt(sum(y * y for y in b))
|
|
return dot / (na * nb) if na and nb else 0.0
|
|
|
|
|
|
class ExtractRequest(BaseModel):
|
|
user_message: str
|
|
assistant_response: str
|
|
|
|
|
|
@app.post("/memories/extract")
|
|
async def extract_and_save(req: ExtractRequest):
|
|
"""LLM-curated extraction — asks Mistral to evaluate the exchange against all
|
|
existing memory items and save only new, permanent personal facts."""
|
|
existing = store.all()
|
|
existing_sections = list({i.section for i in existing})
|
|
existing_texts = [i.text for i in existing]
|
|
|
|
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
|
|
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
|
|
settings = store.get_settings()
|
|
mgr = get_ollama_manager()
|
|
model = settings.get("memory_model") or await mgr.select_best_model()
|
|
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
|
|
try:
|
|
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
|
|
except (TypeError, ValueError):
|
|
merge_threshold = 0.88
|
|
|
|
try:
|
|
results = await asyncio.wait_for(
|
|
extract_memory(
|
|
req.user_message,
|
|
req.assistant_response,
|
|
existing_sections,
|
|
existing_texts,
|
|
mgr,
|
|
model=model,
|
|
num_gpu=num_gpu,
|
|
),
|
|
timeout=300.0,
|
|
)
|
|
|
|
# Embed existing facts once so each new fact can be matched against them.
|
|
# A near-duplicate UPDATES the matched fact in place (edit with new info)
|
|
# rather than appending a copy. Best effort: if embeddings are down we
|
|
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
|
|
existing_embeds: dict = {}
|
|
if results and 0 < merge_threshold < 1:
|
|
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
|
|
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
|
|
|
|
saved = []
|
|
for result in results:
|
|
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
|
|
match_id, best = None, 0.0
|
|
if new_vec:
|
|
for eid, ev in existing_embeds.items():
|
|
sim = _cosine(new_vec, ev)
|
|
if sim > best:
|
|
best, match_id = sim, eid
|
|
if best < merge_threshold:
|
|
match_id = None
|
|
|
|
target = store.get(match_id) if match_id else None
|
|
if target:
|
|
# Near-duplicate of an existing fact — overwrite with the newer
|
|
# statement, keeping the original id/section/position.
|
|
updated = MemoryItem(id=target.id, section=target.section,
|
|
text=result["text"], tags=target.tags)
|
|
store.update(updated)
|
|
if new_vec:
|
|
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
|
|
saved.append({"id": updated.id, "section": updated.section,
|
|
"text": updated.text, "updated": True})
|
|
else:
|
|
item = MemoryItem(id=str(uuid.uuid4()),
|
|
section=result["section"], text=result["text"])
|
|
store.add(item)
|
|
if new_vec:
|
|
existing_embeds[item.id] = new_vec
|
|
saved.append({"id": item.id, "section": item.section, "text": item.text})
|
|
if saved:
|
|
first = {k: saved[0][k] for k in ("id", "section", "text")}
|
|
return {"saved": True, "items": saved, **first}
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
return {"saved": False}
|