"""Memory MCP server — persistent markdown-based memory for Hope. Runs over stdio inside the voice container. The agent attaches it via MCPServerStdio, so the LLM can recall and save memories during a conversation. Memories live as plain markdown files in MEMORY_DIR (default /memory), one file per topic, mounted from the host so they survive rebuilds and are backed up via git. Tools: - memory_recall(query) -> relevant passages from all .md files - memory_save(topic, content) -> append a timestamped note to {topic}.md - memory_list() -> topics with their first line """ from __future__ import annotations import os import re from datetime import datetime, timezone from mcp.server.fastmcp import FastMCP MEMORY_DIR = os.environ.get("MEMORY_DIR", "/memory") mcp = FastMCP("memory") def _slugify(topic: str) -> str: """Turn a topic into a safe filename slug.""" slug = re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-") return slug or "misc" def _read_files() -> list[tuple[str, str]]: """Return (filename, content) for every .md file in MEMORY_DIR.""" files: list[tuple[str, str]] = [] if not os.path.isdir(MEMORY_DIR): return files for name in sorted(os.listdir(MEMORY_DIR)): if not name.endswith(".md"): continue path = os.path.join(MEMORY_DIR, name) try: with open(path, encoding="utf-8") as f: files.append((name, f.read())) except OSError: continue return files def _first_line(content: str) -> str: for line in content.splitlines(): if line.strip(): return line.strip() return "(empty)" @mcp.tool() def memory_recall(query: str) -> str: """Search saved memories for information relevant to a query. Use this at the start of a conversation, or whenever the user references past information ("what did I tell you about...", "remember when..."). Returns matching passages with their topic names, or a message saying nothing was found. """ q = (query or "").strip().lower() if not q: return "I don't have any memory of that." words = [w for w in re.split(r"\W+", q) if len(w) > 2] files = _read_files() if not files: return "I don't have any memory of that." # Strip markdown heading markers and timestamp lines so snippets are clean prose. def _clean(text: str) -> str: text = text.replace("\n", " ") text = re.sub(r"#+\s*", "", text) text = re.sub(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\s*", "", text) return re.sub(r"\s{2,}", " ", text).strip() scored: list[tuple[int, str, list[str]]] = [] for name, content in files: lower = content.lower() score = 0 matches: list[str] = [] if q in lower: score += len(q) seen: set[int] = set() for w in words: start = 0 while True: idx = lower.find(w, start) if idx == -1: break score += 1 key = idx // 200 # one snippet per ~200-char window if key not in seen: seen.add(key) s = max(0, idx - 80) e = min(len(content), idx + len(w) + 120) snippet = _clean(content[s:e]) if snippet and snippet not in matches: matches.append(snippet) start = idx + len(w) if score > 0: scored.append((score, name, matches[:3])) if not scored: return "I don't have any memory of that." scored.sort(key=lambda s: s[0], reverse=True) parts = [] for _, name, snippets in scored[:5]: topic = name.removesuffix(".md") parts.append(f"[{topic}] " + " ".join(snippets)) return "\n".join(parts) @mcp.tool() def memory_save(topic: str, content: str) -> str: """Save a fact or preference to persistent memory. Use this when the user shares personal information worth keeping: names, birthdays, preferences, projects, important facts. Creates or appends to a markdown file named after the topic. Returns a short confirmation. """ topic = (topic or "").strip() content = (content or "").strip() if not topic or not content: return "Nothing saved — both a topic and some content are needed." os.makedirs(MEMORY_DIR, exist_ok=True) path = os.path.join(MEMORY_DIR, f"{_slugify(topic)}.md") is_new = not os.path.exists(path) stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") with open(path, "a", encoding="utf-8") as f: if is_new: f.write(f"# {topic}\n\n") f.write(f"## {stamp}\n\n{content}\n\n") return f"Saved to memory under '{topic}'." @mcp.tool() def memory_list() -> str: """List all saved memories with a brief description of each. Returns the topic name and first line of every memory file, or a message saying no memories exist yet. """ files = _read_files() if not files: return "No memories saved yet." lines = [f"{name.removesuffix('.md')}: {_first_line(content)}" for name, content in files] return "\n".join(lines) if __name__ == "__main__": mcp.run(transport="stdio")