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
+5
-2
@@ -15,8 +15,8 @@ LIVEKIT_URL=http://localhost:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=devsecret
|
||||
|
||||
# Web UI port
|
||||
WEB_PORT=8090
|
||||
# Web UI port (9178 avoids the existing Unraid service on 8090)
|
||||
WEB_PORT=9178
|
||||
|
||||
# ── Web access (MCP tools: web_search + web_scrape) ────────────────────────
|
||||
# Backed by the self-hosted Firecrawl stack on xNAS.
|
||||
@@ -25,6 +25,9 @@ FIRECRAWL_BASE=http://192.168.86.2:3002
|
||||
# Only needed if your Firecrawl instance requires an API key
|
||||
# FIRECRAWL_API_KEY=
|
||||
|
||||
# Per-user memory profiles and Bearer tokens live in config/users.json.
|
||||
VOICE_DEFAULT_USER=shane
|
||||
|
||||
# Extra MCP servers (optional). JSON list of {url, transport} objects.
|
||||
# transport: "sse" | "streamable_http" | omit for auto-detect
|
||||
# EXTRA_MCP_SERVERS=[{"url":"http://192.168.86.2:3001/mcp","transport":"streamable_http"}]
|
||||
@@ -8,7 +8,7 @@ One Docker container runs three processes via supervisord:
|
||||
|
||||
1. **LiveKit server** — open-source WebRTC media transport (port 7880 TCP, 7882 UDP muxed media)
|
||||
2. **Voice agent** — Python LiveKit Agents pipeline: Azure STT → Gemma LLM → Azure TTS
|
||||
3. **Web frontend** — static HTML served by a tiny HTTP server (port 8090)
|
||||
3. **Web frontend** — static HTML served by nginx (port 9178 by default)
|
||||
|
||||
```
|
||||
Browser ──WebRTC──► LiveKit Server ──audio──► Agent
|
||||
@@ -27,10 +27,10 @@ export AZURE_SPEECH_KEY=$(grep '^AZURE_SPEECH=' ~/.hermes/.env | cut -d= -f2)
|
||||
docker compose up --build -d
|
||||
|
||||
# 3. Open the web UI (self-signed cert: accept the browser warning once)
|
||||
# https://<host-ip>:8090
|
||||
# https://<host-ip>:9178
|
||||
```
|
||||
|
||||
LAN access requires ufw rules: `8090/tcp` (UI + signaling), `7882/udp`
|
||||
LAN access requires firewall rules: `9178/tcp` (UI + signaling), `7882/udp`
|
||||
(WebRTC media), `7881/tcp` (media TCP fallback).
|
||||
|
||||
## Ports
|
||||
@@ -40,7 +40,7 @@ LAN access requires ufw rules: `8090/tcp` (UI + signaling), `7882/udp`
|
||||
| 7880 | TCP | LiveKit HTTP/WS | internal only (proxied via nginx at /livekit/) |
|
||||
| 7881 | TCP | LiveKit RTC media (TCP fallback) | LAN |
|
||||
| 7882 | UDP | LiveKit RTC media (muxed) | LAN |
|
||||
| 8090 | TCP | Web frontend (HTTPS) | LAN |
|
||||
| 9178 | TCP | Web frontend (HTTPS) | LAN |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -125,7 +125,7 @@ curl -s "https://eastus.tts.speech.microsoft.com/cognitiveservices/v1" \
|
||||
- **No published UDP ports in compose.** LiveKit binds its media ports directly on the host network (`network_mode: host`). This avoids the docker-proxy process explosion that hit hope-webui.
|
||||
- **Agent dispatch via roomConfig token claim.** LiveKit only dispatches agents to rooms that request them; a room auto-created by a participant join gets none. The token endpoint embeds `roomConfig.agents` in every JWT so the agent is dispatched when the browser joins. Do not pre-create rooms instead — if the agent worker isn't registered yet (first ~15s after container start), the dispatch silently fails and never retries; joining later re-fires it. The token server now pins the room name to "voice-room" server-side and no longer accepts arbitrary room names.
|
||||
- **Interruption mode must be "vad".** `interruption={"mode": "adaptive"}` requires the LiveKit Cloud barge-in service (agent-gateway.livekit.cloud) and spams 401 retries on self-hosted setups.
|
||||
- **Mic requires HTTPS.** Browsers block getUserMedia outside a secure context. nginx serves the UI on 8090 over HTTPS with a self-signed cert whose SAN includes the detected LAN IP (generated by entrypoint.sh at container start). The cert is persisted in `./certs/` (mounted as a volume) and only regenerated when the LAN IP changes, not on every container start. The LiveKit WS is proxied through nginx at `/livekit/` so everything stays on one origin (no mixed content).
|
||||
- **Mic requires HTTPS.** Browsers block getUserMedia outside a secure context. nginx serves the UI on `WEB_PORT` (9178 by default) over HTTPS with a self-signed cert whose SAN includes the detected LAN IP. The cert is persisted in `./certs/` and the LiveKit WS is proxied through nginx at `/livekit/`.
|
||||
- **No CDN dependencies.** livekit-client UMD bundle is vendored into `web/`; LAN devices may have no internet access.
|
||||
- **Transcripts flow over the data channel.** The agent publishes `{type: "transcript", role, text}` JSON on topic "transcript"; the UI renders them. Voice changes flow the other way as `{type: "set_voice", voice}` on topic "voice-control".
|
||||
- **Gemma is a reasoning model.** It sometimes spends tokens on hidden reasoning before producing content. The agent implements this with `max_completion_tokens=1000` and a `GemmaLLM` subclass that wraps the LLM stream, falling back to `reasoning_content` if `content` is empty.
|
||||
|
||||
+7
-4
@@ -60,8 +60,9 @@ COPY web/token_server.py /opt/voice/token_server.py
|
||||
# Config files
|
||||
COPY livekit.yaml /etc/livekit.yaml
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/voice.conf
|
||||
COPY config/users.json /etc/hope/users.json
|
||||
|
||||
# Configure nginx to serve the voice UI on port 8090 over HTTPS.
|
||||
# Configure nginx to serve the voice UI on WEB_PORT (9178 by default).
|
||||
# Browsers require a secure context (HTTPS or localhost) for microphone access.
|
||||
# The self-signed cert (with the LAN IP in the SAN) is generated at container
|
||||
# start by entrypoint.sh.
|
||||
@@ -70,13 +71,15 @@ RUN rm -f /etc/nginx/sites-enabled/default \
|
||||
&& ln -sf /etc/nginx/sites-available/voice /etc/nginx/sites-enabled/voice \
|
||||
&& mkdir -p /etc/voice/certs
|
||||
|
||||
# Create non-root user for agent
|
||||
RUN useradd -m -s /bin/bash voiceuser || true
|
||||
# Create non-root user for agent and protect the server-side user registry.
|
||||
RUN useradd -m -s /bin/bash voiceuser || true \
|
||||
&& chown -R voiceuser:voiceuser /etc/hope \
|
||||
&& chmod 600 /etc/hope/users.json
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 7880 7881 7882/udp 8090
|
||||
EXPOSE 7880 7881 7882/udp 9178
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/voice.conf"]
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
# Memory Server (Cognee MCP)
|
||||
# Legacy Memory Server (Cognee MCP)
|
||||
|
||||
> The Voice agent now uses Shane Memory System at `http://192.168.86.2:8790/mcp`
|
||||
> with the per-user Bearer token in `config/users.json`. The Cognee service below
|
||||
> is retained temporarily for migration/reference.
|
||||
|
||||
A persistent, centralized **AI memory** service you can attach to any agentic
|
||||
harness (OpenCode, Hermes, Claude Desktop, custom agents). It runs on the Unraid
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Replace the Voice API's single shared Cognee memory connection with a per-user connection to the deployed Shane Memory System. The first Voice user is `shane`; his server-side profile uses the existing remote memory token pulled from Unraid. Future Voice users can point to separate memory servers and tokens without exposing those credentials to the browser or LLM.
|
||||
|
||||
This document is the implementation plan. In this investigation pass, only the bootstrap user registry and requested Azure voice/prompt changes were made; the authentication and per-user runtime described below are not implemented yet.
|
||||
This document is the implementation plan. The Unraid test deployment now implements the Shane bootstrap path: trusted Shane room identity, server-side profile loading, authenticated per-session memory MCP, and Dragon HD Omni speech. The login UI and full multi-user isolation hardening described below remain future work.
|
||||
|
||||
## Investigation summary
|
||||
|
||||
@@ -14,8 +14,8 @@ This document is the implementation plan. In this investigation pass, only the b
|
||||
- `POST /token` is unauthenticated. It accepts no real user credential and creates a random browser identity and room.
|
||||
- The browser has no login/user-selection flow.
|
||||
- Every agent session calls `build_mcp_toolsets()` with process-global configuration.
|
||||
- Memory is hardcoded to the unauthenticated Cognee endpoint at `http://192.168.86.2:8003/mcp`.
|
||||
- The prompt uses one global Cognee session ID (`hope`), so it is not a user isolation boundary.
|
||||
- Before the bootstrap implementation, memory was hardcoded to the unauthenticated Cognee endpoint at `http://192.168.86.2:8003/mcp` and used one global Cognee session ID (`hope`).
|
||||
- The bootstrap implementation now resolves `shane` from a server-minted room and creates an authenticated MCP client using his profile in `config/users.json`.
|
||||
- Local skills use the shared `/skills` directory.
|
||||
- Background tasks use a global `/tmp/tasks/events.jsonl` and one global tail cursor. With concurrent rooms, one room can consume another room's task event. This must be fixed before calling the product multi-user safe.
|
||||
- Browser transcript history and voice preference use unscoped `localStorage` keys, which can expose one user's local history to the next user on a shared browser.
|
||||
|
||||
+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
|
||||
|
||||
+4
-2
@@ -15,13 +15,15 @@ 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}"
|
||||
WEB_PORT: "${WEB_PORT:-9178}"
|
||||
VOICE_USERS_FILE: "/etc/hope/users.json"
|
||||
VOICE_DEFAULT_USER: "${VOICE_DEFAULT_USER:-shane}"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
- ./certs:/etc/voice/certs
|
||||
- ./skills:/skills
|
||||
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:$${WEB_PORT:-9178}/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
set -e
|
||||
|
||||
CERT_DIR=/etc/voice/certs
|
||||
WEB_PORT=${WEB_PORT:-9178}
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# The container uses host networking, so select a free host port at runtime.
|
||||
sed -i -E "s/listen [0-9]+ ssl;/listen ${WEB_PORT} ssl;/" /etc/nginx/sites-available/voice
|
||||
|
||||
LAN_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}')
|
||||
[ -z "$LAN_IP" ] && LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
[ -z "$LAN_IP" ] && LAN_IP=127.0.0.1
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
listen 8090 ssl;
|
||||
listen 9178 ssl;
|
||||
root /var/www/voice;
|
||||
index index.html;
|
||||
ssl_certificate /etc/voice/certs/cert.pem;
|
||||
|
||||
+5
-2
@@ -31,7 +31,8 @@ environment=
|
||||
GEMMA_MODEL="%(ENV_GEMMA_MODEL)s",
|
||||
GEMMA_API_KEY="%(ENV_GEMMA_API_KEY)s",
|
||||
WEB_MCP_ENABLED="%(ENV_WEB_MCP_ENABLED)s",
|
||||
FIRECRAWL_BASE="%(ENV_FIRECRAWL_BASE)s"
|
||||
FIRECRAWL_BASE="%(ENV_FIRECRAWL_BASE)s",
|
||||
VOICE_USERS_FILE="%(ENV_VOICE_USERS_FILE)s"
|
||||
|
||||
[program:web]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
@@ -52,4 +53,6 @@ stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
environment=
|
||||
LIVEKIT_API_KEY="%(ENV_LIVEKIT_API_KEY)s",
|
||||
LIVEKIT_API_SECRET="%(ENV_LIVEKIT_API_SECRET)s"
|
||||
LIVEKIT_API_SECRET="%(ENV_LIVEKIT_API_SECRET)s",
|
||||
VOICE_USERS_FILE="%(ENV_VOICE_USERS_FILE)s",
|
||||
VOICE_DEFAULT_USER="%(ENV_VOICE_DEFAULT_USER)s"
|
||||
+2
-2
@@ -24,9 +24,9 @@ python -m pytest tests/ -v -m "not slow" # skip LLM/network-heavy tests
|
||||
|
||||
| File | Target | Notes |
|
||||
|------|--------|-------|
|
||||
| `test_web_ui.py` | nginx HTTPS UI (:8090) + token endpoint (:8091) | static assets, `/token` (direct + via nginx), `/livekit/` proxy |
|
||||
| `test_web_ui.py` | nginx HTTPS UI (:9178) + token endpoint (:8091) | static assets, `/token` (direct + via nginx), `/livekit/` proxy |
|
||||
| `test_llm_api.py` | Gemma LLM API directly | completion, tool calling, `enable_thinking=false` latency, streaming |
|
||||
| `test_mcp_tools.py` | MCP servers via `docker exec` | weather (Fahrenheit check), time, memory save/recall/list, skills save/recall/list — probe files are cleaned up |
|
||||
| `test_mcp_tools.py` | MCP servers via `docker exec` | weather/time, authenticated remote memory save/recall/delete, and local skills — probes are cleaned up |
|
||||
| `test_agent_integration.py` | running container | agent process alive, worker registered with LiveKit, all 4 supervised processes up, container health, LiveKit HTTP API |
|
||||
| `test_compaction.py` | `GemmaLLM._compact_context` in-container | compaction triggers >24 items, system prompt preserved, short context untouched |
|
||||
|
||||
|
||||
+14
-11
@@ -1,7 +1,7 @@
|
||||
"""Shared fixtures for the Hope voice-assistant test suite.
|
||||
|
||||
Tests run on the HOST machine against the live "voice" container:
|
||||
- HTTPS web UI -> https://localhost:8090 (self-signed cert, verify=False)
|
||||
- HTTPS web UI -> https://localhost:9178 (self-signed cert, verify=False)
|
||||
- Token server -> http://127.0.0.1:8091 (host network mode)
|
||||
- LLM API -> GEMMA_BASE_URL (default http://192.168.86.2:8023/v1)
|
||||
- In-container -> docker exec voice ...
|
||||
@@ -10,16 +10,20 @@ Tests run on the HOST machine against the live "voice" container:
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
CONTAINER = "voice"
|
||||
WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://localhost:8090")
|
||||
WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://localhost:9178")
|
||||
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")
|
||||
_users = json.loads((Path(__file__).parents[1] / "config" / "users.json").read_text())
|
||||
_shane_memory = _users["users"]["shane"]["memory"]
|
||||
MEMORY_MCP_URL = os.environ.get("MEMORY_MCP_URL", _shane_memory["url"])
|
||||
MEMORY_MCP_TOKEN = os.environ.get("MEMORY_MCP_TOKEN", _shane_memory["api_key"])
|
||||
|
||||
# Path to the agent's venv python inside the container (has mcp, livekit.agents)
|
||||
CONTAINER_PYTHON = "/opt/voice-agent/.venv/bin/python"
|
||||
@@ -138,12 +142,8 @@ asyncio.run(main())
|
||||
|
||||
|
||||
@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 memory_client():
|
||||
"""Call Shane's authenticated remote memory MCP server."""
|
||||
|
||||
def _call(tool_name: str, args: dict | None = None) -> tuple[str, bool]:
|
||||
code = f"""
|
||||
@@ -152,7 +152,10 @@ 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 streamablehttp_client(
|
||||
{MEMORY_MCP_URL!r},
|
||||
headers={{"Authorization": "Bearer " + {MEMORY_MCP_TOKEN!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 {})})
|
||||
@@ -167,7 +170,7 @@ 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"Memory MCP call {tool_name} failed (rc={proc.returncode}): "
|
||||
f"stderr={proc.stderr[-800:]}"
|
||||
)
|
||||
data = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
+25
-23
@@ -4,7 +4,9 @@ Each test spawns the real MCP server script inside the container and drives it
|
||||
with a JSON-RPC session using the mcp client library from the agent venv.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -46,30 +48,30 @@ def test_time_get(mcp_client_factory):
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
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"
|
||||
def test_memory_save_recall_and_delete(memory_client):
|
||||
"""Authenticated memory is durable and scoped to Shane's token."""
|
||||
marker = f"voice-probe-{uuid.uuid4().hex[:10]}"
|
||||
saved, err = memory_client(
|
||||
"memory_save",
|
||||
{
|
||||
"title": f"Voice integration probe {marker}",
|
||||
"body": f"{marker} is the remote memory integration probe.",
|
||||
"tags": ["test", "voice"],
|
||||
},
|
||||
)
|
||||
assert not err, f"memory_save failed: {saved}"
|
||||
note = json.loads(saved)
|
||||
|
||||
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 "zebra42" in recalled
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
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
|
||||
try:
|
||||
recalled, err = memory_client("memory_recall", {"query": marker, "topK": 3})
|
||||
assert not err
|
||||
assert marker in recalled
|
||||
finally:
|
||||
deleted, err = memory_client(
|
||||
"delete_note",
|
||||
{"folder": note["folder"], "slug": note["slug"]},
|
||||
)
|
||||
assert not err, f"delete_note failed: {deleted}"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the web UI served by nginx (HTTPS :8090) and the token endpoint."""
|
||||
"""Tests for the web UI served by nginx (HTTPS :9178) and the token endpoint."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -54,6 +54,7 @@ def test_token_endpoint_direct():
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data and data["token"].count(".") == 2 # JWT shape
|
||||
assert data["user"]["id"] == "shane"
|
||||
|
||||
|
||||
def test_token_endpoint_via_nginx(client):
|
||||
|
||||
+36
-5
@@ -23,6 +23,17 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey")
|
||||
API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret")
|
||||
AGENT_NAME = os.environ.get("VOICE_AGENT_NAME", "voice-assistant")
|
||||
USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
|
||||
DEFAULT_USER = os.environ.get("VOICE_DEFAULT_USER", "shane")
|
||||
|
||||
|
||||
def load_default_user() -> tuple[str, dict]:
|
||||
with open(USERS_FILE, encoding="utf-8") as users_file:
|
||||
registry = json.load(users_file)
|
||||
profile = registry.get("users", {}).get(DEFAULT_USER)
|
||||
if not isinstance(profile, dict):
|
||||
raise ValueError(f"unknown default voice user: {DEFAULT_USER}")
|
||||
return DEFAULT_USER, profile
|
||||
|
||||
|
||||
def b64url(data: bytes) -> str:
|
||||
@@ -65,13 +76,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
# Unique room per session so LiveKit dispatches the agent at creation.
|
||||
room_name = f"voice-{uuid.uuid4().hex[:8]}"
|
||||
identity = body.get("identity") or f"user-{uuid.uuid4().hex[:8]}"
|
||||
# Consume the body for protocol compatibility, but never trust a
|
||||
# browser-supplied room or identity. This LAN bootstrap deployment is
|
||||
# pinned to VOICE_DEFAULT_USER until the planned login flow is added.
|
||||
self.rfile.read(length)
|
||||
try:
|
||||
user_id, profile = load_default_user()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
resp = json.dumps({"error": str(exc)}).encode()
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(resp)))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp)
|
||||
return
|
||||
|
||||
room_name = f"voice-{user_id}-{uuid.uuid4().hex[:8]}"
|
||||
identity = profile.get("livekit_identity") or f"user:{user_id}"
|
||||
token = make_token(room_name, identity)
|
||||
resp = json.dumps({"token": token}).encode()
|
||||
resp = json.dumps(
|
||||
{
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"display_name": profile.get("display_name") or user_id,
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(resp)))
|
||||
|
||||
Reference in new issue
Block a user