mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
* fix(memory): recall auto-captured facts * fix(memory): harden recalled context injection * fix(memory): preserve mixed context history * fix(agents): preserve caller system context
235 lines
7.6 KiB
Python
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",
|
|
]
|