| """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: |
| 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: |
| pass |
|
|
|
|
| def reset() -> None: |
| """Clear memory (used by tests).""" |
| with _lock: |
| _save({"facts": [], "seq": 0}) |
|
|
|
|
| |
| |
| |
| |
| |
| 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: |
| pass |
| return out |
|
|