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:
@@ -42,6 +42,7 @@ COPY --from=build /app/agent/.venv /opt/voice-agent/.venv
|
|||||||
COPY agent/agent.py /opt/voice-agent/agent.py
|
COPY agent/agent.py /opt/voice-agent/agent.py
|
||||||
COPY agent/web_mcp.py /opt/voice-agent/web_mcp.py
|
COPY agent/web_mcp.py /opt/voice-agent/web_mcp.py
|
||||||
COPY agent/weather_mcp.py /opt/voice-agent/weather_mcp.py
|
COPY agent/weather_mcp.py /opt/voice-agent/weather_mcp.py
|
||||||
|
COPY agent/memory_mcp.py /opt/voice-agent/memory_mcp.py
|
||||||
|
|
||||||
# Copy web frontend + token endpoint
|
# Copy web frontend + token endpoint
|
||||||
COPY web/index.html /var/www/voice/
|
COPY web/index.html /var/www/voice/
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ GEMMA_MODEL = os.environ.get("GEMMA_MODEL", "gemma-4-e4b")
|
|||||||
GEMMA_API_KEY = os.environ.get("GEMMA_API_KEY", "not-needed")
|
GEMMA_API_KEY = os.environ.get("GEMMA_API_KEY", "not-needed")
|
||||||
WEB_MCP_ENABLED = os.environ.get("WEB_MCP_ENABLED", "true").lower() in ("1", "true", "yes")
|
WEB_MCP_ENABLED = os.environ.get("WEB_MCP_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||||
FIRECRAWL_BASE = os.environ.get("FIRECRAWL_BASE", "http://192.168.86.2:3002")
|
FIRECRAWL_BASE = os.environ.get("FIRECRAWL_BASE", "http://192.168.86.2:3002")
|
||||||
|
MEMORY_DIR = os.environ.get("MEMORY_DIR", "/memory")
|
||||||
|
|
||||||
SYSTEM_PROMPT = textwrap.dedent("""\
|
SYSTEM_PROMPT = textwrap.dedent("""\
|
||||||
You are Hope, a warm, conversational voice assistant. You are talking TO
|
You are Hope, a warm, conversational voice assistant. You are talking TO
|
||||||
@@ -76,6 +77,20 @@ SYSTEM_PROMPT = textwrap.dedent("""\
|
|||||||
short summary — relay it naturally in your own words.
|
short summary — relay it naturally in your own words.
|
||||||
Use get_time when the user asks what time or day it is. If they mention
|
Use get_time when the user asks what time or day it is. If they mention
|
||||||
a city, pass it as the location argument.
|
a city, pass it as the location argument.
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
You have persistent memory across conversations, stored as notes you can
|
||||||
|
search and add to.
|
||||||
|
- Use memory_recall at the start of a conversation, or whenever the user
|
||||||
|
references past information ("what did I tell you about...", "remember
|
||||||
|
when..."). Weave what you find in naturally.
|
||||||
|
- Use memory_save when the user shares personal information, preferences,
|
||||||
|
or important facts worth remembering: names, birthdays, preferences,
|
||||||
|
projects, anything they'd expect you to know later. Pick a short topic
|
||||||
|
name for each thing you save.
|
||||||
|
- Be natural about it. Never announce "I'm saving that to memory" — just
|
||||||
|
remember it and move on. If a recall comes up empty, don't mention the
|
||||||
|
search; just answer as if you'd never heard it before.
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +215,20 @@ def build_mcp_toolsets() -> list[mcp.MCPToolset]:
|
|||||||
)
|
)
|
||||||
logger.info("Weather MCP toolset enabled (wttr.in)")
|
logger.info("Weather MCP toolset enabled (wttr.in)")
|
||||||
|
|
||||||
|
memory_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "memory_mcp.py")
|
||||||
|
toolsets.append(
|
||||||
|
mcp.MCPToolset(
|
||||||
|
id="memory",
|
||||||
|
mcp_server=mcp.MCPServerStdio(
|
||||||
|
command=python_bin,
|
||||||
|
args=[memory_mcp_script],
|
||||||
|
env={**os.environ, "MEMORY_DIR": "/memory"},
|
||||||
|
client_session_timeout_seconds=30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("Memory MCP toolset enabled (%s)", MEMORY_DIR)
|
||||||
|
|
||||||
extra = os.environ.get("EXTRA_MCP_SERVERS", "")
|
extra = os.environ.get("EXTRA_MCP_SERVERS", "")
|
||||||
if extra:
|
if extra:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
- ./certs:/etc/voice/certs
|
- ./certs:/etc/voice/certs
|
||||||
|
- ./memory:/memory
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sk https://localhost:8090/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null"]
|
test: ["CMD-SHELL", "curl -sk https://localhost:8090/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Hope's Memory
|
||||||
|
|
||||||
|
Persistent memory for the voice assistant, stored as plain markdown files.
|
||||||
|
This directory is bind-mounted into the container at `/memory`, so memories
|
||||||
|
survive rebuilds and are backed up via git.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Each file is one topic, named after a slugified version of the topic
|
||||||
|
(e.g. `user-preferences.md`, `birthday.md`). Content is freeform markdown
|
||||||
|
notes. When Hope saves something, it appends a timestamped section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# User preferences
|
||||||
|
|
||||||
|
## 2026-08-22 14:30 UTC
|
||||||
|
|
||||||
|
Prefers to be called Shane. Works on the voice assistant project.
|
||||||
|
|
||||||
|
## 2026-08-23 09:15 UTC
|
||||||
|
|
||||||
|
Likes dark roast coffee, no sugar.
|
||||||
|
```
|
||||||
|
|
||||||
|
- `#` heading = the topic (written once when the file is created)
|
||||||
|
- `## <timestamp>` headings = individual notes, appended over time
|
||||||
|
- The MCP server (`agent/memory_mcp.py`) reads and appends these files;
|
||||||
|
you can also edit them by hand — Hope will pick up changes on next recall.
|
||||||
Reference in New Issue
Block a user