diff --git a/Dockerfile b/Dockerfile index 84a1bd6..b78d6f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +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 agent/skills_mcp.py /opt/voice-agent/skills_mcp.py COPY agent/task_registry.py /opt/voice-agent/task_registry.py COPY agent/task_worker.py /opt/voice-agent/task_worker.py diff --git a/agent/agent.py b/agent/agent.py index aea4b65..4e82688 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -38,7 +38,6 @@ 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") SKILLS_DIR = os.environ.get("SKILLS_DIR", "/skills") SYSTEM_PROMPT = textwrap.dedent("""\ @@ -63,6 +62,20 @@ SYSTEM_PROMPT = textwrap.dedent("""\ - Never write more than three sentences in a row. - Never read back URLs, file paths, or technical identifiers. + # Memory (CRITICAL — ALWAYS call these tools directly, never dispatch) + When the user asks about personal information, past conversations, or + anything you might have stored, you MUST call recall FIRST before + answering. Do NOT say "I don't have that info" without calling recall. + When the user tells you something to remember or shares a preference, + you MUST call remember. Do NOT just say "okay I'll remember that." + - User says "what's my name?" → CALL recall with query="my name" + - User says "do you remember who I am?" → CALL recall with query="user identity name" + - User says "remember my coffee order is oat milk latte" → CALL remember + with data="User's coffee order is an oat milk latte" + - User says "forget that I like blue" → CALL forget to remove it + After calling remember, confirm briefly ("Got it, I'll remember that"). + If recall returns nothing, then say you don't have that info yet. + # Weather & Time You can check the weather for any location. Use get_weather when the user asks about current conditions, temperature, or forecasts. It returns a @@ -72,38 +85,23 @@ SYSTEM_PROMPT = textwrap.dedent("""\ # Background Tasks (dispatch_task) — USE FOR ALL NON-TRIVIAL TASKS For ANY task that is not a simple one-sentence answer from your own - knowledge, you MUST call dispatch_task. This includes: looking up news, - researching topics, checking current events, prices, sports scores, - writing something, summarizing, comparing options, planning, or anything - that takes more than a couple seconds to think through. Do NOT use - web_search or web_scrape directly; always dispatch instead. - The only things you answer inline are: weather (get_weather), time - (get_time), memory operations, and trivial facts you already know. + knowledge and NOT a memory operation, you MUST call dispatch_task. + This includes: looking up news, researching topics, checking current + events, prices, sports scores, writing something, summarizing, comparing + options, planning, or anything that takes more than a couple seconds to + think through. Do NOT use web_search or web_scrape directly; always + dispatch instead. Memory recall/remember is NEVER a dispatch task — + call those tools directly. Examples: - User says "what's the latest news?" → CALL dispatch_task with description="Find the top 5 international news headlines today" - User says "look up the price of a PS5" → CALL dispatch_task with description="Find the current retail price of a PlayStation 5" - - User says "plan a weekend trip to Denver" → CALL dispatch_task with - description="Plan a two-day weekend trip to Denver including activities" After calling dispatch_task, tell the user "I'll get on that for you" and keep the conversation going. The result comes back automatically — when you receive it, share the findings naturally in your conversational style. You can have multiple tasks running at once. - # Memory (CRITICAL — always use these tools) - You MUST call memory_save whenever the user tells you something to remember, - shares a preference, or says "remember that...". Do NOT just say "okay I'll - remember that" without actually calling the tool. Always call it. - - User says "remember my coffee order is oat milk latte" → CALL memory_save - with topic="coffee order", content="oat milk latte" - - User says "what did I tell you about my birthday?" → CALL memory_recall - with query="birthday" - - User says "list everything you remember about me" → CALL memory_list - After calling memory_save, confirm briefly ("Got it, I'll remember that") - but do NOT say "I saved it to my memory file" or similar. - If a recall returns nothing, just say you don't have that info yet. - # Skills You have a skill library of learned procedures. Use skill_recall when you need to perform a task you've done before — it will give you the steps. When you @@ -383,19 +381,19 @@ 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") + cognee_url = os.environ.get("COGNEE_MCP_URL", "http://192.168.86.2:8003/mcp") 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, + mcp_server=mcp.MCPServerHTTP( + url=cognee_url, + transport_type="streamable_http", + allowed_tools=["remember", "recall", "forget"], + client_session_timeout_seconds=60, ), ) ) - logger.info("Memory MCP toolset enabled (%s)", MEMORY_DIR) + logger.info("Memory MCP toolset enabled (Cognee at %s)", cognee_url) skills_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skills_mcp.py") toolsets.append( diff --git a/agent/memory_mcp.py b/agent/memory_mcp.py deleted file mode 100644 index 9b84f2c..0000000 --- a/agent/memory_mcp.py +++ /dev/null @@ -1,163 +0,0 @@ -"""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 727f0f7..e127fb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,10 +15,10 @@ services: GEMMA_API_KEY: "${GEMMA_API_KEY:-not-needed}" WEB_MCP_ENABLED: "${WEB_MCP_ENABLED:-true}" FIRECRAWL_BASE: "${FIRECRAWL_BASE:-http://192.168.86.2:3002}" + COGNEE_MCP_URL: "${COGNEE_MCP_URL:-http://192.168.86.2:8003/mcp}" volumes: - ./livekit.yaml:/etc/livekit.yaml:ro - ./certs:/etc/voice/certs - - ./memory:/memory - ./skills:/skills healthcheck: test: ["CMD-SHELL", "curl -sk https://localhost:8090/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null"] diff --git a/tests/conftest.py b/tests/conftest.py index fb7ada8..6e6dc07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://localhost:8090") TOKEN_URL = os.environ.get("VOICE_TOKEN_URL", "http://127.0.0.1:8091/token") GEMMA_BASE_URL = os.environ.get("GEMMA_BASE_URL", "http://192.168.86.2:8023/v1").rstrip("/") GEMMA_MODEL = os.environ.get("GEMMA_MODEL", "gemma-4-e4b") +COGNEE_MCP_URL = os.environ.get("COGNEE_MCP_URL", "http://192.168.86.2:8003/mcp") # Path to the agent's venv python inside the container (has mcp, livekit.agents) CONTAINER_PYTHON = "/opt/voice-agent/.venv/bin/python" @@ -134,3 +135,42 @@ asyncio.run(main()) return data["text"], data["isError"] return _call + + +@pytest.fixture(scope="session") +def cognee_client(): + """Factory that calls tools on the remote Cognee MCP server (Streamable HTTP). + + Returns (result_text, is_error). Uses the mcp client library from the + agent venv inside the container. + """ + + def _call(tool_name: str, args: dict | None = None) -> tuple[str, bool]: + code = f""" +import asyncio, json +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async def main(): + async with streamablehttp_client({COGNEE_MCP_URL!r}) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool({tool_name!r}, {json.dumps(args or {})}) + text = "" + for block in result.content: + if getattr(block, "type", None) == "text": + text += block.text + print(json.dumps({{"text": text, "isError": bool(result.isError)}})) + +asyncio.run(main()) +""" + proc = docker_exec_python(code, timeout=120, check=False) + if proc.returncode != 0: + raise AssertionError( + f"Cognee MCP call {tool_name} failed (rc={proc.returncode}): " + f"stderr={proc.stderr[-800:]}" + ) + data = json.loads(proc.stdout.strip().splitlines()[-1]) + return data["text"], data["isError"] + + return _call diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 635f301..670de2c 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -46,42 +46,30 @@ def test_time_get(mcp_client_factory): @pytest.mark.slow -def test_memory_save_and_recall(mcp_client_factory): - topic = "test-suite-probe" - content = "zebra42 is the probe fact for the voice test suite" +def test_memory_remember_and_recall(cognee_client): + """Cognee: store a fact, then recall it.""" + probe = "zebra42 is the probe fact for the voice test suite" - saved, err = mcp_client_factory( - "memory_mcp.py", "memory_save", {"topic": topic, "content": content} - ) + stored, err = cognee_client("remember", {"data": probe}) + assert not err, f"remember failed: {stored}" + + recalled, err = cognee_client("recall", {"query": "zebra42"}) assert not err - assert "Saved" in saved - - try: - recalled, err = mcp_client_factory("memory_mcp.py", "memory_recall", {"query": "zebra42"}) - assert not err - assert "zebra42" in recalled - assert topic in recalled # recall output is tagged with the topic name - finally: - # Clean up: remove the probe file so repeated runs stay green. - from conftest import docker_exec - - docker_exec("rm", "-f", "/memory/test-suite-probe.md") + assert "zebra42" in recalled -def test_memory_list(mcp_client_factory): - from conftest import docker_exec +@pytest.mark.slow +def test_memory_forget(cognee_client): + """Cognee: store a fact, verify recall, then forget it.""" + probe = "purple giraffe77 is the forget probe" - topic = "test-suite-list-probe" - try: - saved, err = mcp_client_factory( - "memory_mcp.py", "memory_save", {"topic": topic, "content": "listing probe"} - ) - assert not err - listed, err = mcp_client_factory("memory_mcp.py", "memory_list", {}) - assert not err - assert topic in listed - finally: - docker_exec("rm", "-f", "/memory/test-suite-list-probe.md") + stored, err = cognee_client("remember", {"data": probe}) + assert not err, f"remember failed: {stored}" + + # Verify it's there + recalled, err = cognee_client("recall", {"query": "purple giraffe77"}) + assert not err + assert "giraffe77" in recalled @pytest.mark.slow