Terminal / memory /sync.py
Pulka
sync: 111 file da Baida98/AI@e8f2ba6a (2026-06-21 16:56 UTC)
068e397 verified
Raw
History Blame Contribute Delete
9.23 kB
"""
sync.py — Memory Sync Protocol
Sincronizza in modo idempotente la memoria locale del browser con il backend.
Il protocollo accetta batch di elementi firmati da source/id/versione e salva
il contenuto nei layer episodic/semantic senza richiedere dipendenze aggiuntive.
GAP-VAULT-AUTH (fix): aggiunge _require_sync_auth (Bearer VAULT_ADMIN_TOKEN) su
push / pull / export / import — impedisce dump completo della memoria semantica
via curl non autenticata.
"""
from __future__ import annotations
import hashlib
import os
import time
from typing import Any, Iterable, Literal, Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
MemoryLayer = Literal["working", "episodic", "semantic", "reflection"]
MemoryType = Literal["fact", "preference", "summary", "skill", "episode", "reflection"]
class SyncMemoryItem(BaseModel):
id: str = Field(..., min_length=1)
content: str = Field(..., min_length=1)
type: MemoryType = "fact"
layer: MemoryLayer = "semantic"
topic: str | None = None
weight: float = 1.0
created_at: int | None = None
updated_at: int | None = None
source: str = "browser"
metadata: dict[str, Any] = Field(default_factory=dict)
class MemorySyncPushRequest(BaseModel):
device_id: str = "browser"
since: int | None = None
items: list[SyncMemoryItem] = Field(default_factory=list)
class MemorySyncPullRequest(BaseModel):
device_id: str = "browser"
query: str | None = None
since: int | None = None
limit: int = Field(default=50, ge=1, le=200)
layer: MemoryLayer | None = None
class MemorySyncStatus(BaseModel):
ok: bool
protocol: str = "memory-sync-v1"
server_time: int
stats: dict[str, Any]
# ── GAP-VAULT-AUTH: autenticazione Bearer ─────────────────────────────────────
_SYNC_ADMIN_TOKEN = os.getenv('VAULT_ADMIN_TOKEN', '') # stessa variabile del vault
async def _require_sync_auth(authorization: Optional[str] = Header(None)) -> None:
"""Richiede Authorization: Bearer VAULT_ADMIN_TOKEN se configurato.
Protegge push/pull/export/import da dump non autenticati via curl.
/status rimane pubblico (nessun dato esposto, solo statistiche aggregate).
"""
if not _SYNC_ADMIN_TOKEN:
return # Auth disabilitata — imposta VAULT_ADMIN_TOKEN per proteggere
if authorization != f'Bearer {_SYNC_ADMIN_TOKEN}':
raise HTTPException(
status_code=401,
detail='Memory sync: non autorizzato — Bearer token non valido o mancante',
)
def _now_ms() -> int:
return int(time.time() * 1000)
def _fingerprint(item: SyncMemoryItem) -> str:
raw = f"{item.source}:{item.id}:{item.updated_at or item.created_at or 0}:{item.content}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:16]
def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
"""Best-effort export from available memory layers."""
if layer in (None, "working") and hasattr(memory, "working"):
# S571-GAP8: WorkingMemory usa self._buf (deque), NON entries.
recent = memory.working.get_recent(n=limit)
for idx, entry in enumerate(recent):
content = entry.get("content", str(entry))
role = entry.get("role", "unknown")
yield {
"id": f"working-{idx}",
"content": content,
"type": "episode",
"layer": "working",
"topic": role,
"weight": 1,
"updated_at": _now_ms(),
"source": "backend",
"metadata": {"role": role},
}
def create_memory_sync_router(memory: Any) -> APIRouter:
router = APIRouter(prefix="/api/memory/sync", tags=["memory-sync"])
@router.get("/status", response_model=MemorySyncStatus)
async def sync_status() -> MemorySyncStatus:
"""Pubblico: espone solo statistiche aggregate, nessun contenuto."""
return MemorySyncStatus(ok=True, server_time=_now_ms(), stats=memory.stats())
@router.post("/push")
async def sync_push(
req: MemorySyncPushRequest,
_auth: None = Depends(_require_sync_auth),
) -> dict[str, Any]:
accepted: list[str] = []
skipped: list[dict[str, str]] = []
now = _now_ms()
for item in req.items:
if not item.content.strip():
skipped.append({"id": item.id, "reason": "empty-content"})
continue
item.metadata.setdefault("sync_id", item.id)
item.metadata.setdefault("device_id", req.device_id)
item.metadata.setdefault("fingerprint", _fingerprint(item))
item.metadata.setdefault("synced_at", now)
item.metadata.setdefault("source", item.source)
item.metadata.setdefault("topic", item.topic or "generale")
try:
if item.layer == "working" and hasattr(memory, "working"):
role = str(item.metadata.get("role") or item.type)
memory.working.add(role, item.content)
elif item.layer == "episodic":
await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
else:
if getattr(memory.semantic, "available", False):
memory.semantic.add(item.content, {"type": item.type, **item.metadata})
else:
await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
accepted.append(item.id)
except Exception as exc:
skipped.append({"id": item.id, "reason": str(exc)[:300]})
return {
"ok": True,
"protocol": "memory-sync-v1",
"accepted": accepted,
"skipped": skipped,
"server_time": now,
"stats": memory.stats(),
}
@router.post("/pull")
async def sync_pull(
req: MemorySyncPullRequest,
_auth: None = Depends(_require_sync_auth),
) -> dict[str, Any]:
query = (req.query or "").strip()
items: list[dict[str, Any]] = []
if query:
results = await memory.search(query, req.limit, layer=req.layer)
for idx, hit in enumerate(results):
content = str(hit.get("content", ""))
items.append({
"id": str(hit.get("id") or f"backend-search-{idx}"),
"content": content,
"type": str(hit.get("type") or "fact"),
"layer": str(hit.get("layer") or req.layer or "semantic"),
"topic": str(hit.get("topic") or "generale"),
"weight": float(hit.get("similarity") or 1),
"updated_at": _now_ms(),
"source": "backend",
"metadata": {k: v for k, v in hit.items() if k not in {"content"}},
})
else:
items.extend(list(_iter_local_items(memory, req.layer, req.limit)))
return {
"ok": True,
"protocol": "memory-sync-v1",
"items": items[: req.limit],
"server_time": _now_ms(),
"stats": memory.stats(),
}
@router.get("/export")
async def memory_export(
limit: int = 2000,
_auth: None = Depends(_require_sync_auth),
) -> dict[str, Any]:
"""Esporta l'intera semantic memory. Richiede Bearer VAULT_ADMIN_TOKEN."""
if not getattr(memory.semantic, "available", False):
return {"ok": False, "error": "semantic memory non disponibile", "records": [], "count": 0}
import asyncio as _asyncio
records = await _asyncio.to_thread(memory.semantic.export_all, limit)
return {
"ok": True,
"protocol": "semantic-export-v1",
"count": len(records),
"records": records,
"server_time": _now_ms(),
}
class _MemoryImportRequest(BaseModel):
records: list[dict[str, Any]] = Field(default_factory=list)
overwrite: bool = False
@router.post("/import")
async def memory_import(
req: _MemoryImportRequest,
_auth: None = Depends(_require_sync_auth),
) -> dict[str, Any]:
"""Importa records nella semantic memory. Richiede Bearer VAULT_ADMIN_TOKEN."""
if not getattr(memory.semantic, "available", False):
return {"ok": False, "error": "semantic memory non disponibile", "imported": 0, "skipped": 0, "total": 0}
if not req.records:
return {"ok": True, "imported": 0, "skipped": 0, "total": 0, "server_time": _now_ms()}
import asyncio as _asyncio
result = await _asyncio.to_thread(memory.semantic.import_all, req.records, req.overwrite)
return {
"ok": True,
"protocol": "semantic-export-v1",
**result,
"server_time": _now_ms(),
}
return router