feat: markdown memory system — Hope learns from conversations

- agent/memory_mcp.py: MCP server with memory_recall, memory_save, memory_list
  tools backed by .md files in /memory (simple keyword matching for v1)
- memory/ dir bind-mounted into container, persists across rebuilds,
  easily backed up via git
- System prompt instructs Hope to recall on past references and save
  personal info/preferences naturally without announcing it
- Dockerfile: copy memory_mcp.py; compose: ./memory:/memory volume
This commit is contained in:
Shane
2026-08-22 16:33:00 -04:00
parent 49d8577a4c
commit de1a5d8400
6 changed files with 222 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
"""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")