OffGridSchedula / server /memory.py
ParetoOptimal's picture
Initial Commit
0366d65
Raw
History Blame Contribute Delete
6.41 kB
"""Persistent 'grows-with-you' agent memory.
Durable facts and preferences that personalize extraction over time:
people->roles ("Dana is the soccer coach"), rules ("you decline Mondays"),
default locations. Stored as JSON at MEMORY_PATH.
- recall() -> a compact block injected into the agent prompt (server/agent.py)
- remember() -> add/strengthen a fact (Memory tab, or a Hermes `remember` tool-call)
- forget() -> drop a fact (Memory tab)
- observe_plan()-> conservatively learn recurring contacts from extracted events
This is the "memory" half of a Hermes-style grows-with-you agent; the model
(served via INFERENCE_BASE_URL) is the reasoning half.
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
MEMORY_PATH = Path(os.environ.get("MEMORY_PATH", "/tmp/agent_memory.json"))
MAX_FACTS = 200
KINDS = ("contact", "preference", "location", "note")
_lock = threading.Lock()
def _norm(text: str) -> str:
return " ".join(text.lower().split())
def _load() -> dict:
try:
data = json.loads(MEMORY_PATH.read_text())
if isinstance(data, dict) and isinstance(data.get("facts"), list):
return data
except Exception: # noqa: BLE001 missing/corrupt -> empty
pass
return {"facts": [], "seq": 0}
def _save(state: dict) -> None:
MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True)
MEMORY_PATH.write_text(json.dumps(state, indent=2))
def remember(text: str, kind: str = "note") -> dict | None:
"""Add a fact, or strengthen (bump weight) an existing one with the same text."""
text = (text or "").strip()
if not text:
return None
if kind not in KINDS:
kind = "note"
key = _norm(text)
with _lock:
state = _load()
for f in state["facts"]:
if _norm(f["text"]) == key:
f["weight"] = f.get("weight", 1) + 1
_save(state)
return f
state["seq"] += 1
fact = {"id": state["seq"], "kind": kind, "text": text, "weight": 1}
state["facts"].append(fact)
state["facts"] = state["facts"][-MAX_FACTS:]
_save(state)
return fact
def forget(fact_id: int) -> bool:
with _lock:
state = _load()
before = len(state["facts"])
state["facts"] = [f for f in state["facts"] if f["id"] != int(fact_id)]
changed = len(state["facts"]) != before
if changed:
_save(state)
return changed
def list_facts() -> list[dict]:
return _load()["facts"]
def recall(limit: int = 20) -> str:
"""Compact 'what I know about you' block for the prompt; '' if empty.
Strongest (most-reinforced) facts first so the prompt stays small but useful.
"""
facts = sorted(list_facts(), key=lambda f: f.get("weight", 1), reverse=True)[:limit]
if not facts:
return ""
lines = "\n".join(f"- {f['text']}" for f in facts)
return "What I know about you (memory):\n" + lines
def observe_plan(plan) -> None:
"""Conservatively learn from an extracted ActionPlan: record event attendees as
contacts (reinforced over time). Cheap, deterministic 'growth' without an LLM
round-trip; explicit facts still come via remember()/the Memory tab/tool-calls."""
try:
for ev in getattr(plan, "events", []) or []:
for name in getattr(ev, "attendees", []) or []:
name = (name or "").strip()
if name and len(name) <= 40:
remember(f"{name} is a contact you make plans with", kind="contact")
except Exception: # noqa: BLE001 memory must never break extraction
pass
def reset() -> None:
"""Clear memory (used by tests)."""
with _lock:
_save({"facts": [], "seq": 0})
# --------------------------------------------------------------------------- #
# Client-owned memory (per-user, browser localStorage). These are PURE helpers
# that operate on a passed-in facts list — no global file — so each visitor's
# memory can live on their device and be threaded through the agent per request.
# --------------------------------------------------------------------------- #
def facts_to_recall(facts: list[dict], limit: int = 20) -> str:
"""Same compact 'what I know about you' block as recall(), but for a passed
facts list (client/localStorage memory). '' if empty."""
facts = sorted(facts or [], key=lambda f: f.get("weight", 1), reverse=True)[:limit]
if not facts:
return ""
lines = "\n".join(f"- {f['text']}" for f in facts if (f or {}).get("text"))
return "What I know about you (memory):\n" + lines if lines else ""
def merge_facts(facts: list[dict], texts, kind: str = "note") -> list[dict]:
"""Add texts to a facts list (dedup by normalized text → bump weight), keeping
ids stable. Returns a NEW list (caller persists it). `texts` is an iterable of
strings, or of (text, kind) pairs."""
facts = [dict(f) for f in (facts or [])]
by_key = {_norm(f["text"]): f for f in facts if f.get("text")}
next_id = max((int(f.get("id", 0)) for f in facts), default=0) + 1
for item in texts or []:
if isinstance(item, (tuple, list)):
text, k = item[0], (item[1] if len(item) > 1 else kind)
else:
text, k = item, kind
text = (text or "").strip()
if not text:
continue
if k not in KINDS:
k = "note"
key = _norm(text)
if key in by_key:
f = by_key[key]
f["weight"] = f.get("weight", 1) + 1
else:
f = {"id": next_id, "kind": k, "text": text, "weight": 1}
next_id += 1
facts.append(f)
by_key[key] = f
return facts[-MAX_FACTS:]
def learn_from_plan(plan) -> list[str]:
"""Contact texts to learn from an ActionPlan (the observe_plan() logic, but
RETURNED for client-side merge instead of written to the global file)."""
out: list[str] = []
try:
for ev in getattr(plan, "events", []) or []:
for name in getattr(ev, "attendees", []) or []:
name = (name or "").strip()
if name and len(name) <= 40:
out.append(f"{name} is a contact you make plans with")
except Exception: # noqa: BLE001 memory must never break extraction
pass
return out