| """Local journal store with private semantic memory. |
| |
| Everything lives on-device: entries in SQLite, embeddings computed by a local |
| ollama embedding model. No cloud, no account. The semantic recall is what lets |
| the companion say "you mentioned the dentist on Tuesday — how'd that go?" without |
| the model needing a huge context window: we retrieve only the relevant past |
| entries and hand them to the chat model as context. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import math |
| import os |
| import sqlite3 |
| import time |
| from dataclasses import dataclass |
| from datetime import datetime, timezone |
| from pathlib import Path |
|
|
| import requests |
|
|
| DEFAULT_DB = Path.home() / ".pocket-confidant" / "journal.db" |
| EMBED_MODEL = "nomic-embed-text:latest" |
| EMBED_DIM = 768 |
| OLLAMA = "http://localhost:11434" |
|
|
| |
| |
| ST_MODEL_ID = "nomic-ai/nomic-embed-text-v1.5" |
|
|
| |
| EMBED_GGUF_REPO = "nomic-ai/nomic-embed-text-v1.5-GGUF" |
| EMBED_GGUF_FILE = "nomic-embed-text-v1.5.Q4_K_M.gguf" |
|
|
| DEMO_DATA_PATH = Path(__file__).parent.parent / "data" / "demo_entries.json" |
|
|
| _ST_MODEL = None |
| _LLAMACPP_EMBED = None |
|
|
|
|
| def _get_st_model(): |
| """Lazy-load the sentence-transformers model (dev fallback).""" |
| global _ST_MODEL |
| if _ST_MODEL is None: |
| from sentence_transformers import SentenceTransformer |
| _ST_MODEL = SentenceTransformer(ST_MODEL_ID, trust_remote_code=True) |
| return _ST_MODEL |
|
|
|
|
| def _get_llamacpp_embed(): |
| """Lazy-load the llama.cpp embedding backend (HF Space path).""" |
| global _LLAMACPP_EMBED |
| if _LLAMACPP_EMBED is None: |
| import os |
| from .backends import LlamaCppTextBackend |
| gguf = os.environ.get("POCKET_CONFIDANT_EMBED_GGUF", "") |
| if not gguf: |
| raise RuntimeError( |
| "POCKET_CONFIDANT_EMBED_GGUF not set. Space load_model.py should set it." |
| ) |
| _LLAMACPP_EMBED = LlamaCppTextBackend(model_path=gguf, embedding=True) |
| return _LLAMACPP_EMBED |
|
|
|
|
| def embed(text: str, model: str = EMBED_MODEL, host: str = OLLAMA) -> list[float]: |
| """Embed text with a local model. Tries in order: |
| 1. ollama (dev) |
| 2. llama.cpp embedding (HF Space, when POCKET_CONFIDANT_BACKEND=llamacpp) |
| 3. sentence-transformers (dev fallback) |
| |
| Private — never leaves the machine. |
| """ |
| backend = os.environ.get("POCKET_CONFIDANT_BACKEND", "ollama").lower() |
|
|
| if backend == "llamacpp": |
| |
| try: |
| be = _get_llamacpp_embed() |
| return be.embed(text) |
| except Exception: |
| pass |
| else: |
| |
| try: |
| resp = requests.post( |
| f"{host.rstrip('/')}/api/embeddings", |
| json={"model": model, "prompt": text}, |
| timeout=20, |
| ) |
| resp.raise_for_status() |
| return resp.json()["embedding"] |
| except Exception: |
| pass |
|
|
| |
| st = _get_st_model() |
| return st.encode(text, normalize_embeddings=True).tolist() |
|
|
|
|
| def _cosine(a: list[float], b: list[float]) -> 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)) |
| if na == 0 or nb == 0: |
| return 0.0 |
| return dot / (na * nb) |
|
|
|
|
| @dataclass |
| class Entry: |
| id: int |
| ts: float |
| day: str |
| text: str |
| reflection: str = "" |
| question: str = "" |
|
|
| @property |
| def when(self) -> str: |
| return self.day |
|
|
|
|
| class JournalStore: |
| def __init__(self, db_path: Path | str = DEFAULT_DB): |
| self.db_path = Path(db_path) |
| self.db_path.parent.mkdir(parents=True, exist_ok=True) |
| self.conn = sqlite3.connect(self.db_path, check_same_thread=False) |
| self.conn.row_factory = sqlite3.Row |
| self._init_schema() |
|
|
| def _init_schema(self) -> None: |
| self.conn.execute( |
| """CREATE TABLE IF NOT EXISTS entries ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| ts REAL NOT NULL, |
| day TEXT NOT NULL, |
| text TEXT NOT NULL, |
| reflection TEXT DEFAULT '', |
| question TEXT DEFAULT '', |
| embedding TEXT DEFAULT '' |
| )""" |
| ) |
| self.conn.commit() |
|
|
| def add(self, text: str, day: str, ts: float, reflection: str = "", question: str = "") -> Entry: |
| try: |
| emb = json.dumps(embed(text)) |
| except Exception: |
| emb = "" |
| cur = self.conn.execute( |
| "INSERT INTO entries (ts, day, text, reflection, question, embedding) VALUES (?,?,?,?,?,?)", |
| (ts, day, text, reflection, question, emb), |
| ) |
| self.conn.commit() |
| return Entry(id=cur.lastrowid, ts=ts, day=day, text=text, reflection=reflection, question=question) |
|
|
| def update_response(self, entry_id: int, reflection: str, question: str) -> None: |
| self.conn.execute( |
| "UPDATE entries SET reflection=?, question=? WHERE id=?", (reflection, question, entry_id) |
| ) |
| self.conn.commit() |
|
|
| def recent(self, n: int = 3, before_id: int | None = None) -> list[Entry]: |
| """Most recent entries chronologically (for day-to-day continuity).""" |
| if before_id is not None: |
| rows = self.conn.execute( |
| "SELECT * FROM entries WHERE id < ? ORDER BY id DESC LIMIT ?", (before_id, n) |
| ).fetchall() |
| else: |
| rows = self.conn.execute("SELECT * FROM entries ORDER BY id DESC LIMIT ?", (n,)).fetchall() |
| return [self._row(r) for r in reversed(rows)] |
|
|
| def recall(self, query: str, k: int = 3, before_id: int | None = None, |
| min_score: float = 0.0) -> list[Entry]: |
| """Semantically most-relevant past entries — the 'it remembers' magic. |
| |
| `min_score` is a cosine-similarity floor: only entries that are genuinely |
| related surface. This is what stops the companion from force-connecting an |
| unrelated memory (e.g. dragging 'dentist dread' into a journal entry about a |
| side project) and reusing a canned callback line. |
| """ |
| try: |
| qe = embed(query) |
| except Exception: |
| return [] |
| rows = self.conn.execute( |
| "SELECT * FROM entries WHERE embedding != ''" |
| + (" AND id < ?" if before_id is not None else ""), |
| (before_id,) if before_id is not None else (), |
| ).fetchall() |
| scored: list[tuple[float, Entry]] = [] |
| for r in rows: |
| try: |
| emb = json.loads(r["embedding"]) |
| except (json.JSONDecodeError, TypeError): |
| continue |
| score = _cosine(qe, emb) |
| if score >= min_score: |
| scored.append((score, self._row(r))) |
| scored.sort(key=lambda t: t[0], reverse=True) |
| return [e for _, e in scored[:k]] |
|
|
| def all(self) -> list[Entry]: |
| rows = self.conn.execute("SELECT * FROM entries ORDER BY id").fetchall() |
| return [self._row(r) for r in rows] |
|
|
| @staticmethod |
| def _row(r: sqlite3.Row) -> Entry: |
| return Entry(id=r["id"], ts=r["ts"], day=r["day"], text=r["text"], |
| reflection=r["reflection"], question=r["question"]) |
|
|
| def close(self) -> None: |
| self.conn.close() |
|
|
|
|
| def seed_demo_data(db_path: Path | str) -> int: |
| conn = sqlite3.connect(str(db_path)) |
| entries = json.loads(DEMO_DATA_PATH.read_text()) |
| count = 0 |
| for e in entries: |
| day = e["created_at"][:10] |
| try: |
| ts_str = e["created_at"] |
| if "." in ts_str: |
| ts_str = ts_str.split(".")[0] |
| ts_f = datetime.fromisoformat(ts_str).replace(tzinfo=timezone.utc).timestamp() |
| except Exception: |
| ts_f = 0.0 |
| conn.execute( |
| "INSERT INTO entries (ts, day, text, reflection, question) VALUES (?,?,?,?,?)", |
| (ts_f, day, e["text"], e.get("reflection", ""), e.get("question", "")), |
| ) |
| count += 1 |
| conn.commit() |
| conn.close() |
| return count |
|
|
|
|
| def is_db_empty(db_path: Path | str) -> bool: |
| conn = sqlite3.connect(str(db_path)) |
| (row,) = conn.execute("SELECT COUNT(*) FROM entries").fetchone() |
| conn.close() |
| return row == 0 |
|
|
|
|
| def clear_all_entries(db_path: str) -> int: |
| """Delete ALL entries from the database. Returns count deleted. |
| WARNING: This is destructive! Only use in dev mode or with user confirmation. |
| """ |
| conn = sqlite3.connect(db_path) |
| cur = conn.cursor() |
| cur.execute("SELECT COUNT(*) FROM entries") |
| count = cur.fetchone()[0] |
| cur.execute("DELETE FROM entries") |
| conn.commit() |
| conn.close() |
| return count |
|
|