personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

voice: ship Wave 2 voice server (root /api/voice/*, 9-tool sideband)

Stands up the solstone voice backend: a persistent sol brain (Claude CLI
session adapted from hub-phone's pattern, session persisted at
journal/health/voice-brain-session), five root-level endpoints at
/api/voice/*, a sideband dispatcher that bridges OpenAI Realtime tool
calls to the 9-tool manifest, and a per-call-id nav-hint queue the native
client polls to drive WebView routing.

Endpoints:
- POST /api/voice/session — mint OpenAI ephemeral key + current brain
instruction + 9-tool manifest; HTTP 503 if key not configured or
brain still cold after 10s.
- POST /api/voice/connect — spawn async sideband task for a call_id;
registered in app.voice_tasks and cancelled on shutdown.
- POST /api/voice/refresh-brain — force brain refresh (waits up to 30s).
- GET /api/voice/nav-hints?call_id=… — drain the per-call-id nav queue;
TTL 60s, cap 8 hints, FIFO drop.
- GET /api/voice/status — brain readiness, key-configured flag, active
session count; polled by the native client at 30s.

9-tool manifest — every handler reuses the existing solstone surface, no
new data layer:
- journal.get_day → think.cluster.scan_day / cluster_segments
- journal.search → think.indexer.journal.search_journal
- entities.get → think.surfaces.profile.full
- entities.recent_with → profile.full + think.activities.load_activity_records
- commitments.list → think.surfaces.ledger.list (strips `sources`)
- commitments.complete → think.surfaces.ledger.close
- calendar.today → load_activity_records filtered to source=anticipated
- briefing.get → apps.home.routes._load_briefing_md
- observer.start_listening → stub (Wave 4 wires the real observer)

Runtime: a singleton daemon-thread asyncio loop (think/voice/runtime.py)
with explicit lifecycle hooks. Brain start is lazy — the first
/api/voice/session call triggers it via wait_until_ready, so process
startup is cheap and tests need no special warm-up.

Config: journal/config/journal.json gains a voice block:
voice.openai_api_key, voice.model (default "gpt-realtime"),
voice.brain_model (default "haiku"). OPENAI_API_KEY env is the
fallback for the key.

Nav hints are strictly side-channel — the sideband strips _nav_target
from the OpenAI payload and queues it per-call-id for the native client
to drain.

Design record at docs/design/voice-server.md captures the decisions and
the scope deviations that were approved at gate (brain-not-ready=503,
briefing path correction, commitments resolution mapping, OpenAI key
sourcing, ask_sol clause dropped).

New tests (8 files, all green under `make ci`):
- tests/test_voice_config.py, tests/test_voice_nav_queue.py,
tests/test_voice_brain.py, tests/test_voice_runtime.py,
tests/test_voice_tools.py, tests/test_voice_sideband.py,
tests/test_voice_routes.py, tests/test_voice_integration.py

make ci: 3584 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+3207
+8
convey/__init__.py
··· 109 109 110 110 def create_app(journal: str = "") -> Flask: 111 111 """Create and configure the Convey Flask application.""" 112 + from think.voice.runtime import start_voice_runtime 113 + 114 + from .voice import voice_bp 115 + 112 116 app = Flask( 113 117 __name__, 114 118 template_folder=os.path.join(os.path.dirname(__file__), "templates"), ··· 143 147 # Register system health API blueprint 144 148 app.register_blueprint(system.bp) 145 149 150 + # Register voice API blueprint 151 + app.register_blueprint(voice_bp) 152 + 146 153 # Initialize and register app system 147 154 registry = AppRegistry() 148 155 registry.discover() ··· 153 160 154 161 sock = Sock(app) 155 162 register_websocket(sock) 163 + start_voice_runtime(app) 156 164 157 165 if journal: 158 166 state.journal_root = journal
+187
convey/voice.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Root-level voice API.""" 5 + 6 + from __future__ import annotations 7 + 8 + import asyncio 9 + import logging 10 + from concurrent.futures import TimeoutError as FutureTimeoutError 11 + from typing import Any 12 + 13 + from flask import Blueprint, current_app, jsonify, request 14 + from openai import AsyncOpenAI 15 + from werkzeug.exceptions import BadRequest 16 + 17 + from think.voice import brain 18 + from think.voice.config import get_openai_api_key, get_voice_model 19 + from think.voice.nav_queue import get_nav_queue 20 + from think.voice.runtime import get_runtime_state 21 + from think.voice.sideband import _run_sideband, register_voice_task 22 + from think.voice.tools import get_tool_manifest 23 + 24 + logger = logging.getLogger(__name__) 25 + 26 + voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice") 27 + 28 + 29 + def _error(message: str, status: int): 30 + return jsonify({"error": message}), status 31 + 32 + 33 + def _optional_json_object() -> tuple[dict[str, Any], Any | None]: 34 + if not request.get_data(cache=True): 35 + return {}, None 36 + try: 37 + data = request.get_json(silent=False) 38 + except BadRequest: 39 + return {}, _error("request body must be valid JSON", 400) 40 + if not isinstance(data, dict): 41 + return {}, _error("request body must be a JSON object", 400) 42 + return data, None 43 + 44 + 45 + def _required_json_object() -> tuple[dict[str, Any], Any | None]: 46 + try: 47 + data = request.get_json(silent=False) 48 + except BadRequest: 49 + return {}, _error("request body must be valid JSON", 400) 50 + if not isinstance(data, dict): 51 + return {}, _error("request body must be a JSON object", 400) 52 + return data, None 53 + 54 + 55 + def _require_runtime(app: Any): 56 + if not getattr(app, "voice_runtime_started", False): 57 + return None, _error("voice runtime unavailable", 500) 58 + runtime = get_runtime_state() 59 + if runtime.loop is None: 60 + return None, _error("voice runtime unavailable", 500) 61 + return runtime, None 62 + 63 + 64 + @voice_bp.post("/session") 65 + async def create_voice_session(): 66 + _, error = _optional_json_object() 67 + if error is not None: 68 + return error 69 + 70 + app = current_app._get_current_object() 71 + _, runtime_error = _require_runtime(app) 72 + if runtime_error is not None: 73 + return runtime_error 74 + 75 + openai_key = get_openai_api_key() 76 + if openai_key is None: 77 + return _error("voice unavailable — openai key not configured", 503) 78 + 79 + ready = await asyncio.to_thread(brain.wait_until_ready, app, 10.0) 80 + if not ready: 81 + return _error("voice unavailable — brain not ready", 503) 82 + 83 + if brain.brain_is_stale(app): 84 + brain.schedule_refresh(app) 85 + 86 + client = AsyncOpenAI(api_key=openai_key) 87 + try: 88 + response = await client.realtime.client_secrets.create( 89 + session={ 90 + "type": "realtime", 91 + "model": get_voice_model(), 92 + "instructions": app.voice_brain_instruction, 93 + "tool_choice": "auto", 94 + "tools": get_tool_manifest(), 95 + "output_modalities": ["audio"], 96 + } 97 + ) 98 + except Exception: 99 + logger.exception("voice session mint failed") 100 + return _error("voice session unavailable", 500) 101 + 102 + return jsonify({"ephemeral_key": response.value}) 103 + 104 + 105 + @voice_bp.post("/connect") 106 + def connect_voice_sideband(): 107 + body, error = _required_json_object() 108 + if error is not None: 109 + return error 110 + 111 + app = current_app._get_current_object() 112 + runtime, runtime_error = _require_runtime(app) 113 + if runtime_error is not None: 114 + return runtime_error 115 + 116 + if get_openai_api_key() is None: 117 + return _error("voice unavailable — openai key not configured", 503) 118 + 119 + call_id = body.get("call_id") 120 + if not isinstance(call_id, str) or not call_id.strip(): 121 + return _error("call_id is required", 400) 122 + 123 + future = asyncio.run_coroutine_threadsafe( 124 + _run_sideband(call_id.strip(), app), runtime.loop 125 + ) 126 + register_voice_task(app, future) 127 + return jsonify({"status": "connected"}) 128 + 129 + 130 + @voice_bp.post("/refresh-brain") 131 + def refresh_voice_brain(): 132 + _, error = _optional_json_object() 133 + if error is not None: 134 + return error 135 + 136 + app = current_app._get_current_object() 137 + _, runtime_error = _require_runtime(app) 138 + if runtime_error is not None: 139 + return runtime_error 140 + 141 + future = brain.schedule_refresh(app, force=True) 142 + try: 143 + _, instruction = future.result(timeout=30) 144 + except FutureTimeoutError: 145 + return jsonify({"status": "refreshing"}), 202 146 + except Exception: 147 + logger.exception("voice brain refresh failed") 148 + return _error("brain refresh failed", 500) 149 + 150 + return jsonify( 151 + { 152 + "status": "refreshed", 153 + "instruction_preview": instruction[:240], 154 + "brain_ready": bool(app.voice_brain_instruction), 155 + "brain_age_seconds": brain.brain_age_seconds(app), 156 + } 157 + ) 158 + 159 + 160 + @voice_bp.get("/nav-hints") 161 + def nav_hints(): 162 + call_id = request.args.get("call_id", "").strip() 163 + if not call_id: 164 + return _error("call_id is required", 400) 165 + hints = get_nav_queue().drain(call_id) 166 + return jsonify({"hints": hints, "consumed": True}) 167 + 168 + 169 + @voice_bp.get("/status") 170 + def voice_status(): 171 + app = current_app._get_current_object() 172 + active_sessions = sum( 173 + 1 174 + for future in getattr(app, "voice_tasks", set()) 175 + if hasattr(future, "done") and not future.done() 176 + ) 177 + return jsonify( 178 + { 179 + "brain_ready": bool(getattr(app, "voice_brain_instruction", "")), 180 + "brain_age_seconds": brain.brain_age_seconds(app), 181 + "openai_configured": get_openai_api_key() is not None, 182 + "active_sessions": active_sessions, 183 + } 184 + ) 185 + 186 + 187 + __all__ = ["voice_bp"]
+473
docs/design/voice-server.md
··· 1 + # Wave 2 voice server 2 + 3 + ## 1. Summary 4 + 5 + Wave 2 ships a root-level voice API for the existing Convey server: `POST /api/voice/session`, `POST /api/voice/connect`, `POST /api/voice/refresh-brain`, `GET /api/voice/nav-hints`, and `GET /api/voice/status`, all mounted from a new `convey/voice.py` blueprint at `/api/voice/*`. The implementation reuses existing journal, ledger, entity, briefing, and anticipated-activity read surfaces, keeps all voice-owned writes inside `journal/health/voice-brain-session*`, and treats the bridge contract in the scope as canonical when it conflicts with older prose (`convey/__init__.py:126-155`, `convey/system.py:18`, `apps/home/routes.py:149-198`, `think/surfaces/ledger.py:441-529`, `think/indexer/journal.py:1865-1948`). 6 + 7 + ## 2. Module layout 8 + 9 + New files: 10 + 11 + - `convey/voice.py` — root-level Flask blueprint for `/api/voice/*`; validates requests, reads app voice state, and bridges HTTP to the background runtime. 12 + - `think/voice/__init__.py` — small public surface for runtime start/stop helpers and brain refresh helpers. 13 + - `think/voice/brain.py` — persistent Claude CLI session manager; start, refresh, ask, readiness state, and `journal/health/voice-brain-session*` persistence. 14 + - `think/voice/runtime.py` — singleton daemon-thread asyncio loop, app attachment, task tracking, and shutdown helper. 15 + - `think/voice/sideband.py` — OpenAI Realtime sideband join loop, event filter, tool-call output emission, and task cleanup. 16 + - `think/voice/tools.py` — the 9 tool schemas, argument validation, handler dispatch, `_nav_target` extraction, and model-facing JSON shaping. 17 + - `think/voice/nav_queue.py` — thread-safe per-`call_id` nav-hint queue with TTL and capacity enforcement. 18 + - `think/voice/config.py` — config readers for OpenAI key, voice model, and brain model. 19 + - `tests/test_voice_config.py` — config-reader defaults, env fallback, and missing-key cases. 20 + - `tests/test_voice_brain.py` — brain prompt content, session persistence, start/refresh/ask flow, and stale-refresh behavior with mocked Claude CLI. 21 + - `tests/test_voice_tools.py` — unit coverage for all 9 tool handlers, including one happy path and one failure path per tool. 22 + - `tests/test_voice_sideband.py` — sideband event routing, `_nav_target` stripping, tool error wrapping, and output emission. 23 + - `tests/test_voice_nav_queue.py` — TTL, FIFO capacity, drain semantics, and lock-protected multi-thread sanity. 24 + - `tests/test_voice_runtime.py` — loop/thread lifecycle, idempotent startup, future registration, and explicit shutdown. 25 + - `tests/test_voice_routes.py` — endpoint validation and error-shape coverage with Flask test client. 26 + - `tests/test_voice_integration.py` — end-to-end session mint, sideband task spawn, one tool dispatch, and nav-hint polling with fake OpenAI clients. 27 + 28 + Existing files updated during implementation: 29 + 30 + - `convey/__init__.py` — register `voice_bp` directly beside `system.bp`, then call `start_voice_runtime(app)` before returning the app (`convey/__init__.py:134-155`). 31 + - `think/journal_default.json` — add default `voice` config block so new journals inherit the documented keys. 32 + 33 + Deliberate non-change: 34 + 35 + - No `apps/voice/` package. `AppRegistry` would tolerate a custom `url_prefix`, but the app shell assumes `/app/<name>` and `workspace.html`; this feature is a root API, not a Convey app surface (`apps/__init__.py:124-127`, `apps/__init__.py:267-271`, `apps/__init__.py:322-337`, `convey/apps.py:241-251`). 36 + 37 + ## 3. Flow diagrams 38 + 39 + ### Startup sequence 40 + 41 + 1. `convey.create_app()` registers the existing root/config/triage/system blueprints. 42 + 2. `convey.create_app()` registers `voice_bp` at `url_prefix="/api/voice"`. 43 + 3. `convey.create_app()` calls `start_voice_runtime(app)`. 44 + 4. `start_voice_runtime(app)` creates or reuses the module-level event loop, starts a daemon thread running `loop.run_forever()`, attaches `app.voice_tasks = set()`, and stores voice state on the app. 45 + 5. The server begins serving immediately; the voice brain is started lazily on the first `POST /api/voice/session` request or an explicit `POST /api/voice/refresh-brain`. 46 + 6. On successful brain startup, `app.voice_brain_session`, `app.voice_brain_instruction`, and `app.voice_brain_refreshed_at` are populated, and the session file is atomically written under `journal/health/`. 47 + 48 + ### Session mint flow (`POST /api/voice/session`) 49 + 50 + 1. Validate that the request body is empty or a JSON object. 51 + 2. Resolve the OpenAI key through `think.voice.config.get_openai_api_key()`. 52 + 3. If no key exists, return HTTP 503 `{"error": "voice unavailable — openai key not configured"}`. 53 + 4. If `app.voice_brain_instruction` is empty, ensure a brain start is in flight and wait up to 10 seconds for readiness. 54 + 5. If the brain is still not ready after 10 seconds, return HTTP 503 `{"error": "voice unavailable — brain not ready"}`. 55 + 6. If the current instruction exists but is older than `BRAIN_REFRESH_MAX_AGE_SECONDS`, queue a non-blocking refresh and continue with the current instruction. 56 + 7. Build the 9-tool manifest from `think.voice.tools`. 57 + 8. Call `AsyncOpenAI(api_key=...).realtime.client_secrets.create(session=...)` with `model`, `instructions`, `tool_choice="auto"`, `tools`, and the Realtime modalities block. 58 + 9. Return `{"ephemeral_key": "<value>"}`. 59 + 60 + ### Connect + sideband loop (`POST /api/voice/connect`) 61 + 62 + 1. Validate JSON object body and required non-empty `call_id`. 63 + 2. Resolve OpenAI key; if missing, return HTTP 503 key-not-configured. 64 + 3. Schedule `_run_sideband(call_id, app)` onto the background loop with `asyncio.run_coroutine_threadsafe(...)`. 65 + 4. Insert the returned `Future` into `app.voice_tasks`. 66 + 5. Register a done-callback that removes the future from `app.voice_tasks`. 67 + 6. Return `{"status": "connected"}` immediately. 68 + 7. `_run_sideband(...)` opens `AsyncOpenAI(...).realtime.connect(call_id=call_id, model=get_voice_model())`. 69 + 8. `_sideband_loop(...)` consumes events until the call ends or the task is cancelled. 70 + 71 + ### Tool dispatch 72 + 73 + 1. `_sideband_loop(...)` filters for `event.type == "response.function_call_arguments.done"`. 74 + 2. `think.voice.tools.dispatch_tool_call(name, arguments, call_id, app)` JSON-decodes the arguments and validates the input schema for that tool. 75 + 3. The tool handler reads the existing surface, shapes the model-facing response, and may include `_nav_target`. 76 + 4. `dispatch_tool_call(...)` removes `_nav_target` from the JSON sent back to OpenAI and enqueues it into `nav_queue` for the `call_id`. 77 + 5. `_sideband_loop(...)` posts `function_call_output` with the stripped JSON string through `conn.conversation.item.create(...)`. 78 + 6. `_sideband_loop(...)` calls `conn.response.create()` so the model can continue the turn. 79 + 7. Any tool exception becomes `{"error": "<generic message>"}` and does not end the sideband task. 80 + 81 + ### Nav-hint polling 82 + 83 + 1. Native client calls `GET /api/voice/nav-hints?call_id=...`. 84 + 2. The route validates `call_id`. 85 + 3. `nav_queue.drain(call_id)` drops expired hints, returns remaining hints in FIFO order, and clears the queue entry. 86 + 4. The route returns `{"hints": [...], "consumed": true}`. 87 + 5. Unknown `call_id` returns `{"hints": [], "consumed": true}` with HTTP 200. 88 + 89 + ### Shutdown 90 + 91 + 1. Process exit or explicit test cleanup calls `stop_voice_runtime(app)`. 92 + 2. `stop_voice_runtime(app)` cancels each non-done future in `app.voice_tasks`. 93 + 3. `stop_voice_runtime(app)` requests `loop.stop()` with `loop.call_soon_threadsafe(...)`. 94 + 4. The daemon thread is joined with a bounded timeout. 95 + 5. Module-level runtime state is cleared so the next app instance can start cleanly. 96 + 97 + ## 4. Endpoint specs 98 + 99 + ### `POST /api/voice/session` 100 + 101 + Request body: 102 + 103 + - Empty body is allowed. 104 + - If a body is present it must decode to a JSON object; any other JSON type returns HTTP 400. 105 + - No request fields are currently used. 106 + 107 + Success response: 108 + 109 + - HTTP 200 110 + - Body: `{"ephemeral_key": "<string>"}` 111 + 112 + Failure responses: 113 + 114 + - HTTP 400 `{"error": "request body must be a JSON object"}` 115 + - HTTP 503 `{"error": "voice unavailable — openai key not configured"}` 116 + - HTTP 503 `{"error": "voice unavailable — brain not ready"}` 117 + - HTTP 500 `{"error": "voice session unavailable"}` for OpenAI hard failures after logging detail server-side 118 + 119 + Side effects: 120 + 121 + - Starts brain init if needed. 122 + - Waits up to 10 seconds for first-time brain readiness. 123 + - Queues a non-blocking brain refresh if the current instruction is older than 6 hours. 124 + 125 + ### `POST /api/voice/connect` 126 + 127 + Request body: 128 + 129 + - Required JSON object. 130 + - Required `call_id: string`, trimmed, non-empty. 131 + 132 + Success response: 133 + 134 + - HTTP 200 135 + - Body: `{"status": "connected"}` 136 + 137 + Failure responses: 138 + 139 + - HTTP 400 `{"error": "request body must be valid JSON"}` 140 + - HTTP 400 `{"error": "call_id is required"}` 141 + - HTTP 503 `{"error": "voice unavailable — openai key not configured"}` 142 + - HTTP 500 `{"error": "voice runtime unavailable"}` if the background loop was not attached 143 + 144 + Side effects: 145 + 146 + - Schedules a background sideband task. 147 + - Adds the future to `app.voice_tasks`. 148 + 149 + ### `POST /api/voice/refresh-brain` 150 + 151 + Request body: 152 + 153 + - Empty body is allowed. 154 + - If a body is present it must decode to a JSON object; contents are ignored in Wave 2. 155 + 156 + Success response: 157 + 158 + - HTTP 200 `{"status": "refreshed", "instruction_preview": "<first 240 chars>", "brain_ready": true, "brain_age_seconds": 0}` 159 + - HTTP 202 `{"status": "refreshing"}` if the queued refresh has not finished within 30 seconds 160 + 161 + Failure responses: 162 + 163 + - HTTP 400 `{"error": "request body must be a JSON object"}` 164 + - HTTP 500 `{"error": "brain refresh failed"}` if the queued future raises before returning a fresh instruction 165 + 166 + Side effects: 167 + 168 + - Forces a brain refresh even if the current instruction is fresh. 169 + - Starts the brain first if no session has been established yet. 170 + - Updates `app.voice_brain_*` state on success. 171 + 172 + ### `GET /api/voice/nav-hints` 173 + 174 + Query params: 175 + 176 + - Required `call_id: string`, trimmed, non-empty. 177 + 178 + Success response: 179 + 180 + - HTTP 200 181 + - Body: `{"hints": ["entity/sarah_chen", "today"], "consumed": true}` 182 + 183 + Failure responses: 184 + 185 + - HTTP 400 `{"error": "call_id is required"}` 186 + 187 + Side effects: 188 + 189 + - Drains and clears the queue for the given `call_id`. 190 + - Drops expired hints before returning. 191 + 192 + ### `GET /api/voice/status` 193 + 194 + Request body: 195 + 196 + - None. 197 + 198 + Success response: 199 + 200 + - HTTP 200 201 + - Body always contains: 202 + - `brain_ready: bool` 203 + - `brain_age_seconds: int | null` 204 + - `openai_configured: bool` 205 + - `active_sessions: int` 206 + 207 + Failure responses: 208 + 209 + - No endpoint-specific failure response; if internal state lookup fails, log and return the default falsey payload with HTTP 200. 210 + 211 + Side effects: 212 + 213 + - None. 214 + 215 + ## 5. Tool-handler table (canonical) 216 + 217 + Rules that apply to every tool: 218 + 219 + - Handlers return model-facing JSON objects only. 220 + - If a handler emits `_nav_target`, `think.voice.tools.dispatch_tool_call(...)` strips it before sending the JSON string to OpenAI and pushes the hint into `think.voice.nav_queue`. 221 + - Tool exceptions become `{"error": "<generic message>"}` and are returned inline through the sideband, never as HTTP errors. 222 + 223 + | Tool | Input JSON | Output JSON | Nav rule | Reused surface | Failure shape | 224 + |---|---|---|---|---|---| 225 + | `journal.get_day` | `{"day": "YYYY-MM-DD"}` | `{"day": "YYYY-MM-DD", "segments": [{"id": "HHMMSS_LEN", "time_of_day": "HH:MM", "duration_s": 300, "summary": "<string>", "agent_type": "<stream>"}], "summary": "<string>", "_nav_target": "today/journal/YYYY-MM-DD"}` | Always emit `_nav_target` for a valid day lookup | `think.cluster.scan_day`, `think.cluster.cluster_segments`, and read-only day-path inspection for summary text. Use `scan_day()` for day existence and segment inventory, `cluster_segments()` for normalized segment rows, and synthesize `summary` from per-segment `*_summary.md` files under the day directory without calling `think.utils.segment_path()` because it creates missing directories (`think/cluster.py:413-505`, `think/utils.py:155-182`, `think/utils.py:247-260`) | `{"error": "invalid day"}` or `{"error": "day not found"}` | 226 + | `journal.search` | `{"query": "<string>", "facet": "<string>|null", "days": 30|null, "limit": 10|null}` | `{"results": [{"id": "<id>", "day": "YYYY-MM-DD", "source": "<agent-or-path>", "snippet": "<text>", "entity_slug": "<slug>"?}], "count": N, "_nav_target": "today/search?q=<urlencoded-query>"}` | Emit `_nav_target` only when `query.strip()` is non-empty | `think.indexer.journal.search_journal(query, limit=limit, facet=facet, day_from=..., day_to=None)` with shaping inspired by `apps/search/routes.py::_format_result` (`think/indexer/journal.py:1865-1948`, `apps/search/routes.py:89-126`, `apps/search/routes.py:127-240`) | `{"error": "query is required"}` | 227 + | `entities.get` | `{"entity_slug": "<slug>"}` | `{"slug": "<slug>", "name": "<name>", "type": "<type>", "profile": "<markdown>", "tags": ["<facet-or-aka>"], "recent_context": [{"date": "YYYY-MM-DD", "summary": "<string>"}], "_nav_target": "entity/<slug>"}` | Always emit `_nav_target` when found | Primary source is `think.surfaces.profile.full(slug)`. If it resolves, build `profile`, `tags`, and `recent_context` from the returned `Profile`; if facet relationship details are needed, mirror `apps/entities/routes.py::_build_facet_relationships(...)` and `think.entities.journal.load_journal_entity(slug)` (`think/surfaces/profile.py:207-245`, `apps/entities/routes.py:685-730`, `think/entities/journal.py:43-69`) | `{"error": "not found"}` | 228 + | `entities.recent_with` | `{"entity_slug": "<slug>", "days": 7, "facet": "<string>|null"}` | `{"slug": "<slug>", "interactions": [{"date": "YYYY-MM-DD", "activity": "<title>", "context": "<story-or-description>", "note": "<details>"}], "count": N}` | No nav hint | Resolve the entity with `think.surfaces.profile.full(slug)`, then scan `think.activities.load_activity_records(facet, day)` across the requested day window. Match `participation[].entity_id` first and fall back to casefolded name / aka matching for older rows without `entity_id` (`think/surfaces/profile.py:207-245`, `think/activities.py:877-890`, `apps/activities/call.py:133-197`) | `{"error": "not found"}` or `{"error": "invalid days"}` | 229 + | `commitments.list` | `{"state": "open"|"closed"|"dropped"|null, "facet": "<string>|null", "limit": 20|null}` | `{"commitments": [{"id": "<id>", "owner": "<owner>", "action": "<action>", "counterparty": "<counterparty>", "state": "<state>", "context": "<context>", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<resolution>"?}]}` | No nav hint | `think.surfaces.ledger.list(state=..., facets=[facet] if facet else None, top=limit or 20)`. Convert each `LedgerItem` dataclass to a dict, drop `sources`, and derive `day_*` strings from the millisecond timestamps. `resolution` is best-effort only: set it to `"dropped"` when `item.state == "dropped"`, otherwise omit because the ledger surface does not expose the close-note resolution (`think/surfaces/ledger.py:441-487`, `think/surfaces/types.py:16-32`) | `{"error": "invalid state"}` | 230 + | `commitments.complete` | `{"commitment_id": "lg_...", "resolution": "done"|"sent"|"signed"|"dropped"|"deferred"}` | `{"ok": true, "commitment": {"id": "...", "owner": "...", "action": "...", "counterparty": "...", "state": "...", "context": "...", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": "<input-resolution>"}}` | No nav hint | Validate `resolution`. Map `dropped -> as_state="dropped", note="resolution: dropped"`. Map `done|sent|signed|deferred -> as_state="closed", note="resolution: <value>"`. Call `think.surfaces.ledger.close(...)`, catch `KeyError`, and shape the returned `LedgerItem` as above (`think/surfaces/ledger.py:497-529`, `think/activities.py:1156-1207`) | `{"error": "invalid resolution"}` or `{"error": "not found"}` | 231 + | `calendar.today` | `{}` | `{"date": "YYYY-MM-DD", "events": [{"time": "HH:MM", "title": "<title>", "attendees": ["<name>"], "location": "<string>", "prep_notes": "<string>"}], "_nav_target": "today"}` | Always emit `_nav_target` | `think.activities.load_activity_records(facet, day)` across all enabled facets, filtered to `source == "anticipated"` using the same participation parsing pattern Home uses today (`apps/home/routes.py:305-337`, `think/activities.py:877-890`) | `{"error": "today unavailable"}` only on unexpected failures; normal empty day is `{"date": "...", "events": [], "_nav_target": "today"}` | 232 + | `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."], "_nav_target": "today"}` or `{"error": "no briefing today yet"}` | Emit `_nav_target` only when a fresh briefing exists | Reuse `apps/home/routes.py::_load_briefing_md(today)` exactly. If `metadata.date != today`, return the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` bullets first, then falls back to the first three bullets across the other sections (`apps/home/routes.py:149-198`) | `{"error": "no briefing today yet"}` | 233 + | `observer.start_listening` | `{"mode": "meeting"|"voice_memo"}` | `{"status": "ack", "mode": "<mode>", "note": "wave-4 observer not yet wired"}` | No nav hint | No data dependency in Wave 2. Log the requested mode at INFO and return the stub acknowledgement. | `{"error": "invalid mode"}` | 234 + 235 + Implementation notes by tool: 236 + 237 + - `journal.get_day` normalizes input day strings from `YYYY-MM-DD` to internal `YYYYMMDD`, then returns the external hyphenated form. 238 + - `journal.get_day.summary` is synthesized from per-segment summary files if present; otherwise it is the empty string. 239 + - `journal.search` derives `day_from` as `today - days` when `days` is provided; omit the filter when `days` is null. 240 + - `journal.search.entity_slug` is best-effort and should only be included when it can be inferred from `metadata.path`, such as `entities/<slug>/...` or `entity:<slug>`. 241 + - `entities.get.profile` is a markdown summary synthesized from the `Profile` object: description, cadence sentence, open commitments count, closed commitments count, and the most relevant facets. 242 + - `entities.recent_with` sorts interactions descending by activity timestamp and truncates to a small spoken-friendly limit, default 10. 243 + - `commitments.list` and `commitments.complete` must strip `sources` before returning anything model-facing. 244 + - `calendar.today.location` and `calendar.today.prep_notes` default to `""` because current anticipated activity rows do not guarantee either field. 245 + - `briefing.get.facet` is the literal string `"identity"` because the canonical source is `journal/identity/briefing.md`, not a facet-scoped talent output. 246 + 247 + ## 6. Brain init prompt (full text) 248 + 249 + Runtime template values: 250 + 251 + - `{agent_name}` comes from `get_config().get("agent", {}).get("name") or "sol"` (`think/utils.py:557-588`, `tests/fixtures/journal/config/journal.json:70-74`). 252 + - `{today}` is the external date string `YYYY-MM-DD` produced by the voice tool `_today()` helper. 253 + 254 + Prompt text: 255 + 256 + ```text 257 + You are preparing the current voice-session instruction for {agent_name}, the spoken identity of this solstone journal. 258 + 259 + Your task right now is to read the current journal state and produce exactly one fresh instruction for an OpenAI Realtime voice session. The instruction must sound like spoken English. Keep it concise, natural, and useful in conversation. No markdown. No bullets. No XML outside the required wrapper tags. 260 + 261 + Voice style rules: 262 + - Write for speech, not reading. 263 + - Keep the voice model oriented toward short spoken turns, usually 2 to 4 sentences unless the user clearly asks for more. 264 + - Prefer concrete wording over abstract wording. 265 + - If context is missing, the instruction should say to answer honestly and briefly rather than guessing. 266 + 267 + Terminology covenant: 268 + - Use the words observer and listen when referring to the live sensing system. 269 + - Never use the words keeper, assistant, record, or capture. 270 + 271 + Before you write the instruction, ingest the current context: 272 + - Read the identity material under journal/identity/ and treat {agent_name} as the canonical spoken name. 273 + - Read today's journal summary and today's segment-level summaries if they exist. 274 + - Read the active entities that matter right now. 275 + - Read the open commitments. 276 + - Read today's calendar and anticipated activities. 277 + - Read the latest briefing in journal/identity/briefing.md if it is for today. 278 + 279 + Then write one system instruction that does all of the following: 280 + - Establish who {agent_name} is and how the voice should speak. 281 + - Anchor the voice in today's real context. 282 + - Name the most important people, commitments, and upcoming events if they are present. 283 + - Tell the voice to stay concise, spoken, and honest about missing information. 284 + - Preserve the terminology covenant above. 285 + 286 + Output only this wrapper and the instruction inside it: 287 + <voice_instruction> 288 + ... 289 + </voice_instruction> 290 + ``` 291 + 292 + Notes: 293 + 294 + - The prompt intentionally has no `ask_sol` clause. Wave 2 ships the 9-tool manifest only. 295 + - `think.voice.brain.ask_brain(...)` still exists as a parity helper for future expansion, but it is not referenced in the Wave 2 prompt or tool manifest. 296 + 297 + ## 7. Background runtime + shutdown 298 + 299 + Exact runtime shape: 300 + 301 + - `think.voice.runtime` owns a module-level `RuntimeState` singleton with: 302 + - `loop: asyncio.AbstractEventLoop | None` 303 + - `thread: threading.Thread | None` 304 + - `started: bool` 305 + - `lock: threading.Lock` 306 + - `atexit_registered: bool` 307 + - `start_voice_runtime(app)` is idempotent. 308 + - The thread target creates the loop, sets it as current for that thread, and runs `loop.run_forever()`. 309 + - `app.voice_tasks` is attached directly to the Flask app as `set[concurrent.futures.Future]`. 310 + - `app.voice_brain_session`, `app.voice_brain_instruction`, and `app.voice_brain_refreshed_at` are attached directly to the Flask app as mutable voice state. 311 + - `app.voice_runtime_started = True` is attached so routes can fail loudly if startup wiring is missing. 312 + 313 + Startup behavior: 314 + 315 + 1. `convey.create_app()` calls `start_voice_runtime(app)` exactly once per app instance. 316 + 2. `start_voice_runtime(app)` initializes the app attributes above. 317 + 3. `brain.wait_until_ready(app, timeout)` or `brain.schedule_refresh(app, force=True)` is the first caller that schedules `brain.schedule_start(app)` onto the runtime loop. 318 + 4. `brain.schedule_start(app)` either resumes an existing session from `journal/health/voice-brain-session` or starts a new Claude session if none exists. 319 + 320 + Sideband scheduling: 321 + 322 + - `POST /api/voice/connect` calls `asyncio.run_coroutine_threadsafe(_run_sideband(call_id, app), runtime.loop)`. 323 + - The returned `Future` is inserted into `app.voice_tasks`. 324 + - `future.add_done_callback(app.voice_tasks.discard)` prunes completed tasks. 325 + 326 + Shutdown choice: 327 + 328 + - Use `atexit.register(stop_voice_runtime)` as the process-level fallback. 329 + - Also export `stop_voice_runtime(app)` and require tests to call it explicitly in teardown. 330 + - Do not use `teardown_appcontext`; it fires per request and is the wrong lifecycle for long-lived sideband tasks. 331 + 332 + Signal boundary: 333 + 334 + - The Flask dev server and reloader are not treated as a reliable global-shutdown environment for this feature. 335 + - Clean shutdown is expected when the stack runs under the supervisor-managed process model, not `flask run`. 336 + - Tests must explicitly call `stop_voice_runtime(app)` so the daemon thread does not leak across cases. 337 + 338 + Brain refresh lifecycle: 339 + 340 + - `BRAIN_REFRESH_MAX_AGE_SECONDS = 6 * 3600`. 341 + - Startup only prepares the runtime loop and app state; the first voice session or explicit refresh starts the brain. 342 + - `POST /api/voice/session` queues a refresh when the current instruction is older than the threshold, but does not wait for it. 343 + - `POST /api/voice/refresh-brain` forces a refresh and waits up to 30 seconds because it exists for explicit founder/debug use. 344 + 345 + ## 8. Config surface 346 + 347 + Journal config additions: 348 + 349 + ```json 350 + { 351 + "voice": { 352 + "openai_api_key": null, 353 + "model": "gpt-realtime", 354 + "brain_model": "haiku" 355 + } 356 + } 357 + ``` 358 + 359 + Reader functions: 360 + 361 + - `think.voice.config.get_openai_api_key()`: 362 + - Read `get_config().get("voice", {}).get("openai_api_key")`. 363 + - If that value is blank, fall back to `OPENAI_API_KEY`. 364 + - If still blank, return `None`. 365 + - `think.voice.config.get_voice_model()`: 366 + - Read `config.voice.model`. 367 + - Default to `"gpt-realtime"`. 368 + - `think.voice.config.get_brain_model()`: 369 + - Read `config.voice.brain_model`. 370 + - Default to `"haiku"`. 371 + 372 + Error behavior: 373 + 374 + - Missing OpenAI key is not a startup crash. 375 + - Missing OpenAI key produces HTTP 503 only when a voice endpoint needs it, with `{"error": "voice unavailable — openai key not configured"}`. 376 + 377 + Rationale: 378 + 379 + - `get_config()` already defines the repo’s journal-config source of truth (`think/utils.py:557-588`). 380 + - There is no existing `journal/config/openai.json` consumer in this repo. 381 + - The fixture journal already carries `agent.name = "sol"` but no `voice` block, so the readers must provide defaults (`tests/fixtures/journal/config/journal.json:70-74`). 382 + 383 + ## 9. Status endpoint fields 384 + 385 + - `brain_ready: bool` — `True` when `app.voice_brain_instruction` is a non-empty string. 386 + - `brain_age_seconds: int | null` — `int(time.time() - app.voice_brain_refreshed_at)` when refreshed, otherwise `null`. 387 + - `openai_configured: bool` — `True` if `get_openai_api_key()` resolves a non-empty key at request time. 388 + - `active_sessions: int` — count of futures in `app.voice_tasks` where `future.done()` is false. 389 + 390 + All four fields are always present. 391 + 392 + ## 10. Error shapes (universal) 393 + 394 + | Scenario | HTTP code | JSON body | 395 + |---|---|---| 396 + | Request body missing or invalid JSON for an endpoint that expects JSON | 400 | `{"error": "request body must be valid JSON"}` | 397 + | Request body decodes but is not a JSON object | 400 | `{"error": "request body must be a JSON object"}` | 398 + | Missing `call_id` on `/api/voice/connect` or `/api/voice/nav-hints` | 400 | `{"error": "call_id is required"}` | 399 + | OpenAI key not configured | 503 | `{"error": "voice unavailable — openai key not configured"}` | 400 + | Brain not ready after the `/api/voice/session` 10-second wait | 503 | `{"error": "voice unavailable — brain not ready"}` | 401 + | OpenAI session mint hard failure | 500 | `{"error": "voice session unavailable"}` | 402 + | Background runtime missing | 500 | `{"error": "voice runtime unavailable"}` | 403 + | Explicit brain refresh future raises | 500 | `{"error": "brain refresh failed"}` | 404 + | Tool handler exception in sideband | n/a, inline tool output | `{"error": "<message>"}` | 405 + | Unknown `call_id` on `/api/voice/nav-hints` | 200 | `{"hints": [], "consumed": true}` | 406 + 407 + Logging rule: 408 + 409 + - HTTP and tool errors return generic client-safe strings. 410 + - Diagnostic detail stays in logs only. 411 + 412 + ## 11. Test strategy 413 + 414 + Uniform fixture-date strategy: 415 + 416 + - Add one narrow helper in `think.voice.tools`, for example `_today() -> datetime.date` plus a formatter helper in the same module. 417 + - All date-sensitive voice tools (`journal.get_day`, `journal.search` day-window math, `calendar.today`, `briefing.get`) use that helper. 418 + - Tests monkeypatch the helper to the fixture briefing date or another explicit date instead of rewriting shared fixture files. 419 + - This keeps the shared fixture journal stable and avoids clock-driven flakes from `tests/fixtures/journal/identity/briefing.md` being dated `20260327`. 420 + 421 + Per-file plan: 422 + 423 + | Test file | Coverage | Fixtures used | Mocking | 424 + |---|---|---|---| 425 + | `tests/test_voice_config.py` | `get_openai_api_key`, `get_voice_model`, `get_brain_model`, config defaults, env fallback | `tests/conftest.py` default fixture journal | `monkeypatch.setenv` only | 426 + | `tests/test_voice_brain.py` | prompt rendering, session-file load/save/touch, start/refresh/ask control flow, 6-hour stale threshold, readiness state | fixture journal or `journal_copy` for isolated `health/` state | mock Claude CLI subprocess entry points such as `asyncio.create_subprocess_exec` or a small `_run_claude(...)` seam | 427 + | `tests/test_voice_tools.py` | all 9 tool handlers, one happy path and one failure path each, `_nav_target` presence rules, `sources` stripping | real fixture journal via `tests/conftest.py`; `journal_copy` for close/edit cases | monkeypatch `_today()` and any narrow parser seams; do not mock journal contents | 428 + | `tests/test_voice_sideband.py` | event filter, argument decode errors, tool dispatch, `_nav_target` stripping, `function_call_output` emission, `conn.response.create()` cadence | no special journal beyond the fixture default | fake `conn` object and patched dispatcher | 429 + | `tests/test_voice_nav_queue.py` | TTL expiry, cap 8, FIFO drop, drain-clears, unknown `call_id`, basic multi-thread push/drain sanity | none | no external mocks | 430 + | `tests/test_voice_runtime.py` | singleton startup, duplicate start no-op, future registration and pruning, explicit shutdown, atexit-registration guard | none beyond a minimal Flask app | patch thread join timing if needed | 431 + | `tests/test_voice_routes.py` | endpoint validation: bad JSON, missing key, missing `call_id`, status payload defaults, nav-hint drain | Flask app from `convey.create_app()` with fixture journal | patch `think.voice.config.get_openai_api_key`, `brain.wait_until_ready`, and `AsyncOpenAI` as needed | 432 + | `tests/test_voice_integration.py` | full flow: session mint, connect, one tool event through sideband, nav-hint fetch, active session count transition | real fixture journal plus `journal_copy` when a tool mutates ledger state | patch the `openai` module with fake `AsyncOpenAI`; this follows the existing module-patching precedent in `tests/test_validate_key.py:56-75` | 433 + 434 + Specific integration test shape: 435 + 436 + 1. Build the Flask app with the fixture journal override already supplied by `tests/conftest.py`. 437 + 2. Patch `openai.AsyncOpenAI` or the voice-module import site with a fake client that: 438 + - returns a fixed ephemeral key from `realtime.client_secrets.create(...)` 439 + - yields a scripted tool-call event stream from `realtime.connect(...)` 440 + - tracks `conversation.item.create(...)` and `response.create(...)` calls 441 + 3. Seed `app.voice_brain_instruction` to avoid the first-session wait in the happy-path integration case. 442 + 4. `POST /api/voice/session` and assert the key plus tool manifest wiring. 443 + 5. `POST /api/voice/connect` with a fake `call_id`. 444 + 6. Let the fake sideband drive one tool call that emits `_nav_target`. 445 + 7. Assert the OpenAI output JSON does not contain `_nav_target`. 446 + 8. `GET /api/voice/nav-hints?call_id=...` and assert the hint is returned and then cleared. 447 + 448 + Journal-data rule: 449 + 450 + - Unit and integration tests both read the real fixture journal. 451 + - No fake journal abstraction is introduced. 452 + - Mutation tests use `journal_copy` so shared fixture files remain unchanged (`tests/conftest.py:61-68`). 453 + 454 + ## 12. Open questions / deviations for Jer's approval (gate list) 455 + 456 + - Brain-not-ready behavior: this design treats the bridge contract and acceptance list as canonical and returns HTTP 503 from `/api/voice/session` after a 10-second wait, instead of using the older static fallback instruction path from the scope prose. 457 + - Routing location: this design uses a root-level `convey/voice.py` blueprint, not `apps/voice/`, because the feature is a root API and the app shell assumes `/app/<name>` plus `workspace.html`. 458 + - Briefing source path: this design treats `journal/identity/briefing.md` via `apps/home/routes.py::_load_briefing_md(...)` as canonical, not the older chronicle talent-output path described in the scope. 459 + - Commitments resolution mapping: this design maps `done|sent|signed|deferred -> as_state="closed"` and `dropped -> as_state="dropped"` because `think.surfaces.ledger.close(...)` only accepts `closed|dropped`. 460 + - OpenAI key sourcing: this design uses `config.voice.openai_api_key` in `journal/config/journal.json` first, then `OPENAI_API_KEY`, and does not add `journal/config/openai.json`. 461 + - `ask_sol` clause: this design removes it from the brain init prompt and does not add a 10th tool to the manifest. 462 + - Decision-record location: this design keeps the Wave 2 voice decisions in `docs/design/voice-server.md` because `records/decisions/` does not exist in the repo. 463 + - Config keys: this design adds a `voice` block to journal config and defaults, which is a scope-visible contract change. 464 + 465 + ## 13. Risks / sharp edges 466 + 467 + - Async runtime on a daemon thread plus Flask test client: if tests do not call `stop_voice_runtime(app)`, the loop thread can leak across test cases. 468 + - OpenAI Realtime API stability: the repo pins `openai>=1.2.0`, but the local environment on 2026-04-19 reports `openai 2.17.0`; implementation should lock tests to the currently observed surfaces `client.realtime.client_secrets.create(session=...)` and `client.realtime.connect(call_id=..., model=...)`. 469 + - Brain subprocess prompt safety: the prompt itself is fixed and repo-controlled, but the Claude CLI invocation must avoid shell interpolation and must pass arguments as a list to `create_subprocess_exec`. 470 + - Fixture briefing date drift: without the `_today()` seam, `briefing.get` and date-window tools would fail when run against the real system clock. 471 + - `commitments.list.resolution` diminishment is accepted as a Wave 2 known-limit. The ledger surface stores resolution nuance in close-note edits rather than a typed field, so `commitments.list` surfaces `"dropped"` only for dropped items and omits the field otherwise. Full resolution carry-through remains available for a follow-up if post-ship live validation reveals a real need. 472 + - `sources` leakage: `LedgerItem.sources` is provenance, not model-facing data. The tool layer must strip it before returning commitments to OpenAI. 473 + - `segment_path()` is write-creating: `journal.get_day` must not call `think.utils.segment_path()` when reading segment summaries because that helper creates directories.
+9
tests/conftest.py
··· 20 20 from think.entities.observations import clear_observation_cache 21 21 from think.entities.relationships import clear_relationship_caches 22 22 from think.utils import now_ms 23 + from think.voice import brain as voice_brain 24 + from think.voice.runtime import stop_all_voice_runtime 23 25 24 26 25 27 @pytest.fixture(autouse=True) ··· 56 58 clear_journal_entity_cache() 57 59 clear_relationship_caches() 58 60 clear_observation_cache() 61 + 62 + 63 + @pytest.fixture(autouse=True) 64 + def _cleanup_voice_runtime(): 65 + yield 66 + stop_all_voice_runtime() 67 + voice_brain.clear_brain_state() 59 68 60 69 61 70 @pytest.fixture
+138
tests/test_voice_brain.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import asyncio 7 + 8 + from flask import Flask 9 + 10 + from think.voice import brain 11 + from think.voice.runtime import start_voice_runtime, stop_voice_runtime 12 + 13 + 14 + def test_extract_instruction(): 15 + text = "before<voice_instruction>Hello there</voice_instruction>after" 16 + assert brain.extract_instruction(text) == "Hello there" 17 + assert brain.extract_instruction("no tags here") is None 18 + 19 + 20 + def test_start_brain_persists_session(monkeypatch, journal_copy): 21 + async def fake_run_claude(message, extra_args, *, timeout): 22 + assert "voice-session instruction" in message 23 + assert extra_args == ["-n", "voice-brain"] 24 + assert timeout == 300 25 + return "<voice_instruction>Speak clearly</voice_instruction>", "session-1" 26 + 27 + monkeypatch.setattr(brain, "_run_claude", fake_run_claude) 28 + 29 + session_id, instruction = asyncio.run(brain.start_brain()) 30 + 31 + assert session_id == "session-1" 32 + assert instruction == "Speak clearly" 33 + assert (journal_copy / "health" / "voice-brain-session").read_text( 34 + encoding="utf-8" 35 + ) == "session-1" 36 + 37 + 38 + def test_refresh_brain_touches_session_file(monkeypatch, journal_copy): 39 + session_file = journal_copy / "health" / "voice-brain-session" 40 + session_file.parent.mkdir(parents=True, exist_ok=True) 41 + session_file.write_text("session-1", encoding="utf-8") 42 + 43 + async def fake_run_claude(message, extra_args, *, timeout): 44 + assert extra_args == ["--resume", "session-1"] 45 + assert timeout == 300 46 + return "<voice_instruction>Fresh voice</voice_instruction>", "session-1" 47 + 48 + monkeypatch.setattr(brain, "_run_claude", fake_run_claude) 49 + before = session_file.stat().st_mtime 50 + 51 + instruction = asyncio.run(brain.refresh_brain("session-1")) 52 + 53 + assert instruction == "Fresh voice" 54 + assert session_file.stat().st_mtime >= before 55 + 56 + 57 + def test_ask_brain_uses_resume(monkeypatch): 58 + async def fake_run_claude(message, extra_args, *, timeout): 59 + assert message == "What changed?" 60 + assert extra_args == ["--resume", "session-1"] 61 + assert timeout == 120 62 + return "Short answer", "session-1" 63 + 64 + monkeypatch.setattr(brain, "_run_claude", fake_run_claude) 65 + 66 + assert asyncio.run(brain.ask_brain("session-1", "What changed?")) == "Short answer" 67 + 68 + 69 + def test_schedule_start_and_wait_until_ready(monkeypatch, journal_copy): 70 + brain.clear_brain_state() 71 + app = Flask(__name__) 72 + 73 + async def fake_run_claude(message, extra_args, *, timeout): 74 + return "<voice_instruction>Ready voice</voice_instruction>", "session-2" 75 + 76 + monkeypatch.setattr(brain, "_run_claude", fake_run_claude) 77 + 78 + start_voice_runtime(app) 79 + try: 80 + assert brain.wait_until_ready(app, 1.0) is True 81 + assert app.voice_brain_session == "session-2" 82 + assert app.voice_brain_instruction == "Ready voice" 83 + assert isinstance(brain.brain_age_seconds(app), int) 84 + finally: 85 + stop_voice_runtime(app) 86 + brain.clear_brain_state() 87 + 88 + 89 + def test_schedule_refresh_updates_instruction(monkeypatch, journal_copy): 90 + brain.clear_brain_state() 91 + app = Flask(__name__) 92 + app.voice_brain_session = "session-3" 93 + app.voice_brain_instruction = "Old voice" 94 + app.voice_brain_refreshed_at = None 95 + (journal_copy / "health").mkdir(parents=True, exist_ok=True) 96 + (journal_copy / "health" / "voice-brain-session").write_text( 97 + "session-3", encoding="utf-8" 98 + ) 99 + 100 + async def fake_run_claude(message, extra_args, *, timeout): 101 + return "<voice_instruction>New voice</voice_instruction>", "session-3" 102 + 103 + monkeypatch.setattr(brain, "_run_claude", fake_run_claude) 104 + 105 + start_voice_runtime(app) 106 + try: 107 + future = brain.schedule_refresh(app, force=True) 108 + assert future.result(timeout=1.0) == ("session-3", "New voice") 109 + assert app.voice_brain_instruction == "New voice" 110 + finally: 111 + stop_voice_runtime(app) 112 + brain.clear_brain_state() 113 + 114 + 115 + def test_brain_session_file_stays_on_bound_journal(monkeypatch, tmp_path): 116 + brain.clear_brain_state() 117 + initial_journal = tmp_path / "initial-journal" 118 + later_journal = tmp_path / "later-journal" 119 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(initial_journal)) 120 + app = Flask(__name__) 121 + 122 + async def fake_run_claude(message, extra_args, *, timeout): 123 + await asyncio.sleep(0.01) 124 + return "<voice_instruction>Ready voice</voice_instruction>", "session-4" 125 + 126 + monkeypatch.setattr(brain, "_run_claude", fake_run_claude) 127 + 128 + start_voice_runtime(app) 129 + try: 130 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(later_journal)) 131 + assert brain.wait_until_ready(app, 1.0) is True 132 + assert (initial_journal / "health" / "voice-brain-session").read_text( 133 + encoding="utf-8" 134 + ) == "session-4" 135 + assert not (later_journal / "health" / "voice-brain-session").exists() 136 + finally: 137 + stop_voice_runtime(app) 138 + brain.clear_brain_state()
+43
tests/test_voice_config.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from think.voice import config 7 + 8 + 9 + def test_voice_config_defaults(monkeypatch): 10 + monkeypatch.setattr(config, "get_config", lambda: {"agent": {"name": "sol"}}) 11 + monkeypatch.delenv("OPENAI_API_KEY", raising=False) 12 + 13 + assert config.get_openai_api_key() is None 14 + assert config.get_voice_model() == "gpt-realtime" 15 + assert config.get_brain_model() == "haiku" 16 + 17 + 18 + def test_voice_config_prefers_journal_key(monkeypatch): 19 + monkeypatch.setattr( 20 + config, 21 + "get_config", 22 + lambda: { 23 + "voice": { 24 + "openai_api_key": "sk-config", 25 + "model": "gpt-realtime-mini", 26 + "brain_model": "sonnet", 27 + } 28 + }, 29 + ) 30 + monkeypatch.setenv("OPENAI_API_KEY", "sk-env") 31 + 32 + assert config.get_openai_api_key() == "sk-config" 33 + assert config.get_voice_model() == "gpt-realtime-mini" 34 + assert config.get_brain_model() == "sonnet" 35 + 36 + 37 + def test_voice_config_falls_back_to_env(monkeypatch): 38 + monkeypatch.setattr( 39 + config, "get_config", lambda: {"voice": {"openai_api_key": " "}} 40 + ) 41 + monkeypatch.setenv("OPENAI_API_KEY", "sk-env") 42 + 43 + assert config.get_openai_api_key() == "sk-env"
+159
tests/test_voice_integration.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import time 8 + from types import SimpleNamespace 9 + 10 + import pytest 11 + 12 + from convey import create_app 13 + 14 + 15 + class _FakeEvent: 16 + def __init__(self, *, name: str, arguments: str, call_id: str) -> None: 17 + self.type = "response.function_call_arguments.done" 18 + self.name = name 19 + self.arguments = arguments 20 + self.call_id = call_id 21 + 22 + 23 + class _FakeConversationItem: 24 + def __init__(self, state) -> None: 25 + self._state = state 26 + 27 + async def create(self, *, item): 28 + self._state.outputs.append(item) 29 + 30 + 31 + class _FakeResponse: 32 + def __init__(self, state) -> None: 33 + self._state = state 34 + 35 + async def create(self): 36 + self._state.response_creates += 1 37 + 38 + 39 + class _FakeConversation: 40 + def __init__(self, state) -> None: 41 + self.item = _FakeConversationItem(state) 42 + 43 + 44 + class _FakeConn: 45 + def __init__(self, state) -> None: 46 + self._state = state 47 + self.conversation = _FakeConversation(state) 48 + self.response = _FakeResponse(state) 49 + self._events = iter( 50 + [ 51 + _FakeEvent( 52 + name="journal.get_day", 53 + arguments='{"day":"2026-03-04"}', 54 + call_id="call-1", 55 + ) 56 + ] 57 + ) 58 + 59 + def __aiter__(self): 60 + return self 61 + 62 + async def __anext__(self): 63 + try: 64 + return next(self._events) 65 + except StopIteration: 66 + raise StopAsyncIteration 67 + 68 + 69 + class _FakeConnManager: 70 + def __init__(self, state) -> None: 71 + self._state = state 72 + 73 + async def __aenter__(self): 74 + return _FakeConn(self._state) 75 + 76 + async def __aexit__(self, exc_type, exc, tb): 77 + return False 78 + 79 + 80 + class _FakeClientSecrets: 81 + def __init__(self, state) -> None: 82 + self._state = state 83 + 84 + async def create(self, *, session): 85 + self._state.session_payloads.append(session) 86 + return SimpleNamespace(value="ek-test") 87 + 88 + 89 + class _FakeRealtime: 90 + def __init__(self, state) -> None: 91 + self._state = state 92 + self.client_secrets = _FakeClientSecrets(state) 93 + 94 + def connect(self, *, call_id, model): 95 + self._state.connect_calls.append({"call_id": call_id, "model": model}) 96 + return _FakeConnManager(self._state) 97 + 98 + 99 + class FakeAsyncOpenAI: 100 + def __init__(self, *, api_key): 101 + self.api_key = api_key 102 + self.realtime = _FakeRealtime(FakeAsyncOpenAI.state) 103 + 104 + 105 + @pytest.fixture 106 + def integration_client(journal_copy): 107 + app = create_app(str(journal_copy)) 108 + app.config["TESTING"] = True 109 + app.voice_brain_instruction = "Ready voice" 110 + app.voice_brain_session = "session-1" 111 + app.voice_brain_refreshed_at = time.time() 112 + return app.test_client(), app 113 + 114 + 115 + def test_voice_flow_round_trip(integration_client, monkeypatch): 116 + client, _ = integration_client 117 + state = SimpleNamespace( 118 + session_payloads=[], 119 + connect_calls=[], 120 + outputs=[], 121 + response_creates=0, 122 + ) 123 + FakeAsyncOpenAI.state = state 124 + 125 + monkeypatch.setattr("convey.voice.AsyncOpenAI", FakeAsyncOpenAI) 126 + monkeypatch.setattr("think.voice.sideband.AsyncOpenAI", FakeAsyncOpenAI) 127 + monkeypatch.setattr("convey.voice.get_openai_api_key", lambda: "sk-test") 128 + monkeypatch.setattr("think.voice.sideband.get_openai_api_key", lambda: "sk-test") 129 + monkeypatch.setattr( 130 + "convey.voice.brain.wait_until_ready", lambda app, timeout: True 131 + ) 132 + monkeypatch.setattr("convey.voice.brain.brain_is_stale", lambda app: False) 133 + 134 + session_response = client.post("/api/voice/session") 135 + assert session_response.status_code == 200 136 + assert session_response.get_json() == {"ephemeral_key": "ek-test"} 137 + assert state.session_payloads 138 + assert state.session_payloads[0]["instructions"] == "Ready voice" 139 + 140 + connect_response = client.post("/api/voice/connect", json={"call_id": "call-1"}) 141 + assert connect_response.status_code == 200 142 + assert connect_response.get_json() == {"status": "connected"} 143 + 144 + deadline = time.time() + 1.0 145 + while time.time() < deadline and not state.outputs: 146 + time.sleep(0.01) 147 + 148 + assert state.connect_calls == [{"call_id": "call-1", "model": "gpt-realtime"}] 149 + assert state.outputs 150 + tool_output = json.loads(state.outputs[0]["output"]) 151 + assert "_nav_target" not in tool_output 152 + assert state.response_creates == 1 153 + 154 + hints_response = client.get("/api/voice/nav-hints?call_id=call-1") 155 + assert hints_response.status_code == 200 156 + assert hints_response.get_json() == { 157 + "hints": ["today/journal/2026-03-04"], 158 + "consumed": True, 159 + }
+59
tests/test_voice_nav_queue.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from concurrent.futures import ThreadPoolExecutor 7 + 8 + from think.voice.nav_queue import NavHintQueue 9 + 10 + 11 + def test_nav_queue_returns_empty_for_unknown_call_id(): 12 + queue = NavHintQueue() 13 + assert queue.drain("call-1", now=100.0) == [] 14 + 15 + 16 + def test_nav_queue_drain_clears_queue(): 17 + queue = NavHintQueue() 18 + queue.push("call-1", "today", now=100.0) 19 + 20 + assert queue.drain("call-1", now=100.0) == ["today"] 21 + assert queue.drain("call-1", now=100.0) == [] 22 + 23 + 24 + def test_nav_queue_drops_expired_hints(): 25 + queue = NavHintQueue(ttl_seconds=10) 26 + queue.push("call-1", "today", now=100.0) 27 + queue.push("call-1", "entity/sarah", now=111.0) 28 + 29 + assert queue.drain("call-1", now=111.0) == ["entity/sarah"] 30 + 31 + 32 + def test_nav_queue_enforces_fifo_capacity(): 33 + queue = NavHintQueue(capacity=3) 34 + for idx in range(5): 35 + queue.push("call-1", f"hint-{idx}", now=float(idx)) 36 + 37 + assert queue.drain("call-1", now=10.0) == ["hint-2", "hint-3", "hint-4"] 38 + 39 + 40 + def test_nav_queue_ignores_blank_values(): 41 + queue = NavHintQueue() 42 + queue.push(" ", "today") 43 + queue.push("call-1", " ") 44 + 45 + assert queue.drain("call-1", now=100.0) == [] 46 + 47 + 48 + def test_nav_queue_is_thread_safe_for_push_then_drain(): 49 + queue = NavHintQueue(capacity=16) 50 + 51 + def push_hint(index: int) -> None: 52 + queue.push("call-1", f"hint-{index}", now=float(index)) 53 + 54 + with ThreadPoolExecutor(max_workers=4) as pool: 55 + list(pool.map(push_hint, range(8))) 56 + 57 + drained = queue.drain("call-1", now=8.0) 58 + assert len(drained) == 8 59 + assert set(drained) == {f"hint-{idx}" for idx in range(8)}
+118
tests/test_voice_routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from concurrent.futures import Future 7 + from concurrent.futures import TimeoutError as FutureTimeoutError 8 + 9 + import pytest 10 + 11 + from convey import create_app 12 + 13 + 14 + @pytest.fixture 15 + def voice_app(journal_copy): 16 + app = create_app(str(journal_copy)) 17 + app.config["TESTING"] = True 18 + return app 19 + 20 + 21 + @pytest.fixture 22 + def voice_client(voice_app): 23 + return voice_app.test_client() 24 + 25 + 26 + def test_session_rejects_non_object_json(voice_client): 27 + response = voice_client.post("/api/voice/session", json=["bad"]) 28 + assert response.status_code == 400 29 + assert response.get_json() == {"error": "request body must be a JSON object"} 30 + 31 + 32 + def test_session_requires_openai_key(voice_client, monkeypatch): 33 + monkeypatch.setattr("convey.voice.get_openai_api_key", lambda: None) 34 + response = voice_client.post("/api/voice/session") 35 + assert response.status_code == 503 36 + assert response.get_json() == { 37 + "error": "voice unavailable — openai key not configured" 38 + } 39 + 40 + 41 + def test_session_returns_brain_not_ready(voice_client, monkeypatch): 42 + monkeypatch.setattr("convey.voice.get_openai_api_key", lambda: "sk-test") 43 + monkeypatch.setattr( 44 + "convey.voice.brain.wait_until_ready", lambda app, timeout: False 45 + ) 46 + response = voice_client.post("/api/voice/session") 47 + assert response.status_code == 503 48 + assert response.get_json() == {"error": "voice unavailable — brain not ready"} 49 + 50 + 51 + def test_connect_requires_call_id(voice_client, monkeypatch): 52 + monkeypatch.setattr("convey.voice.get_openai_api_key", lambda: "sk-test") 53 + response = voice_client.post("/api/voice/connect", json={}) 54 + assert response.status_code == 400 55 + assert response.get_json() == {"error": "call_id is required"} 56 + 57 + 58 + def test_nav_hints_unknown_call_id_returns_empty(voice_client): 59 + response = voice_client.get("/api/voice/nav-hints?call_id=missing") 60 + assert response.status_code == 200 61 + assert response.get_json() == {"hints": [], "consumed": True} 62 + 63 + 64 + def test_status_reports_all_fields(voice_client, voice_app, monkeypatch): 65 + pending: Future[None] = Future() 66 + done: Future[None] = Future() 67 + done.set_result(None) 68 + voice_app.voice_tasks.update({pending, done}) 69 + voice_app.voice_brain_instruction = "Ready voice" 70 + voice_app.voice_brain_refreshed_at = 1.0 71 + monkeypatch.setattr("convey.voice.get_openai_api_key", lambda: "sk-test") 72 + monkeypatch.setattr("convey.voice.brain.brain_age_seconds", lambda app: 12) 73 + 74 + response = voice_client.get("/api/voice/status") 75 + 76 + assert response.status_code == 200 77 + assert response.get_json() == { 78 + "brain_ready": True, 79 + "brain_age_seconds": 12, 80 + "openai_configured": True, 81 + "active_sessions": 1, 82 + } 83 + 84 + 85 + def test_refresh_brain_returns_202_while_running(voice_client, monkeypatch): 86 + class PendingFuture: 87 + def result(self, timeout=None): 88 + raise FutureTimeoutError() 89 + 90 + monkeypatch.setattr( 91 + "convey.voice.brain.schedule_refresh", 92 + lambda app, force: PendingFuture(), 93 + ) 94 + 95 + response = voice_client.post("/api/voice/refresh-brain") 96 + 97 + assert response.status_code == 202 98 + assert response.get_json() == {"status": "refreshing"} 99 + 100 + 101 + def test_refresh_brain_returns_preview(voice_client, monkeypatch, voice_app): 102 + future: Future[tuple[str, str]] = Future() 103 + future.set_result(("session-1", "Voice preview")) 104 + voice_app.voice_brain_instruction = "Voice preview" 105 + monkeypatch.setattr( 106 + "convey.voice.brain.schedule_refresh", lambda app, force: future 107 + ) 108 + monkeypatch.setattr("convey.voice.brain.brain_age_seconds", lambda app: 0) 109 + 110 + response = voice_client.post("/api/voice/refresh-brain") 111 + 112 + assert response.status_code == 200 113 + assert response.get_json() == { 114 + "status": "refreshed", 115 + "instruction_preview": "Voice preview", 116 + "brain_ready": True, 117 + "brain_age_seconds": 0, 118 + }
+103
tests/test_voice_runtime.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from concurrent.futures import Future 7 + 8 + from flask import Flask 9 + 10 + from think.voice import brain 11 + from think.voice.runtime import ( 12 + get_runtime_state, 13 + start_voice_runtime, 14 + stop_all_voice_runtime, 15 + stop_voice_runtime, 16 + ) 17 + 18 + 19 + def test_start_voice_runtime_attaches_state(monkeypatch, tmp_path): 20 + app = Flask(__name__) 21 + monkeypatch.setattr( 22 + "think.voice.runtime.get_journal", 23 + lambda: str((tmp_path / "journal").resolve()), 24 + ) 25 + 26 + start_voice_runtime(app) 27 + try: 28 + runtime = get_runtime_state() 29 + assert app.voice_runtime_started is True 30 + assert app.voice_tasks == set() 31 + assert app.voice_journal_root == str((tmp_path / "journal").resolve()) 32 + assert app.voice_brain_instruction == "" 33 + assert runtime.loop is not None 34 + assert runtime.thread is not None 35 + finally: 36 + stop_voice_runtime(app) 37 + 38 + 39 + def test_start_voice_runtime_is_idempotent(monkeypatch, tmp_path): 40 + app = Flask(__name__) 41 + monkeypatch.setattr( 42 + "think.voice.runtime.get_journal", 43 + lambda: str((tmp_path / "journal").resolve()), 44 + ) 45 + 46 + start_voice_runtime(app) 47 + runtime = get_runtime_state() 48 + first_loop = runtime.loop 49 + first_thread = runtime.thread 50 + try: 51 + start_voice_runtime(app) 52 + assert runtime.loop is first_loop 53 + assert runtime.thread is first_thread 54 + assert runtime.apps.count(app) == 1 55 + finally: 56 + stop_voice_runtime(app) 57 + 58 + 59 + def test_stop_voice_runtime_cancels_registered_futures(monkeypatch, tmp_path): 60 + app = Flask(__name__) 61 + monkeypatch.setattr( 62 + "think.voice.runtime.get_journal", 63 + lambda: str((tmp_path / "journal").resolve()), 64 + ) 65 + start_voice_runtime(app) 66 + pending: Future[None] = Future() 67 + app.voice_tasks.add(pending) 68 + 69 + stop_voice_runtime(app) 70 + 71 + assert pending.cancelled() 72 + assert app.voice_runtime_started is False 73 + 74 + 75 + def test_stop_voice_runtime_cancels_pending_brain_futures(monkeypatch, tmp_path): 76 + app = Flask(__name__) 77 + monkeypatch.setattr( 78 + "think.voice.runtime.get_journal", 79 + lambda: str((tmp_path / "journal").resolve()), 80 + ) 81 + start_voice_runtime(app) 82 + pending: Future[tuple[str, str]] = Future() 83 + brain.clear_brain_state() 84 + brain._BRAIN_STATE.start_future = pending 85 + 86 + stop_voice_runtime(app) 87 + 88 + assert pending.cancelled() 89 + 90 + 91 + def test_stop_all_voice_runtime_cleans_registered_apps(monkeypatch, tmp_path): 92 + app = Flask(__name__) 93 + monkeypatch.setattr( 94 + "think.voice.runtime.get_journal", 95 + lambda: str((tmp_path / "journal").resolve()), 96 + ) 97 + start_voice_runtime(app) 98 + 99 + stop_all_voice_runtime() 100 + 101 + runtime = get_runtime_state() 102 + assert runtime.loop is None 103 + assert runtime.thread is None
+121
tests/test_voice_sideband.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import asyncio 7 + import json 8 + from concurrent.futures import Future 9 + 10 + from think.voice import sideband 11 + 12 + 13 + class _FakeEvent: 14 + def __init__( 15 + self, event_type: str, *, name: str = "", arguments: str = "", call_id: str = "" 16 + ): 17 + self.type = event_type 18 + self.name = name 19 + self.arguments = arguments 20 + self.call_id = call_id 21 + 22 + 23 + class _FakeConversationItem: 24 + def __init__(self) -> None: 25 + self.items: list[dict] = [] 26 + 27 + async def create(self, *, item): 28 + self.items.append(item) 29 + 30 + 31 + class _FakeResponse: 32 + def __init__(self) -> None: 33 + self.count = 0 34 + 35 + async def create(self): 36 + self.count += 1 37 + 38 + 39 + class _FakeConversation: 40 + def __init__(self) -> None: 41 + self.item = _FakeConversationItem() 42 + 43 + 44 + class _FakeConn: 45 + def __init__(self, events): 46 + self._events = iter(events) 47 + self.conversation = _FakeConversation() 48 + self.response = _FakeResponse() 49 + 50 + def __aiter__(self): 51 + return self 52 + 53 + async def __anext__(self): 54 + try: 55 + return next(self._events) 56 + except StopIteration: 57 + raise StopAsyncIteration 58 + 59 + 60 + def test_sideband_loop_dispatches_function_calls(monkeypatch): 61 + conn = _FakeConn( 62 + [ 63 + _FakeEvent("session.created"), 64 + _FakeEvent( 65 + "response.function_call_arguments.done", 66 + name="journal.get_day", 67 + arguments='{"day":"2026-03-04"}', 68 + call_id="call-1", 69 + ), 70 + ] 71 + ) 72 + seen: list[tuple[str, str, str]] = [] 73 + 74 + async def fake_dispatch(name, arguments, call_id, app): 75 + seen.append((name, arguments, call_id)) 76 + return json.dumps({"day": "2026-03-04"}) 77 + 78 + monkeypatch.setattr(sideband, "dispatch_tool_call", fake_dispatch) 79 + 80 + asyncio.run(sideband._sideband_loop(conn, "call-1", object())) 81 + 82 + assert seen == [("journal.get_day", '{"day":"2026-03-04"}', "call-1")] 83 + assert conn.conversation.item.items == [ 84 + { 85 + "type": "function_call_output", 86 + "call_id": "call-1", 87 + "output": '{"day": "2026-03-04"}', 88 + } 89 + ] 90 + assert conn.response.count == 1 91 + 92 + 93 + def test_sideband_loop_ignores_non_tool_events(monkeypatch): 94 + conn = _FakeConn([_FakeEvent("session.created")]) 95 + called = False 96 + 97 + async def fake_dispatch(name, arguments, call_id, app): 98 + nonlocal called 99 + called = True 100 + return "{}" 101 + 102 + monkeypatch.setattr(sideband, "dispatch_tool_call", fake_dispatch) 103 + 104 + asyncio.run(sideband._sideband_loop(conn, "call-1", object())) 105 + 106 + assert called is False 107 + 108 + 109 + def test_register_voice_task_tracks_and_prunes(): 110 + class DummyApp: 111 + def __init__(self): 112 + self.voice_tasks = set() 113 + 114 + app = DummyApp() 115 + future: Future[None] = Future() 116 + 117 + sideband.register_voice_task(app, future) 118 + assert future in app.voice_tasks 119 + 120 + future.set_result(None) 121 + assert future not in app.voice_tasks
+289
tests/test_voice_tools.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import asyncio 7 + import json 8 + from datetime import date 9 + from pathlib import Path 10 + 11 + from tests.test_surfaces_ledger import ( 12 + _commitment, 13 + _minimal_facet_tree, 14 + _utc_ms, 15 + _write_story_activity, 16 + ) 17 + from think.indexer.journal import scan_journal 18 + from think.voice import tools 19 + 20 + 21 + def _set_today(monkeypatch, day_value: date) -> None: 22 + monkeypatch.setattr(tools, "_today", lambda: day_value) 23 + 24 + 25 + def _write_jsonl(path: Path, rows: list[dict]) -> None: 26 + path.parent.mkdir(parents=True, exist_ok=True) 27 + path.write_text( 28 + "\n".join(json.dumps(row) for row in rows) + "\n", 29 + encoding="utf-8", 30 + ) 31 + 32 + 33 + def test_journal_get_day_happy(monkeypatch, journal_copy): 34 + summary_path = ( 35 + journal_copy 36 + / "chronicle" 37 + / "20260304" 38 + / "default" 39 + / "090000_300" 40 + / "voice_summary.md" 41 + ) 42 + summary_path.write_text("Morning journal summary", encoding="utf-8") 43 + 44 + result = tools.handle_journal_get_day({"day": "2026-03-04"}, object()) 45 + 46 + assert result["day"] == "2026-03-04" 47 + assert result["_nav_target"] == "today/journal/2026-03-04" 48 + assert result["segments"] 49 + assert any( 50 + segment["summary"] == "Morning journal summary" 51 + for segment in result["segments"] 52 + ) 53 + 54 + 55 + def test_journal_get_day_failure(): 56 + assert tools.handle_journal_get_day({"day": "bad"}, object()) == { 57 + "error": "invalid day" 58 + } 59 + 60 + 61 + def test_journal_search_happy(monkeypatch, journal_copy): 62 + scan_journal(str(journal_copy.resolve()), full=True) 63 + 64 + result = tools.handle_journal_search({"query": "prototype", "limit": 3}, object()) 65 + 66 + assert result["count"] >= 1 67 + assert result["_nav_target"] == "today/search?q=prototype" 68 + assert result["results"][0]["snippet"] 69 + 70 + 71 + def test_journal_search_failure(): 72 + assert tools.handle_journal_search({"query": " "}, object()) == { 73 + "error": "query is required" 74 + } 75 + 76 + 77 + def test_entities_get_happy(): 78 + result = tools.handle_entities_get({"entity_slug": "romeo_montague"}, object()) 79 + 80 + assert result["slug"] == "romeo_montague" 81 + assert result["_nav_target"] == "entity/romeo_montague" 82 + assert result["name"] 83 + 84 + 85 + def test_entities_get_failure(): 86 + assert tools.handle_entities_get({"entity_slug": "missing_slug"}, object()) == { 87 + "error": "not found" 88 + } 89 + 90 + 91 + def test_entities_recent_with_happy(monkeypatch, journal_copy): 92 + _set_today(monkeypatch, date(2026, 3, 27)) 93 + activity_path = ( 94 + journal_copy / "facets" / "montague" / "activities" / "20260327.jsonl" 95 + ) 96 + _write_jsonl( 97 + activity_path, 98 + [ 99 + { 100 + "id": "meeting_090000_300", 101 + "activity": "meeting", 102 + "title": "Founder sync", 103 + "description": "Planning session", 104 + "details": "Bring roadmap notes", 105 + "source": "user", 106 + "participation": [ 107 + { 108 + "name": "Romeo Montague", 109 + "role": "attendee", 110 + "source": "user", 111 + "confidence": 1.0, 112 + "context": "Confirmed in the room", 113 + "entity_id": "romeo_montague", 114 + } 115 + ], 116 + "created_at": _utc_ms("2026-03-27T09:00:00Z"), 117 + } 118 + ], 119 + ) 120 + 121 + result = tools.handle_entities_recent_with( 122 + {"entity_slug": "romeo_montague", "days": 7}, object() 123 + ) 124 + 125 + assert result["slug"] == "romeo_montague" 126 + assert result["count"] == 1 127 + assert result["interactions"][0]["activity"] == "Founder sync" 128 + 129 + 130 + def test_entities_recent_with_failure(): 131 + assert tools.handle_entities_recent_with( 132 + {"entity_slug": "missing_slug"}, object() 133 + ) == {"error": "not found"} 134 + 135 + 136 + def test_commitments_list_happy(monkeypatch, tmp_path): 137 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 138 + _minimal_facet_tree(tmp_path) 139 + _write_story_activity( 140 + "work", 141 + "20260410", 142 + "meeting_090000_300", 143 + _utc_ms("2026-04-10T09:00:00Z"), 144 + commitments=[_commitment()], 145 + ) 146 + 147 + result = tools.handle_commitments_list({}, object()) 148 + 149 + assert result["commitments"] 150 + assert result["commitments"][0]["state"] == "open" 151 + assert "sources" not in result["commitments"][0] 152 + 153 + 154 + def test_commitments_list_failure(): 155 + assert tools.handle_commitments_list({"state": "bad"}, object()) == { 156 + "error": "invalid state" 157 + } 158 + 159 + 160 + def test_commitments_complete_happy(monkeypatch, tmp_path): 161 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 162 + _minimal_facet_tree(tmp_path) 163 + _write_story_activity( 164 + "work", 165 + "20260410", 166 + "meeting_090000_300", 167 + _utc_ms("2026-04-10T09:00:00Z"), 168 + commitments=[_commitment()], 169 + ) 170 + open_item = tools.handle_commitments_list({}, object())["commitments"][0] 171 + 172 + result = tools.handle_commitments_complete( 173 + {"commitment_id": open_item["id"], "resolution": "done"}, 174 + object(), 175 + ) 176 + 177 + assert result["ok"] is True 178 + assert result["commitment"]["resolution"] == "done" 179 + assert result["commitment"]["state"] == "closed" 180 + 181 + 182 + def test_commitments_complete_failure(): 183 + assert tools.handle_commitments_complete( 184 + {"commitment_id": "lg_missing", "resolution": "bad"}, object() 185 + ) == {"error": "invalid resolution"} 186 + 187 + 188 + def test_calendar_today_happy(monkeypatch, journal_copy): 189 + _set_today(monkeypatch, date(2026, 3, 27)) 190 + activity_path = ( 191 + journal_copy / "facets" / "montague" / "activities" / "20260327.jsonl" 192 + ) 193 + _write_jsonl( 194 + activity_path, 195 + [ 196 + { 197 + "id": "anticipated_meeting_090000", 198 + "activity": "meeting", 199 + "title": "Launch sync", 200 + "source": "anticipated", 201 + "start": "09:00", 202 + "location": "Room A", 203 + "prep_notes": "Bring launch notes", 204 + "participation": [ 205 + { 206 + "name": "Juliet Capulet", 207 + "role": "attendee", 208 + "source": "user", 209 + "confidence": 1.0, 210 + "context": "", 211 + } 212 + ], 213 + "created_at": _utc_ms("2026-03-27T09:00:00Z"), 214 + } 215 + ], 216 + ) 217 + 218 + result = tools.handle_calendar_today({}, object()) 219 + 220 + assert result["date"] == "2026-03-27" 221 + assert result["_nav_target"] == "today" 222 + assert result["events"][0]["title"] == "Launch sync" 223 + 224 + 225 + def test_calendar_today_failure(monkeypatch): 226 + monkeypatch.setattr( 227 + tools, 228 + "load_activity_records", 229 + lambda facet, day: (_ for _ in ()).throw(RuntimeError("boom")), 230 + ) 231 + 232 + assert tools.handle_calendar_today({}, object()) == {"error": "today unavailable"} 233 + 234 + 235 + def test_briefing_get_happy(monkeypatch): 236 + _set_today(monkeypatch, date(2026, 3, 27)) 237 + 238 + result = tools.handle_briefing_get({}, object()) 239 + 240 + assert result["date"] == "2026-03-27" 241 + assert result["facet"] == "identity" 242 + assert result["_nav_target"] == "today" 243 + assert result["highlights"] 244 + 245 + 246 + def test_briefing_get_failure(monkeypatch): 247 + _set_today(monkeypatch, date(2026, 3, 28)) 248 + assert tools.handle_briefing_get({}, object()) == {"error": "no briefing today yet"} 249 + 250 + 251 + def test_observer_start_listening_happy(): 252 + assert tools.handle_observer_start_listening({"mode": "meeting"}, object()) == { 253 + "status": "ack", 254 + "mode": "meeting", 255 + "note": "wave-4 observer not yet wired", 256 + } 257 + 258 + 259 + def test_observer_start_listening_failure(): 260 + assert tools.handle_observer_start_listening({"mode": "bad"}, object()) == { 261 + "error": "invalid mode" 262 + } 263 + 264 + 265 + def test_dispatch_tool_call_strips_nav_target(monkeypatch): 266 + queue = tools.get_nav_queue() 267 + queue.clear() 268 + result = asyncio.run( 269 + tools.dispatch_tool_call( 270 + "observer.start_listening", 271 + '{"mode":"meeting"}', 272 + "call-123", 273 + object(), 274 + ) 275 + ) 276 + assert json.loads(result)["status"] == "ack" 277 + assert queue.drain("call-123") == [] 278 + 279 + stripped = asyncio.run( 280 + tools.dispatch_tool_call( 281 + "journal.get_day", 282 + '{"day":"2026-03-04"}', 283 + "call-123", 284 + object(), 285 + ) 286 + ) 287 + payload = json.loads(stripped) 288 + assert "_nav_target" not in payload 289 + assert queue.drain("call-123") == ["today/journal/2026-03-04"]
+5
think/journal_default.json
··· 32 32 "named_date": null, 33 33 "proposal_count": 0 34 34 }, 35 + "voice": { 36 + "openai_api_key": null, 37 + "model": "gpt-realtime", 38 + "brain_model": "haiku" 39 + }, 35 40 "retention": { 36 41 "raw_media": "days", 37 42 "raw_media_days": 7,
+20
think/voice/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Public voice helpers.""" 5 + 6 + from think.voice.config import get_brain_model, get_openai_api_key, get_voice_model 7 + from think.voice.runtime import ( 8 + start_voice_runtime, 9 + stop_all_voice_runtime, 10 + stop_voice_runtime, 11 + ) 12 + 13 + __all__ = [ 14 + "get_brain_model", 15 + "get_openai_api_key", 16 + "get_voice_model", 17 + "start_voice_runtime", 18 + "stop_all_voice_runtime", 19 + "stop_voice_runtime", 20 + ]
+397
think/voice/brain.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Persistent voice brain for session instructions.""" 5 + 6 + from __future__ import annotations 7 + 8 + import asyncio 9 + import json 10 + import logging 11 + import os 12 + import shutil 13 + import threading 14 + import time 15 + from concurrent.futures import Future 16 + from dataclasses import dataclass, field 17 + from pathlib import Path 18 + from typing import Any 19 + 20 + from think.utils import get_config, get_journal 21 + from think.voice.config import get_brain_model 22 + 23 + logger = logging.getLogger(__name__) 24 + 25 + BRAIN_REFRESH_MAX_AGE_SECONDS = 6 * 3600 26 + SESSION_FILENAME = "voice-brain-session" 27 + 28 + _INIT_PROMPT_TEMPLATE = """You are preparing the current voice-session instruction for {agent_name}, the spoken identity of this solstone journal. 29 + 30 + Your task right now is to read the current journal state and produce exactly one fresh instruction for an OpenAI Realtime voice session. The instruction must sound like spoken English. Keep it concise, natural, and useful in conversation. No markdown. No bullets. No XML outside the required wrapper tags. 31 + 32 + Voice style rules: 33 + - Write for speech, not reading. 34 + - Keep the voice model oriented toward short spoken turns, usually 2 to 4 sentences unless the user clearly asks for more. 35 + - Prefer concrete wording over abstract wording. 36 + - If context is missing, the instruction should say to answer honestly and briefly rather than guessing. 37 + 38 + Terminology covenant: 39 + - Use the words observer and listen when referring to the live sensing system. 40 + - Never use the words keeper, assistant, record, or capture. 41 + 42 + Before you write the instruction, ingest the current context: 43 + - Read the identity material under journal/identity/ and treat {agent_name} as the canonical spoken name. 44 + - Read today's journal summary and today's segment-level summaries if they exist. 45 + - Read the active entities that matter right now. 46 + - Read the open commitments. 47 + - Read today's calendar and anticipated activities. 48 + - Read the latest briefing in journal/identity/briefing.md if it is for today. 49 + 50 + Then write one system instruction that does all of the following: 51 + - Establish who {agent_name} is and how the voice should speak. 52 + - Anchor the voice in today's real context. 53 + - Name the most important people, commitments, and upcoming events if they are present. 54 + - Tell the voice to stay concise, spoken, and honest about missing information. 55 + - Preserve the terminology covenant above. 56 + 57 + Output only this wrapper and the instruction inside it: 58 + <voice_instruction> 59 + ... 60 + </voice_instruction> 61 + """ 62 + 63 + _REFRESH_PROMPT_TEMPLATE = """Read the current journal state again and refresh the voice-session instruction for {agent_name}. 64 + 65 + Repeat the same steps as the startup pass: 66 + - read the identity material 67 + - read today's journal summary and segment-level summaries if they exist 68 + - read active entities, open commitments, today's calendar, and today's briefing if present 69 + - keep the terminology covenant in force 70 + 71 + Output only the refreshed instruction between <voice_instruction> tags. 72 + """ 73 + 74 + 75 + @dataclass 76 + class BrainState: 77 + lock: threading.Lock = field(default_factory=threading.Lock) 78 + ready_event: threading.Event = field(default_factory=threading.Event) 79 + start_future: Future[tuple[str, str]] | None = None 80 + refresh_future: Future[tuple[str, str]] | None = None 81 + last_error: str | None = None 82 + 83 + 84 + _BRAIN_STATE = BrainState() 85 + 86 + 87 + def _repo_root() -> Path: 88 + return Path(__file__).resolve().parents[2] 89 + 90 + 91 + def _session_file(journal: str | Path | None = None) -> Path: 92 + journal_root = Path(journal) if journal is not None else Path(get_journal()) 93 + return journal_root / "health" / SESSION_FILENAME 94 + 95 + 96 + def extract_instruction(text: str) -> str | None: 97 + start_tag = "<voice_instruction>" 98 + end_tag = "</voice_instruction>" 99 + start = text.find(start_tag) 100 + end = text.find(end_tag) 101 + if start == -1 or end == -1: 102 + return None 103 + return text[start + len(start_tag) : end].strip() or None 104 + 105 + 106 + def _agent_name() -> str: 107 + config = get_config() 108 + agent = config.get("agent") 109 + if isinstance(agent, dict): 110 + value = agent.get("name") 111 + if isinstance(value, str) and value.strip(): 112 + return value.strip() 113 + return "sol" 114 + 115 + 116 + def _build_init_prompt() -> str: 117 + return _INIT_PROMPT_TEMPLATE.format(agent_name=_agent_name()) 118 + 119 + 120 + def _build_refresh_prompt() -> str: 121 + return _REFRESH_PROMPT_TEMPLATE.format(agent_name=_agent_name()) 122 + 123 + 124 + def _claude_cmd() -> str: 125 + executable = shutil.which("claude") 126 + if executable: 127 + return executable 128 + raise RuntimeError("claude CLI not available") 129 + 130 + 131 + async def _run_claude( 132 + message: str, 133 + extra_args: list[str], 134 + *, 135 + timeout: float, 136 + ) -> tuple[str, str]: 137 + cmd = [ 138 + _claude_cmd(), 139 + "-p", 140 + message, 141 + "--model", 142 + get_brain_model(), 143 + "--output-format", 144 + "json", 145 + "--permission-mode", 146 + "bypassPermissions", 147 + *extra_args, 148 + ] 149 + proc = await asyncio.create_subprocess_exec( 150 + *cmd, 151 + stdout=asyncio.subprocess.PIPE, 152 + stderr=asyncio.subprocess.PIPE, 153 + cwd=str(_repo_root()), 154 + ) 155 + try: 156 + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) 157 + except asyncio.TimeoutError: 158 + proc.kill() 159 + await proc.communicate() 160 + raise 161 + 162 + if proc.returncode != 0: 163 + message = stderr.decode("utf-8", errors="replace").strip() or ( 164 + f"claude exited {proc.returncode}" 165 + ) 166 + raise RuntimeError(message) 167 + 168 + payload = json.loads(stdout.decode("utf-8")) 169 + result = payload.get("result") 170 + session_id = payload.get("session_id") 171 + if not isinstance(result, str) or not isinstance(session_id, str): 172 + raise RuntimeError("claude response missing result or session_id") 173 + return result, session_id 174 + 175 + 176 + def load_session_id(journal: str | Path | None = None) -> str | None: 177 + try: 178 + session_id = _session_file(journal).read_text(encoding="utf-8").strip() 179 + except FileNotFoundError: 180 + return None 181 + return session_id or None 182 + 183 + 184 + def _save_session_id(session_id: str, journal: str | Path | None = None) -> None: 185 + path = _session_file(journal) 186 + path.parent.mkdir(parents=True, exist_ok=True) 187 + tmp = path.with_name(f"{path.name}.tmp") 188 + tmp.write_text(session_id, encoding="utf-8") 189 + os.replace(tmp, path) 190 + 191 + 192 + def _touch_session_file(journal: str | Path | None = None) -> None: 193 + path = _session_file(journal) 194 + if path.exists(): 195 + path.touch() 196 + 197 + 198 + def brain_age_seconds(app: Any) -> int | None: 199 + refreshed_at = getattr(app, "voice_brain_refreshed_at", None) 200 + if not isinstance(refreshed_at, (int, float)): 201 + return None 202 + return int(max(0, time.time() - refreshed_at)) 203 + 204 + 205 + def brain_is_stale( 206 + app: Any, *, max_age_seconds: int = BRAIN_REFRESH_MAX_AGE_SECONDS 207 + ) -> bool: 208 + age = brain_age_seconds(app) 209 + return age is not None and age > max_age_seconds 210 + 211 + 212 + async def start_brain(journal: str | Path | None = None) -> tuple[str, str]: 213 + logger.info("voice brain starting") 214 + text, session_id = await _run_claude( 215 + _build_init_prompt(), 216 + ["-n", "voice-brain"], 217 + timeout=300, 218 + ) 219 + instruction = extract_instruction(text) 220 + if instruction is None: 221 + raise RuntimeError("voice instruction missing") 222 + _save_session_id(session_id, journal) 223 + return session_id, instruction 224 + 225 + 226 + async def refresh_brain( 227 + session_id: str, 228 + journal: str | Path | None = None, 229 + ) -> str: 230 + logger.info("voice brain refreshing") 231 + text, _ = await _run_claude( 232 + _build_refresh_prompt(), 233 + ["--resume", session_id], 234 + timeout=300, 235 + ) 236 + instruction = extract_instruction(text) 237 + if instruction is None: 238 + raise RuntimeError("voice instruction missing") 239 + _touch_session_file(journal) 240 + return instruction 241 + 242 + 243 + async def ask_brain(session_id: str, question: str) -> str: 244 + logger.info("voice brain answering follow-up") 245 + text, _ = await _run_claude( 246 + question, 247 + ["--resume", session_id], 248 + timeout=120, 249 + ) 250 + return text 251 + 252 + 253 + async def _start_or_resume_brain(app: Any) -> tuple[str, str]: 254 + journal_root = getattr(app, "voice_journal_root", None) 255 + session_id = load_session_id(journal_root) 256 + if session_id: 257 + try: 258 + instruction = await refresh_brain(session_id, journal_root) 259 + return session_id, instruction 260 + except Exception: 261 + logger.warning( 262 + "voice brain resume failed; starting new session", exc_info=True 263 + ) 264 + return await start_brain(journal_root) 265 + 266 + 267 + async def _refresh_existing_brain(app: Any) -> tuple[str, str]: 268 + journal_root = getattr(app, "voice_journal_root", None) 269 + session_id = getattr(app, "voice_brain_session", None) or load_session_id( 270 + journal_root 271 + ) 272 + if not isinstance(session_id, str) or not session_id.strip(): 273 + return await _start_or_resume_brain(app) 274 + instruction = await refresh_brain(session_id, journal_root) 275 + return session_id, instruction 276 + 277 + 278 + def _apply_brain_result(app: Any, session_id: str, instruction: str) -> None: 279 + app.voice_brain_session = session_id 280 + app.voice_brain_instruction = instruction 281 + app.voice_brain_refreshed_at = time.time() 282 + _BRAIN_STATE.ready_event.set() 283 + _BRAIN_STATE.last_error = None 284 + 285 + 286 + def _complete_future( 287 + app: Any, 288 + attr_name: str, 289 + future: Future[tuple[str, str]], 290 + ) -> None: 291 + try: 292 + session_id, instruction = future.result() 293 + except Exception as exc: 294 + logger.exception("voice brain task failed") 295 + with _BRAIN_STATE.lock: 296 + setattr(_BRAIN_STATE, attr_name, None) 297 + _BRAIN_STATE.last_error = str(exc) 298 + if not getattr(app, "voice_brain_instruction", ""): 299 + _BRAIN_STATE.ready_event.clear() 300 + return 301 + 302 + with _BRAIN_STATE.lock: 303 + setattr(_BRAIN_STATE, attr_name, None) 304 + _apply_brain_result(app, session_id, instruction) 305 + 306 + 307 + def _runtime_loop(): 308 + from think.voice.runtime import get_runtime_state 309 + 310 + runtime = get_runtime_state() 311 + if runtime.loop is None: 312 + raise RuntimeError("voice runtime unavailable") 313 + return runtime.loop 314 + 315 + 316 + def schedule_start(app: Any) -> Future[tuple[str, str]]: 317 + if getattr(app, "voice_brain_instruction", ""): 318 + _BRAIN_STATE.ready_event.set() 319 + with _BRAIN_STATE.lock: 320 + existing = _BRAIN_STATE.start_future 321 + if existing is not None and not existing.done(): 322 + return existing 323 + future = asyncio.run_coroutine_threadsafe( 324 + _start_or_resume_brain(app), _runtime_loop() 325 + ) 326 + _BRAIN_STATE.start_future = future 327 + future.add_done_callback( 328 + lambda done: _complete_future(app, "start_future", done) 329 + ) 330 + return future 331 + 332 + 333 + def schedule_refresh(app: Any, *, force: bool = False) -> Future[tuple[str, str]]: 334 + if ( 335 + not force 336 + and getattr(app, "voice_brain_instruction", "") 337 + and not brain_is_stale(app) 338 + ): 339 + return schedule_start(app) 340 + with _BRAIN_STATE.lock: 341 + existing = _BRAIN_STATE.refresh_future 342 + if existing is not None and not existing.done(): 343 + return existing 344 + if ( 345 + _BRAIN_STATE.start_future is not None 346 + and not _BRAIN_STATE.start_future.done() 347 + ): 348 + return _BRAIN_STATE.start_future 349 + future = asyncio.run_coroutine_threadsafe( 350 + _refresh_existing_brain(app), 351 + _runtime_loop(), 352 + ) 353 + _BRAIN_STATE.refresh_future = future 354 + future.add_done_callback( 355 + lambda done: _complete_future(app, "refresh_future", done) 356 + ) 357 + return future 358 + 359 + 360 + def wait_until_ready(app: Any, timeout: float) -> bool: 361 + if getattr(app, "voice_brain_instruction", ""): 362 + _BRAIN_STATE.ready_event.set() 363 + return True 364 + schedule_start(app) 365 + ready = _BRAIN_STATE.ready_event.wait(timeout) 366 + return ready and bool(getattr(app, "voice_brain_instruction", "")) 367 + 368 + 369 + def clear_brain_state() -> None: 370 + with _BRAIN_STATE.lock: 371 + futures = [ 372 + _BRAIN_STATE.start_future, 373 + _BRAIN_STATE.refresh_future, 374 + ] 375 + _BRAIN_STATE.start_future = None 376 + _BRAIN_STATE.refresh_future = None 377 + _BRAIN_STATE.last_error = None 378 + _BRAIN_STATE.ready_event.clear() 379 + for future in futures: 380 + if future is not None and not future.done(): 381 + future.cancel() 382 + 383 + 384 + __all__ = [ 385 + "BRAIN_REFRESH_MAX_AGE_SECONDS", 386 + "ask_brain", 387 + "brain_age_seconds", 388 + "brain_is_stale", 389 + "clear_brain_state", 390 + "extract_instruction", 391 + "load_session_id", 392 + "refresh_brain", 393 + "schedule_refresh", 394 + "schedule_start", 395 + "start_brain", 396 + "wait_until_ready", 397 + ]
+51
think/voice/config.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Voice config readers.""" 5 + 6 + from __future__ import annotations 7 + 8 + import os 9 + from typing import Any 10 + 11 + from think.utils import get_config 12 + 13 + DEFAULT_VOICE_MODEL = "gpt-realtime" 14 + DEFAULT_BRAIN_MODEL = "haiku" 15 + 16 + 17 + def _voice_config() -> dict[str, Any]: 18 + config = get_config() 19 + voice = config.get("voice") 20 + return voice if isinstance(voice, dict) else {} 21 + 22 + 23 + def _clean_str(value: Any) -> str | None: 24 + if not isinstance(value, str): 25 + return None 26 + cleaned = value.strip() 27 + return cleaned or None 28 + 29 + 30 + def get_openai_api_key() -> str | None: 31 + configured = _clean_str(_voice_config().get("openai_api_key")) 32 + if configured: 33 + return configured 34 + return _clean_str(os.environ.get("OPENAI_API_KEY")) 35 + 36 + 37 + def get_voice_model() -> str: 38 + return _clean_str(_voice_config().get("model")) or DEFAULT_VOICE_MODEL 39 + 40 + 41 + def get_brain_model() -> str: 42 + return _clean_str(_voice_config().get("brain_model")) or DEFAULT_BRAIN_MODEL 43 + 44 + 45 + __all__ = [ 46 + "DEFAULT_BRAIN_MODEL", 47 + "DEFAULT_VOICE_MODEL", 48 + "get_brain_model", 49 + "get_openai_api_key", 50 + "get_voice_model", 51 + ]
+93
think/voice/nav_queue.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """In-memory nav hints for voice turns.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + import threading 10 + import time 11 + from collections import defaultdict, deque 12 + from dataclasses import dataclass 13 + from typing import DefaultDict, Deque 14 + 15 + logger = logging.getLogger(__name__) 16 + 17 + NAV_HINT_TTL_SECONDS = 60 18 + NAV_HINT_CAPACITY = 8 19 + 20 + 21 + @dataclass(frozen=True) 22 + class QueuedHint: 23 + value: str 24 + created_at: float 25 + 26 + 27 + class NavHintQueue: 28 + """Thread-safe FIFO queue for voice nav hints.""" 29 + 30 + def __init__( 31 + self, 32 + *, 33 + ttl_seconds: int = NAV_HINT_TTL_SECONDS, 34 + capacity: int = NAV_HINT_CAPACITY, 35 + ) -> None: 36 + self.ttl_seconds = ttl_seconds 37 + self.capacity = capacity 38 + self._lock = threading.Lock() 39 + self._queues: DefaultDict[str, Deque[QueuedHint]] = defaultdict(deque) 40 + 41 + def push(self, call_id: str, hint: str, *, now: float | None = None) -> None: 42 + cleaned_call_id = call_id.strip() 43 + cleaned_hint = hint.strip() 44 + if not cleaned_call_id or not cleaned_hint: 45 + return 46 + current = time.time() if now is None else now 47 + with self._lock: 48 + queue = self._queues[cleaned_call_id] 49 + self._drop_expired(queue, current) 50 + queue.append(QueuedHint(cleaned_hint, current)) 51 + while len(queue) > self.capacity: 52 + dropped = queue.popleft() 53 + logger.debug("voice nav hint dropped for capacity: %s", dropped.value) 54 + 55 + def drain(self, call_id: str, *, now: float | None = None) -> list[str]: 56 + cleaned_call_id = call_id.strip() 57 + if not cleaned_call_id: 58 + return [] 59 + current = time.time() if now is None else now 60 + with self._lock: 61 + queue = self._queues.get(cleaned_call_id) 62 + if not queue: 63 + return [] 64 + self._drop_expired(queue, current) 65 + hints = [entry.value for entry in queue] 66 + if cleaned_call_id in self._queues: 67 + del self._queues[cleaned_call_id] 68 + return hints 69 + 70 + def clear(self) -> None: 71 + with self._lock: 72 + self._queues.clear() 73 + 74 + def _drop_expired(self, queue: Deque[QueuedHint], now: float) -> None: 75 + while queue and now - queue[0].created_at > self.ttl_seconds: 76 + dropped = queue.popleft() 77 + logger.debug("voice nav hint expired: %s", dropped.value) 78 + 79 + 80 + _NAV_QUEUE = NavHintQueue() 81 + 82 + 83 + def get_nav_queue() -> NavHintQueue: 84 + return _NAV_QUEUE 85 + 86 + 87 + __all__ = [ 88 + "NAV_HINT_CAPACITY", 89 + "NAV_HINT_TTL_SECONDS", 90 + "NavHintQueue", 91 + "QueuedHint", 92 + "get_nav_queue", 93 + ]
+127
think/voice/runtime.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Background runtime for voice tasks.""" 5 + 6 + from __future__ import annotations 7 + 8 + import asyncio 9 + import atexit 10 + import logging 11 + import threading 12 + from concurrent.futures import Future 13 + from dataclasses import dataclass, field 14 + from typing import Any 15 + 16 + from think.utils import get_journal 17 + 18 + logger = logging.getLogger(__name__) 19 + 20 + 21 + @dataclass 22 + class RuntimeState: 23 + lock: threading.Lock = field(default_factory=threading.Lock) 24 + loop: asyncio.AbstractEventLoop | None = None 25 + thread: threading.Thread | None = None 26 + started_event: threading.Event = field(default_factory=threading.Event) 27 + apps: list[Any] = field(default_factory=list) 28 + atexit_registered: bool = False 29 + 30 + 31 + _RUNTIME_STATE = RuntimeState() 32 + 33 + 34 + def get_runtime_state() -> RuntimeState: 35 + return _RUNTIME_STATE 36 + 37 + 38 + def _run_loop(loop: asyncio.AbstractEventLoop, started_event: threading.Event) -> None: 39 + asyncio.set_event_loop(loop) 40 + started_event.set() 41 + loop.run_forever() 42 + 43 + 44 + def _attach_app(app: Any) -> None: 45 + app.voice_tasks = set() 46 + app.voice_journal_root = get_journal() 47 + app.voice_brain_session = None 48 + app.voice_brain_instruction = "" 49 + app.voice_brain_refreshed_at = None 50 + app.voice_runtime_started = True 51 + 52 + 53 + def start_voice_runtime(app: Any) -> None: 54 + _attach_app(app) 55 + with _RUNTIME_STATE.lock: 56 + if app not in _RUNTIME_STATE.apps: 57 + _RUNTIME_STATE.apps.append(app) 58 + if _RUNTIME_STATE.loop is None or _RUNTIME_STATE.thread is None: 59 + loop = asyncio.new_event_loop() 60 + started_event = threading.Event() 61 + thread = threading.Thread( 62 + target=_run_loop, 63 + args=(loop, started_event), 64 + name="voice-runtime", 65 + daemon=True, 66 + ) 67 + _RUNTIME_STATE.loop = loop 68 + _RUNTIME_STATE.thread = thread 69 + _RUNTIME_STATE.started_event = started_event 70 + thread.start() 71 + if not _RUNTIME_STATE.atexit_registered: 72 + atexit.register(stop_all_voice_runtime) 73 + _RUNTIME_STATE.atexit_registered = True 74 + started_event = _RUNTIME_STATE.started_event 75 + started_event.wait(timeout=1.0) 76 + 77 + 78 + def _cancel_app_futures(app: Any) -> None: 79 + tasks = getattr(app, "voice_tasks", None) 80 + if not isinstance(tasks, set): 81 + return 82 + for future in list(tasks): 83 + if isinstance(future, Future) and not future.done(): 84 + future.cancel() 85 + tasks.clear() 86 + 87 + 88 + def stop_voice_runtime(app: Any) -> None: 89 + with _RUNTIME_STATE.lock: 90 + _cancel_app_futures(app) 91 + if app in _RUNTIME_STATE.apps: 92 + _RUNTIME_STATE.apps.remove(app) 93 + should_stop = not _RUNTIME_STATE.apps 94 + loop = _RUNTIME_STATE.loop if should_stop else None 95 + thread = _RUNTIME_STATE.thread if should_stop else None 96 + if should_stop: 97 + _RUNTIME_STATE.loop = None 98 + _RUNTIME_STATE.thread = None 99 + _RUNTIME_STATE.started_event = threading.Event() 100 + app.voice_runtime_started = False 101 + if loop is not None: 102 + from think.voice import brain 103 + 104 + brain.clear_brain_state() 105 + loop.call_soon_threadsafe(loop.stop) 106 + if thread is not None: 107 + thread.join(timeout=1.0) 108 + loop.close() 109 + 110 + 111 + def stop_all_voice_runtime() -> None: 112 + with _RUNTIME_STATE.lock: 113 + apps = list(_RUNTIME_STATE.apps) 114 + for app in apps: 115 + try: 116 + stop_voice_runtime(app) 117 + except Exception: 118 + logger.exception("voice runtime shutdown failed") 119 + 120 + 121 + __all__ = [ 122 + "RuntimeState", 123 + "get_runtime_state", 124 + "start_voice_runtime", 125 + "stop_all_voice_runtime", 126 + "stop_voice_runtime", 127 + ]
+61
think/voice/sideband.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """OpenAI Realtime sideband loop for voice sessions.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + from concurrent.futures import Future 10 + from typing import Any 11 + 12 + from openai import AsyncOpenAI 13 + 14 + from think.voice.config import get_openai_api_key, get_voice_model 15 + from think.voice.tools import dispatch_tool_call 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + async def _sideband_loop(conn: Any, call_id: str, app: Any) -> None: 21 + async for event in conn: 22 + if getattr(event, "type", None) != "response.function_call_arguments.done": 23 + continue 24 + output = await dispatch_tool_call( 25 + getattr(event, "name", ""), 26 + getattr(event, "arguments", ""), 27 + call_id, 28 + app, 29 + ) 30 + await conn.conversation.item.create( 31 + item={ 32 + "type": "function_call_output", 33 + "call_id": getattr(event, "call_id", ""), 34 + "output": output, 35 + } 36 + ) 37 + await conn.response.create() 38 + 39 + 40 + async def _run_sideband(call_id: str, app: Any) -> None: 41 + openai_key = get_openai_api_key() 42 + if openai_key is None: 43 + raise RuntimeError("voice unavailable — openai key not configured") 44 + client = AsyncOpenAI(api_key=openai_key) 45 + logger.info("voice sideband starting call_id=%s", call_id) 46 + try: 47 + async with client.realtime.connect( 48 + call_id=call_id, 49 + model=get_voice_model(), 50 + ) as conn: 51 + await _sideband_loop(conn, call_id, app) 52 + except Exception: 53 + logger.exception("voice sideband failed call_id=%s", call_id) 54 + 55 + 56 + def register_voice_task(app: Any, future: Future[Any]) -> None: 57 + app.voice_tasks.add(future) 58 + future.add_done_callback(app.voice_tasks.discard) 59 + 60 + 61 + __all__ = ["_run_sideband", "_sideband_loop", "register_voice_task"]
+746
think/voice/tools.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Voice tool manifest and dispatch.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import logging 10 + import re 11 + from dataclasses import asdict 12 + from datetime import date, datetime, timedelta 13 + from pathlib import Path 14 + from typing import Any, Callable 15 + from urllib.parse import quote_plus 16 + 17 + from apps.entities.routes import _build_facet_relationships 18 + from apps.home.routes import _load_briefing_md 19 + from think.activities import load_activity_records 20 + from think.cluster import cluster_segments, scan_day 21 + from think.entities.journal import load_journal_entity 22 + from think.facets import get_facets 23 + from think.indexer.journal import search_journal 24 + from think.surfaces import ledger as ledger_surface 25 + from think.surfaces.profile import full as load_profile 26 + from think.utils import day_path 27 + from think.voice.nav_queue import get_nav_queue 28 + 29 + logger = logging.getLogger(__name__) 30 + 31 + SEARCH_ENTITY_RE = re.compile(r"(?:^entity:|entities/)([a-z0-9_]+)") 32 + SUMMARY_MARKERS_RE = re.compile(r"[*_`>#]") 33 + TOOL_MANIFEST: list[dict[str, Any]] = [ 34 + { 35 + "type": "function", 36 + "name": "journal.get_day", 37 + "description": "Read one journal day and summarize the available segments.", 38 + "parameters": { 39 + "type": "object", 40 + "properties": {"day": {"type": "string"}}, 41 + "required": ["day"], 42 + }, 43 + }, 44 + { 45 + "type": "function", 46 + "name": "journal.search", 47 + "description": "Search the journal for recent matching entries.", 48 + "parameters": { 49 + "type": "object", 50 + "properties": { 51 + "query": {"type": "string"}, 52 + "facet": {"type": ["string", "null"]}, 53 + "days": {"type": ["integer", "null"]}, 54 + "limit": {"type": ["integer", "null"]}, 55 + }, 56 + "required": ["query"], 57 + }, 58 + }, 59 + { 60 + "type": "function", 61 + "name": "entities.get", 62 + "description": "Read one entity profile by slug.", 63 + "parameters": { 64 + "type": "object", 65 + "properties": {"entity_slug": {"type": "string"}}, 66 + "required": ["entity_slug"], 67 + }, 68 + }, 69 + { 70 + "type": "function", 71 + "name": "entities.recent_with", 72 + "description": "Read recent interactions with an entity.", 73 + "parameters": { 74 + "type": "object", 75 + "properties": { 76 + "entity_slug": {"type": "string"}, 77 + "days": {"type": ["integer", "null"]}, 78 + "facet": {"type": ["string", "null"]}, 79 + }, 80 + "required": ["entity_slug"], 81 + }, 82 + }, 83 + { 84 + "type": "function", 85 + "name": "commitments.list", 86 + "description": "List commitments from the ledger surface.", 87 + "parameters": { 88 + "type": "object", 89 + "properties": { 90 + "state": {"type": ["string", "null"]}, 91 + "facet": {"type": ["string", "null"]}, 92 + "limit": {"type": ["integer", "null"]}, 93 + }, 94 + }, 95 + }, 96 + { 97 + "type": "function", 98 + "name": "commitments.complete", 99 + "description": "Close a commitment through the ledger surface.", 100 + "parameters": { 101 + "type": "object", 102 + "properties": { 103 + "commitment_id": {"type": "string"}, 104 + "resolution": {"type": "string"}, 105 + }, 106 + "required": ["commitment_id", "resolution"], 107 + }, 108 + }, 109 + { 110 + "type": "function", 111 + "name": "calendar.today", 112 + "description": "Read today's anticipated activities.", 113 + "parameters": {"type": "object", "properties": {}}, 114 + }, 115 + { 116 + "type": "function", 117 + "name": "briefing.get", 118 + "description": "Read today's briefing if one exists.", 119 + "parameters": {"type": "object", "properties": {}}, 120 + }, 121 + { 122 + "type": "function", 123 + "name": "observer.start_listening", 124 + "description": "Acknowledge the Wave 2 observer-listen stub.", 125 + "parameters": { 126 + "type": "object", 127 + "properties": {"mode": {"type": "string"}}, 128 + "required": ["mode"], 129 + }, 130 + }, 131 + ] 132 + 133 + VALID_COMMITMENT_STATES = {"open", "closed", "dropped"} 134 + VALID_RESOLUTIONS = {"done", "sent", "signed", "dropped", "deferred"} 135 + VALID_LISTEN_MODES = {"meeting", "voice_memo"} 136 + 137 + 138 + def _today() -> date: 139 + return date.today() 140 + 141 + 142 + def _format_day_external(day_value: str) -> str: 143 + parsed = datetime.strptime(day_value, "%Y%m%d").date() 144 + return parsed.isoformat() 145 + 146 + 147 + def _today_internal() -> str: 148 + return _today().strftime("%Y%m%d") 149 + 150 + 151 + def _normalize_day(value: Any) -> tuple[str, str]: 152 + if not isinstance(value, str) or not value.strip(): 153 + raise ValueError("invalid day") 154 + raw = value.strip() 155 + try: 156 + parsed = datetime.strptime(raw, "%Y-%m-%d").date() 157 + except ValueError as exc: 158 + raise ValueError("invalid day") from exc 159 + internal = parsed.strftime("%Y%m%d") 160 + return internal, parsed.isoformat() 161 + 162 + 163 + def _as_int(value: Any, *, default: int, field_name: str, minimum: int = 1) -> int: 164 + if value is None: 165 + return default 166 + if isinstance(value, bool) or not isinstance(value, int): 167 + raise ValueError(f"invalid {field_name}") 168 + if value < minimum: 169 + raise ValueError(f"invalid {field_name}") 170 + return value 171 + 172 + 173 + def _clean_optional_str(value: Any) -> str | None: 174 + if value is None: 175 + return None 176 + if not isinstance(value, str): 177 + raise ValueError("invalid string field") 178 + cleaned = value.strip() 179 + return cleaned or None 180 + 181 + 182 + def get_tool_manifest() -> list[dict[str, Any]]: 183 + return json.loads(json.dumps(TOOL_MANIFEST)) 184 + 185 + 186 + def _segment_duration(segment_key: str) -> int: 187 + try: 188 + return int(segment_key.rsplit("_", 1)[1]) 189 + except (IndexError, ValueError) as exc: 190 + raise ValueError("invalid segment key") from exc 191 + 192 + 193 + def _segment_summary(day_dir: Path, stream: str, segment_key: str) -> str: 194 + segment_dir = day_dir / stream / segment_key 195 + if not segment_dir.is_dir(): 196 + return "" 197 + summaries = sorted(segment_dir.glob("*summary.md")) 198 + if not summaries: 199 + return "" 200 + chunks: list[str] = [] 201 + for path in summaries: 202 + text = path.read_text(encoding="utf-8").strip() 203 + if text: 204 + chunks.append(text) 205 + return "\n\n".join(chunks) 206 + 207 + 208 + def _build_day_summary(segment_summaries: list[str]) -> str: 209 + cleaned = [summary.strip() for summary in segment_summaries if summary.strip()] 210 + return "\n\n".join(cleaned) 211 + 212 + 213 + def _extract_entity_slug(path_value: Any) -> str | None: 214 + if not isinstance(path_value, str): 215 + return None 216 + match = SEARCH_ENTITY_RE.search(path_value) 217 + return match.group(1) if match else None 218 + 219 + 220 + def _truncate_snippet(text: str, *, words: int = 50) -> str: 221 + tokens = text.split() 222 + if len(tokens) <= words: 223 + return text 224 + return " ".join(tokens[:words]) + "..." 225 + 226 + 227 + def _plain_text(value: str) -> str: 228 + cleaned_lines = [] 229 + for line in value.splitlines(): 230 + stripped = line.strip() 231 + if stripped.startswith("- "): 232 + stripped = stripped[2:] 233 + stripped = SUMMARY_MARKERS_RE.sub("", stripped) 234 + stripped = re.sub(r"\s+", " ", stripped).strip() 235 + if stripped: 236 + cleaned_lines.append(stripped) 237 + return "\n".join(cleaned_lines) 238 + 239 + 240 + def _item_day(epoch_ms: int | None) -> str | None: 241 + if not epoch_ms: 242 + return None 243 + return datetime.fromtimestamp(epoch_ms / 1000).date().isoformat() 244 + 245 + 246 + def _shape_commitment(item: Any, *, resolution: str | None = None) -> dict[str, Any]: 247 + payload = asdict(item) 248 + payload.pop("sources", None) 249 + result = { 250 + "id": payload["id"], 251 + "owner": payload["owner"], 252 + "action": payload["action"], 253 + "counterparty": payload["counterparty"], 254 + "state": payload["state"], 255 + "context": payload["context"], 256 + "day_opened": _item_day(payload.get("opened_at")), 257 + } 258 + closed_day = _item_day(payload.get("closed_at")) 259 + if closed_day: 260 + result["day_closed"] = closed_day 261 + if resolution: 262 + result["resolution"] = resolution 263 + elif payload["state"] == "dropped": 264 + result["resolution"] = "dropped" 265 + return result 266 + 267 + 268 + def _build_profile_markdown(profile: Any) -> str: 269 + lines = [f"# {profile.name}", ""] 270 + if profile.description: 271 + lines.append(profile.description) 272 + lines.append("") 273 + lines.append(f"- Type: {profile.type}") 274 + if profile.facets: 275 + lines.append(f"- Facets: {', '.join(profile.facets)}") 276 + lines.append( 277 + f"- Recent interactions (30d): {profile.cadence.recent_interactions_count_30d}" 278 + ) 279 + lines.append(f"- Open commitments: {len(profile.open_with_them)}") 280 + lines.append(f"- Decisions (30d): {len(profile.decisions_involving_them)}") 281 + return "\n".join(lines).strip() 282 + 283 + 284 + def _recent_context_from_profile(profile: Any) -> list[dict[str, str]]: 285 + items = list(profile.open_with_them) + list(profile.closed_with_them_30d) 286 + items.sort(key=lambda item: item.closed_at or item.opened_at or 0, reverse=True) 287 + context: list[dict[str, str]] = [] 288 + for item in items[:5]: 289 + when = ( 290 + item.when 291 + if isinstance(item.when, str) and item.when 292 + else _item_day(item.opened_at) 293 + ) 294 + if not when: 295 + continue 296 + context.append( 297 + { 298 + "date": when, 299 + "summary": item.summary or item.context or item.action, 300 + } 301 + ) 302 + return context 303 + 304 + 305 + def _tag_values( 306 + profile: Any | None, journal_entity: dict[str, Any] | None 307 + ) -> list[str]: 308 + tags: list[str] = [] 309 + for value in getattr(profile, "facets", ()) or (): 310 + if value not in tags: 311 + tags.append(value) 312 + for value in getattr(profile, "aka", ()) or (): 313 + if value not in tags: 314 + tags.append(value) 315 + if isinstance(journal_entity, dict): 316 + for value in journal_entity.get("aka", []) or []: 317 + if isinstance(value, str) and value not in tags: 318 + tags.append(value) 319 + return tags 320 + 321 + 322 + def _resolve_entity(entity_slug: Any) -> tuple[Any | None, dict[str, Any] | None]: 323 + slug = _clean_optional_str(entity_slug) 324 + if slug is None: 325 + raise ValueError("entity_slug is required") 326 + profile = load_profile(slug) 327 + journal_entity = load_journal_entity(slug) 328 + if profile is None and journal_entity is None: 329 + return None, None 330 + return profile, journal_entity 331 + 332 + 333 + def _matching_names( 334 + profile: Any | None, journal_entity: dict[str, Any] | None 335 + ) -> set[str]: 336 + values: set[str] = set() 337 + if profile is not None: 338 + values.add(profile.name.casefold()) 339 + values.update(alias.casefold() for alias in profile.aka) 340 + if isinstance(journal_entity, dict): 341 + name = journal_entity.get("name") 342 + if isinstance(name, str) and name.strip(): 343 + values.add(name.strip().casefold()) 344 + for alias in journal_entity.get("aka", []) or []: 345 + if isinstance(alias, str) and alias.strip(): 346 + values.add(alias.strip().casefold()) 347 + return values 348 + 349 + 350 + def _iter_days(window_days: int) -> list[str]: 351 + today = _today() 352 + return [ 353 + (today - timedelta(days=offset)).strftime("%Y%m%d") 354 + for offset in range(window_days) 355 + ] 356 + 357 + 358 + def _list_facets(facet: str | None) -> list[str]: 359 + if facet: 360 + return [facet] 361 + return list(get_facets().keys()) 362 + 363 + 364 + def _matching_participation_note( 365 + record: dict[str, Any], names: set[str], slug: str 366 + ) -> str: 367 + for entry in record.get("participation", []) or []: 368 + if not isinstance(entry, dict): 369 + continue 370 + entity_id = entry.get("entity_id") 371 + if entity_id == slug: 372 + context = entry.get("context") 373 + return context.strip() if isinstance(context, str) else "" 374 + name = entry.get("name") 375 + if isinstance(name, str) and name.strip().casefold() in names: 376 + context = entry.get("context") 377 + return context.strip() if isinstance(context, str) else "" 378 + return "" 379 + 380 + 381 + def handle_journal_get_day(payload: dict[str, Any], app: Any) -> dict[str, Any]: 382 + del app 383 + try: 384 + internal_day, external_day = _normalize_day(payload.get("day")) 385 + except ValueError as exc: 386 + return {"error": str(exc)} 387 + day_dir = day_path(internal_day, create=False) 388 + if not day_dir.is_dir(): 389 + return {"error": "day not found"} 390 + 391 + _, _, scan_rows = scan_day(internal_day) 392 + segment_rows = cluster_segments(internal_day) 393 + scan_keys = {row["key"] for row in scan_rows} 394 + summaries: list[str] = [] 395 + segments: list[dict[str, Any]] = [] 396 + for row in segment_rows: 397 + summary = _segment_summary(day_dir, row["stream"], row["key"]) 398 + summaries.append(summary) 399 + segments.append( 400 + { 401 + "id": row["key"], 402 + "time_of_day": row["start"], 403 + "duration_s": _segment_duration(row["key"]), 404 + "summary": summary, 405 + "agent_type": row["stream"], 406 + } 407 + ) 408 + if scan_keys and not segments: 409 + logger.warning( 410 + "voice day lookup saw scan rows but no segments for %s", internal_day 411 + ) 412 + return { 413 + "day": external_day, 414 + "segments": segments, 415 + "summary": _build_day_summary(summaries), 416 + "_nav_target": f"today/journal/{external_day}", 417 + } 418 + 419 + 420 + def handle_journal_search(payload: dict[str, Any], app: Any) -> dict[str, Any]: 421 + del app 422 + query = _clean_optional_str(payload.get("query")) 423 + if query is None: 424 + return {"error": "query is required"} 425 + facet = _clean_optional_str(payload.get("facet")) 426 + try: 427 + days = payload.get("days") 428 + days_value = ( 429 + _as_int(days, default=30, field_name="days") if days is not None else None 430 + ) 431 + limit = _as_int(payload.get("limit"), default=10, field_name="limit") 432 + except ValueError as exc: 433 + return {"error": str(exc)} 434 + day_from = None 435 + if days_value is not None: 436 + day_from = (_today() - timedelta(days=days_value)).strftime("%Y%m%d") 437 + count, rows = search_journal(query, limit=limit, facet=facet, day_from=day_from) 438 + results: list[dict[str, Any]] = [] 439 + for row in rows: 440 + metadata = row.get("metadata", {}) 441 + day_value = metadata.get("day", "") 442 + result = { 443 + "id": row.get("id", ""), 444 + "day": _format_day_external(day_value) if day_value else "", 445 + "source": metadata.get("agent") or metadata.get("path") or "journal", 446 + "snippet": _truncate_snippet(str(row.get("text", "")).strip()), 447 + } 448 + entity_slug = _extract_entity_slug(metadata.get("path")) 449 + if entity_slug: 450 + result["entity_slug"] = entity_slug 451 + results.append(result) 452 + output = {"results": results, "count": count} 453 + if query: 454 + output["_nav_target"] = f"today/search?q={quote_plus(query)}" 455 + return output 456 + 457 + 458 + def handle_entities_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: 459 + del app 460 + try: 461 + slug = _clean_optional_str(payload.get("entity_slug")) 462 + if slug is None: 463 + raise ValueError("entity_slug is required") 464 + except ValueError as exc: 465 + return {"error": str(exc)} 466 + profile, journal_entity = _resolve_entity(slug) 467 + if profile is None and journal_entity is None: 468 + return {"error": "not found"} 469 + 470 + if profile is None: 471 + facets_config = get_facets() 472 + name = str(journal_entity.get("name") or slug) 473 + facet_relationships, _, _ = _build_facet_relationships( 474 + slug, name, facets_config 475 + ) 476 + profile_text = "\n".join( 477 + [ 478 + f"# {name}", 479 + "", 480 + f"- Type: {journal_entity.get('type', '')}", 481 + ( 482 + "- Facets: " + ", ".join(rel["name"] for rel in facet_relationships) 483 + if facet_relationships 484 + else "- Facets: " 485 + ), 486 + ] 487 + ).strip() 488 + recent_context: list[dict[str, str]] = [] 489 + entity_name = name 490 + entity_type = str(journal_entity.get("type") or "") 491 + else: 492 + profile_text = _build_profile_markdown(profile) 493 + recent_context = _recent_context_from_profile(profile) 494 + entity_name = profile.name 495 + entity_type = profile.type 496 + return { 497 + "slug": slug, 498 + "name": entity_name, 499 + "type": entity_type, 500 + "profile": profile_text, 501 + "tags": _tag_values(profile, journal_entity), 502 + "recent_context": recent_context, 503 + "_nav_target": f"entity/{slug}", 504 + } 505 + 506 + 507 + def handle_entities_recent_with(payload: dict[str, Any], app: Any) -> dict[str, Any]: 508 + del app 509 + try: 510 + slug = _clean_optional_str(payload.get("entity_slug")) 511 + if slug is None: 512 + raise ValueError("entity_slug is required") 513 + days = _as_int(payload.get("days"), default=7, field_name="days") 514 + facet = _clean_optional_str(payload.get("facet")) 515 + except ValueError as exc: 516 + return {"error": str(exc)} 517 + profile, journal_entity = _resolve_entity(slug) 518 + if profile is None and journal_entity is None: 519 + return {"error": "not found"} 520 + 521 + names = _matching_names(profile, journal_entity) 522 + interactions: list[tuple[int, dict[str, str]]] = [] 523 + for facet_name in _list_facets(facet): 524 + for day_value in _iter_days(days): 525 + for row in load_activity_records(facet_name, day_value): 526 + matches = False 527 + for entry in row.get("participation", []) or []: 528 + if not isinstance(entry, dict): 529 + continue 530 + if entry.get("entity_id") == slug: 531 + matches = True 532 + break 533 + name = entry.get("name") 534 + if isinstance(name, str) and name.strip().casefold() in names: 535 + matches = True 536 + break 537 + if not matches: 538 + continue 539 + story = row.get("story") 540 + story_body = "" 541 + if isinstance(story, dict): 542 + body = story.get("body") 543 + if isinstance(body, str): 544 + story_body = body.strip() 545 + created_at = int(row.get("created_at", 0) or 0) 546 + interactions.append( 547 + ( 548 + created_at, 549 + { 550 + "date": _format_day_external(day_value), 551 + "activity": str( 552 + row.get("title") or row.get("activity") or "" 553 + ), 554 + "context": story_body or str(row.get("description") or ""), 555 + "note": str(row.get("details") or "") 556 + or _matching_participation_note(row, names, slug), 557 + }, 558 + ) 559 + ) 560 + interactions.sort(key=lambda item: item[0], reverse=True) 561 + shaped = [item for _, item in interactions[:10]] 562 + return {"slug": slug, "interactions": shaped, "count": len(interactions)} 563 + 564 + 565 + def handle_commitments_list(payload: dict[str, Any], app: Any) -> dict[str, Any]: 566 + del app 567 + try: 568 + state = _clean_optional_str(payload.get("state")) 569 + if state is not None and state not in VALID_COMMITMENT_STATES: 570 + raise ValueError("invalid state") 571 + facet = _clean_optional_str(payload.get("facet")) 572 + limit = _as_int(payload.get("limit"), default=20, field_name="limit") 573 + except ValueError as exc: 574 + return {"error": str(exc)} 575 + kwargs: dict[str, Any] = {"top": limit} 576 + if state is not None: 577 + kwargs["state"] = state 578 + if facet: 579 + kwargs["facets"] = [facet] 580 + items = ledger_surface.list(**kwargs) 581 + return {"commitments": [_shape_commitment(item) for item in items]} 582 + 583 + 584 + def handle_commitments_complete(payload: dict[str, Any], app: Any) -> dict[str, Any]: 585 + del app 586 + commitment_id = _clean_optional_str(payload.get("commitment_id")) 587 + resolution = _clean_optional_str(payload.get("resolution")) 588 + if commitment_id is None: 589 + return {"error": "commitment_id is required"} 590 + if resolution is None or resolution not in VALID_RESOLUTIONS: 591 + return {"error": "invalid resolution"} 592 + as_state = "dropped" if resolution == "dropped" else "closed" 593 + note = f"resolution: {resolution}" 594 + try: 595 + item = ledger_surface.close(commitment_id, note=note, as_state=as_state) 596 + except KeyError: 597 + return {"error": "not found"} 598 + return {"ok": True, "commitment": _shape_commitment(item, resolution=resolution)} 599 + 600 + 601 + def handle_calendar_today(payload: dict[str, Any], app: Any) -> dict[str, Any]: 602 + del app, payload 603 + try: 604 + internal_day = _today_internal() 605 + events: list[dict[str, Any]] = [] 606 + for facet_name in _list_facets(None): 607 + for row in load_activity_records(facet_name, internal_day): 608 + if row.get("source") != "anticipated": 609 + continue 610 + attendees: list[str] = [] 611 + for entry in row.get("participation", []) or []: 612 + if not isinstance(entry, dict) or entry.get("role") != "attendee": 613 + continue 614 + name = entry.get("name") 615 + if isinstance(name, str) and name.strip(): 616 + attendees.append(name.strip()) 617 + events.append( 618 + { 619 + "time": str(row.get("start") or ""), 620 + "title": str(row.get("title") or ""), 621 + "attendees": attendees, 622 + "location": str(row.get("location") or ""), 623 + "prep_notes": str( 624 + row.get("prep_notes") or row.get("notes") or "" 625 + ), 626 + } 627 + ) 628 + events.sort(key=lambda item: item["time"]) 629 + return { 630 + "date": _today().isoformat(), 631 + "events": events, 632 + "_nav_target": "today", 633 + } 634 + except Exception: 635 + logger.exception("voice calendar lookup failed") 636 + return {"error": "today unavailable"} 637 + 638 + 639 + def _briefing_text(sections: dict[str, str]) -> str: 640 + ordered_keys = [ 641 + "your_day", 642 + "yesterday", 643 + "needs_attention", 644 + "forward_look", 645 + "reading", 646 + ] 647 + chunks: list[str] = [] 648 + for key in ordered_keys: 649 + body = sections.get(key) 650 + if body: 651 + chunks.append(_plain_text(body)) 652 + return "\n\n".join(chunk for chunk in chunks if chunk) 653 + 654 + 655 + def _briefing_highlights( 656 + sections: dict[str, str], needs_attention_items: list[str] 657 + ) -> list[str]: 658 + if needs_attention_items: 659 + return needs_attention_items[:3] 660 + highlights: list[str] = [] 661 + for body in sections.values(): 662 + for line in body.splitlines(): 663 + stripped = line.strip() 664 + if stripped.startswith("- "): 665 + highlights.append(_plain_text(stripped[2:])) 666 + if len(highlights) == 3: 667 + return highlights 668 + return highlights 669 + 670 + 671 + def handle_briefing_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: 672 + del payload, app 673 + internal_day = _today_internal() 674 + sections, metadata, needs_attention_items = _load_briefing_md(internal_day) 675 + if not metadata or str(metadata.get("date")) != internal_day: 676 + return {"error": "no briefing today yet"} 677 + return { 678 + "date": _format_day_external(internal_day), 679 + "facet": "identity", 680 + "text": _briefing_text(sections), 681 + "highlights": _briefing_highlights(sections, needs_attention_items), 682 + "_nav_target": "today", 683 + } 684 + 685 + 686 + def handle_observer_start_listening( 687 + payload: dict[str, Any], app: Any 688 + ) -> dict[str, Any]: 689 + del app 690 + mode = _clean_optional_str(payload.get("mode")) 691 + if mode is None or mode not in VALID_LISTEN_MODES: 692 + return {"error": "invalid mode"} 693 + logger.info("voice observer listen request acknowledged mode=%s", mode) 694 + return { 695 + "status": "ack", 696 + "mode": mode, 697 + "note": "wave-4 observer not yet wired", 698 + } 699 + 700 + 701 + TOOL_HANDLERS: dict[str, Callable[[dict[str, Any], Any], dict[str, Any]]] = { 702 + "journal.get_day": handle_journal_get_day, 703 + "journal.search": handle_journal_search, 704 + "entities.get": handle_entities_get, 705 + "entities.recent_with": handle_entities_recent_with, 706 + "commitments.list": handle_commitments_list, 707 + "commitments.complete": handle_commitments_complete, 708 + "calendar.today": handle_calendar_today, 709 + "briefing.get": handle_briefing_get, 710 + "observer.start_listening": handle_observer_start_listening, 711 + } 712 + 713 + 714 + async def dispatch_tool_call( 715 + name: str, 716 + arguments: str, 717 + call_id: str, 718 + app: Any, 719 + ) -> str: 720 + handler = TOOL_HANDLERS.get(name) 721 + if handler is None: 722 + return json.dumps({"error": f"unknown tool: {name}"}) 723 + try: 724 + parsed = json.loads(arguments or "{}") 725 + except json.JSONDecodeError: 726 + return json.dumps({"error": "tool arguments must be valid JSON"}) 727 + if not isinstance(parsed, dict): 728 + return json.dumps({"error": "tool arguments must decode to an object"}) 729 + try: 730 + result = handler(parsed, app) 731 + except Exception: 732 + logger.exception("voice tool failed: %s", name) 733 + result = {"error": "tool failed"} 734 + if not isinstance(result, dict): 735 + result = {"error": "tool failed"} 736 + nav_target = result.pop("_nav_target", None) 737 + if isinstance(nav_target, str) and nav_target.strip(): 738 + get_nav_queue().push(call_id, nav_target) 739 + return json.dumps(result) 740 + 741 + 742 + __all__ = [ 743 + "TOOL_MANIFEST", 744 + "dispatch_tool_call", 745 + "get_tool_manifest", 746 + ]