diff --git a/Dockerfile b/Dockerfile index 0f10d54..2015b4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/web_mcp.py /opt/voice-agent/web_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/index.html /var/www/voice/ diff --git a/agent/agent.py b/agent/agent.py index 5850e3c..0d97f14 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -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") 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") +MEMORY_DIR = os.environ.get("MEMORY_DIR", "/memory") SYSTEM_PROMPT = textwrap.dedent("""\ 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. Use get_time when the user asks what time or day it is. If they mention 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)") + 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", "") if extra: try: diff --git a/agent/memory_mcp.py b/agent/memory_mcp.py new file mode 100644 index 0000000..9b84f2c --- /dev/null +++ b/agent/memory_mcp.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index d79cf26..730d7fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: volumes: - ./livekit.yaml:/etc/livekit.yaml:ro - ./certs:/etc/voice/certs + - ./memory:/memory healthcheck: test: ["CMD-SHELL", "curl -sk https://localhost:8090/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null"] interval: 15s diff --git a/memory/.gitkeep b/memory/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/memory/README.md b/memory/README.md new file mode 100644 index 0000000..c17366a --- /dev/null +++ b/memory/README.md @@ -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) +- `## ` 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.