feat(vocero): add linked content and TALKFIRST greetings (#2)

Opening a report or podcast link now establishes an authenticated starting reference for the conversation. Users can select other accessible content or compare reports while retaining the original link, and TALKFIRST greetings can name the verified report or podcast.

Release notes:

- Added authenticated report and episode link resolution with bounded lookups and unavailable-content handling
- Added explicit conversation selection without restricting searches or changing the reference during comparisons
- Added contextual TALKFIRST greetings through both voice-proxy and HTTP reasoning paths
- Preserved backend permission checks, conversation isolation, cancellation, and the existing speech protocol

Reviewed-on: AquaPortal/s2s#2
This commit is contained in:
2026-10-08 09:34:16 -04:00
parent 96895996d9
commit 4b0108ecf0
21 changed files with 946 additions and 66 deletions
+20 -5
View File
@@ -16,8 +16,7 @@ from vocero_api.core import config as settings
from vocero_api.core.logging import logger
from vocero_api.core.urls import _proxy_upstream_url
from vocero_api.models.mcp import IntegrationError
from vocero_api.providers import mcp
from vocero_api.services import conversations, mcp_tools
from vocero_api.services import content_context, conversations, mcp_tools
_TEXT_DELTA = {
"response.text.delta",
@@ -81,6 +80,16 @@ class McpRelay:
self.upstream_lock = asyncio.Lock()
self.browser_lock = asyncio.Lock()
self.tool_tasks: set[asyncio.Task[None]] = set()
self.base_instructions = settings.S2S_PRIMARY_PROMPT or ""
def instructions_event(self) -> str:
return json.dumps(
{
"type": "session.update",
"session": {"instructions": mcp_tools.instructions(self.base_instructions, self.conversation)},
},
ensure_ascii=False,
)
async def send_browser(self, raw: str | bytes) -> None:
async with self.browser_lock:
@@ -112,9 +121,12 @@ class McpRelay:
async def tool_call(self, name: str, arguments: dict[str, Any], turn: mcp_tools.ToolTurn) -> None:
try:
await self.browser_event({"type": "vocero.tool.started", "name": name})
previous_content = self.conversation.current_content
result = await turn.call(name, arguments)
async with self.upstream_lock:
turn.require_current()
if previous_content != self.conversation.current_content:
await self.upstream.send(self.instructions_event())
await self.upstream.send(
json.dumps(
{
@@ -175,8 +187,8 @@ class McpRelay:
session = event.get("session")
if kind == "session.update" and isinstance(session, dict):
# Browser instructions also carry the current session's RAG notes.
base = str(session.get("instructions") or settings.S2S_PRIMARY_PROMPT or "")
session["instructions"] = mcp_tools.instructions(base, self.conversation)
self.base_instructions = str(session.get("instructions") or settings.S2S_PRIMARY_PROMPT or "")
session["instructions"] = mcp_tools.instructions(self.base_instructions, self.conversation)
raw = json.dumps(event, ensure_ascii=False)
await self.send_upstream(raw)
@@ -288,6 +300,9 @@ class McpRelay:
if response.get("output") is None:
response["output"] = []
await self.browser_event(event)
if self.conversation.greeting_pending and response.get("status", "completed") == "completed":
self.conversation.greeting_pending = False
await self.send_upstream(self.instructions_event())
continue
if buffer.generation == self.conversation.generation:
buffer.collect(raw, event)
@@ -310,7 +325,7 @@ async def realtime_mcp_proxy(ws: WebSocket) -> None:
claimed = True
await ws.accept()
if settings.S2S_REASONING_TYPE == "PROXY":
conversation.catalog = await mcp.discover(conversation.backend_token)
conversation.catalog = await content_context.catalog(conversation.backend_token)
conversation.require_active()
# Only the existing speech protocols cross the upstream boundary.
protocols = [