Compare commits
2
Commits
3700b52883
...
c940923539
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c940923539 | ||
|
|
2a2a2ff768 |
No files matched your search
+7
-4
@@ -2,8 +2,8 @@
|
||||
AZURE_SPEECH_KEY=your-azure-speech-key
|
||||
AZURE_SPEECH_REGION=eastus
|
||||
|
||||
# Default TTS voice (any Azure Neural voice)
|
||||
AZURE_TTS_VOICE=en-US-AvaNeural
|
||||
# Default TTS voice (Azure Dragon HD Omni; supports inline delivery markers)
|
||||
AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural
|
||||
|
||||
# LLM (Gemma on xNAS via llama.cpp, OpenAI-compatible)
|
||||
GEMMA_BASE_URL=http://192.168.86.2:8023/v1
|
||||
@@ -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
|
||||
|
||||
@@ -49,7 +49,7 @@ All config lives in `.env` (git-ignored). See `.env.example` for the full list.
|
||||
Key variables:
|
||||
- `AZURE_SPEECH_KEY` — Azure Speech resource key
|
||||
- `AZURE_SPEECH_REGION` — default `eastus`
|
||||
- `AZURE_TTS_VOICE` — default voice (e.g. `en-US-AvaNeural`)
|
||||
- `AZURE_TTS_VOICE` — default voice (`en-US-Ava:DragonHDOmniLatestNeural`)
|
||||
- `GEMMA_BASE_URL` — LLM endpoint (default `http://192.168.86.2:8023/v1`)
|
||||
- `GEMMA_MODEL` — model name (default `gemma-4-e4b`)
|
||||
|
||||
@@ -61,7 +61,7 @@ Supported voices: any Azure Neural voice. See https://learn.microsoft.com/en-us/
|
||||
|
||||
## SSML / Expressive Speech
|
||||
|
||||
The TTS layer uses full SSML with `<mstts:express-as>` for style and `<prosody>` for rate/volume/pitch. The agent's system prompt instructs the LLM to write in a conversational, spoken style (short sentences, natural phrasing) so the output sounds like speech, not text.
|
||||
The TTS layer wraps output in SSML. The default Azure Dragon HD Omni voice recognizes inline style markers such as `[appreciative]`, `[whispering]`, and `[Neutral]`; the agent prompt allows these markers while keeping responses short and conversational.
|
||||
|
||||
## Agent Prompt
|
||||
|
||||
@@ -89,7 +89,7 @@ curl -s "https://eastus.tts.speech.microsoft.com/cognitiveservices/v1" \
|
||||
-H "Ocp-Apim-Subscription-Key: $AZURE_SPEECH_KEY" \
|
||||
-H "Content-Type: application/ssml+xml" \
|
||||
-H "X-Microsoft-OutputFormat: audio-24khz-48kbitrate-mono-mp3" \
|
||||
--data-binary '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US"><voice name="en-US-AvaNeural">Test</voice></speak>' > /tmp/test.mp3
|
||||
--data-binary '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US"><voice name="en-US-Ava:DragonHDOmniLatestNeural">[appreciative] Test complete.</voice></speak>' > /tmp/test.mp3
|
||||
```
|
||||
|
||||
## File Layout
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Plan: Per-user Voice API + Shane Memory System
|
||||
|
||||
## Goal
|
||||
|
||||
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. 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
|
||||
|
||||
### Voice API today
|
||||
|
||||
- One Docker container runs LiveKit, the Python voice agent, nginx, and `web/token_server.py`.
|
||||
- `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.
|
||||
- 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.
|
||||
- Rooms are unique, but `close_on_disconnect=False` leaves their agents alive after the user disconnects. This can accumulate orphan sessions.
|
||||
|
||||
### Shane Memory System today
|
||||
|
||||
Repository: `~/dev/shane-memory-system`
|
||||
|
||||
Deployed service:
|
||||
|
||||
- Host: `192.168.86.2`
|
||||
- Container: `shane-memory`
|
||||
- MCP URL: `http://192.168.86.2:8790/mcp`
|
||||
- Data: `/mnt/user/appdata/shane-memory` mounted at `/data`
|
||||
- Transport: MCP Streamable HTTP
|
||||
- Authentication: `Authorization: Bearer sm_...`
|
||||
- Isolation: each token resolves to one SQLite user; note files and vector rows are scoped by that user.
|
||||
- Tools: `create_note`, `read_note`, `update_note`, `delete_note`, `list_notes`, `search_notes`, `memory_save`, `memory_recall`, and `reindex_all`.
|
||||
|
||||
The deployed database currently has `pi`, `alice`, and `alice2`. There is no remote account literally named `shane`. The `pi` account is Shane's active namespace and has both `default` and `voice-agent` tokens. The dedicated `voice-agent` token was selected for this product.
|
||||
|
||||
The selected token was tested against the deployed MCP server: initialization, tool listing, and an authenticated `list_notes` call all succeeded.
|
||||
|
||||
### Bootstrap credential stored in this repository
|
||||
|
||||
`config/users.json` now contains the first Voice profile:
|
||||
|
||||
- Voice user: `shane`
|
||||
- LiveKit identity: `shane`
|
||||
- Memory URL: `http://192.168.86.2:8790/mcp`
|
||||
- Current remote memory username: `pi`
|
||||
- Remote token label: `voice-agent`
|
||||
- Token SHA-256 for audit comparison: `d6af807287472806769926b1ef5e20fa2afc3e5ae0d6d21b0d5406605309f335`
|
||||
|
||||
The actual token is deliberately not repeated in this plan. It is stored in plaintext in `config/users.json` as requested. The file was created with mode `0600`, but Git does not preserve read permissions, only the executable bit. Deployment must therefore set ownership/permissions explicitly.
|
||||
|
||||
### LiveKit MCP compatibility
|
||||
|
||||
The locked `livekit-agents` version is `1.7.0`. Its `MCPServerHTTP` accepts a `headers` dictionary, so the per-user integration can use:
|
||||
|
||||
```python
|
||||
mcp.MCPServerHTTP(
|
||||
url=profile.memory.url,
|
||||
transport_type="streamable_http",
|
||||
headers={"Authorization": f"Bearer {profile.memory.api_key}"},
|
||||
)
|
||||
```
|
||||
|
||||
No custom MCP proxy is required.
|
||||
|
||||
## Requested voice change completed
|
||||
|
||||
The default voice is now:
|
||||
|
||||
```text
|
||||
en-US-Ava:DragonHDOmniLatestNeural
|
||||
```
|
||||
|
||||
Updated locations:
|
||||
|
||||
- `agent/agent.py`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
- `web/app.js`
|
||||
- `AGENTS.md`
|
||||
|
||||
The prompt now teaches Azure HD plain-text delivery markers such as `[appreciative]`, `[whispering]`, `[laughter]`, and `[Neutral]`, with a limit of zero to two markers per response.
|
||||
|
||||
This marker syntax is intentional. Azure supports HD styles as plain-text square-bracket markers, and LiveKit's Azure plugin already wraps agent text in SSML. The model should not emit raw XML/SSML. The locked plugin concatenates input text into its SSML wrapper, so a later hardening pass should also XML-escape ordinary model text while preserving bracket markers.
|
||||
|
||||
## Target architecture
|
||||
|
||||
```text
|
||||
Browser
|
||||
| POST /api/login (username/password)
|
||||
| HttpOnly Secure session cookie
|
||||
| POST /token
|
||||
v
|
||||
Token server -------------------------> config/users.json
|
||||
| |
|
||||
| signed LiveKit JWT | server-only memory URL/token
|
||||
| room: voice-shane-<nonce> |
|
||||
v v
|
||||
LiveKit room -----------------------> Voice agent job
|
||||
|
|
||||
| MCP Streamable HTTP
|
||||
| Authorization: Bearer <Shane token>
|
||||
v
|
||||
Shane Memory System /mcp
|
||||
token -> isolated remote user
|
||||
```
|
||||
|
||||
Principles:
|
||||
|
||||
1. The browser proves which Voice user it is to the token server.
|
||||
2. The token server, not the browser, selects the user ID and room name.
|
||||
3. The memory token remains server-side and is never placed in a LiveKit JWT, room metadata visible to clients, data-channel message, transcript, or log.
|
||||
4. The agent resolves a trusted Voice user ID and constructs a new memory MCP client for that session.
|
||||
5. Every mutable local resource is namespaced by user and, where appropriate, room.
|
||||
|
||||
## User registry design
|
||||
|
||||
Evolve `config/users.json` to this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"users": {
|
||||
"shane": {
|
||||
"enabled": true,
|
||||
"display_name": "Shane",
|
||||
"livekit_identity": "user:shane",
|
||||
"auth": {
|
||||
"password_hash": "<scrypt or argon2id hash>"
|
||||
},
|
||||
"memory": {
|
||||
"url": "http://192.168.86.2:8790/mcp",
|
||||
"api_key": "<server-only token>",
|
||||
"remote_username": "pi",
|
||||
"remote_token_label": "voice-agent",
|
||||
"allowed_tools": [
|
||||
"create_note",
|
||||
"read_note",
|
||||
"update_note",
|
||||
"delete_note",
|
||||
"list_notes",
|
||||
"search_notes",
|
||||
"memory_save",
|
||||
"memory_recall"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- Reject duplicate, malformed, disabled, or unknown user IDs at startup.
|
||||
- Require HTTPS for non-LAN memory URLs; explicitly allow configured RFC1918 HTTP URLs.
|
||||
- Require `sm_` only as a warning, not as a universal protocol rule, because future users may run a different compatible memory product.
|
||||
- Never serialize `api_key` in status responses or exceptions.
|
||||
- Make startup fail closed if the registry is invalid.
|
||||
|
||||
Create `shared/user_registry.py` so the token server and agent use one parser and validation implementation.
|
||||
|
||||
## Authentication and identity flow
|
||||
|
||||
### 1. Add Voice login
|
||||
|
||||
Add these endpoints to `web/token_server.py`:
|
||||
|
||||
- `POST /api/login` — verify username/password and issue an opaque session cookie.
|
||||
- `GET /api/session` — return only safe profile fields (`user_id`, `display_name`).
|
||||
- `POST /api/logout` — revoke the current session and clear the cookie.
|
||||
- `POST /token` — require a valid session; ignore any client-supplied identity or room.
|
||||
|
||||
Cookie requirements:
|
||||
|
||||
- `HttpOnly`
|
||||
- `Secure`
|
||||
- `SameSite=Strict`
|
||||
- short idle expiration with a bounded absolute expiration
|
||||
- random opaque value; only a hash stored server-side
|
||||
|
||||
Also enforce same-origin requests and basic login rate limiting. Keep the service LAN-only, but do not treat LAN access as authentication.
|
||||
|
||||
### 2. Bind user to a trusted room
|
||||
|
||||
The token server generates a room such as `voice-shane-<random nonce>` after authenticating Shane. The user slug is not accepted from the browser. The LiveKit participant identity is set from the registry, not request JSON.
|
||||
|
||||
The agent parses the trusted room format and looks up the profile. Unknown/malformed rooms fail closed before an LLM or MCP session starts. If supported cleanly by the deployed LiveKit version, signed agent-dispatch metadata may replace room parsing later; the memory token must never be put in that metadata.
|
||||
|
||||
### 3. Update the frontend
|
||||
|
||||
- Add a small login state before “Start Conversation.”
|
||||
- Fetch `/api/session` on load.
|
||||
- Fetch `/token` only after authentication.
|
||||
- On `401`, return to login rather than repeatedly reconnecting.
|
||||
- Namespace local keys as `voice_history:<user_id>` and `preferred_voice:<user_id>`.
|
||||
- Clear in-memory room, transcript, task, and audio state on logout.
|
||||
- Never store the memory API key in browser storage.
|
||||
|
||||
## Per-user agent and memory integration
|
||||
|
||||
### Refactor construction
|
||||
|
||||
Change:
|
||||
|
||||
```python
|
||||
VoiceAssistant()
|
||||
build_mcp_toolsets()
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```python
|
||||
VoiceAssistant(user_profile, room_context)
|
||||
build_mcp_toolsets(user_profile, room_context)
|
||||
```
|
||||
|
||||
For the memory toolset:
|
||||
|
||||
- URL comes from `user_profile.memory.url`.
|
||||
- Add `Authorization: Bearer <api_key>` through `MCPServerHTTP(headers=...)`.
|
||||
- Use Streamable HTTP.
|
||||
- Apply the profile's allowlist.
|
||||
- Create one toolset/client per agent session; never mutate shared MCP headers between sessions.
|
||||
- Redact token-bearing headers from errors and logs.
|
||||
- Log only user ID, remote hostname, and connection status.
|
||||
|
||||
### Update memory behavior in the prompt
|
||||
|
||||
The new memory server does not expose Cognee's `remember`, `recall`, or `forget`, and does not accept `session_id="hope"`.
|
||||
|
||||
Replace those instructions with:
|
||||
|
||||
- Personal/history question: call `memory_recall` before answering.
|
||||
- Durable fact or preference: call `memory_save` with a concise title/body and useful tags.
|
||||
- Precise note lookup: use `search_notes`, then `read_note` if full content is needed.
|
||||
- Correction: find the note and call `update_note`.
|
||||
- Forget request: find the exact note, confirm ambiguity when needed, then call `delete_note`.
|
||||
- Do not save every utterance; save durable facts, preferences, decisions, and explicit “remember this” requests.
|
||||
|
||||
Inject the safe display name into the prompt so the assistant knows it is speaking with Shane. Do not inject the token or raw registry JSON.
|
||||
|
||||
### Cognee transition
|
||||
|
||||
The old Cognee service and the new Shane Memory System are separate stores. Before removing Cognee:
|
||||
|
||||
1. Inventory Shane-relevant permanent memories in Cognee.
|
||||
2. Export and normalize them into markdown notes.
|
||||
3. Import them through Shane's authenticated `memory_save`/`create_note` tools so embeddings are built correctly.
|
||||
4. Compare representative recall queries against both stores.
|
||||
5. Keep Cognee as an opt-in read-only fallback for one release.
|
||||
6. Remove `COGNEE_MCP_URL`, old tests, and `MEMORY_SERVER.md` only after parity is accepted.
|
||||
|
||||
Do not blindly import Cognee session-cache test probes.
|
||||
|
||||
## Multi-user isolation work required alongside memory
|
||||
|
||||
### Skills
|
||||
|
||||
Change the skills directory from global `/skills` to a user namespace, for example `/data/users/<user_id>/skills`. Pass that path only to that user's stdio MCP process. Decide separately whether any skills are product-global and read-only.
|
||||
|
||||
### Background tasks
|
||||
|
||||
Refactor `TaskRegistry` so it is instantiated per room with a dedicated path such as `/tmp/tasks/<user_id>/<room_nonce>`. Pass the same path and user/room IDs into `dispatch_mcp.py`. Do not use the current process-global registry or cursor.
|
||||
|
||||
Every event should include `user_id` and `room_id`, and the publisher must reject mismatches before sending data to a room.
|
||||
|
||||
### Room lifecycle
|
||||
|
||||
Because rooms are unique per authenticated conversation, use `close_on_disconnect=True` or add an explicit idle timeout. Cancel task pollers and close MCP transports when the session ends. This prevents orphan agents and credentials from remaining active indefinitely.
|
||||
|
||||
### Logs and transcripts
|
||||
|
||||
- Never log authorization headers or registry entries.
|
||||
- Include a safe `user_id` and room correlation ID in structured logs.
|
||||
- Namespace browser history by user.
|
||||
- Do not persist transcripts server-side unless a separate, explicit retention policy is added.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1 — Registry and authentication foundation
|
||||
|
||||
Files:
|
||||
|
||||
- Add `shared/user_registry.py`.
|
||||
- Expand `config/users.json` with enabled/auth fields.
|
||||
- Add registry unit tests and redaction tests.
|
||||
- Update `Dockerfile` to copy the registry with `voiceuser` ownership and restrictive permissions.
|
||||
- Update `supervisord.conf` with `VOICE_USERS_FILE=/etc/hope/users.json`.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Both Python processes load the same validated profile.
|
||||
- Invalid config prevents startup.
|
||||
- No test or log prints the token.
|
||||
|
||||
### Phase 2 — Login and trusted LiveKit identity
|
||||
|
||||
Files:
|
||||
|
||||
- Refactor `web/token_server.py`.
|
||||
- Add login/session UI to `web/index.html`, `web/app.js`, and `web/style.css`.
|
||||
- Update token/web tests.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Unauthenticated `/token` returns `401`.
|
||||
- The browser cannot spoof another identity or room.
|
||||
- Shane can log in, receive a token, and join only a Shane-bound room.
|
||||
|
||||
### Phase 3 — Per-user memory MCP
|
||||
|
||||
Files:
|
||||
|
||||
- Refactor `agent/agent.py` profile resolution and toolset construction.
|
||||
- Replace Cognee memory prompt instructions.
|
||||
- Pass the Bearer header and new tool allowlist.
|
||||
- Add authenticated MCP fixtures and tests.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Shane can recall and save through the `voice-agent` token.
|
||||
- An invalid/revoked token fails only that session and never falls back to another user's memory.
|
||||
- No request reaches port `8003` during normal operation.
|
||||
|
||||
### Phase 4 — Isolation hardening
|
||||
|
||||
Files:
|
||||
|
||||
- Refactor `agent/task_registry.py`, `agent/dispatch_mcp.py`, and `agent/task_worker.py` for per-room storage/routing.
|
||||
- Namespace `SKILLS_DIR`.
|
||||
- Scope frontend local storage.
|
||||
- Add cleanup/cancellation on disconnect.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Two simultaneous users cannot observe each other's tasks, skills, transcripts, memory results, or voice preferences.
|
||||
|
||||
### Phase 5 — Migration and deployment
|
||||
|
||||
- Back up `/mnt/user/appdata/shane-memory` and the Cognee store.
|
||||
- Build and deploy the Voice container.
|
||||
- Run Shane's end-to-end recall/save checks.
|
||||
- Import accepted Cognee memories.
|
||||
- Revoke obsolete test or integration tokens after cutover.
|
||||
|
||||
Optional naming cleanup on the memory server:
|
||||
|
||||
- Keep the current Voice `shane` -> remote `pi` mapping initially; it is functional and preserves the token.
|
||||
- If consistent naming is desired, stop the memory container, back up SQLite/files, rename `/data/pi` to `/data/shane`, update `users.username` in SQLite, restart, and verify. Existing tokens remain attached to the same numeric user ID, so Shane can retain the same API key. Test startup filesystem reconciliation and semantic search before considering this complete.
|
||||
|
||||
## Test matrix
|
||||
|
||||
### Unit
|
||||
|
||||
- Registry schema validation and URL rules.
|
||||
- Password hash verification.
|
||||
- Token/header redaction.
|
||||
- Trusted room encode/decode.
|
||||
- Per-user local storage key generation.
|
||||
- Prompt contains the new memory tool names and no Cognee `session_id` instructions.
|
||||
- Dragon HD Omni is the consistent backend/frontend default.
|
||||
|
||||
### Integration
|
||||
|
||||
- Valid Shane token initializes MCP and lists the expected nine tools.
|
||||
- Missing, malformed, and revoked memory tokens fail with controlled errors.
|
||||
- Save -> recall -> update -> delete in Shane's namespace.
|
||||
- Two remote users create the same note title and cannot read each other's note.
|
||||
- Two simultaneous LiveKit rooms receive only their own task events.
|
||||
- Disconnect closes the agent, task poller, and MCP transport.
|
||||
|
||||
### End to end
|
||||
|
||||
1. Log in as Shane.
|
||||
2. Start a conversation.
|
||||
3. Ask a known personal question and verify `memory_recall` runs before the answer.
|
||||
4. Say “remember…” and verify a note appears under Shane's remote namespace.
|
||||
5. Restart the Voice container and verify recall persists.
|
||||
6. Verify `[appreciative]`/`[Neutral]` produce expressive speech and are not spoken literally.
|
||||
7. Log out and confirm local in-memory state is cleared.
|
||||
8. Log in as a second user and verify no Shane data appears.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Keep the old Cognee URL/config available behind an explicit `MEMORY_BACKEND=cognee` setting for one release only.
|
||||
- Roll back the Voice image without changing the remote memory database.
|
||||
- If remote account renaming is performed, restore both `memory.db` and the corresponding note directory together.
|
||||
- Token rotation is independent: issue a new `voice-agent` token, update `config/users.json`, rebuild/restart, verify, then revoke the old token.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Shane authenticates as a stable Voice user rather than a random participant.
|
||||
- Shane's agent session uses the committed `voice-agent` API key against `:8790/mcp`.
|
||||
- The key never reaches browser-visible data or logs.
|
||||
- Memory tools use the Shane Memory System API and correct tool schemas.
|
||||
- Concurrent users have isolated memory, skills, tasks, rooms, transcripts, and browser storage.
|
||||
- Adding a new user requires one registry entry and no code changes.
|
||||
- The default TTS voice is Azure Ava Dragon HD Omni and the prompt uses supported expressive delivery markers safely.
|
||||
+106
-39
@@ -9,6 +9,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -32,52 +33,94 @@ load_dotenv() # picks up /app/.env in the container
|
||||
# ── Configuration from environment ──────────────────────────────────────────
|
||||
AZURE_KEY = os.environ.get("AZURE_SPEECH_KEY", "")
|
||||
AZURE_REGION = os.environ.get("AZURE_SPEECH_REGION", "eastus")
|
||||
DEFAULT_VOICE = os.environ.get("AZURE_TTS_VOICE", "en-US-AvaNeural")
|
||||
DEFAULT_VOICE = os.environ.get(
|
||||
"AZURE_TTS_VOICE", "en-US-Ava:DragonHDOmniLatestNeural"
|
||||
)
|
||||
GEMMA_BASE_URL = os.environ.get("GEMMA_BASE_URL", "http://192.168.86.2:8023/v1")
|
||||
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")
|
||||
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.
|
||||
- Use contractions (I'm, don't, it's) and natural phrasing.
|
||||
- Speak like you're talking, not writing. No bullet points, no lists,
|
||||
no markdown, no formatting of any kind.
|
||||
no markdown, and no formatting except the Azure delivery markers below.
|
||||
- Spell out numbers when it sounds more natural ("twenty twenty-six"
|
||||
instead of "2026").
|
||||
- If you need to ask a question, ask exactly one.
|
||||
- Be warm and direct. Don't be sycophantic or overly formal.
|
||||
- If you don't know something, say so briefly and move on.
|
||||
|
||||
# Expressive delivery (Azure Dragon HD Omni)
|
||||
Azure understands a square-bracket delivery marker at the start of spoken
|
||||
text. Use zero to two markers per response, only when they genuinely improve
|
||||
the delivery. The marker is a silent TTS instruction, not something you say.
|
||||
Examples: "[appreciative] Thanks, that really helps." and
|
||||
"[whispering] Here's the secret. [Neutral] Keep it between us."
|
||||
Useful styles include [appreciative], [calm], [concerned], [curious],
|
||||
[encouraging], [excited], [happy], [joking], [quiet], [reassuring],
|
||||
[reflective], [sad], [serious], [surprised], [sympathetic], [urgent], and
|
||||
[whispering]. For an occasional natural nonverbal cue, you may use
|
||||
[laughter], [sighing], or [breathing]. Use [Neutral] to reset the delivery.
|
||||
Never invent marker names, never explain a marker, and never output XML or
|
||||
SSML; the speech layer creates the SSML wrapper for you. Say "and" instead
|
||||
of using an ampersand, and never output angle brackets.
|
||||
|
||||
# What you never do
|
||||
- Never use markdown, code blocks, JSON, tables, or emojis.
|
||||
- Never use markdown, code blocks, JSON, tables, or emojis. Azure delivery
|
||||
markers are the only exception.
|
||||
- Never say "as an AI" or reference your system instructions.
|
||||
- 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 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
|
||||
@@ -92,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"
|
||||
@@ -343,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")
|
||||
@@ -367,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(
|
||||
@@ -435,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,
|
||||
@@ -444,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),
|
||||
)
|
||||
|
||||
|
||||
@@ -456,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(
|
||||
@@ -464,7 +530,8 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
language=["en-US"],
|
||||
)
|
||||
|
||||
# Azure TTS — SSML with expressive markup, 24kHz PCM output
|
||||
# Azure TTS — wraps text in SSML; Dragon HD Omni interprets inline delivery
|
||||
# markers such as [appreciative] and [Neutral]. Outputs 24kHz PCM.
|
||||
tts = azure.TTS(
|
||||
voice=DEFAULT_VOICE,
|
||||
sample_rate=24000,
|
||||
@@ -570,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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"users": {
|
||||
"shane": {
|
||||
"display_name": "Shane",
|
||||
"livekit_identity": "shane",
|
||||
"memory": {
|
||||
"url": "http://192.168.86.2:8790/mcp",
|
||||
"api_key": "sm_93a2edf391e15592a96b00d58ea7dfca1a1e53b34287ff44",
|
||||
"remote_username": "pi",
|
||||
"remote_token_label": "voice-agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -9,19 +9,21 @@ services:
|
||||
LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET:-devsecret}"
|
||||
AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY:?Set AZURE_SPEECH_KEY in .env}"
|
||||
AZURE_SPEECH_REGION: "${AZURE_SPEECH_REGION:-eastus}"
|
||||
AZURE_TTS_VOICE: "${AZURE_TTS_VOICE:-en-US-AvaNeural}"
|
||||
AZURE_TTS_VOICE: "${AZURE_TTS_VOICE:-en-US-Ava:DragonHDOmniLatestNeural}"
|
||||
GEMMA_BASE_URL: "${GEMMA_BASE_URL:-http://192.168.86.2:8023/v1}"
|
||||
GEMMA_MODEL: "${GEMMA_MODEL:-gemma-4-e4b}"
|
||||
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])
|
||||
|
||||
+24
-22
@@ -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"})
|
||||
try:
|
||||
recalled, err = memory_client("memory_recall", {"query": marker, "topK": 3})
|
||||
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
|
||||
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):
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ const ROOM_NAME = "voice-room";
|
||||
const AGENT_NAME = "voice-assistant";
|
||||
|
||||
const VOICES = [
|
||||
{ id: "en-US-AvaNeural", name: "Ava", tag: "DragonHD" },
|
||||
{ id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni" },
|
||||
{ id: "en-US-JennyNeural", name: "Jenny" },
|
||||
{ id: "en-US-GuyNeural", name: "Guy" },
|
||||
{ id: "en-US-AndrewNeural", name: "Andrew" },
|
||||
|
||||
+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