Files
OpenJarvis/src/openjarvis/memory/store.py
T
Elliot Slusky c3a7ffebff fix(memory): recall auto-captured facts across sessions (#740)
* fix(memory): recall auto-captured facts

* fix(memory): harden recalled context injection

* fix(memory): preserve mixed context history

* fix(agents): preserve caller system context
2026-08-13 17:07:10 -07:00

235 lines
7.6 KiB
Python

"""Persistent stores for automatically extracted long-term memory facts.
A *fact* is a short, durable statement worth remembering about the user
(e.g. ``"Prefers concise answers"``). Facts are produced by the memory
service's background extractor and persisted here so they survive across
sessions. The store is intentionally small and self-contained: it dedupes,
caps the total number of facts, and is safe to call from multiple threads.
"""
from __future__ import annotations
import json
import os
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable, List
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import FactStoreRegistry
def _default_fact_path() -> Path:
"""Return the env-aware default JSONL path for automatic memory facts."""
return get_config_dir() / "memory_facts.jsonl"
@dataclass(slots=True)
class Fact:
"""A single durable memory entry."""
text: str
source: str = ""
created_at: float = 0.0
class FactStore(ABC):
"""Abstract persistent store for extracted memory facts."""
@abstractmethod
def add(self, text: str, source: str = "") -> bool:
"""Store *text* as a fact. Returns True if a new fact was stored."""
def add_many(self, texts: Iterable[str], source: str = "") -> int:
"""Store several facts, returning the count of newly stored ones."""
added = 0
for text in texts:
if self.add(text, source=source):
added += 1
return added
@abstractmethod
def list(self) -> List[Fact]:
"""Return all stored facts, oldest first."""
@abstractmethod
def clear(self) -> int:
"""Remove all stored facts, returning the number removed."""
@abstractmethod
def count(self) -> int:
"""Return the number of stored facts."""
@FactStoreRegistry.register("local")
class LocalFactStore(FactStore):
"""Append-only JSONL fact store on the local filesystem.
Facts are kept human-readable (one JSON object per line) so they can be
inspected or edited by hand. Writes are atomic (temp file + rename) and
guarded by a lock, so concurrent ``add`` calls from the extraction worker
and ``list``/``clear`` from the CLI never corrupt the file.
"""
def __init__(
self,
path: str | Path | None = None,
*,
max_facts: int = 1000,
) -> None:
self._path = (
Path(path).expanduser() if path is not None else _default_fact_path()
)
self._max_facts = max(0, int(max_facts))
self._lock = threading.Lock()
self._facts: List[Fact] = self._load()
# -- persistence --------------------------------------------------------
def _load(self) -> List[Fact]:
if not self._path.exists():
return []
facts: List[Fact] = []
try:
text = self._path.read_text(encoding="utf-8")
except OSError:
return []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue # skip malformed lines rather than crashing
fact_text = str(obj.get("text", "")).strip()
if not fact_text:
continue
facts.append(
Fact(
text=fact_text,
source=str(obj.get("source", "")),
created_at=float(obj.get("created_at", 0.0) or 0.0),
)
)
return facts
def _flush(self) -> None:
"""Atomically rewrite the JSONL file from the in-memory list."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
payload = "".join(
json.dumps(asdict(f), ensure_ascii=False) + "\n" for f in self._facts
)
tmp.write_text(payload, encoding="utf-8")
os.replace(tmp, self._path)
def _sync_from_disk_locked(self) -> None:
"""Refresh in-memory facts from disk while holding ``self._lock``."""
self._facts = self._load()
# -- FactStore API ------------------------------------------------------
def add(self, text: str, source: str = "") -> bool:
text = (text or "").strip()
if not text:
return False
with self._lock:
self._sync_from_disk_locked()
lowered = text.lower()
if any(f.text.lower() == lowered for f in self._facts):
return False # dedupe
self._facts.append(Fact(text=text, source=source, created_at=time.time()))
# Enforce the cap by evicting the oldest entries.
if self._max_facts and len(self._facts) > self._max_facts:
self._facts = self._facts[-self._max_facts :]
self._flush()
return True
def list(self) -> List[Fact]:
with self._lock:
self._sync_from_disk_locked()
return list(self._facts)
def clear(self) -> int:
with self._lock:
self._sync_from_disk_locked()
removed = len(self._facts)
self._facts = []
if self._path.exists():
try:
self._path.unlink()
except OSError:
self._flush()
return removed
def count(self) -> int:
with self._lock:
self._sync_from_disk_locked()
return len(self._facts)
@property
def path(self) -> Path:
"""Filesystem location of the JSONL store."""
return self._path
def _ensure_fact_store_backends_registered() -> None:
"""Restore built-in fact-store registrations if a test cleared registries."""
if not FactStoreRegistry.contains("local"):
FactStoreRegistry.register_value("local", LocalFactStore)
def create_fact_store(
backend: str = "local",
*,
path: str | Path | None = None,
max_facts: int = 1000,
) -> FactStore:
"""Construct a fact store for the configured *backend*.
Only the ``"local"`` (on-disk JSONL) backend is supported today; the
registry-backed constructor exists so additional backends can be added
without changing the service or CLI wiring.
"""
_ensure_fact_store_backends_registered()
key = (backend or "local").strip().lower()
if not FactStoreRegistry.contains(key):
supported = ", ".join(FactStoreRegistry.keys())
raise ValueError(
f"Unknown memory backend '{backend}'. Supported backends: {supported}"
)
return FactStoreRegistry.create(key, path, max_facts=max_facts)
def load_configured_facts(config: Any) -> List[Fact]:
"""Load automatic-memory facts from *config* when the service is enabled.
Context injection is also used by short-lived commands such as
``jarvis ask``, where no :class:`MemoryService` instance exists. This
helper gives those callers the same configured fact-store view without
coupling them to the service lifecycle.
"""
memory = getattr(config, "memory", None)
if memory is None or not getattr(memory, "enabled", False):
return []
store = create_fact_store(
getattr(memory, "backend", "local"),
path=getattr(memory, "facts_path", None),
max_facts=getattr(memory, "max_facts", 1000),
)
return store.list()
__all__ = [
"Fact",
"FactStore",
"LocalFactStore",
"create_fact_store",
"load_configured_facts",
]