feat: connect Shane voice sessions to memory
This commit is contained in:
1 parent
2a2a2ff768
commit
c940923539
15 files changed
+201
-97
No files matched your search
+83
-35
@@ -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
|
||||
|
||||
Reference in new issue
Block a user