Spaces:
Running on Zero
Running on Zero
| """ | |
| Frox AI — Memory Tool | |
| A local-first, self-contained memory store: JSON-persisted facts per | |
| user, retrieved by cosine similarity over embeddings from Morph's own | |
| model (ctx.engine.embed() — see inference/engine/morph_engine.py). | |
| No external vector DB required, so this works standalone in this repo. | |
| The production backend architecture document specs a Postgres + | |
| Qdrant version of this same idea for multi-instance deployments | |
| (Section 5: Memory System) — this module is the reference | |
| implementation / local-dev equivalent, not a competing design. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| import time | |
| import uuid | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| from tools.registry import tool, ToolContext | |
| class MemoryItem: | |
| id: str | |
| user_id: str | |
| text: str | |
| category: str | |
| embedding: List[float] | |
| created_at: float | |
| def to_dict(self, include_embedding: bool = False) -> dict: | |
| d = {"id": self.id, "user_id": self.user_id, "text": self.text, | |
| "category": self.category, "created_at": self.created_at} | |
| if include_embedding: | |
| d["embedding"] = self.embedding | |
| return d | |
| def _cosine(a: List[float], b: List[float]) -> float: | |
| dot = sum(x * y for x, y in zip(a, b)) | |
| norm_a = math.sqrt(sum(x * x for x in a)) | |
| norm_b = math.sqrt(sum(y * y for y in b)) | |
| if norm_a == 0 or norm_b == 0: | |
| return 0.0 | |
| return dot / (norm_a * norm_b) | |
| class MemoryStore: | |
| """ | |
| Per-process memory store, JSON-persisted to disk. Embeddings come | |
| from whatever MorphInferenceEngine is passed in at save time — the | |
| store itself has no model dependency, so it's easy to unit test | |
| with fake embeddings. | |
| """ | |
| def __init__(self, path: Optional[str] = None): | |
| self.path = Path(path) if path else None | |
| self._items: Dict[str, MemoryItem] = {} | |
| if self.path and self.path.exists(): | |
| self._load() | |
| def _load(self): | |
| try: | |
| data = json.loads(self.path.read_text()) | |
| for item in data: | |
| self._items[item["id"]] = MemoryItem(**item) | |
| except (json.JSONDecodeError, TypeError, KeyError): | |
| pass # start fresh rather than crash on a corrupted file | |
| def _persist(self): | |
| if self.path is None: | |
| return | |
| self.path.parent.mkdir(parents=True, exist_ok=True) | |
| self.path.write_text(json.dumps( | |
| [i.to_dict(include_embedding=True) for i in self._items.values()], indent=2 | |
| )) | |
| def add(self, user_id: str, text: str, embedding: List[float], category: str = "fact") -> MemoryItem: | |
| item = MemoryItem( | |
| id=str(uuid.uuid4()), user_id=user_id, text=text, | |
| category=category, embedding=embedding, created_at=time.time(), | |
| ) | |
| self._items[item.id] = item | |
| self._persist() | |
| return item | |
| def search(self, user_id: str, query_embedding: List[float], k: int = 5) -> List[MemoryItem]: | |
| candidates = [i for i in self._items.values() if i.user_id == user_id] | |
| scored = sorted(candidates, key=lambda i: _cosine(i.embedding, query_embedding), reverse=True) | |
| return scored[:k] | |
| def list_all(self, user_id: str) -> List[MemoryItem]: | |
| return [i for i in self._items.values() if i.user_id == user_id] | |
| def delete(self, memory_id: str) -> bool: | |
| if memory_id in self._items: | |
| del self._items[memory_id] | |
| self._persist() | |
| return True | |
| return False | |
| def clear(self, user_id: str): | |
| to_delete = [i.id for i in self._items.values() if i.user_id == user_id] | |
| for mid in to_delete: | |
| del self._items[mid] | |
| self._persist() | |
| def memory_save(ctx: ToolContext, text: str, category: str = "fact") -> dict: | |
| """ | |
| Args: | |
| text: The fact to remember, e.g. "User prefers Python over JavaScript". | |
| category: One of fact | preference | project | person (freeform, for organization). | |
| Plain `def`, not `async def`: engine.embed() is synchronous and | |
| GPU-bound — thread-offloaded by the registry. | |
| """ | |
| if ctx.memory_store is None: | |
| raise RuntimeError("No memory_store configured in ToolContext") | |
| if ctx.engine is None: | |
| raise RuntimeError("No engine configured in ToolContext (needed to embed the memory)") | |
| embedding = ctx.engine.embed(text) | |
| item = ctx.memory_store.add(ctx.user_id, text, embedding, category) | |
| return {"saved": True, "memory_id": item.id, "text": text} | |
| def memory_search(ctx: ToolContext, query: str, k: int = 5) -> dict: | |
| """ | |
| Args: | |
| query: What to search for, e.g. "what programming language does the user like". | |
| k: Max number of memories to return. | |
| Plain `def`, not `async def`: engine.embed() is synchronous and | |
| GPU-bound — thread-offloaded by the registry. | |
| """ | |
| if ctx.memory_store is None: | |
| raise RuntimeError("No memory_store configured in ToolContext") | |
| if ctx.engine is None: | |
| raise RuntimeError("No engine configured in ToolContext (needed to embed the query)") | |
| query_embedding = ctx.engine.embed(query) | |
| results = ctx.memory_store.search(ctx.user_id, query_embedding, k=k) | |
| return {"query": query, "memories": [r.to_dict() for r in results]} | |
| def memory_forget(ctx: ToolContext, memory_id: str) -> dict: | |
| """ | |
| Args: | |
| memory_id: The id returned by memory_save. | |
| Plain `def` for consistency with memory_save/memory_search in this | |
| module — no async I/O here either. | |
| """ | |
| if ctx.memory_store is None: | |
| raise RuntimeError("No memory_store configured in ToolContext") | |
| deleted = ctx.memory_store.delete(memory_id) | |
| return {"deleted": deleted, "memory_id": memory_id} | |