Ante / src /ante /memory.py
Ransom070's picture
Add 0G Compute Network speech backend
1445a0e
Raw History Blame Contribute Delete
6.3 kB
from __future__ import annotations
import os
import json
import time
import random
import string
import logging
import threading
from pathlib import Path
from dataclasses import dataclass
from collections.abc import Mapping
logger = logging.getLogger(__name__)
SCHEMA_VERSION = 1
MAX_FACTS = 60
MAX_FACT_CHARS = 280
MEMORY_FILENAME = "memory.v1.json"
_STORE_LOCK = threading.Lock()
@dataclass(frozen=True)
class MemoryFact:
"""One short long-term memory fact."""
id: str
text: str
created_at: int
def to_json(self) -> dict[str, object]:
"""Return the persisted JSON shape used by the mobile app."""
return {
"id": self.id,
"text": self.text,
"createdAt": self.created_at,
}
@dataclass(frozen=True)
class ForgetMemoryResult:
"""Result of removing a memory fact."""
removed: MemoryFact | None
candidates: tuple[MemoryFact, ...]
def memory_path_for_instance(instance_path: str | Path | None = None) -> Path:
"""Return the memory JSON path for this app instance."""
if instance_path is not None:
return Path(instance_path).expanduser() / MEMORY_FILENAME
data_home = os.getenv("XDG_DATA_HOME")
data_root = Path(data_home).expanduser() if data_home else Path.home() / ".local" / "share"
return data_root / "ante" / MEMORY_FILENAME
def normalize_memory_text(text: str) -> str:
"""Collapse whitespace and enforce the fact length cap."""
normalized = " ".join(text.split()).strip()
if len(normalized) <= MAX_FACT_CHARS:
return normalized
return f"{normalized[: MAX_FACT_CHARS - 3]}..."
def _make_id() -> str:
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
return f"m_{int(time.time() * 1000)}_{suffix}"
def _now_ms() -> int:
return int(time.time() * 1000)
def _fact_from_json(value: object) -> MemoryFact | None:
if not isinstance(value, Mapping):
return None
fact_id = value.get("id")
text = value.get("text")
created_at = value.get("createdAt")
if not isinstance(fact_id, str):
return None
if not isinstance(text, str):
return None
if not isinstance(created_at, (int, float)):
return None
normalized = normalize_memory_text(text)
if not normalized:
return None
return MemoryFact(id=fact_id, text=normalized, created_at=int(created_at))
def _read_memory_file(path: Path) -> list[MemoryFact]:
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return []
except OSError as exc:
logger.warning("Failed to read memory store at %s: %s", path, exc)
return []
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning("Failed to parse memory store at %s: %s", path, exc)
return []
if not isinstance(parsed, Mapping):
return []
facts_value = parsed.get("facts")
if not isinstance(facts_value, list):
return []
facts: list[MemoryFact] = []
for item in facts_value:
fact = _fact_from_json(item)
if fact is not None:
facts.append(fact)
return facts[:MAX_FACTS]
def _write_memory_file(path: Path, facts: list[MemoryFact]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": SCHEMA_VERSION,
"facts": [fact.to_json() for fact in facts[:MAX_FACTS]],
}
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(path)
finally:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
def list_memory_facts(instance_path: str | Path | None = None) -> list[MemoryFact]:
"""Return stored memory facts, newest first."""
with _STORE_LOCK:
return list(_read_memory_file(memory_path_for_instance(instance_path)))
def add_memory_fact(instance_path: str | Path | None, text: str) -> MemoryFact | None:
"""Store one short fact, deduplicating exact case-insensitive matches."""
normalized = normalize_memory_text(text)
if not normalized:
return None
path = memory_path_for_instance(instance_path)
with _STORE_LOCK:
facts = _read_memory_file(path)
existing = next((fact for fact in facts if fact.text.lower() == normalized.lower()), None)
if existing is not None:
return existing
fact = MemoryFact(id=_make_id(), text=normalized, created_at=_now_ms())
_write_memory_file(path, [fact, *facts][:MAX_FACTS])
return fact
def forget_memory_fact(
instance_path: str | Path | None,
*,
query: str | None = None,
) -> ForgetMemoryResult:
"""Remove a fact by case-insensitive substring query."""
path = memory_path_for_instance(instance_path)
with _STORE_LOCK:
facts = _read_memory_file(path)
normalized_query = normalize_memory_text(query or "").lower()
if not normalized_query:
return ForgetMemoryResult(removed=None, candidates=())
candidates = tuple(fact for fact in facts if normalized_query in fact.text.lower())
if not candidates:
return ForgetMemoryResult(removed=None, candidates=())
removed = candidates[0]
_write_memory_file(path, [fact for fact in facts if fact.id != removed.id])
return ForgetMemoryResult(removed=removed, candidates=candidates)
def clear_memory_facts(instance_path: str | Path | None = None) -> None:
"""Remove all stored memory facts."""
path = memory_path_for_instance(instance_path)
with _STORE_LOCK:
_write_memory_file(path, [])
def format_memory_for_prompt(instance_path: str | Path | None = None) -> str:
"""Return the prompt fragment injected before the session instructions."""
facts = list_memory_facts(instance_path)
if not facts:
return ""
bullets = "\n".join(f"- {fact.text}" for fact in facts)
return "\n".join(
[
"Things you remember about the user (use this context naturally,",
"do not recite the list verbatim):",
bullets,
]
)