"""Long-term memory for LifeOS — fully local RAG. Embeddings run through llama.cpp (nomic-embed-text-v1.5 GGUF, 146MB) — the same runtime as the chat model, so the whole stack stays on-device. Notes live in data/longterm.json with their vectors; retrieval is numpy cosine top-k. No vector DB, no cloud. """ import hashlib import json import os import threading import time import uuid import numpy as np import config DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") STORE_PATH = os.path.join(DATA_DIR, "longterm.json") EMBED_REPO = config.EMBED_REPO EMBED_FILE = config.EMBED_FILE _lock = threading.Lock() _embedder = None def warmup() -> None: """Load the embedder eagerly. Called at startup so nomic-embed is resident before the vision model ever loads — loading a model *after* the VLM can fail on small GPUs, and every chat needs the embedder for recall anyway.""" _get_embedder() def _get_embedder(): global _embedder if _embedder is None: import cuda_bootstrap cuda_bootstrap.ensure() from llama_cpp import Llama _embedder = Llama.from_pretrained( repo_id=EMBED_REPO, filename=EMBED_FILE, embedding=True, n_ctx=2048, n_gpu_layers=config.GPU_LAYERS, verbose=False, ) return _embedder def embed(text: str, is_query: bool = False) -> np.ndarray: # nomic-embed expects task prefixes prefixed = ("search_query: " if is_query else "search_document: ") + text with _lock: out = _get_embedder().create_embedding(prefixed) vec = np.asarray(out["data"][0]["embedding"], dtype=np.float32) norm = np.linalg.norm(vec) return vec / norm if norm > 0 else vec def _load_store() -> list[dict]: if not os.path.exists(STORE_PATH): return [] with open(STORE_PATH, "r", encoding="utf-8") as f: notes = json.load(f) # Backfill ids on notes written before note management existed. if any(not n.get("id") for n in notes): for n in notes: if not n.get("id"): n["id"] = uuid.uuid4().hex[:8] _save_store(notes) return notes def _save_store(notes: list[dict]) -> None: os.makedirs(DATA_DIR, exist_ok=True) with open(STORE_PATH, "w", encoding="utf-8") as f: json.dump(notes, f, ensure_ascii=False) def remember(text: str, kind: str = "fact", meta: dict | None = None) -> dict: """Embed and persist a long-term note. kind: fact|event|preference.""" note = { "id": hashlib.md5(f"{text}{time.time()}".encode()).hexdigest()[:12], "text": text, "kind": kind, "ts": time.time(), "meta": meta or {}, "vec": embed(text).tolist(), } with _lock: notes = _load_store() # skip exact-duplicate texts if any(n["text"] == text for n in notes): return note notes.append(note) _save_store(notes) return note def recall(query: str, k: int = 5, kind: str | None = None) -> list[dict]: """Top-k notes by cosine similarity; returns [{text, kind, score}].""" with _lock: notes = _load_store() if kind: notes = [n for n in notes if n["kind"] == kind] if not notes: return [] q = embed(query, is_query=True) # Self-heal: notes embedded under a different embedder (e.g. a store from # an older build) have the wrong vector dimension — re-embed them. stale = [n for n in notes if len(n["vec"]) != len(q)] if stale: for n in stale: n["vec"] = embed(n["text"]).tolist() with _lock: current = _load_store() by_id = {n["id"]: n["vec"] for n in notes} for n in current: if n["id"] in by_id: n["vec"] = by_id[n["id"]] _save_store(current) mat = np.asarray([n["vec"] for n in notes], dtype=np.float32) scores = mat @ q order = np.argsort(-scores)[:k] return [ {"text": notes[i]["text"], "kind": notes[i]["kind"], "score": float(scores[i])} for i in order if scores[i] > 0 ] def list_notes() -> list[dict]: """All long-term notes without vectors: [{id, text, kind}].""" with _lock: notes = _load_store() return [{"id": n["id"], "text": n["text"], "kind": n["kind"]} for n in notes] def update_note(note_id: str, text: str) -> bool: """Replace a note's text (re-embeds; kind/meta preserved). True if found.""" vec = embed(text).tolist() with _lock: notes = _load_store() for n in notes: if n["id"] == note_id: n["text"] = text n["vec"] = vec n["ts"] = time.time() _save_store(notes) return True return False def delete_note(note_id: str) -> bool: with _lock: notes = _load_store() kept = [n for n in notes if n["id"] != note_id] if len(kept) != len(notes): _save_store(kept) return True return False SEED_NOTES = [ ("Awais is lactose-sensitive — fine with yogurt and hard cheese, avoid milk-heavy dishes", "preference"), ("Awais eats halal only and skips pork entirely", "preference"), ("Awais hates cilantro", "preference"), ("Awais aims for at least 120g of protein per day", "preference"), ("Awais's weekly grocery budget is $80 and he shops on Saturdays", "fact"), ("Awais shares streaming accounts with his roommate Hamza — Disney+ was Hamza's idea", "fact"), ("Awais signed up for FitnessPal Pro during a January resolution and stopped using it in March", "event"), ("Awais is training for a 10K in September; longest run so far is 6km", "fact"), ("Awais's left knee gets sore if he runs two days in a row — needs a rest day between runs", "preference"), ("Awais prefers lifting in the evening after classes, runs in the morning", "preference"), ("Awais's go-to cheap protein is chicken thighs and canned chickpeas", "preference"), ("Awais cooked too much pasta lately and wants more variety in meals", "event"), ("Awais gets paid around the 1st of each month — roughly $1400 from his part-time job", "fact"), ("Awais wants to save $150 a month toward a climbing gym membership", "fact"), ("Awais usually meal-preps rice in bulk on Sundays", "preference"), ] def ensure_seeded() -> int: """Seed the long-term store on first run. Returns note count.""" with _lock: existing = _load_store() if existing: return len(existing) for text, kind in SEED_NOTES: remember(text, kind=kind, meta={"seed": True}) with _lock: return len(_load_store()) def reset_to_seed() -> int: with _lock: if os.path.exists(STORE_PATH): os.remove(STORE_PATH) return ensure_seeded() def reset_to_empty() -> int: """Delete all long-term notes (real-user reset). Returns 0.""" with _lock: if os.path.exists(STORE_PATH): os.remove(STORE_PATH) return 0