feat: connect Shane voice sessions to memory

This commit is contained in:
snowspeeder committed 2026-08-28 16:22:18 -04:00
1 parent 2a2a2ff768
commit c940923539
15 files changed
+201 -97

No files matched your search

+83 -35
View File
@@ -9,6 +9,7 @@ import asyncio
import json
import logging
import os
import re
import textwrap
from dotenv import load_dotenv
@@ -41,11 +42,35 @@ 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")
SKILLS_DIR = os.environ.get("SKILLS_DIR", "/skills")
VOICE_USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
def load_user_profile(user_id: str) -> dict:
"""Load one server-side user profile without exposing its credentials."""
with open(VOICE_USERS_FILE, encoding="utf-8") as users_file:
registry = json.load(users_file)
profile = registry.get("users", {}).get(user_id)
if not isinstance(profile, dict):
raise ValueError(f"unknown voice user: {user_id}")
memory = profile.get("memory")
if not isinstance(memory, dict) or not memory.get("url") or not memory.get("api_key"):
raise ValueError(f"voice user {user_id} has no valid memory profile")
return {**profile, "user_id": user_id}
def user_id_from_room(room_name: str) -> str:
"""Extract the trusted user slug minted into a server-generated room name."""
match = re.fullmatch(r"voice-([a-z0-9][a-z0-9_-]{1,30})-[0-9a-f]{8}", room_name)
if not match:
raise ValueError(f"invalid voice room name: {room_name}")
return match.group(1)
SYSTEM_PROMPT = textwrap.dedent("""\
You are Hope, a warm, conversational voice assistant. You are talking TO
someone, not writing for them to read. Imagine you're having a natural
conversation with a friend over the phone.
You are Hope, a warm, conversational voice assistant speaking with
{user_display_name}. You are talking TO someone, not writing for them to
read. Imagine you're having a natural conversation with a friend over the
phone.
# How you speak
- Keep every response to one or three sentences. That's it.
@@ -81,21 +106,21 @@ SYSTEM_PROMPT = textwrap.dedent("""\
- 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 you call recall, ALWAYS include session_id="hope" to search the
session cache.
When the user tells you something to remember or shares a preference,
you MUST call remember with session_id="hope". Always include
session_id="hope" in every remember call — this makes it instant.
- User says "what's my name?" → CALL recall with query="my name", session_id="hope"
- User says "do you remember who I am?" → CALL recall with query="user identity name", session_id="hope"
- User says "remember my coffee order is oat milk latte" → CALL remember
with data="User's coffee order is an oat milk latte", session_id="hope"
- 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.
Your memory tools are private to the current user. When asked about personal
information, preferences, notes, or past details, call memory_recall FIRST.
Never claim you lack the information before searching memory.
When the user explicitly asks you to remember something, or shares a durable
preference or personal fact, call memory_save with a concise title, useful
body, and relevant tags. Do not save every utterance.
- "What's my name?" → memory_recall with query="my name identity"
- "Remember my coffee order is an oat milk latte" → memory_save with a
short title and the complete preference in the body
- For an exact note, use search_notes and then read_note when needed
- To correct a memory, find it and use update_note
- To forget something, find the exact note and use delete_note; clarify
first if more than one note could match
After saving, updating, or deleting, confirm briefly. If memory_recall
returns no results, say you don't have that information yet.
# Weather & Time
You can check the weather for any location. Use get_weather when the user
@@ -110,8 +135,8 @@ SYSTEM_PROMPT = textwrap.dedent("""\
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. Memory recall/remember is NEVER a dispatch task —
call those tools directly.
think through. Memory tool calls are NEVER dispatch tasks — 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"
@@ -361,13 +386,8 @@ class GemmaLLM(openai.LLM):
# ── MCP toolsets (web access + any extra configured servers) ────────────────
def build_mcp_toolsets() -> list[mcp.MCPToolset]:
"""Build the list of MCP toolsets to attach to the agent.
Always includes the local web-access server (Firecrawl-backed search/scrape)
when WEB_MCP_ENABLED is true. Additional servers can be configured via the
EXTRA_MCP_SERVERS env var (JSON list of {url, transport} objects).
"""
def build_mcp_toolsets(user_profile: dict) -> list[mcp.MCPToolset]:
"""Build isolated MCP toolsets for one authenticated voice user."""
toolsets: list[mcp.MCPToolset] = []
python_bin = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".venv", "bin", "python")
@@ -385,19 +405,38 @@ def build_mcp_toolsets() -> list[mcp.MCPToolset]:
)
logger.info("Weather MCP toolset enabled (wttr.in)")
cognee_url = os.environ.get("COGNEE_MCP_URL", "http://192.168.86.2:8003/mcp")
memory = user_profile["memory"]
memory_url = memory["url"]
allowed_memory_tools = memory.get(
"allowed_tools",
[
"create_note",
"read_note",
"update_note",
"delete_note",
"list_notes",
"search_notes",
"memory_save",
"memory_recall",
],
)
toolsets.append(
mcp.MCPToolset(
id="memory",
mcp_server=mcp.MCPServerHTTP(
url=cognee_url,
url=memory_url,
transport_type="streamable_http",
allowed_tools=["remember", "recall", "forget"],
allowed_tools=allowed_memory_tools,
headers={"Authorization": f"Bearer {memory['api_key']}"},
client_session_timeout_seconds=60,
),
)
)
logger.info("Memory MCP toolset enabled (Cognee at %s)", cognee_url)
logger.info(
"Memory MCP enabled for user %s at %s",
user_profile["user_id"],
memory_url,
)
skills_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skills_mcp.py")
toolsets.append(
@@ -453,7 +492,9 @@ def build_mcp_toolsets() -> list[mcp.MCPToolset]:
class VoiceAssistant(Agent):
"""The conversational agent. LLM is the brain; STT/TTS are senses."""
def __init__(self) -> None:
def __init__(self, user_profile: dict) -> None:
display_name = user_profile.get("display_name") or user_profile["user_id"]
instructions = SYSTEM_PROMPT.replace("{user_display_name}", display_name)
super().__init__(
llm=GemmaLLM(
model=GEMMA_MODEL,
@@ -462,8 +503,8 @@ class VoiceAssistant(Agent):
max_completion_tokens=1000,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
),
instructions=SYSTEM_PROMPT,
tools=build_mcp_toolsets(),
instructions=instructions,
tools=build_mcp_toolsets(user_profile),
)
@@ -474,6 +515,13 @@ server = AgentServer()
@server.rtc_session(agent_name="voice-assistant")
async def handle_job(ctx: JobContext) -> None:
logger.info("Job started for room %s", ctx.room.name)
try:
user_id = user_id_from_room(ctx.room.name)
user_profile = load_user_profile(user_id)
except (OSError, ValueError, json.JSONDecodeError) as exc:
logger.error("Rejecting voice job: %s", exc)
return
logger.info("Resolved voice user %s", user_id)
# Azure STT — streaming, reads AZURE_SPEECH_KEY / AZURE_SPEECH_REGION from env
stt = azure.STT(
@@ -589,7 +637,7 @@ async def handle_job(ctx: JobContext) -> None:
asyncio.create_task(_poll_tasks())
await session.start(
agent=VoiceAssistant(),
agent=VoiceAssistant(user_profile),
room=ctx.room,
room_options=room_io.RoomOptions(
# Keep the agent in the room when a participant leaves; it must