diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent
index 35a2f463..4f41fec2 100644
--- a/dockerfiles/Dockerfile.hermes-agent
+++ b/dockerfiles/Dockerfile.hermes-agent
@@ -1526,7 +1526,7 @@ function replaceOnce(source, before, after, label) {
it to the shared Hermes bot from your Telegram account.
Open Telegram setup
diff --git a/dockerfiles/Dockerfile.hermes-jetson-tts b/dockerfiles/Dockerfile.hermes-jetson-tts
index deba4080..4257934a 100644
--- a/dockerfiles/Dockerfile.hermes-jetson-tts
+++ b/dockerfiles/Dockerfile.hermes-jetson-tts
@@ -24,18 +24,41 @@ ADD --checksum=sha256:f7d01dde371555732c4c314111ac79672b1a5ce2fc19266ab42178fd8d
ADD --checksum=sha256:45754dfdebb3b8661c3fc564713772deec6e064feeb5b4e9594857dc7305193a --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/low/en_US-lessac-low.onnx.json?download=true \
/opt/models/piper/en_US-lessac-low.onnx.json
+
+# Multilingual chat voice policy: English -> amy, Russian -> irina, Spanish ->
+# claude (Mexican Spanish, the only "claude" voice rhasspy/piper-voices
+# publishes; there is no es_ES-claude).
+ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true \
+ /opt/models/piper/en_US-amy-medium.onnx
+ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx.json?download=true \
+ /opt/models/piper/en_US-amy-medium.onnx.json
+ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx?download=true \
+ /opt/models/piper/ru_RU-irina-medium.onnx
+ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx.json?download=true \
+ /opt/models/piper/ru_RU-irina-medium.onnx.json
+ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx?download=true \
+ /opt/models/piper/es_MX-claude-high.onnx
+ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx.json?download=true \
+ /opt/models/piper/es_MX-claude-high.onnx.json
RUN chmod 0555 /opt/models /opt/models/piper
COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py
-# Load the actual pinned voice during the ARM64 build. This catches package or
-# model-format drift before the image can reach Flux.
-RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
+# Load every pinned voice during the ARM64 build, including the three baked
+# for the multilingual chat policy. This catches package or model-format
+# drift before the image can reach Flux.
+RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx',p/'en_US-amy-medium.onnx',p/'ru_RU-irina-medium.onnx',p/'es_MX-claude-high.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
ENV HERMES_TTS_HOST=0.0.0.0 \
HERMES_TTS_PORT=9001 \
- HERMES_TTS_VOICE=en_US-lessac-medium \
+ HERMES_TTS_VOICE=en_US-amy-medium \
HERMES_TTS_CACHE=/opt/models/piper \
OMP_NUM_THREADS=2 \
PYTHONDONTWRITEBYTECODE=1 \
diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui
index 52803505..d6b83e98 100644
--- a/dockerfiles/Dockerfile.hermes-webui
+++ b/dockerfiles/Dockerfile.hermes-webui
@@ -90,11 +90,14 @@ PY
# Add the Atlas voice bridge as a narrow integration layer. It activates only
# when a tenant's server-side STT capability reports the private Jetson route.
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
+COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
+COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
+RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py
@@ -108,14 +111,24 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
+ && grep -Fq 'Audio conversion failed: upload is invalid' /opt/hermes/tools/transcription_tools.py \
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
+ && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \
+ && grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
+ && grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
+ && grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
+ && grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
+ && grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
+ && grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
&& /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes-webui/api/routes.py \
- /opt/hermes-webui/api/gateway_chat.py
+ /opt/hermes-webui/api/upload.py \
+ /opt/hermes-webui/api/gateway_chat.py \
+ /opt/hermes/tools/transcription_tools.py
# Exercise the real server process in the target architecture before publish.
RUN set -eu; \
diff --git a/dockerfiles/hermes-jetson-stt-server.py b/dockerfiles/hermes-jetson-stt-server.py
index 1ecb9374..6e0efd08 100644
--- a/dockerfiles/hermes-jetson-stt-server.py
+++ b/dockerfiles/hermes-jetson-stt-server.py
@@ -62,6 +62,34 @@ def _clean_transcript(result: dict) -> str:
return " ".join(kept).strip()
+def _detected_language(result: object) -> str:
+ """Return the bare ISO-639 code Whisper decoded with, or nothing at all.
+
+ ``whisper.transcribe`` reports the language it auto-detected (or the one it
+ was told to use) as a plain lowercase token such as ``en``/``ru``/``yue``.
+ Anything that is not that exact shape is dropped rather than guessed at, so
+ a surprising model result can never become a downstream voice selector.
+ """
+ if not isinstance(result, dict):
+ return ""
+ value = result.get("language")
+ if not isinstance(value, str):
+ return ""
+ code = value.strip().lower()
+ if not 2 <= len(code) <= 3 or not code.isascii() or not code.isalpha():
+ return ""
+ return code
+
+
+def _transcription_payload(result: dict) -> dict:
+ """Build the transcription contract: text plus the model's own language."""
+ return {
+ "text": _clean_transcript(result),
+ "model": MODEL_NAME,
+ "language": _detected_language(result),
+ }
+
+
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
@@ -148,8 +176,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
no_speech_threshold=0.5,
verbose=False,
)
- transcript = _clean_transcript(result)
- _json(self, 200, {"text": transcript, "model": MODEL_NAME})
+ _json(self, 200, _transcription_payload(result))
except Exception as exc:
print(f"[stt] transcription failed: {exc}", flush=True)
_json(self, 500, {"error": "transcription failed"})
diff --git a/dockerfiles/hermes-jetson-tts-server.py b/dockerfiles/hermes-jetson-tts-server.py
index 3abdc28b..0b962ae2 100644
--- a/dockerfiles/hermes-jetson-tts-server.py
+++ b/dockerfiles/hermes-jetson-tts-server.py
@@ -17,12 +17,50 @@ from piper import PiperConfig, PiperVoice, SynthesisConfig
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
-VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high")
CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper"))
MAX_TEXT_CHARS = 5000
ONNX_THREADS = max(1, int(os.getenv("HERMES_TTS_ONNX_THREADS", "4")))
VOICE_LOCK = threading.Lock()
+# Fixed, allow-listed language -> baked voice mapping. This is the ONLY path
+# from a client-supplied string to a model name: client input is looked up
+# here and never used to build a filesystem path directly. Both "-" and "_"
+# separators and any case are accepted; anything not present here falls back
+# to DEFAULT_VOICE_NAME (safe English default), never an error and never an
+# unbaked model.
+LANGUAGE_VOICE_MAP = {
+ "en": "en_US-amy-medium",
+ "en-us": "en_US-amy-medium",
+ "ru": "ru_RU-irina-medium",
+ "ru-ru": "ru_RU-irina-medium",
+ "es": "es_MX-claude-high",
+ "es-mx": "es_MX-claude-high",
+ "es-es": "es_MX-claude-high",
+}
+BAKED_VOICE_NAMES = frozenset(LANGUAGE_VOICE_MAP.values())
+DEFAULT_VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-amy-medium")
+
+
+def normalize_language(value: object) -> str | None:
+ """Lowercase and fold "_"/"-" separators; reject non-string/blank input."""
+ if not isinstance(value, str):
+ return None
+ normalized = value.strip().lower().replace("_", "-")
+ return normalized or None
+
+
+def resolve_voice_name(language: object) -> str:
+ """Map a client-supplied language to one of the baked policy voices.
+
+ Unknown, missing, or malformed language always resolves to the safe
+ default rather than raising, and the result is always a member of
+ BAKED_VOICE_NAMES.
+ """
+ normalized = normalize_language(language)
+ if normalized is None:
+ return DEFAULT_VOICE_NAME
+ return LANGUAGE_VOICE_MAP.get(normalized, DEFAULT_VOICE_NAME)
+
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
@@ -46,7 +84,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
if self.path != "/health":
_json(self, 404, {"error": "not found"})
return
- _json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"})
+ _json(
+ self,
+ 200,
+ {
+ "ok": True,
+ "voices": sorted(self.server.voices), # type: ignore[attr-defined]
+ "default_voice": self.server.default_voice_name, # type: ignore[attr-defined]
+ "device": "cpu",
+ },
+ )
def do_POST(self) -> None:
if self.path != "/v1/audio/speech":
@@ -71,10 +118,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
return
speed = min(2.0, max(0.5, speed))
+ # Policy is driven ONLY by "language". A client-supplied "voice"
+ # field is deliberately never read here; it cannot override the
+ # allow-listed mapping.
+ voice_name = resolve_voice_name(payload.get("language"))
+ voice = self.server.voices[voice_name] # type: ignore[attr-defined]
+
output = io.BytesIO()
try:
with VOICE_LOCK, wave.open(output, "wb") as wav_file:
- self.server.voice.synthesize_wav( # type: ignore[attr-defined]
+ voice.synthesize_wav(
text,
wav_file,
SynthesisConfig(length_scale=1.0 / speed),
@@ -84,6 +137,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.send_header("Cache-Control", "no-store")
+ self.send_header("X-TTS-Voice", voice_name)
self.end_headers()
self.wfile.write(audio)
except Exception as exc:
@@ -91,30 +145,54 @@ class SpeechHandler(BaseHTTPRequestHandler):
_json(self, 500, {"error": "speech synthesis failed"})
-def main() -> None:
- """Load the checksum-pinned voice from the image and serve it on CPU."""
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
- model_path = CACHE_DIR / f"{VOICE_NAME}.onnx"
- config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json"
+def _load_voice(cache_dir: Path, voice_name: str, threads: int) -> PiperVoice:
+ model_path = cache_dir / f"{voice_name}.onnx"
+ config_path = cache_dir / f"{voice_name}.onnx.json"
if not model_path.exists() or not config_path.exists():
- raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}")
+ raise RuntimeError(f"baked Piper voice is missing: {voice_name}")
with config_path.open("r", encoding="utf-8") as config_file:
config = PiperConfig.from_dict(json.load(config_file))
session_options = onnxruntime.SessionOptions()
- session_options.intra_op_num_threads = ONNX_THREADS
+ session_options.intra_op_num_threads = threads
session_options.inter_op_num_threads = 1
session = onnxruntime.InferenceSession(
str(model_path),
sess_options=session_options,
providers=["CPUExecutionProvider"],
)
- voice = PiperVoice(session=session, config=config, download_dir=CACHE_DIR)
+ return PiperVoice(session=session, config=config, download_dir=cache_dir)
+
+
+def load_voices(cache_dir: Path, threads: int) -> dict[str, PiperVoice]:
+ """Eagerly load all three policy voices.
+
+ Preload (not lazy-load-on-first-use) was chosen deliberately: measured
+ RSS on this model set is ~88MB for one voice and ~243MB for all three
+ (~+155MB versus the previous single-voice baseline), which comfortably
+ fits the pod's memory budget on the CPU-only voice node. Preloading
+ avoids a slow, request-serializing first synthesis per language and
+ keeps the fail-closed missing-model check (below) at process start
+ rather than deferring a possible crash to a live user request.
+ """
+ return {name: _load_voice(cache_dir, name, threads) for name in sorted(BAKED_VOICE_NAMES)}
+
+
+def main() -> None:
+ """Load the checksum-pinned policy voices from the image and serve them on CPU."""
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ if DEFAULT_VOICE_NAME not in BAKED_VOICE_NAMES:
+ raise RuntimeError(
+ f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}"
+ )
+ voices = load_voices(CACHE_DIR, ONNX_THREADS)
print(
- f"[tts] loaded Piper voice {VOICE_NAME} on CPU with {ONNX_THREADS} ONNX threads",
+ f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: "
+ + ", ".join(sorted(voices)),
flush=True,
)
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
- server.voice = voice # type: ignore[attr-defined]
+ server.voices = voices # type: ignore[attr-defined]
+ server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined]
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)
diff --git a/dockerfiles/hermes-public-extract/provider.py b/dockerfiles/hermes-public-extract/provider.py
index 1e97576c..631fec56 100644
--- a/dockerfiles/hermes-public-extract/provider.py
+++ b/dockerfiles/hermes-public-extract/provider.py
@@ -65,7 +65,7 @@ def _fetch_public(url: str) -> tuple[str, str, str]:
"""Fetch one public URL with redirect, size, MIME, and policy checks."""
current = url
headers = {
- "User-Agent": "HermesPrivateChat/1.0 (+https://chat.hermes.bstein.dev)",
+ "User-Agent": "HermesPrivateChat/1.0 (+https://chat.bstein.dev)",
"Accept": "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8",
}
with httpx.Client(follow_redirects=False, timeout=15.0, headers=headers) as client:
diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py
index 20d04399..c39e236b 100644
--- a/dockerfiles/hermes-webui-atlas-patch.py
+++ b/dockerfiles/hermes-webui-atlas-patch.py
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
+import os
from pathlib import Path
-ROOT = Path("/opt/hermes-webui")
+ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
+# The WebUI imports the pinned agent's STT tooling from the same image, so the
+# local-command transcription envelope is patched alongside the WebUI itself.
+AGENT_ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
@@ -15,24 +19,126 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
path.write_text(source.replace(before, after, count), encoding="utf-8")
+def replace_between_exact(
+ path: Path, start: str, end: str, after: str = "", count: int = 1
+) -> None:
+ """Replace one exact, bounded upstream region and fail when the pin drifts."""
+ source = path.read_text(encoding="utf-8")
+ if source.count(start) != count or source.count(end) != count:
+ raise SystemExit(
+ f"Atlas voice patch context changed in {path}: {start[:80]!r}"
+ )
+ start_index = source.index(start)
+ end_index = source.index(end, start_index) + len(end)
+ path.write_text(
+ source[:start_index] + after + source[end_index:], encoding="utf-8"
+ )
+
+
+def assert_absent(path: Path, *needles: str) -> None:
+ """Fail the image build if a removed voice-choice surface remains."""
+ source = path.read_text(encoding="utf-8")
+ remaining = [needle for needle in needles if needle in source]
+ if remaining:
+ raise SystemExit(f"Atlas voice choice remains in {path}: {remaining!r}")
+
+
+def remove_lines_containing(path: Path, *needles: str) -> None:
+ """Remove all pinned translation entries for a retired settings control."""
+ source = path.read_text(encoding="utf-8")
+ for needle in needles:
+ if needle not in source:
+ raise SystemExit(f"Atlas voice patch context changed in {path}: {needle!r}")
+ lines = source.splitlines(keepends=True)
+ path.write_text(
+ "".join(line for line in lines if not any(n in line for n in needles)),
+ encoding="utf-8",
+ )
+
+
index = ROOT / "static/index.html"
+replace_exact(
+ index,
+ ' ',
+ ' \n'
+ ' ',
+)
replace_exact(
index,
'Browser speech synthesis Edge TTS (server) ',
'Atlas Jetson (private) Browser speech synthesis Edge TTS (server) ',
)
+replace_exact(
+ index,
+ '''Voice
+
+ Default system voice
+
+
Preferred voice. Populated from your browser's available voices.
+
''',
+ "",
+)
replace_exact(
index,
'',
'\n',
)
+replace_exact(
+ index,
+ '''
+
+
+
''',
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
''',
+)
ui = ROOT / "static/ui.js"
+replace_exact(
+ ui,
+ ''' const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+''',
+ "",
+)
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
+replace_exact(
+ ui,
+ " const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';\n",
+ "",
+)
replace_exact(
ui,
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
- "body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
+ "body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
+)
+replace_exact(
+ ui,
+ " voice: localStorage.getItem('hermes-tts-voice')||'',\n",
+ "",
+ count=2,
)
replace_exact(
ui,
@@ -45,7 +151,177 @@ replace_exact(
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
)
+panels = ROOT / "static/panels.js"
+replace_exact(panels, " tts_voice:'hermes-tts-voice',\n", "")
+replace_exact(
+ panels,
+ ''' const ttsVoiceSel=$('settingsTtsVoice');
+ if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
+''',
+ "",
+)
+replace_exact(
+ panels,
+ ''' localStorage.setItem('hermes-tts-engine',this.value);
+ window._populateTtsVoices();
+ _schedulePreferencesAutosave();''',
+ ''' localStorage.setItem('hermes-tts-engine',this.value);
+ _schedulePreferencesAutosave();''',
+)
+replace_between_exact(
+ panels,
+ " // Populate voice selector based on engine\n",
+ " // TTS rate/pitch sliders\n",
+ " // TTS speaker selection is intentionally server policy only.\n",
+)
+replace_exact(
+ panels,
+ "let _settingsSpeechChangedKeys=new Set();\n",
+ "let _settingsSpeechChangedKeys=new Set();\n"
+ "try{localStorage.removeItem('hermes-tts-voice');}catch(_){}\n",
+)
+
+boot = ROOT / "static/boot.js"
+replace_exact(
+ boot,
+ ''' voice: localStorage.getItem("hermes-tts-voice")||'',
+''',
+ "",
+)
+replace_exact(
+ boot,
+ ''' const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
+''',
+ "",
+)
+replace_exact(
+ boot,
+ " body: JSON.stringify({text: clean, voice, rate, pitch})",
+ " body: JSON.stringify({text: clean, rate, pitch})",
+)
+replace_exact(
+ boot,
+ ''' const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+''',
+ "",
+)
+replace_exact(boot, " tts_voice:'',\n", "")
+replace_exact(boot, " ['tts_voice','hermes-tts-voice'],\n", "")
+
+config = ROOT / "api/config.py"
+replace_exact(config, ' "tts_voice": "",\n', "")
+replace_exact(config, ' "tts_voice",\n', "")
+replace_exact(
+ config,
+ ''' if k == "tts_voice":
+ if not isinstance(v, str) or len(v) > 200 or "\\x00" in v:
+ continue
+''',
+ "",
+)
+
+assert_absent(index, "settingsTtsVoice", "settings_label_tts_voice")
+assert_absent(ui, "hermes-tts-voice", "voice:voice")
+assert_absent(panels, "settingsTtsVoice", "tts_voice")
+assert_absent(boot, "hermes-tts-voice", "tts_voice", "text: clean, voice")
+assert_absent(config, '"tts_voice"')
+
+i18n = ROOT / "static/i18n.js"
+remove_lines_containing(
+ i18n,
+ "settings_label_tts_voice:",
+ "settings_desc_tts_voice:",
+)
+assert_absent(i18n, "settings_label_tts_voice", "settings_desc_tts_voice")
+
+# The private Whisper service reports the language it decoded with. Carry that
+# through the agent's local-command STT envelope so the WebUI can hand a voice
+# hint to Piper instead of guessing the reply's language from its text.
+transcription = AGENT_ROOT / "tools/transcription_tools.py"
+replace_exact(
+ transcription,
+ ''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
+ logger.info(
+ "Transcribed %s via local STT command (%s, %d chars)",
+ Path(file_path).name,
+ normalized_model,
+ len(transcript_text),
+ )
+ return {"success": True, "transcript": transcript_text, "provider": "local_command"}
+''',
+ ''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
+ logger.info(
+ "Transcribed %s via local STT command (%s, %d chars)",
+ Path(file_path).name,
+ normalized_model,
+ len(transcript_text),
+ )
+ detected_language = ""
+ language_files = sorted(Path(output_dir).glob("*.language"))
+ if language_files:
+ try:
+ candidate = language_files[0].read_text(encoding="utf-8").strip().lower()
+ except (OSError, ValueError):
+ candidate = ""
+ if 2 <= len(candidate) <= 3 and candidate.isascii() and candidate.isalpha():
+ detected_language = candidate
+ return {
+ "success": True,
+ "transcript": transcript_text,
+ "provider": "local_command",
+ "language": detected_language,
+ }
+''',
+)
+
+upload = ROOT / "api/upload.py"
+replace_exact(
+ upload,
+ """ transcript = str(result.get('transcript') or '').strip()
+ return j(handler, {'ok': True, 'transcript': transcript})
+""",
+ """ transcript = str(result.get('transcript') or '').strip()
+ detected = str(result.get('language') or '').strip().lower()
+ if not (2 <= len(detected) <= 3 and detected.isascii() and detected.isalpha()):
+ detected = ''
+ return j(handler, {'ok': True, 'transcript': transcript, 'language': detected})
+""",
+)
+
routes = ROOT / "api/routes.py"
+replace_exact(
+ routes,
+ "def _tts_open(req, *, timeout=30, opener_factory=None):",
+ '''ATLAS_TTS_LANGUAGES = ("en", "ru", "es")
+
+
+def _atlas_tts_language(body):
+ """Return a plain, allow-listed en/ru/es code, or "" to send no language.
+
+ This is a trust boundary, not a parser. Only the exact normalized codes the
+ private Piper deployment bakes a voice for are forwarded; a missing field,
+ a wrong type, a region tag, padding, control characters, a traversal or
+ injection string, an oversized value, an object, an array, a number or a
+ client-supplied "voice" all resolve to "" and the language field is then
+ omitted entirely, so the Jetson service applies its own English default.
+ Coercing a malformed value into a supported code would let a browser
+ describe hostile input as a language we support; omission cannot.
+ """
+ if not isinstance(body, dict):
+ return ""
+ value = body.get("language")
+ if not isinstance(value, str):
+ return ""
+ return value if value in ATLAS_TTS_LANGUAGES else ""
+
+
+def _tts_open(req, *, timeout=30, opener_factory=None):''',
+)
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
if engine == "atlas":
@@ -60,12 +336,19 @@ atlas = ''' # ── Atlas private Jetson TTS ──────────
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
except ValueError:
speed = 1.0
- request_body = json.dumps({
+ request_payload = {
"model": "piper",
"input": text,
- "voice": "en_US-lessac-high",
"speed": speed,
- }).encode("utf-8")
+ }
+ # Attach a language ONLY when the browser sent a plain allow-listed
+ # code. Omitting it is the fail-safe: the Jetson service then speaks
+ # its own English default, which is also what every partially rolled
+ # out combination of these components degrades to.
+ _atlas_language = _atlas_tts_language(data)
+ if _atlas_language:
+ request_payload["language"] = _atlas_language
+ request_body = json.dumps(request_payload).encode("utf-8")
request = Request(atlas_url, data=request_body, headers={
"Content-Type": "application/json",
"Accept": "audio/wav",
diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css
new file mode 100644
index 00000000..910fbfad
--- /dev/null
+++ b/dockerfiles/hermes-webui-atlas-voice.css
@@ -0,0 +1,336 @@
+/* Private hands-free conversation instrument for the Atlas voice bridge. */
+.voice-mode-bar {
+ --voice-accent: 72, 207, 204;
+ --voice-accent-secondary: 76, 164, 205;
+ position: relative;
+ isolation: isolate;
+ box-sizing: border-box;
+ min-height: 106px;
+ padding: 9px 12px 10px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ overflow: hidden;
+ border-bottom: 1px solid rgba(174, 192, 218, 0.1);
+ background:
+ radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.075), transparent 46%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(4, 8, 18, 0.035));
+}
+
+.voice-mode-bar::before {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ left: 18%;
+ right: 18%;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, rgba(233, 244, 255, 0.16), transparent);
+}
+
+:root:not(.dark) .voice-mode-bar {
+ border-bottom-color: rgba(38, 55, 78, 0.1);
+ background:
+ radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.09), transparent 48%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(226, 232, 240, 0.12));
+}
+
+.voice-mode-indicator {
+ --voice-ripple-scale: 1.035;
+ --voice-ripple-opacity: 0.3;
+ position: relative;
+ width: 70px;
+ height: 70px;
+ flex: 0 0 70px;
+ border-radius: 50%;
+ transform: translateZ(0);
+}
+
+.voice-mode-indicator.listening {
+ --voice-accent: 69, 218, 207;
+ --voice-accent-secondary: 73, 169, 209;
+}
+
+.voice-mode-indicator.transcribing {
+ --voice-accent: 73, 193, 218;
+ --voice-accent-secondary: 93, 129, 224;
+}
+
+.voice-mode-indicator.thinking {
+ --voice-accent: 135, 111, 244;
+ --voice-accent-secondary: 81, 62, 190;
+}
+
+.voice-mode-indicator.speaking {
+ --voice-accent: 249, 169, 105;
+ --voice-accent-secondary: 236, 112, 137;
+}
+
+.voice-mode-indicator.error {
+ --voice-accent: 235, 135, 88;
+ --voice-accent-secondary: 198, 74, 78;
+}
+
+.voice-instrument-halo,
+.voice-instrument-ripple,
+.voice-instrument-orbit,
+.voice-instrument-core {
+ position: absolute;
+ display: block;
+ box-sizing: border-box;
+ border-radius: 50%;
+ pointer-events: none;
+}
+
+.voice-instrument-halo {
+ inset: 2px;
+ opacity: 0;
+ background: radial-gradient(circle, rgba(var(--voice-accent), 0.2), rgba(var(--voice-accent), 0.05) 43%, transparent 70%);
+ filter: blur(4px);
+}
+
+.voice-instrument-ripple {
+ inset: 3px;
+ opacity: 0;
+ border: 1px solid rgba(var(--voice-accent), 0.56);
+ box-shadow: 0 0 12px rgba(var(--voice-accent), 0.13);
+}
+
+.voice-instrument-orbit {
+ inset: 2px;
+ opacity: 0;
+ border: 1px solid rgba(var(--voice-accent), 0.18);
+ box-shadow:
+ inset 0 0 8px rgba(var(--voice-accent), 0.06),
+ 0 0 11px rgba(var(--voice-accent), 0.07);
+}
+
+.voice-instrument-orbit::before {
+ content: "";
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ border-top: 1.5px solid rgba(var(--voice-accent), 0.88);
+ border-right: 1px solid rgba(var(--voice-accent-secondary), 0.35);
+ border-bottom: 1px solid transparent;
+ border-left: 1px solid transparent;
+}
+
+.voice-instrument-orbit::after {
+ content: "";
+ position: absolute;
+ top: -2px;
+ left: 50%;
+ width: 5px;
+ height: 5px;
+ margin-left: -2.5px;
+ border-radius: 50%;
+ background: rgb(var(--voice-accent));
+ box-shadow:
+ 0 0 5px rgba(var(--voice-accent), 0.92),
+ 0 0 12px rgba(var(--voice-accent), 0.44);
+}
+
+.voice-instrument-core {
+ inset: 12px;
+ display: grid;
+ place-items: center;
+ color: rgba(242, 250, 255, 0.94);
+ border: 1px solid rgba(238, 247, 255, 0.2);
+ background:
+ radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.26), transparent 24%),
+ radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.36), transparent 64%),
+ linear-gradient(145deg, rgba(36, 45, 61, 0.96), rgba(10, 15, 27, 0.98));
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.22),
+ inset 0 -9px 18px rgba(0, 0, 0, 0.24),
+ 0 4px 13px rgba(0, 0, 0, 0.25),
+ 0 0 17px rgba(var(--voice-accent), 0.17);
+}
+
+:root:not(.dark) .voice-instrument-core {
+ color: rgba(31, 48, 66, 0.9);
+ border-color: rgba(255, 255, 255, 0.82);
+ background:
+ radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.96), transparent 27%),
+ radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.2), transparent 65%),
+ linear-gradient(145deg, rgba(248, 250, 251, 0.98), rgba(216, 225, 231, 0.96));
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.94),
+ inset 0 -8px 17px rgba(64, 84, 100, 0.1),
+ 0 4px 12px rgba(35, 53, 72, 0.15),
+ 0 0 15px rgba(var(--voice-accent), 0.13);
+}
+
+.voice-instrument-symbol {
+ display: block;
+ width: 22px;
+ height: 22px;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32));
+}
+
+.voice-instrument-symbol svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.voice-symbol {
+ display: none;
+}
+
+.voice-mode-indicator.listening .voice-symbol-listening,
+.voice-mode-indicator.transcribing .voice-symbol-transcribing,
+.voice-mode-indicator.thinking .voice-symbol-thinking,
+.voice-mode-indicator.speaking .voice-symbol-speaking,
+.voice-mode-indicator.error .voice-symbol-error {
+ display: inline;
+}
+
+.voice-mode-indicator.listening .voice-instrument-halo {
+ opacity: 0.62;
+ animation: voice-instrument-breathe 3.8s ease-in-out infinite;
+}
+
+.voice-mode-indicator.listening .voice-instrument-ripple {
+ opacity: var(--voice-ripple-opacity);
+ transform: scale(var(--voice-ripple-scale));
+ transition: transform 140ms ease-out, opacity 160ms ease-out;
+}
+
+.voice-mode-indicator.transcribing .voice-instrument-orbit {
+ opacity: 0.92;
+ animation: voice-instrument-orbit 3.4s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite;
+}
+
+.voice-mode-indicator.thinking .voice-instrument-orbit {
+ opacity: 0.96;
+ animation: voice-instrument-orbit 4.6s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite;
+}
+
+.voice-mode-indicator.thinking .voice-instrument-core {
+ animation: voice-instrument-core-breathe 5.7s ease-in-out infinite;
+}
+
+.voice-mode-indicator.speaking .voice-instrument-orbit {
+ opacity: 0.86;
+ transform: rotate(38deg);
+}
+
+.voice-mode-indicator.speaking.is-playing .voice-instrument-halo {
+ opacity: 0.66;
+ animation: voice-instrument-speaking-pulse 2.15s ease-out infinite;
+}
+
+.voice-mode-indicator.error .voice-instrument-orbit {
+ opacity: 0.9;
+ transform: rotate(-32deg);
+ border-color: rgba(var(--voice-accent), 0.44);
+}
+
+.voice-mode-indicator.error .voice-instrument-core {
+ color: rgba(255, 232, 219, 0.95);
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.2),
+ inset 0 -9px 18px rgba(0, 0, 0, 0.24),
+ 0 4px 13px rgba(0, 0, 0, 0.25),
+ 0 0 12px rgba(var(--voice-accent), 0.17);
+}
+
+.voice-mode-label {
+ position: relative;
+ z-index: 1;
+ min-height: 16px;
+ max-width: min(100%, 34rem);
+ overflow: hidden;
+ color: var(--text, #f4f6fb);
+ font-size: 12px;
+ font-weight: 650;
+ line-height: 1.35;
+ letter-spacing: 0.018em;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
+}
+
+:root:not(.dark) .voice-mode-label {
+ color: #263648;
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);
+}
+
+@keyframes voice-instrument-breathe {
+ 0%, 100% { opacity: 0.38; transform: scale(0.94); }
+ 50% { opacity: 0.72; transform: scale(1.06); }
+}
+
+@keyframes voice-instrument-orbit {
+ 0% { transform: rotate(0deg); }
+ 50% { transform: rotate(177deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes voice-instrument-core-breathe {
+ 0%, 100% { filter: saturate(0.96) brightness(0.98); }
+ 50% { filter: saturate(1.08) brightness(1.06); }
+}
+
+@keyframes voice-instrument-speaking-pulse {
+ 0% { opacity: 0.58; transform: scale(0.88); }
+ 58% { opacity: 0.22; transform: scale(1.12); }
+ 100% { opacity: 0; transform: scale(1.2); }
+}
+
+@media (max-width: 640px) {
+ .voice-mode-bar {
+ min-height: 94px;
+ padding: 7px 10px 8px;
+ gap: 4px;
+ }
+
+ .voice-mode-indicator {
+ width: 62px;
+ height: 62px;
+ flex-basis: 62px;
+ }
+
+ .voice-instrument-core {
+ inset: 11px;
+ }
+
+ .voice-instrument-symbol {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .voice-mode-bar *,
+ .voice-mode-bar *::before,
+ .voice-mode-bar *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+
+ .voice-mode-indicator.listening .voice-instrument-halo {
+ opacity: 0.45;
+ transform: none;
+ }
+
+ .voice-mode-indicator.listening .voice-instrument-ripple {
+ opacity: 0.34;
+ transform: scale(1.035);
+ }
+
+ .voice-mode-indicator.transcribing .voice-instrument-orbit,
+ .voice-mode-indicator.thinking .voice-instrument-orbit {
+ transform: rotate(24deg);
+ }
+
+ .voice-mode-indicator.speaking.is-playing .voice-instrument-halo {
+ opacity: 0.42;
+ transform: scale(1.03);
+ }
+}
diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js
index e83c35b7..a5042f5e 100644
--- a/dockerfiles/hermes-webui-atlas-voice.js
+++ b/dockerfiles/hermes-webui-atlas-voice.js
@@ -1,4 +1,4 @@
-// Natural turn-taking for chat.hermes.bstein.dev using the private Jetsons.
+// Natural turn-taking for chat.bstein.dev using the private Jetsons.
(function(){
'use strict';
@@ -19,9 +19,49 @@
let vadTimer=null;
let currentAudio=null;
let thinkingSession=null;
+ let errorTimer=null;
+ let visualInputLevel=0;
+ const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false};
+ const ERROR_VISIBLE_MS=3200;
+ const STATE_LABELS={
+ listening:'Listening',
+ transcribing:'Transcribing…',
+ thinking:'Thinking…',
+ speaking:'Speaking',
+ error:'Voice unavailable',
+ idle:'',
+ };
+ // The only language signal this file trusts is the one the private Whisper
+ // service returned for the audio of the turn currently being answered. It is
+ // bound to that turn's generation token and consumed exactly once.
+ let sttLanguage='';
+ let sttLanguageToken=-1;
+ const TTS_LANGUAGES=['en','ru','es'];
const originalAutoRead=window.autoReadLastAssistant;
const originalApplyPreference=window._applyVoiceModePref;
+ function normalizeSttLanguage(value){
+ if(typeof value!=='string') return '';
+ const code=value.trim().toLowerCase();
+ return TTS_LANGUAGES.indexOf(code)>=0?code:'';
+ }
+
+ function clearSttLanguage(){
+ sttLanguage='';
+ sttLanguageToken=-1;
+ }
+
+ function rememberSttLanguage(language, token){
+ sttLanguage=language||'';
+ sttLanguageToken=sttLanguage?token:-1;
+ }
+
+ function takeSttLanguage(token){
+ const language=sttLanguageToken===token?sttLanguage:'';
+ clearSttLanguage();
+ return language;
+ }
+
function toast(message){
if(typeof window.showToast==='function') window.showToast(message,3000);
}
@@ -29,8 +69,48 @@
function setState(next, customLabel){
state=next;
indicator.className='voice-mode-indicator '+next;
- label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':'');
- bar.style.display=active&&next!=='idle'?'':'none';
+ bar.dataset.voiceState=next;
+ bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false');
+ label.textContent=customLabel||STATE_LABELS[next]||'';
+ bar.style.display=(active&&next!=='idle')||next==='error'?'':'none';
+ resetInputLevel();
+ }
+
+ function resetInputLevel(){
+ visualInputLevel=0;
+ indicator.style.setProperty('--voice-ripple-scale','1.035');
+ indicator.style.setProperty('--voice-ripple-opacity','0.3');
+ }
+
+ function updateInputLevel(rms){
+ if(reducedMotion.matches||state!=='listening') return;
+ const target=Math.max(0,Math.min(1,(rms-0.01)/0.18));
+ visualInputLevel=(visualInputLevel*0.72)+(target*0.28);
+ indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3));
+ indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3));
+ }
+
+ function clearErrorTimer(){
+ if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;}
+ }
+
+ function errorMessage(error, fallback){
+ return String((error&&error.message)||fallback||'Voice unavailable').trim();
+ }
+
+ function showUnavailable(message){
+ generation+=1;
+ active=false;
+ thinkingSession=null;
+ stopCapture();
+ stopPlayback();
+ modeBtn.classList.remove('active');
+ setState('error',message);
+ clearErrorTimer();
+ errorTimer=window.setTimeout(function(){
+ errorTimer=null;
+ if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle');
+ },ERROR_VISIBLE_MS);
}
function stopCapture(){
@@ -47,17 +127,19 @@
if(!currentAudio) return;
try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ }
currentAudio=null;
+ indicator.classList.remove('is-playing');
}
function deactivate(showMessage){
generation+=1;
active=false;
- state='idle';
thinkingSession=null;
+ clearErrorTimer();
+ clearSttLanguage();
stopCapture();
stopPlayback();
modeBtn.classList.remove('active');
- bar.style.display='none';
+ setState('idle');
if(showMessage) toast('Hands-free voice mode off');
}
@@ -67,32 +149,41 @@
},delay||500);
}
- function sendTranscript(transcript, token){
+ function sendTranscript(transcript, token, language){
if(!active||token!==generation) return;
const text=String(transcript||'').trim();
- if(!text){restartSoon(token,350);return;}
+ if(!text){clearSttLanguage();restartSoon(token,350);return;}
composer.value=text;
if(typeof window.autoResize==='function') window.autoResize();
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
+ rememberSttLanguage(language,token);
setState('thinking');
if(typeof window.send==='function') window.send();
}
+ function audioExtension(mimeType){
+ const normalized=String(mimeType||'').toLowerCase();
+ if(normalized.indexOf('ogg')>=0) return 'ogg';
+ if(normalized.indexOf('mp4')>=0) return 'mp4';
+ return 'webm';
+ }
+
async function transcribe(blob, token){
if(!active||token!==generation) return;
- setState('thinking','Transcribing…');
- const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm';
+ setState('transcribing');
+ const ext=audioExtension(blob.type);
const form=new FormData();
form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext}));
try{
const response=await fetch('/api/transcribe',{method:'POST',body:form});
const payload=await response.json().catch(function(){return {};});
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
- sendTranscript(payload.transcript,token);
+ sendTranscript(payload.transcript,token,normalizeSttLanguage(payload.language));
}catch(error){
if(!active||token!==generation) return;
- deactivate(false);
- toast((error&&error.message)||'Private Whisper is unavailable');
+ const message=errorMessage(error,'Private Whisper is unavailable');
+ showUnavailable(message);
+ toast(message);
// If the browser supplies its own recognizer, hand control back to the
// upstream voice implementation until the Jetson becomes healthy again.
if(window.SpeechRecognition||window.webkitSpeechRecognition){
@@ -105,6 +196,7 @@
async function startListening(token){
if(!active||token!==generation) return;
stopCapture();
+ clearSttLanguage();
setState('listening');
try{
const capture=await navigator.mediaDevices.getUserMedia({
@@ -128,19 +220,26 @@
audioContext.createMediaStreamSource(stream).connect(highpass);
highpass.connect(analyser);
const samples=new Uint8Array(analyser.fftSize);
- const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm'];
+ const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/webm'];
const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||'';
const chunks=[];
const preRoll=[];
+ let initialChunk=null;
let heardSpeech=false;
let voiceFrames=0;
let noiseFloor=0.008;
let lastSpeech=Date.now();
const started=Date.now();
recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined);
+ let recordedMime=recorder.mimeType||mime||'';
recorder.ondataavailable=function(event){
if(!event.data||!event.data.size) return;
+ if(event.data.type) recordedMime=event.data.type;
if(heardSpeech){chunks.push(event.data);return;}
+ // MediaRecorder's first timeslice owns the container initialization
+ // (EBML/Opus headers for WebM, and equivalent headers for Ogg/MP4).
+ // Keep it separately while bounding the actual audio pre-roll.
+ if(!initialChunk){initialChunk=event.data;return;}
preRoll.push(event.data);
while(preRoll.length>3) preRoll.shift();
};
@@ -153,7 +252,7 @@
recorder=null;
if(!active||token!==generation) return;
if(!heardSpeech||!chunks.length){restartSoon(token,300);return;}
- transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token);
+ transcribe(new Blob(chunks,{type:recordedMime||'audio/webm'}),token);
};
recorder.start(250);
const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600);
@@ -166,6 +265,7 @@
energy+=normalized*normalized;
}
const rms=Math.sqrt(energy/samples.length);
+ updateInputLevel(rms);
const now=Date.now();
const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006);
const voiceNow=rms>speechThreshold;
@@ -174,6 +274,7 @@
if(!heardSpeech&&voiceFrames>=3){
heardSpeech=true;
lastSpeech=now;
+ if(initialChunk){chunks.push(initialChunk);initialChunk=null;}
while(preRoll.length) chunks.push(preRoll.shift());
}else if(heardSpeech&&voiceNow){
lastSpeech=now;
@@ -188,8 +289,9 @@
},100);
}catch(error){
if(!active||token!==generation) return;
- deactivate(false);
- toast((error&&error.message)||'Microphone permission is required');
+ const message=errorMessage(error,'Microphone permission is required');
+ showUnavailable(message);
+ toast(message);
}
}
@@ -206,19 +308,26 @@
currentAudio=audio;
function cleanup(){
if(currentAudio===audio) currentAudio=null;
+ indicator.classList.remove('is-playing');
URL.revokeObjectURL(url);
}
audio.onended=function(){cleanup();resolve();};
audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));};
- audio.play().catch(function(error){cleanup();reject(error);});
+ audio.play().then(function(){
+ if(active&&token===generation&¤tAudio===audio) indicator.classList.add('is-playing');
+ }).catch(function(error){cleanup();reject(error);});
});
}
- async function fetchSpeech(chunk){
+ async function fetchSpeech(chunk, language){
+ // `language` is only ever the private STT result for this turn. When it is
+ // absent the field is omitted entirely and the server picks English.
+ const request={text:chunk,engine:'atlas'};
+ if(language) request.language=language;
const response=await fetch('/api/tts',{
method:'POST',
headers:{'Content-Type':'application/json'},
- body:JSON.stringify({text:chunk,engine:'atlas'}),
+ body:JSON.stringify(request),
});
if(!response.ok){
const payload=await response.json().catch(function(){return {};});
@@ -232,10 +341,12 @@
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
if(thinkingSession&¤tSession&&thinkingSession!==currentSession){
thinkingSession=null;
+ clearSttLanguage();
restartSoon(token,250);
return;
}
thinkingSession=null;
+ const language=takeSttLanguage(token);
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
if(!rows.length){restartSoon(token,250);return;}
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
@@ -243,15 +354,19 @@
setState('speaking');
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
try{
- let pending=fetchSpeech(chunks[0]);
+ let pending=fetchSpeech(chunks[0],language);
for(let index=0;index None:
+ """Replace one exact upstream fragment, failing closed on image drift."""
+ source = path.read_text(encoding="utf-8")
+ if source.count(before) != 1:
+ raise SystemExit(f"Hermes STT patch context changed in {path}: {before[:80]!r}")
+ path.write_text(source.replace(before, after, 1), encoding="utf-8")
+
+
+transcription = ROOT / "tools/transcription_tools.py"
+replace_exact(
+ transcription,
+ """ except subprocess.CalledProcessError as e:
+ details = e.stderr.strip() or e.stdout.strip() or str(e)
+ logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
+ return None, f"Failed to convert audio for local STT: {details}"
+""",
+ """ except subprocess.CalledProcessError as e:
+ details = e.stderr.strip() or e.stdout.strip() or str(e)
+ logger.error(
+ "ffmpeg conversion failed for %s: %s", file_path, details[-2000:]
+ )
+ return None, (
+ "Audio conversion failed: upload is invalid, incomplete, or uses an "
+ "unsupported codec"
+ )
+""",
+)
diff --git a/infrastructure/core/coredns-custom.yaml b/infrastructure/core/coredns-custom.yaml
index beda2f43..9c83c009 100644
--- a/infrastructure/core/coredns-custom.yaml
+++ b/infrastructure/core/coredns-custom.yaml
@@ -18,6 +18,7 @@ data:
192.168.22.9 call.live.bstein.dev
192.168.22.9 cd.bstein.dev
192.168.22.9 chat.ai.bstein.dev
+ 192.168.22.9 chat.bstein.dev
192.168.22.9 chat.hermes.bstein.dev
192.168.22.9 ci.bstein.dev
192.168.22.9 cloud.bstein.dev
@@ -45,6 +46,7 @@ data:
192.168.22.9 stream.bstein.dev
192.168.22.9 wolf.bstein.dev
192.168.22.9 tasks.bstein.dev
+ 192.168.22.9 triage.bstein.dev
192.168.22.9 triage.hermes.bstein.dev
192.168.22.9 vault.bstein.dev
fallthrough
diff --git a/knowledge/catalog/atlas.json b/knowledge/catalog/atlas.json
index 98628e38..d7247bfd 100644
--- a/knowledge/catalog/atlas.json
+++ b/knowledge/catalog/atlas.json
@@ -4404,7 +4404,7 @@
}
},
{
- "host": "chat.hermes.bstein.dev",
+ "host": "chat.bstein.dev",
"path": "/",
"backend": {
"namespace": "hermes",
@@ -5164,7 +5164,7 @@
}
},
{
- "host": "triage.hermes.bstein.dev",
+ "host": "triage.bstein.dev",
"path": "/",
"backend": {
"namespace": "hermes",
diff --git a/knowledge/catalog/atlas.yaml b/knowledge/catalog/atlas.yaml
index 6e355e7f..8ae1b49b 100644
--- a/knowledge/catalog/atlas.yaml
+++ b/knowledge/catalog/atlas.yaml
@@ -2888,7 +2888,7 @@ http_endpoints:
kind: Ingress
name: bstein-dev-home
source: bstein-dev-home
-- host: chat.hermes.bstein.dev
+- host: chat.bstein.dev
path: /
backend:
namespace: hermes
@@ -3361,7 +3361,7 @@ http_endpoints:
kind: Ingress
name: planka
source: planka
-- host: triage.hermes.bstein.dev
+- host: triage.bstein.dev
path: /
backend:
namespace: hermes
diff --git a/knowledge/diagrams/atlas-http.mmd b/knowledge/diagrams/atlas-http.mmd
index 11b55c8a..5c0c9be7 100644
--- a/knowledge/diagrams/atlas-http.mmd
+++ b/knowledge/diagrams/atlas-http.mmd
@@ -51,9 +51,9 @@ flowchart LR
host_chat_ai_bstein_dev --> svc_bstein_dev_home_chat_ai_gateway
wl_bstein_dev_home_chat_ai_gateway["bstein-dev-home/chat-ai-gateway (Deployment)"]
svc_bstein_dev_home_chat_ai_gateway --> wl_bstein_dev_home_chat_ai_gateway
- host_chat_hermes_bstein_dev["chat.hermes.bstein.dev"]
+ host_chat_bstein_dev["chat.bstein.dev"]
svc_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Service)"]
- host_chat_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
+ host_chat_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
wl_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Deployment)"]
svc_hermes_oauth2_proxy_hermes_chat --> wl_hermes_oauth2_proxy_hermes_chat
host_ci_bstein_dev["ci.bstein.dev"]
@@ -170,9 +170,9 @@ flowchart LR
host_tasks_bstein_dev --> svc_planka_planka
wl_planka_planka["planka/planka (Deployment)"]
svc_planka_planka --> wl_planka_planka
- host_triage_hermes_bstein_dev["triage.hermes.bstein.dev"]
+ host_triage_bstein_dev["triage.bstein.dev"]
svc_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Service)"]
- host_triage_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
+ host_triage_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
wl_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Deployment)"]
svc_hermes_oauth2_proxy_hermes_triage --> wl_hermes_oauth2_proxy_hermes_triage
host_vault_bstein_dev["vault.bstein.dev"]
diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml
index 3a211a7d..92fcb22b 100644
--- a/services/bstein-dev-home/kustomization.yaml
+++ b/services/bstein-dev-home/kustomization.yaml
@@ -20,9 +20,9 @@ resources:
- ingress.yaml
images:
- name: registry.bstein.dev/bstein/bstein-dev-home-frontend
- newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"}
+ newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"}
- name: registry.bstein.dev/bstein/bstein-dev-home-backend
- newTag: 0.1.1-476 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"}
+ newTag: 0.1.1-479 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-backend:tag"}
configMapGenerator:
- name: chat-ai-gateway
namespace: bstein-dev-home
diff --git a/services/comms/knowledge/catalog/atlas.json b/services/comms/knowledge/catalog/atlas.json
index 98628e38..d7247bfd 100644
--- a/services/comms/knowledge/catalog/atlas.json
+++ b/services/comms/knowledge/catalog/atlas.json
@@ -4404,7 +4404,7 @@
}
},
{
- "host": "chat.hermes.bstein.dev",
+ "host": "chat.bstein.dev",
"path": "/",
"backend": {
"namespace": "hermes",
@@ -5164,7 +5164,7 @@
}
},
{
- "host": "triage.hermes.bstein.dev",
+ "host": "triage.bstein.dev",
"path": "/",
"backend": {
"namespace": "hermes",
diff --git a/services/comms/knowledge/catalog/atlas.yaml b/services/comms/knowledge/catalog/atlas.yaml
index 6e355e7f..8ae1b49b 100644
--- a/services/comms/knowledge/catalog/atlas.yaml
+++ b/services/comms/knowledge/catalog/atlas.yaml
@@ -2888,7 +2888,7 @@ http_endpoints:
kind: Ingress
name: bstein-dev-home
source: bstein-dev-home
-- host: chat.hermes.bstein.dev
+- host: chat.bstein.dev
path: /
backend:
namespace: hermes
@@ -3361,7 +3361,7 @@ http_endpoints:
kind: Ingress
name: planka
source: planka
-- host: triage.hermes.bstein.dev
+- host: triage.bstein.dev
path: /
backend:
namespace: hermes
diff --git a/services/comms/knowledge/diagrams/atlas-http.mmd b/services/comms/knowledge/diagrams/atlas-http.mmd
index 11b55c8a..5c0c9be7 100644
--- a/services/comms/knowledge/diagrams/atlas-http.mmd
+++ b/services/comms/knowledge/diagrams/atlas-http.mmd
@@ -51,9 +51,9 @@ flowchart LR
host_chat_ai_bstein_dev --> svc_bstein_dev_home_chat_ai_gateway
wl_bstein_dev_home_chat_ai_gateway["bstein-dev-home/chat-ai-gateway (Deployment)"]
svc_bstein_dev_home_chat_ai_gateway --> wl_bstein_dev_home_chat_ai_gateway
- host_chat_hermes_bstein_dev["chat.hermes.bstein.dev"]
+ host_chat_bstein_dev["chat.bstein.dev"]
svc_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Service)"]
- host_chat_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
+ host_chat_bstein_dev --> svc_hermes_oauth2_proxy_hermes_chat
wl_hermes_oauth2_proxy_hermes_chat["hermes/oauth2-proxy-hermes-chat (Deployment)"]
svc_hermes_oauth2_proxy_hermes_chat --> wl_hermes_oauth2_proxy_hermes_chat
host_ci_bstein_dev["ci.bstein.dev"]
@@ -170,9 +170,9 @@ flowchart LR
host_tasks_bstein_dev --> svc_planka_planka
wl_planka_planka["planka/planka (Deployment)"]
svc_planka_planka --> wl_planka_planka
- host_triage_hermes_bstein_dev["triage.hermes.bstein.dev"]
+ host_triage_bstein_dev["triage.bstein.dev"]
svc_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Service)"]
- host_triage_hermes_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
+ host_triage_bstein_dev --> svc_hermes_oauth2_proxy_hermes_triage
wl_hermes_oauth2_proxy_hermes_triage["hermes/oauth2-proxy-hermes-triage (Deployment)"]
svc_hermes_oauth2_proxy_hermes_triage --> wl_hermes_oauth2_proxy_hermes_triage
host_vault_bstein_dev["vault.bstein.dev"]
diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md
index ac0c4216..def0bcd8 100644
--- a/services/hermes/NOTES.md
+++ b/services/hermes/NOTES.md
@@ -1,8 +1,8 @@
# Hermes on Atlas: operator guide
This is the mental model and demonstration script for the operator instance at
-`triage.hermes.bstein.dev`. Read it once, then prove each section in the live UI. The
-consumer instance at `chat.hermes.bstein.dev` is intentionally separate and is not the
+`triage.bstein.dev`. Read it once, then prove each section in the live UI. The
+consumer instance at `chat.bstein.dev` is intentionally separate and is not the
place to perform infrastructure triage.
`agent.hermes.bstein.dev` is the owner-only engineering control plane. Its root
@@ -14,7 +14,7 @@ its conversation-first layout is a better fit.
## Consumer chat and Telegram
-`chat.hermes.bstein.dev` uses the pinned Hermes WebUI rather than the operator
+`chat.bstein.dev` uses the pinned Hermes WebUI rather than the operator
dashboard. Keycloak still authenticates every browser request, and the tenant
router permanently assigns each Keycloak subject to one Hermes process and one
PVC. The four slots are an isolation pool, not a provider round robin: every
@@ -40,6 +40,40 @@ or the WebUI. Browser chat remains available when `bot_token` is empty.
The bot token and relay key must never be added to Git or a Kubernetes Secret.
The router does not log prompt bodies, raw Telegram IDs, link codes, or tokens.
+## Private Jetson voice: multilingual TTS policy
+
+`hermes-tts` on `titan-21` bakes three checksum-pinned Piper voices and
+selects one per request from a fixed, allow-listed `language` field: `en`/
+`en-US` → `en_US-amy-medium`, `ru`/`ru-RU` → `ru_RU-irina-medium`, `es`/
+`es-MX`/`es-ES` → `es_MX-claude-high` (Piper's `claude` voice is Mexican
+Spanish; there is no Castilian `es_ES-claude`). Matching is case-insensitive
+and accepts both `_` and `-` separators. Any language that is missing,
+unrecognized, or malformed falls back to English amy rather than erroring.
+The mapping is a fixed dict from `language` to one of the three baked model
+names only — a client-supplied `voice` field is never read, so no client
+input can select or construct a model path. All three voices are preloaded
+at process start (see `dockerfiles/hermes-jetson-tts-server.py`).
+
+Hermes Chat deliberately exposes no TTS speaker/model choice. The deterministic
+WebUI image patch removes the pinned upstream voice selector, its label and
+translations, its browser/server preference persistence, and every outbound
+client `voice` field while preserving the TTS engine, speech rate/pitch,
+dictation, hands-free Voice Mode, and the conversation instrument. Legacy
+`hermes-tts-voice` browser state is deleted. Voice choice is therefore policy,
+not a client preference: validated English maps to amy, Russian to irina,
+Spanish to claude, and every unsupported or absent language falls back to amy.
+
+The private WebUI voice bridge (`dockerfiles/hermes-webui-atlas-voice.js`,
+patched into `api/routes.py` by `hermes-webui-atlas-patch.py`) has no signal
+for the language of the assistant reply it is about to speak — it sends only
+`text` and `engine`. Until the WebUI or gateway attaches an explicit
+`language` field to that request, every reply speaks in the safe English
+default regardless of its actual language. Closing that gap needs a language
+signal upstream of the TTS call (e.g. tagging the assistant turn with a
+detected/declared reply language and threading it through
+`hermes-webui-atlas-voice.js` → `api/routes.py` → the `language` field), not
+client- or server-side guessing bolted onto the TTS service itself.
+
## The one-sentence explanation
Hermes is the persistent agent runtime and control surface; Codex or the local
@@ -347,6 +381,40 @@ Use this short explanation:
- `Use $tune-atlas-alerts. Trace one currently firing alert to its generated source and raw PromQL, but do not edit it.`
- `Use $master-hermes-on-atlas. Assess me on the request path and permission boundary. One question at a time.`
+## Private voice: choosing the Piper voice from the STT-detected language
+
+Hands-free voice mode in `chat.hermes.bstein.dev` selects the private Piper
+voice from the language the private Jetson Whisper service reports for the
+user's own speech. The signal travels one way only, and every hop narrows it:
+
+1. `hermes-stt` returns `{text, model, language}`. `language` is whatever
+ Whisper decoded with, accepted only as a bare ISO-639 token (`en`, `ru`,
+ `es`, `yue`, …); anything else is reported as empty.
+2. `hermes_stt_client.py` writes the usual `.txt` transcript plus a
+ `.language` sidecar. The transcript stays the only `.txt` in the
+ output directory, so the stock Hermes local-command contract is unchanged.
+3. The patched local-command STT envelope reads that sidecar and adds
+ `language` to its result; `/api/transcribe` re-validates it and returns it
+ next to `transcript`.
+4. `atlas-voice.js` keeps that value only for the turn it belongs to. It is
+ bound to the voice-mode generation token and the chat session id, consumed
+ exactly once by the reply that turn produced, and cleared on cancellation,
+ restart, session change, an empty transcript, or a transcription error.
+5. `/api/tts` accepts `language` only from the fixed allow-list and otherwise
+ sends English. The Jetson TTS service applies the same allow-list again as
+ the final authority.
+
+**What this does not claim.** The language is the language the *user spoke*,
+not the language of the reply. A model asked a Russian question may answer in
+English and will then be read aloud by the Russian voice, and vice versa; this
+is a deliberate policy choice for hands-free mode, not a detection failure.
+Nothing here detects the language of assistant text.
+
+**Everything else stays English.** Typed messages, the manual read-aloud
+button, and any assistant reply that was not produced by a hands-free spoken
+turn carry no trusted STT signal, so they synthesize with `en_US-amy-medium`.
+A `voice` field from a browser is never honoured at any hop.
+
## Honest limits
- Hermes does not currently apply production or cluster changes autonomously.
diff --git a/services/hermes/agent-certificate.yaml b/services/hermes/agent-certificate.yaml
index 486bab8b..6630ae75 100644
--- a/services/hermes/agent-certificate.yaml
+++ b/services/hermes/agent-certificate.yaml
@@ -11,5 +11,9 @@ spec:
name: letsencrypt
dnsNames:
- agent.hermes.bstein.dev
+ - chat.bstein.dev
+ - triage.bstein.dev
+ # Legacy hosts stay on the certificate until they are retired on purpose;
+ # the rename in #34 must not break links or sessions already in flight.
- chat.hermes.bstein.dev
- triage.hermes.bstein.dev
diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml
index d339b8c2..bcec67ac 100644
--- a/services/hermes/agent-configmap.yaml
+++ b/services/hermes/agent-configmap.yaml
@@ -84,7 +84,9 @@ data:
failure_limit: 2
orchestrator_profile: default
default_assignee: cli-auto
- max_in_progress_per_profile: 1
+ # Allow a release review and independent evidence task to run together;
+ # gateway-wide and provider quota/cooldown limits remain in force.
+ max_in_progress_per_profile: 2
auto_decompose: true
auto_decompose_per_tick: 2
# In-pod autonomous supervisor (kanban_supervisor.py). Default false so it
@@ -320,7 +322,7 @@ data:
cannot read Secrets, exec or attach to pods, create service-account tokens,
mutate workloads or RBAC, or reconcile Flux. Put every durable cluster
change on a reviewed titan-iac branch. Never expose credentials in chat or
- logs. Triage belongs at triage.hermes.bstein.dev.
+ logs. Triage belongs at triage.bstein.dev.
## Atlas engineering access
diff --git a/services/hermes/agent-ingress.yaml b/services/hermes/agent-ingress.yaml
index 19ae39b7..775adbce 100644
--- a/services/hermes/agent-ingress.yaml
+++ b/services/hermes/agent-ingress.yaml
@@ -88,10 +88,35 @@ spec:
tls:
- hosts:
- agent.hermes.bstein.dev
+ - chat.bstein.dev
+ - triage.bstein.dev
- chat.hermes.bstein.dev
- triage.hermes.bstein.dev
secretName: hermes-sites-tls
rules:
+ - host: chat.bstein.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: oauth2-proxy-hermes-chat
+ port:
+ name: http
+ - host: triage.bstein.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: oauth2-proxy-hermes-triage
+ port:
+ name: http
+ # Legacy hosts serve the same backends so the rename is additive. They are
+ # kept until an explicit retirement change, not redirected: oauth2-proxy
+ # cookies are host-bound, so a redirect would silently drop the session.
- host: chat.hermes.bstein.dev
http:
paths:
diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml
index 75f2ae52..1ac6e20b 100644
--- a/services/hermes/chat-configmap.yaml
+++ b/services/hermes/chat-configmap.yaml
@@ -68,7 +68,7 @@ data:
atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus}
atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b}
dashboard:
- public_url: https://chat.hermes.bstein.dev
+ public_url: https://chat.bstein.dev
display:
compact: true
tool_progress: all
diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml
index 244b18a6..38756cf7 100644
--- a/services/hermes/chat-statefulset.yaml
+++ b/services/hermes/chat-statefulset.yaml
@@ -277,7 +277,7 @@ spec:
- {name: API_SERVER_ENABLED, value: "true"}
- {name: API_SERVER_HOST, value: 0.0.0.0}
- {name: API_SERVER_PORT, value: "8642"}
- - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
+ - {name: API_SERVER_CORS_ORIGINS, value: https://chat.bstein.dev}
- {name: HERMES_IMAGE_BROKER_URL, value: http://hermes-image-broker.hermes.svc.cluster.local:9002}
- {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
- {name: HERMES_AUTO_ROUTER_PROFILE, value: chat}
@@ -345,7 +345,7 @@ spec:
# NetworkPolicy admits this port only from hermes-chat-router; the
# CIDR lets the WebUI validate that router's changing pod address.
- {name: HERMES_WEBUI_TRUSTED_PROXY_CIDRS, value: 10.42.0.0/16}
- - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
+ - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.bstein.dev}
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
- {name: HERMES_ROUTER_PROFILE, value: chat}
diff --git a/services/hermes/configmap.yaml b/services/hermes/configmap.yaml
index 7d27e93c..722ce75c 100644
--- a/services/hermes/configmap.yaml
+++ b/services/hermes/configmap.yaml
@@ -83,7 +83,7 @@ data:
- "*kubectl describe secret*"
dashboard:
- public_url: https://triage.hermes.bstein.dev
+ public_url: https://triage.bstein.dev
display:
compact: true
@@ -111,10 +111,10 @@ data:
You are Hermes running inside the Titan Kubernetes cluster as a supervised
testing and operations triage assistant.
- This is the dedicated triage appliance at triage.hermes.bstein.dev. Keep
+ This is the dedicated triage appliance at triage.bstein.dev. Keep
automated Ariadne intake and testing conversations here. Project delivery
and coding orchestration belong to agent.hermes.bstein.dev; general user
- chat belongs to chat.hermes.bstein.dev.
+ chat belongs to chat.bstein.dev.
Start in AUTO routing with a careful, intelligence-biased posture. Every
new request is classified locally before a hosted model is selected. The
diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml
index 25b3ada3..15c55b71 100644
--- a/services/hermes/deployment.yaml
+++ b/services/hermes/deployment.yaml
@@ -284,7 +284,7 @@ spec:
- name: HERMES_DASHBOARD
value: "0"
- name: HERMES_DASHBOARD_PUBLIC_URL
- value: https://triage.hermes.bstein.dev
+ value: https://triage.bstein.dev
- name: API_SERVER_ENABLED
value: "true"
- name: API_SERVER_HOST
@@ -292,7 +292,7 @@ spec:
- name: API_SERVER_PORT
value: "8642"
- name: API_SERVER_CORS_ORIGINS
- value: https://triage.hermes.bstein.dev
+ value: https://triage.bstein.dev
- name: VICTORIA_METRICS_URL
value: http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
- name: ARIADNE_BASE_URL
@@ -405,7 +405,7 @@ spec:
- {name: HERMES_WEBUI_GATEWAY_USE_RUNS_API, value: "true"}
- {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"}
- {name: HERMES_WEBUI_SECURE, value: "1"}
- - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://triage.hermes.bstein.dev}
+ - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://triage.bstein.dev}
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
- {name: HERMES_ROUTER_PROFILE, value: triage}
diff --git a/services/hermes/execution-coordinator-patch.yaml b/services/hermes/execution-coordinator-patch.yaml
index b0526d8e..816f6068 100644
--- a/services/hermes/execution-coordinator-patch.yaml
+++ b/services/hermes/execution-coordinator-patch.yaml
@@ -18,7 +18,9 @@ spec:
- name: cli-lane-runner
env:
- {name: HERMES_CLI_LANE_OWNED_WORKSPACES_ONLY, value: "true"}
- - {name: HERMES_CLI_LANE_CONCURRENCY, value: "1"}
+ # Cap simultaneous direct cli-* Codex/Claude runner slots separately
+ # from the normal Hermes profile cap in agent-configmap.yaml.
+ - {name: HERMES_CLI_LANE_CONCURRENCY, value: "2"}
- name: execution-pool-coordinator
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
diff --git a/services/hermes/oauth2-proxy.yaml b/services/hermes/oauth2-proxy.yaml
index 52dea326..5cd9d01f 100644
--- a/services/hermes/oauth2-proxy.yaml
+++ b/services/hermes/oauth2-proxy.yaml
@@ -82,7 +82,7 @@ spec:
args:
- --provider=oidc
- --config=/vault/secrets/oidc-config
- - --redirect-url=https://triage.hermes.bstein.dev/oauth2/callback
+ - --redirect-url=https://triage.bstein.dev/oauth2/callback
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
- --user-id-claim=sub
- --code-challenge-method=S256
@@ -181,7 +181,7 @@ spec:
args:
- --provider=oidc
- --config=/vault/secrets/oidc-config
- - --redirect-url=https://chat.hermes.bstein.dev/oauth2/callback
+ - --redirect-url=https://chat.bstein.dev/oauth2/callback
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
- --user-id-claim=sub
- --code-challenge-method=S256
diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go
index 152ebeca..d63b6348 100644
--- a/services/hermes/router/main_test.go
+++ b/services/hermes/router/main_test.go
@@ -5,8 +5,11 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"path/filepath"
+ "regexp"
+ "strconv"
"strings"
"testing"
"time"
@@ -163,18 +166,24 @@ func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
}
asset := response.Body.String()
for _, expected := range []string{
- "aria-live", "aria-busy", "/api/sessions/", "/messages?limit=24&hermes_fallback=1",
+ "aria-live", "aria-busy", "/api/session?session_id=",
+ "&messages=1&msg_limit=24", "hermes_fallback=1",
"Latest stored activity", "no renderable messages",
- "Session updates disconnected", "oauth2/start?rd=", "payload.session_id",
- "session.ended_at == null", "latest.observed", "addEventListener('online'",
- "addEventListener('pageshow'", "schedule(document.hidden", "fetch(",
+ "Session updates disconnected", "oauth2/start?rd=", "payload.session",
+ "session_profile_mismatch", "session.is_streaming", "latest.observed",
+ "addEventListener('online'", "addEventListener('pageshow'",
+ "schedule(document.hidden", "fetch(",
} {
if !strings.Contains(asset, expected) {
t.Fatalf("session fallback omitted %q", expected)
}
}
for _, forbidden := range []string{
- "location.replace", "location.reload", "history.replaceState", "/api/session?", "WebSocket",
+ "location.replace", "location.reload", "history.replaceState", "WebSocket",
+ // `/api/sessions/` belongs to the Hermes agent dashboard, not to the
+ // tenant WebUI this router proxies. Polling it 404s on every request and
+ // renders a false ownership error after each full page load.
+ "/api/sessions/",
} {
if strings.Contains(asset, forbidden) {
t.Fatalf("session fallback interferes with native continuity via %q", forbidden)
@@ -182,6 +191,227 @@ func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
}
}
+// tenantWebUI mirrors the dispatch contract of the deployed Hermes WebUI
+// (`hermes-webui` server.py): `GET /api/session?session_id=` is the only
+// session read route, an unrouted path falls through to a generic 404, and an
+// unknown session id is reported as "Session not found".
+type tenantWebUI struct {
+ server *httptest.Server
+ sessions map[string]bool
+ identities []string
+ requests int
+ cookies []string
+}
+
+func newTenantWebUI(t *testing.T, marker string, sessions ...string) *tenantWebUI {
+ t.Helper()
+ backend := &tenantWebUI{sessions: map[string]bool{}}
+ for _, id := range sessions {
+ backend.sessions[id] = true
+ }
+ backend.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ backend.requests++
+ backend.identities = append(backend.identities, request.Header.Get(trustedTenantHeader))
+ backend.cookies = append(backend.cookies, request.Header.Get("Cookie"))
+ for _, leaked := range []string{"X-Forwarded-User", "X-Auth-Request-User", "Authorization"} {
+ if request.Header.Get(leaked) != "" {
+ t.Errorf("external identity header %q reached a tenant backend", leaked)
+ }
+ }
+ writer.Header().Set("Content-Type", "application/json")
+ if request.URL.Path != "/api/session" {
+ writer.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(writer, `{"error":"not found"}`)
+ return
+ }
+ id := request.URL.Query().Get("session_id")
+ if !backend.sessions[id] {
+ writer.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(writer, `{"error":"Session not found"}`)
+ return
+ }
+ _ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
+ "session_id": id,
+ "title": marker,
+ "messages": []map[string]any{{"role": "assistant", "content": marker}},
+ "message_count": 1,
+ "is_streaming": false,
+ }})
+ }))
+ t.Cleanup(backend.server.Close)
+ return backend
+}
+
+// continuityPollPath derives the request the injected fallback actually issues,
+// so a regression in the polled contract fails these tests instead of silently
+// reintroducing the permanent 404.
+func continuityPollPath(t *testing.T, router *tenantRouter, sessionID string) string {
+ t.Helper()
+ request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil)
+ request.Header.Set("X-Forwarded-User", "asset-reader")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("continuity asset got status %d", response.Code)
+ }
+ matcher := regexp.MustCompile(`'(/api/[^']*)' \+ encodeURIComponent\(sessionId\) \+\s*'([^']*)'`)
+ parts := matcher.FindStringSubmatch(response.Body.String())
+ if parts == nil {
+ t.Fatal("continuity fallback does not build a session-scoped poll URL")
+ }
+ return parts[1] + url.QueryEscape(sessionID) + parts[2]
+}
+
+func continuityPoll(t *testing.T, router *tenantRouter, subject, cookie, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ request := httptest.NewRequest(http.MethodGet, path, nil)
+ request.Header.Set("X-Forwarded-User", subject)
+ request.Header.Set("X-Auth-Request-User", subject)
+ if cookie != "" {
+ request.Header.Set("Cookie", cookie)
+ }
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ return response
+}
+
+// Reproduction of the reported failure. The tenant WebUI image
+// (registry.bstein.dev/bstein/hermes-webui@sha256:c276a9e1…) routes exactly one
+// session read, `GET /api/session?session_id=`; `/api/sessions/[/messages]`
+// belongs to the separate Hermes agent dashboard and falls through to
+// server.py's generic 404. Polling it could never succeed for anyone, so the
+// banner fired on every full page load rather than on a real ownership problem.
+func TestLegacyDashboardSessionPollAlwaysMissesTheTenantWebUI(t *testing.T) {
+ const sessionID = "sess-1"
+ backend := newTenantWebUI(t, "brad-private-session", sessionID)
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1,
+ func(int) string { return backend.server.URL })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, legacy := range []string{
+ "/api/sessions/" + sessionID,
+ "/api/sessions/" + sessionID + "/messages?limit=24&hermes_fallback=1",
+ } {
+ response := continuityPoll(t, router, "keycloak-subject-brad", "", legacy)
+ if response.Code != http.StatusNotFound {
+ t.Fatalf("%s: got %d, want the WebUI's 404 for an unrouted path", legacy, response.Code)
+ }
+ }
+ if response := continuityPoll(t, router, "keycloak-subject-brad", "",
+ continuityPollPath(t, router, sessionID)); response.Code != http.StatusOK {
+ t.Fatalf("the shipped fallback still misses the tenant WebUI: got %d", response.Code)
+ }
+}
+
+// The Keycloak logout/login round-trip returns the browser to /session/ as
+// a full document load, which is the only moment the injected fallback runs.
+// The durable session must still resolve for its stable owner.
+func TestSessionContinuityPollSurvivesLogoutAndRelogin(t *testing.T) {
+ const subject = "keycloak-subject-brad"
+ const sessionID = "9048e2a574d1"
+ backend := newTenantWebUI(t, "brad-private-session", sessionID)
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 4, func(slot int) string {
+ if slot == 0 {
+ return backend.server.URL
+ }
+ return "http://127.0.0.1:1"
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ pollPath := continuityPollPath(t, router, sessionID)
+
+ before := continuityPoll(t, router, subject,
+ "__Host-hermes_chat=first-sso-session; "+tenantSessionCookie+"=webui-1", pollPath)
+ if before.Code != http.StatusOK {
+ t.Fatalf("first visit could not read its own session: got %d, want 200", before.Code)
+ }
+
+ // Logout invalidates the Keycloak session, so the browser returns with a
+ // completely different oauth2-proxy cookie under the same Keycloak subject.
+ after := continuityPoll(t, router, subject,
+ "__Host-hermes_chat=second-sso-session; "+tenantSessionCookie+"=webui-1", pollPath)
+ if after.Code != http.StatusOK {
+ t.Fatalf("relogin lost the durable session: got %d, want 200", after.Code)
+ }
+ var payload struct {
+ Session struct {
+ SessionID string `json:"session_id"`
+ Title string `json:"title"`
+ } `json:"session"`
+ }
+ if err := json.NewDecoder(after.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.Session.SessionID != sessionID || payload.Session.Title != "brad-private-session" {
+ t.Fatalf("relogin resolved the wrong session: %#v", payload.Session)
+ }
+ if len(backend.identities) != 2 || backend.identities[0] != "slot-0" || backend.identities[1] != "slot-0" {
+ t.Fatalf("relogin did not keep a stable tenant identity: %#v", backend.identities)
+ }
+ for _, cookie := range backend.cookies {
+ if strings.Contains(cookie, "__Host-hermes_chat") {
+ t.Fatalf("the Keycloak session cookie crossed into a tenant: %q", cookie)
+ }
+ }
+}
+
+// Repairing continuity must not turn the poll into a cross-tenant read.
+func TestSessionContinuityNeverExposesAnotherSubjectsSession(t *testing.T) {
+ const ownerSession = "owner-session-id"
+ owner := newTenantWebUI(t, "owner-private-marker", ownerSession)
+ intruder := newTenantWebUI(t, "intruder-private-marker")
+ backends := []*tenantWebUI{owner, intruder}
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 2, func(slot int) string {
+ return backends[slot].server.URL
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ownerSlot, err := router.slotFor("keycloak-owner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ intruderSlot, err := router.slotFor("keycloak-intruder")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ownerSlot == intruderSlot {
+ t.Fatal("two Keycloak subjects shared one isolated slot")
+ }
+ pollPath := continuityPollPath(t, router, ownerSession)
+
+ if response := continuityPoll(t, router, "keycloak-owner",
+ tenantSessionCookie+"=owner-webui", pollPath); response.Code != http.StatusOK {
+ t.Fatalf("the owner lost its own session: got %d", response.Code)
+ }
+ ownerRequests := backends[ownerSlot].requests
+
+ // The intruder replays the owner's session id, the owner's WebUI cookie and
+ // a forged tenant assertion for the owner's slot.
+ request := httptest.NewRequest(http.MethodGet, pollPath, nil)
+ request.Header.Set("X-Forwarded-User", "keycloak-intruder")
+ request.Header.Set("Cookie", tenantSessionCookie+"=owner-webui")
+ request.Header.Set(trustedTenantHeader, "slot-"+strconv.Itoa(ownerSlot))
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+
+ if response.Code != http.StatusNotFound {
+ t.Fatalf("cross-subject session read got %d, want 404", response.Code)
+ }
+ if strings.Contains(response.Body.String(), "owner-private-marker") {
+ t.Fatalf("another subject's session leaked: %s", response.Body.String())
+ }
+ if backends[ownerSlot].requests != ownerRequests {
+ t.Fatal("a forged tenant assertion reached another subject's backend")
+ }
+ identities := backends[intruderSlot].identities
+ if len(identities) == 0 || identities[len(identities)-1] != "slot-"+strconv.Itoa(intruderSlot) {
+ t.Fatalf("the router did not overwrite the forged tenant identity: %#v", identities)
+ }
+}
+
func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
messages := make([]map[string]int, 30)
@@ -189,9 +419,9 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
messages[index] = map[string]int{"index": index}
}
writer.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(writer).Encode(map[string]any{
- "session_id": "resolved", "messages": messages,
- })
+ _ = json.NewEncoder(writer).Encode(map[string]any{"session": map[string]any{
+ "session_id": "resolved", "messages": messages, "message_count": 30,
+ }})
}))
defer backend.Close()
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return backend.URL })
@@ -200,7 +430,7 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
}
request := httptest.NewRequest(
http.MethodGet,
- "/api/sessions/root/messages?limit=24&hermes_fallback=1",
+ continuityPollPath(t, router, "root"),
nil,
)
request.Header.Set("X-Forwarded-User", "subject")
@@ -209,11 +439,8 @@ func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
if response.Code != http.StatusOK {
t.Fatalf("got status %d", response.Code)
}
- var payload sessionSnapshot
- if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
- t.Fatal(err)
- }
- if len(payload.Messages) != sessionSnapshotItems || payload.TotalMessages != 30 {
+ payload := decodeSnapshot(t, response.Result())
+ if len(payload.Messages) != sessionSnapshotItems || payload.MessageCount != 30 {
t.Fatalf("router returned unbounded fallback: %#v", payload)
}
}
diff --git a/services/hermes/router/session_continuity.go b/services/hermes/router/session_continuity.go
index af42b35d..f882c65e 100644
--- a/services/hermes/router/session_continuity.go
+++ b/services/hermes/router/session_continuity.go
@@ -1,5 +1,14 @@
package main
+// The chat router proxies browser traffic to the tenant Hermes WebUI, whose
+// session read contract is `GET /api/session?session_id=`. The dashboard
+// style `/api/sessions/[/messages]` routes belong to the separate Hermes
+// agent deployment (services/hermes/scripts/patch_web_session_activity.py) and
+// are unrouted here, so polling them returned the WebUI's generic 404 on every
+// attempt and rendered a false "unavailable to this account" banner after every
+// full page load — exactly what a Keycloak logout/login round-trip produces.
+const sessionFallbackPath = "/api/session"
+
const sessionContinuityJS = `(() => {
const match = location.pathname.match(/^\/session\/([^/]+)\/?$/);
if (!match) return;
@@ -41,15 +50,27 @@ const sessionContinuityJS = `(() => {
if (latest.observed && typeof latest.content === 'string') return latest.content.slice(0, 120);
return latest.role === 'assistant' ? 'assistant update' : latest.role === 'user' ? 'request stored' : 'working';
};
- const authOrMissing = (response) => {
+ // Only the WebUI's own answers decide what the banner claims. A 409 is the
+ // single case where the stored session really is out of this account's
+ // active scope; a 404 means the conversation is no longer stored at all.
+ const handled = async (response) => {
if (response.status === 401 || response.status === 403) {
const rd = location.pathname + location.search + location.hash;
location.assign('/oauth2/start?rd=' + encodeURIComponent(rd));
return 'auth';
}
+ if (response.status === 409) {
+ let payload = {};
+ try { payload = await response.json(); } catch (_) { payload = {}; }
+ show(payload.code === 'session_profile_mismatch'
+ ? 'This session belongs to a different profile on this account. Switch profiles to reopen it.'
+ : 'This session is unavailable to this account.', false, true);
+ schedule(15000);
+ return 'scoped';
+ }
if (response.status === 404) {
- show('This session is unavailable to this account.', false, true);
- schedule(10000);
+ show('This conversation is no longer stored in your private chat.', false, true);
+ schedule(15000);
return 'missing';
}
return '';
@@ -63,28 +84,25 @@ const sessionContinuityJS = `(() => {
request = new AbortController();
const timeout = window.setTimeout(() => request && request.abort(), 8000);
try {
- const root = '/api/sessions/' + encodeURIComponent(sessionId);
- const messageResponse = await fetch(root + '/messages?limit=24&hermes_fallback=1', {cache:'no-store', credentials:'same-origin', signal:request.signal});
- if (authOrMissing(messageResponse)) return;
- if (!messageResponse.ok) throw new Error('session messages poll failed');
- const payload = await messageResponse.json();
- const messages = Array.isArray(payload.messages) ? payload.messages : [];
- const resolvedId = typeof payload.session_id === 'string' && payload.session_id ? payload.session_id : sessionId;
- const detailResponse = await fetch('/api/sessions/' + encodeURIComponent(resolvedId), {cache:'no-store', credentials:'same-origin', signal:request.signal});
- if (authOrMissing(detailResponse)) return;
- if (!detailResponse.ok) throw new Error('session detail poll failed');
- const session = await detailResponse.json();
- const lastActive = Number(session.last_active || session.started_at || 0);
- const durableWorker = session.source === 'api_server' && Boolean(session.parent_session_id);
- const recentlyInteractive = Number.isFinite(lastActive) && Date.now() / 1000 - lastActive < 300;
- const active = session.ended_at == null && messages.length > 0 && (durableWorker || recentlyInteractive);
- if (active) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false);
- else if (!messages.length && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true);
+ const query = '/api/session?session_id=' + encodeURIComponent(sessionId) +
+ '&messages=1&msg_limit=24&resolve_model=0&hermes_fallback=1';
+ const response = await fetch(query, {cache:'no-store', credentials:'same-origin', signal:request.signal});
+ if (await handled(response)) return;
+ if (!response.ok) throw new Error('session poll failed');
+ const payload = await response.json();
+ const session = payload && typeof payload.session === 'object' && payload.session ? payload.session : {};
+ const messages = Array.isArray(session.messages) ? session.messages : [];
+ const stored = Number(session.message_count);
+ const count = Number.isFinite(stored) && stored > 0 ? stored : messages.length;
+ const working = Boolean(session.is_streaming) || Boolean(session.active_stream_id) ||
+ Boolean(session.has_pending_user_message);
+ if (working) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false);
+ else if (!count && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true);
else hide();
- schedule(document.hidden ? 10000 : 2500);
+ schedule(document.hidden ? 15000 : 3000);
} catch (_) {
show('Session updates disconnected. Retrying without changing this session…', true, false);
- schedule(document.hidden ? 10000 : 3000);
+ schedule(document.hidden ? 15000 : 3000);
} finally {
clearTimeout(timeout);
request = null;
diff --git a/services/hermes/router/session_snapshot.go b/services/hermes/router/session_snapshot.go
index 237dd19b..002ed210 100644
--- a/services/hermes/router/session_snapshot.go
+++ b/services/hermes/router/session_snapshot.go
@@ -14,21 +14,18 @@ const (
sessionSnapshotBytes = 8 << 20
)
-type sessionSnapshot struct {
- SessionID string `json:"session_id"`
- Messages []json.RawMessage `json:"messages"`
- TotalMessages int `json:"total_messages"`
-}
-
// boundSessionSnapshot caps only the continuity fallback response. Native
// WebUI requests remain untouched, including when an older backend ignores
-// its optional `limit` query parameter.
+// its optional `msg_limit` query parameter. The tenant WebUI answers with
+// {"session": {..., "messages": [...], "message_count": N}}, so the envelope
+// is decoded field-by-field: every key other than the message tail is relayed
+// verbatim rather than re-serialized from a fixed struct, which would silently
+// drop session metadata the poller and future WebUI releases depend on.
func boundSessionSnapshot(response *http.Response) error {
request := response.Request
if request == nil || response.StatusCode != http.StatusOK ||
request.URL.Query().Get("hermes_fallback") != "1" ||
- !strings.HasPrefix(request.URL.Path, "/api/sessions/") ||
- !strings.HasSuffix(request.URL.Path, "/messages") {
+ request.URL.Path != sessionFallbackPath {
return nil
}
body, err := io.ReadAll(io.LimitReader(response.Body, sessionSnapshotBytes+1))
@@ -39,19 +36,7 @@ func boundSessionSnapshot(response *http.Response) error {
if len(body) > sessionSnapshotBytes {
return errors.New("session snapshot exceeds safe response limit")
}
- var payload sessionSnapshot
- if err := json.Unmarshal(body, &payload); err != nil || payload.Messages == nil {
- return errors.New("session snapshot is malformed")
- }
- total := len(payload.Messages)
- if payload.TotalMessages > total {
- total = payload.TotalMessages
- }
- if len(payload.Messages) > sessionSnapshotItems {
- payload.Messages = payload.Messages[len(payload.Messages)-sessionSnapshotItems:]
- }
- payload.TotalMessages = total
- body, err = json.Marshal(payload)
+ body, err = boundSessionSnapshotBody(body)
if err != nil {
return err
}
@@ -63,3 +48,49 @@ func boundSessionSnapshot(response *http.Response) error {
response.Header.Del("ETag")
return nil
}
+
+// boundSessionSnapshotBody trims the message tail of one WebUI session payload
+// while preserving the total the backend reported.
+func boundSessionSnapshotBody(body []byte) ([]byte, error) {
+ malformed := errors.New("session snapshot is malformed")
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ return nil, malformed
+ }
+ rawSession, ok := envelope["session"]
+ if !ok {
+ return nil, malformed
+ }
+ var session map[string]json.RawMessage
+ if err := json.Unmarshal(rawSession, &session); err != nil {
+ return nil, malformed
+ }
+ var messages []json.RawMessage
+ if raw, ok := session["messages"]; ok {
+ if err := json.Unmarshal(raw, &messages); err != nil {
+ return nil, malformed
+ }
+ }
+ total := len(messages)
+ if raw, ok := session["message_count"]; ok {
+ var count int
+ if err := json.Unmarshal(raw, &count); err == nil && count > total {
+ total = count
+ }
+ }
+ if len(messages) > sessionSnapshotItems {
+ messages = messages[len(messages)-sessionSnapshotItems:]
+ }
+ trimmed, err := json.Marshal(messages)
+ if err != nil {
+ return nil, err
+ }
+ session["messages"] = trimmed
+ session["message_count"] = json.RawMessage(strconv.Itoa(total))
+ rawSession, err = json.Marshal(session)
+ if err != nil {
+ return nil, err
+ }
+ envelope["session"] = rawSession
+ return json.Marshal(envelope)
+}
diff --git a/services/hermes/router/session_snapshot_test.go b/services/hermes/router/session_snapshot_test.go
index 64aee010..0d35c50e 100644
--- a/services/hermes/router/session_snapshot_test.go
+++ b/services/hermes/router/session_snapshot_test.go
@@ -10,6 +10,20 @@ import (
"testing/iotest"
)
+// webuiSession mirrors the fields of the tenant WebUI `GET /api/session`
+// payload that the continuity fallback reads.
+type webuiSession struct {
+ SessionID string `json:"session_id"`
+ Messages []json.RawMessage `json:"messages"`
+ MessageCount int `json:"message_count"`
+ Title string `json:"title,omitempty"`
+ ReadOnly bool `json:"read_only,omitempty"`
+}
+
+type webuiSessionEnvelope struct {
+ Session webuiSession `json:"session"`
+}
+
func snapshotResponse(target, body string) *http.Response {
request, _ := http.NewRequest(http.MethodGet, target, nil)
return &http.Response{
@@ -20,27 +34,33 @@ func snapshotResponse(target, body string) *http.Response {
}
}
+func decodeSnapshot(t *testing.T, response *http.Response) webuiSession {
+ t.Helper()
+ var envelope webuiSessionEnvelope
+ if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
+ t.Fatal(err)
+ }
+ return envelope.Session
+}
+
func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) {
messages := make([]map[string]int, 30)
for index := range messages {
messages[index] = map[string]int{"index": index}
}
- body, _ := json.Marshal(map[string]any{
- "session_id": "resolved", "messages": messages,
- })
+ body, _ := json.Marshal(map[string]any{"session": map[string]any{
+ "session_id": "resolved", "messages": messages, "message_count": 30,
+ }})
response := snapshotResponse(
- "http://tenant/api/sessions/root/messages?limit=24&hermes_fallback=1",
+ "http://tenant/api/session?session_id=root&messages=1&msg_limit=24&hermes_fallback=1",
string(body),
)
response.Header.Set("ETag", "stale")
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
- var bounded sessionSnapshot
- if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil {
- t.Fatal(err)
- }
- if len(bounded.Messages) != 24 || bounded.TotalMessages != 30 {
+ bounded := decodeSnapshot(t, response)
+ if len(bounded.Messages) != sessionSnapshotItems || bounded.MessageCount != 30 {
t.Fatalf("snapshot was not bounded: %#v", bounded)
}
var first map[string]int
@@ -54,29 +74,59 @@ func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) {
func TestBoundSessionSnapshotPreservesLargerReportedTotal(t *testing.T) {
response := snapshotResponse(
- "http://tenant/api/sessions/root/messages?hermes_fallback=1",
- `{"session_id":"leaf","messages":[],"total_messages":100}`,
+ "http://tenant/api/session?session_id=leaf&hermes_fallback=1",
+ `{"session":{"session_id":"leaf","messages":[],"message_count":100}}`,
)
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
- var bounded sessionSnapshot
- if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil {
+ bounded := decodeSnapshot(t, response)
+ if bounded.SessionID != "leaf" || bounded.MessageCount != 100 {
+ t.Fatalf("reported total was lost: %#v", bounded)
+ }
+}
+
+// The poller and the WebUI both evolve; bounding the message tail must never
+// strip the surrounding session metadata that decides what the banner says.
+func TestBoundSessionSnapshotRelaysUnknownSessionMetadata(t *testing.T) {
+ response := snapshotResponse(
+ "http://tenant/api/session?session_id=root&hermes_fallback=1",
+ `{"session":{"session_id":"root","messages":[{"role":"user"}],`+
+ `"is_streaming":true,"active_stream_id":"stream-1","read_only":false,`+
+ `"future_field":{"kept":true}},"other_envelope_key":7}`,
+ )
+ if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
- if bounded.SessionID != "leaf" || bounded.TotalMessages != 100 {
- t.Fatalf("reported total was lost: %#v", bounded)
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, expected := range []string{
+ `"is_streaming":true`, `"active_stream_id":"stream-1"`,
+ `"future_field":{"kept":true}`, `"other_envelope_key":7`,
+ } {
+ if !strings.Contains(string(body), expected) {
+ t.Fatalf("bounded snapshot dropped %s: %s", expected, body)
+ }
+ }
+ if length := response.Header.Get("Content-Length"); length != "" &&
+ length != strings.TrimSpace(length) {
+ t.Fatal("content length was not rewritten")
}
}
func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) {
for name, body := range map[string]string{
- "malformed": `{`,
- "oversized": strings.Repeat("x", sessionSnapshotBytes+1),
+ "malformed": `{`,
+ "missing": `{"error":"Session not found"}`,
+ "nonObject": `{"session":42}`,
+ "badMessageList": `{"session":{"messages":"all of them"}}`,
+ "oversized": strings.Repeat("x", sessionSnapshotBytes+1),
} {
t.Run(name, func(t *testing.T) {
response := snapshotResponse(
- "http://tenant/api/sessions/root/messages?hermes_fallback=1",
+ "http://tenant/api/session?session_id=root&hermes_fallback=1",
body,
)
if err := boundSessionSnapshot(response); err == nil {
@@ -85,7 +135,7 @@ func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T)
})
}
response := snapshotResponse(
- "http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`,
+ "http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`,
)
response.Body = io.NopCloser(iotest.ErrReader(errors.New("read failed")))
if err := boundSessionSnapshot(response); err == nil {
@@ -96,15 +146,18 @@ func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T)
func TestBoundSessionSnapshotLeavesNativeAndErrorResponsesUntouched(t *testing.T) {
for _, response := range []*http.Response{
{StatusCode: http.StatusOK},
- snapshotResponse("http://tenant/api/sessions/root/messages", `{}`),
- snapshotResponse("http://tenant/api/sessions/root", `{}`),
+ // The WebUI's own session reads carry no fallback marker.
+ snapshotResponse("http://tenant/api/session?session_id=root", `{}`),
+ snapshotResponse("http://tenant/api/sessions", `{}`),
+ // The dashboard-only route is not this backend's contract.
+ snapshotResponse("http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`),
} {
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
}
errorResponse := snapshotResponse(
- "http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`,
+ "http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`,
)
errorResponse.StatusCode = http.StatusNotFound
if err := boundSessionSnapshot(errorResponse); err != nil {
diff --git a/services/hermes/router/telegram.go b/services/hermes/router/telegram.go
index a2d591df..b2dcd799 100644
--- a/services/hermes/router/telegram.go
+++ b/services/hermes/router/telegram.go
@@ -249,7 +249,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
command, args := commandParts(message.Text)
if command == "start" || command == "link" {
if len(args) == 0 {
- _ = bot.sendText(message.Chat.ID, "Sign in to chat.hermes.bstein.dev, open Telegram, and create a one-time link code.")
+ _ = bot.sendText(message.Chat.ID, "Sign in to chat.bstein.dev, open Telegram, and create a one-time link code.")
return
}
if _, err := bot.router.consumeLink(userID, args[0]); err != nil {
@@ -273,7 +273,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
}
slot, linked := bot.router.telegramSlot(userID)
if !linked {
- _ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.hermes.bstein.dev and open Telegram to connect it.")
+ _ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.bstein.dev and open Telegram to connect it.")
return
}
if command == "topic" {
diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go
index adb596a6..1bb2d1b8 100644
--- a/services/hermes/router/web.go
+++ b/services/hermes/router/web.go
@@ -470,7 +470,7 @@ func injectChatBridge(response *http.Response) error {
content = strings.Replace(content, "
+Browser speech synthesis Edge TTS (server)
+Voice
+
+ Default system voice
+
+
Preferred voice. Populated from your browser's available voices.
+
+
+
+", ``, 1)
}
if !strings.Contains(content, "hermes-session-continuity.js") {
- content = strings.Replace(content, "", ``, 1)
+ content = strings.Replace(content, "", ``, 1)
}
response.Body = io.NopCloser(strings.NewReader(content))
response.ContentLength = int64(len(content))
diff --git a/services/hermes/scripts/cli_lane_failover.py b/services/hermes/scripts/cli_lane_failover.py
index 0f6becdc..5cbcae9a 100644
--- a/services/hermes/scripts/cli_lane_failover.py
+++ b/services/hermes/scripts/cli_lane_failover.py
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Callable
from cli_lane_board import _board_call
-from cli_lane_config import ProcessResult, Route
+from cli_lane_config import EFFORTS, ProcessResult, Route
from cli_lane_health import classify_capacity_failure, record_provider_failure
from cli_lane_metrics import (
record_provider_fallback,
@@ -117,20 +117,47 @@ def capacity_failover(
+ "\n\nRouting boundary: the first provider failed from capacity/authentication. "
+ "Select the alternate hosted provider at an appropriate effort."
)
- alternate = "claude" if route.provider == "codex" else "codex"
fallback = _routed_or_blocked(
kanban_db,
board,
task_id,
run_id,
- lambda: select_route(retry_context, f"cli-{alternate}-{route.effort}"),
+ lambda: select_route(
+ retry_context,
+ assignee,
+ exclude_provider=route.provider,
+ exclude_reason=f"hit a {failure_class} failure at this boundary",
+ ),
)
if fallback is None:
return FailoverOutcome(route, result, candidate_file, route.provider, True)
+ if EFFORTS.index(fallback.effort) < EFFORTS.index(route.effort):
+ # Never let a capacity-triggered reclassification downgrade effort:
+ # re-pin the classifier's chosen (healthy) provider at the original
+ # floor, whether the downgrade came from the classifier itself or
+ # from its own excluded-provider health guard.
+ preserved = _routed_or_blocked(
+ kanban_db,
+ board,
+ task_id,
+ run_id,
+ lambda: select_route(
+ retry_context, f"cli-{fallback.provider}-{route.effort}"
+ ),
+ )
+ if preserved is None:
+ return FailoverOutcome(route, result, candidate_file, route.provider, True)
+ comment(
+ f"Effort preserved: Switchyard classification chose {fallback.effort} "
+ f"for {fallback.provider}; escalated to the original {route.effort} "
+ "floor so capacity failover never downgrades a safety task.",
+ )
+ fallback = preserved
record_provider_fallback(route.provider, fallback.provider, failure_class)
comment(
f"Provider fallback: {route.provider} -> {fallback.provider} "
- f"after a {failure_class} failure; Jetson reclassified the retry boundary.",
+ f"after a {failure_class} failure; Jetson reclassified the retry boundary "
+ f"(classifier={fallback.classifier}).",
)
fallback_result = run_provider(
fallback,
diff --git a/services/hermes/scripts/hermes_stt_client.py b/services/hermes/scripts/hermes_stt_client.py
index 639fc7b1..96f7081d 100644
--- a/services/hermes/scripts/hermes_stt_client.py
+++ b/services/hermes/scripts/hermes_stt_client.py
@@ -12,6 +12,34 @@ from pathlib import Path
from urllib.request import Request, urlopen
+LANGUAGE_SUFFIX = ".language"
+
+
+def _normalize_language(value: object) -> str:
+ """Accept only a bare ISO-639 code from the private Whisper response."""
+ if not isinstance(value, str):
+ return ""
+ code = value.strip().lower()
+ if not 2 <= len(code) <= 3 or not code.isascii() or not code.isalpha():
+ return ""
+ return code
+
+
+def _write_result(output_dir: Path, stem: str, transcript: str, language: str) -> Path:
+ """Write the .txt Hermes reads, plus the language sidecar when we have one.
+
+ Hermes' local-command contract is "leave a .txt in --output-dir"; it globs
+ ``*.txt`` and reads the first match. The sidecar deliberately uses another
+ suffix so the transcript stays the only ``.txt`` in the directory.
+ """
+ output_dir.mkdir(parents=True, exist_ok=True)
+ transcript_path = output_dir / f"{stem}.txt"
+ transcript_path.write_text(transcript, encoding="utf-8")
+ if language:
+ (output_dir / f"{stem}{LANGUAGE_SUFFIX}").write_text(language, encoding="utf-8")
+ return transcript_path
+
+
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
@@ -67,9 +95,12 @@ def main() -> None:
with urlopen(request, timeout=120) as response:
result = json.loads(response.read().decode("utf-8"))
transcript = str(result.get("text") or "").strip()
- args.output_dir.mkdir(parents=True, exist_ok=True)
- output = args.output_dir / f"{args.input_path.stem}.txt"
- output.write_text(transcript, encoding="utf-8")
+ _write_result(
+ args.output_dir,
+ args.input_path.stem,
+ transcript,
+ _normalize_language(result.get("language")),
+ )
if __name__ == "__main__":
diff --git a/services/hermes/skills/master-hermes-on-atlas/references/architecture.md b/services/hermes/skills/master-hermes-on-atlas/references/architecture.md
index 9ec70a10..17636d75 100644
--- a/services/hermes/skills/master-hermes-on-atlas/references/architecture.md
+++ b/services/hermes/skills/master-hermes-on-atlas/references/architecture.md
@@ -7,9 +7,9 @@ asserting health, placement, ownership, or current model availability.
| Surface | Purpose | Identity boundary | State and permissions |
| --- | --- | --- | --- |
-| `triage.hermes.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints |
+| `triage.bstein.dev` | Brad's automated testing triage | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace, its own PVC and service account; read-only cluster triage plus approved internal evidence endpoints |
| `agent.hermes.bstein.dev` | Brad's project coordinator | Keycloak plus an outer oauth2-proxy exact-email allow-list for `brad@bstein.dev` | `hermes` namespace and separate PVC; native Hermes delegates bounded work while Herdr preserves real Codex and Claude Code CLI sessions when needed |
-| `chat.hermes.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access |
+| `chat.bstein.dev` | Private consumer chat and research through Hermes WebUI or a linked Telegram DM | Keycloak login plus one-time Telegram account link | One Hermes process and PVC per assigned Keycloak subject; no Kubernetes RBAC, terminal, or private-service access |
The instances do not share conversation state, credentials, profiles, skills
created on their PVCs, or Kubernetes identities. They share only the inference
diff --git a/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml b/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml
index 7eae3a1e..b2020e8e 100644
--- a/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml
+++ b/services/keycloak/bootstrap-jobs/hermes-access-oidc-client-job.yaml
@@ -3,7 +3,7 @@
apiVersion: batch/v1
kind: Job
metadata:
- name: hermes-access-oidc-client-ensure-10
+ name: hermes-access-oidc-client-ensure-11
namespace: sso
spec:
backoffLimit: 3
diff --git a/services/keycloak/scripts/hermes_access_oidc_ensure.sh b/services/keycloak/scripts/hermes_access_oidc_ensure.sh
index 0ab9fbb0..2e54603c 100755
--- a/services/keycloak/scripts/hermes_access_oidc_ensure.sh
+++ b/services/keycloak/scripts/hermes_access_oidc_ensure.sh
@@ -84,10 +84,24 @@ ensure_proxy_client() {
client_id="$1"
public_url="$2"
vault_path="$3"
+ # Optional legacy host kept registered alongside the canonical one during a
+ # hostname rename. Keycloak matches redirect_uri exactly, so dropping the old
+ # entry turns every in-flight login into "Invalid parameter: redirect_uri".
+ legacy_url="${4:-}"
+ if [ -n "${legacy_url}" ]; then
+ origins="$(jq -nc --arg a "${public_url}" --arg b "${legacy_url}" '[$a,$b]')"
+ else
+ origins="$(jq -nc --arg a "${public_url}" '[$a]')"
+ fi
+ redirect_uris="$(printf '%s' "${origins}" | jq -c 'map(. + "/oauth2/callback")')"
+ # Keycloak takes post-logout origins as one "##"-delimited string.
+ post_logout="$(printf '%s' "${origins}" | jq -r 'join("##")')"
payload="$(jq -nc \
--arg client_id "${client_id}" \
- --arg redirect_uri "${public_url}/oauth2/callback" \
+ --argjson redirect_uris "${redirect_uris}" \
+ --argjson web_origins "${origins}" \
--arg web_origin "${public_url}" \
+ --arg post_logout "${post_logout}" \
'{
clientId:$client_id,
name:$client_id,
@@ -98,13 +112,13 @@ ensure_proxy_client() {
implicitFlowEnabled:false,
directAccessGrantsEnabled:false,
serviceAccountsEnabled:false,
- redirectUris:[$redirect_uri],
- webOrigins:[$web_origin],
+ redirectUris:$redirect_uris,
+ webOrigins:$web_origins,
rootUrl:$web_origin,
baseUrl:"/",
attributes:{
"pkce.code.challenge.method":"S256",
- "post.logout.redirect.uris":$web_origin,
+ "post.logout.redirect.uris":$post_logout,
"access.token.lifespan":"1200"
}
}')"
@@ -327,8 +341,10 @@ ensure_telegram_config() {
}
ensure_hermes_owner
-ensure_proxy_client "hermes-chat-proxy" "https://chat.hermes.bstein.dev" "hermes/chat-oidc"
+ensure_proxy_client "hermes-chat-proxy" "https://chat.bstein.dev" "hermes/chat-oidc" \
+ "https://chat.hermes.bstein.dev"
ensure_proxy_client "hermes-agent-proxy" "https://agent.hermes.bstein.dev" "hermes/agent-oidc"
-ensure_proxy_client "hermes-triage-proxy" "https://triage.hermes.bstein.dev" "hermes/triage-oidc"
+ensure_proxy_client "hermes-triage-proxy" "https://triage.bstein.dev" "hermes/triage-oidc" \
+ "https://triage.hermes.bstein.dev"
ensure_service_account_client "hermes-automation" "hermes/developer-keycloak"
ensure_telegram_config
diff --git a/services/maintenance/apps/ariadne-deployment.yaml b/services/maintenance/apps/ariadne-deployment.yaml
index d58b342d..c456464b 100644
--- a/services/maintenance/apps/ariadne-deployment.yaml
+++ b/services/maintenance/apps/ariadne-deployment.yaml
@@ -555,7 +555,7 @@ spec:
# open the run that wrote it rather than taking "Proposed by
# Hermes" on trust.
- name: ARIADNE_HERMES_UI_URL
- value: https://triage.hermes.bstein.dev
+ value: https://triage.bstein.dev
- name: ARIADNE_HERMES_SONAR_ENABLED
value: "true"
- name: ARIADNE_HERMES_SONAR_URL
diff --git a/services/maintenance/apps/metis-configmap.yaml b/services/maintenance/apps/metis-configmap.yaml
index 83b3e6a3..0e26e31a 100644
--- a/services/maintenance/apps/metis-configmap.yaml
+++ b/services/maintenance/apps/metis-configmap.yaml
@@ -15,8 +15,8 @@ data:
METIS_MAX_DEVICE_BYTES: "1000000000000"
METIS_NAMESPACE: maintenance
METIS_REMOTE_POD_TIMEOUT_SEC: "14400"
- METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-302-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
- METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
+ METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-amd64"}
+ METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64"}
METIS_HARBOR_REGISTRY: registry.bstein.dev
METIS_HARBOR_PROJECT: metis
METIS_HARBOR_API_BASE: https://registry.bstein.dev/api/v2.0
diff --git a/services/maintenance/kustomization.yaml b/services/maintenance/kustomization.yaml
index 723c00bd..8a5e9fde 100644
--- a/services/maintenance/kustomization.yaml
+++ b/services/maintenance/kustomization.yaml
@@ -13,6 +13,6 @@ images:
- name: registry.bstein.dev/bstein/ariadne
newTag: 0.1.0-464 # {"$imagepolicy": "maintenance:ariadne:tag"}
- name: registry.bstein.dev/bstein/metis
- newTag: 0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"}
+ newTag: 0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"}
- name: registry.bstein.dev/bstein/soteria
newTag: 0.1.0-120 # {"$imagepolicy": "maintenance:soteria:tag"}
diff --git a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml
index de80e231..64cfb8e6 100644
--- a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml
+++ b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml
@@ -32,7 +32,7 @@ spec:
kubernetes.io/arch: amd64
containers:
- name: metis-sentinel
- image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-302-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
+ image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"}
imagePullPolicy: Always
envFrom:
- configMapRef:
diff --git a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml
index 825002cc..398018dc 100644
--- a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml
+++ b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml
@@ -32,7 +32,7 @@ spec:
kubernetes.io/arch: arm64
containers:
- name: metis-sentinel
- image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-302-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"}
+ image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"}
imagePullPolicy: Always
envFrom:
- configMapRef:
diff --git a/services/quality/zap-baseline-configmap.yaml b/services/quality/zap-baseline-configmap.yaml
index 06bb82bb..c659edbb 100644
--- a/services/quality/zap-baseline-configmap.yaml
+++ b/services/quality/zap-baseline-configmap.yaml
@@ -27,8 +27,8 @@ data:
https://money.bstein.dev
https://health.bstein.dev
https://agent.hermes.bstein.dev
- https://chat.hermes.bstein.dev
- https://triage.hermes.bstein.dev
+ https://chat.bstein.dev
+ https://triage.bstein.dev
https://cassandra.bstein.dev
https://veles.bstein.dev
https://matrix.live.bstein.dev
diff --git a/testing/fixtures/hermes-agent-9de9c25f/SOURCE.md b/testing/fixtures/hermes-agent-9de9c25f/SOURCE.md
new file mode 100644
index 00000000..de2e910c
--- /dev/null
+++ b/testing/fixtures/hermes-agent-9de9c25f/SOURCE.md
@@ -0,0 +1,12 @@
+# Hermes Agent STT fixture provenance
+
+This minimal module contains the exact `_prepare_local_audio` patch context
+extracted from the agent image inherited by `Dockerfile.hermes-webui`:
+
+- Image: `registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107`
+- OCI platform manifest: `sha256:9b4c00a25fd08f0df3bcb800d755bd48576fb65af5ab0df40058fbb78a147e43`
+- Upstream Hermes revision: `9de9c25f620ff7f1ce0fd5457d596052d5159596`
+- Full upstream `tools/transcription_tools.py` SHA-256: `d7d7df56b98dfadc0a7c08d5addd02db4e3106e6fd3b753e9241c23145eb2951`
+
+Tests execute the production patcher against this source fragment and then run
+its real ffmpeg conversion boundary with browser-produced bytes.
diff --git a/testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py b/testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py
new file mode 100644
index 00000000..460b320a
--- /dev/null
+++ b/testing/fixtures/hermes-agent-9de9c25f/tools/transcription_tools.py
@@ -0,0 +1,44 @@
+"""Exact pinned local-audio preparation fragment with its direct dependencies."""
+
+import os
+from pathlib import Path
+import shutil
+import subprocess
+from typing import Optional
+
+
+LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
+logger = __import__("logging").getLogger(__name__)
+
+
+def _find_ffmpeg_binary() -> Optional[str]:
+ return shutil.which("ffmpeg")
+
+
+def windows_hide_flags() -> int:
+ return 0
+
+
+def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]:
+ """Normalize audio for local CLI STT when needed."""
+ audio_path = Path(file_path)
+ if audio_path.suffix.lower() in LOCAL_NATIVE_AUDIO_FORMATS:
+ return file_path, None
+
+ ffmpeg = _find_ffmpeg_binary()
+ if not ffmpeg:
+ return None, "Local STT fallback requires ffmpeg for non-WAV inputs, but ffmpeg was not found"
+
+ converted_path = os.path.join(work_dir, f"{audio_path.stem}.wav")
+ command = [ffmpeg, "-y", "-i", file_path, converted_path]
+
+ try:
+ subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
+ return converted_path, None
+ except subprocess.TimeoutExpired:
+ logger.error("ffmpeg conversion timed out for %s", file_path)
+ return None, "Audio conversion for local STT timed out"
+ except subprocess.CalledProcessError as e:
+ details = e.stderr.strip() or e.stdout.strip() or str(e)
+ logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
+ return None, f"Failed to convert audio for local STT: {details}"
diff --git a/testing/fixtures/hermes-agent/tools/transcription_tools.py b/testing/fixtures/hermes-agent/tools/transcription_tools.py
new file mode 100644
index 00000000..4f04b14b
--- /dev/null
+++ b/testing/fixtures/hermes-agent/tools/transcription_tools.py
@@ -0,0 +1,28 @@
+"""Pinned local-command STT envelope anchor used by image-patch tests."""
+
+import contextlib
+from pathlib import Path
+
+
+class _Logger:
+ def info(self, *args):
+ return None
+
+
+logger = _Logger()
+
+
+def _transcribe_local_command(file_path, normalized_model, output_dir):
+ try:
+ with contextlib.nullcontext(output_dir):
+ txt_files = sorted(Path(output_dir).glob("*.txt"))
+ transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
+ logger.info(
+ "Transcribed %s via local STT command (%s, %d chars)",
+ Path(file_path).name,
+ normalized_model,
+ len(transcript_text),
+ )
+ return {"success": True, "transcript": transcript_text, "provider": "local_command"}
+ except OSError as error:
+ return {"success": False, "transcript": "", "error": str(error)}
diff --git a/testing/fixtures/hermes-webui-0.52.181/SOURCE.md b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md
new file mode 100644
index 00000000..12e0e76b
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md
@@ -0,0 +1,12 @@
+# Hermes WebUI fixture provenance
+
+These minimal files contain the exact patch-context fragments extracted from the
+Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
+
+- Image: `ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2`
+- Version: `0.52.181`
+- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
+- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
+
+The fixture stays intentionally narrow, but tests execute the shipped
+`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic.
diff --git a/testing/fixtures/hermes-webui-0.52.181/api/config.py b/testing/fixtures/hermes-webui-0.52.181/api/config.py
new file mode 100644
index 00000000..98bd4888
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/api/config.py
@@ -0,0 +1,10 @@
+_SETTINGS_DEFAULTS = {
+ "tts_voice": "",
+}
+_SETTINGS_SPEECH_KEYS = {
+ "tts_voice",
+}
+UPSTREAM_VALIDATION_FRAGMENT = ''' if k == "tts_voice":
+ if not isinstance(v, str) or len(v) > 200 or "\x00" in v:
+ continue
+'''
diff --git a/testing/fixtures/hermes-webui-0.52.181/api/routes.py b/testing/fixtures/hermes-webui-0.52.181/api/routes.py
new file mode 100644
index 00000000..499048df
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/api/routes.py
@@ -0,0 +1,41 @@
+"""Pinned Atlas TTS route anchors used by image-patch tests."""
+
+import json
+import os
+from urllib.request import ProxyHandler, Request, build_opener
+
+
+class _NoRedirectTtsHandler:
+ """Placeholder for the upstream no-redirect opener handler."""
+
+
+class _Logger:
+ def exception(self, message):
+ return None
+
+
+logger = _Logger()
+
+
+class _Upstream:
+ def read(self):
+ return b"RIFFsynthetic"
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc_info):
+ return False
+
+
+def _buffer_tts_audio_response(response):
+ return response.read()
+
+
+def _tts_open(req, *, timeout=30, opener_factory=None):
+ return _Upstream()
+
+
+def _handle_tts(handler, data, text, rate_str, engine):
+ # ── ElevenLabs TTS ──────────────────────────────────────────────────
+ return None
diff --git a/testing/fixtures/hermes-webui-0.52.181/api/upload.py b/testing/fixtures/hermes-webui-0.52.181/api/upload.py
new file mode 100644
index 00000000..582cebbe
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/api/upload.py
@@ -0,0 +1,13 @@
+"""Pinned upstream /api/transcribe response anchor used by image-patch tests."""
+
+
+def j(handler, payload, status=200):
+ return {"status": status, "payload": payload}
+
+
+def handle_transcribe(handler, result):
+ try:
+ transcript = str(result.get('transcript') or '').strip()
+ return j(handler, {'ok': True, 'transcript': transcript})
+ except ValueError as error:
+ return j(handler, {'error': str(error)}, status=400)
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/boot.js b/testing/fixtures/hermes-webui-0.52.181/static/boot.js
new file mode 100644
index 00000000..88541e22
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/static/boot.js
@@ -0,0 +1,34 @@
+function speakWithRegisteredEngine(){
+ const _opts={
+ voice: localStorage.getItem("hermes-tts-voice")||'',
+ rate: parseFloat(localStorage.getItem("hermes-tts-rate")),
+ };
+ return _opts;
+}
+function speakWithEdge(clean){
+ const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
+ const rate='';
+ const pitch='';
+ return fetch('/api/tts', {
+ body: JSON.stringify({text: clean, voice, rate, pitch})
+ });
+}
+function speakWithBrowser(clean){
+ const utter=new SpeechSynthesisUtterance(clean);
+ const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+ return utter;
+}
+function _mirrorSpeechSettingsFromServer(s){
+ const defaults={
+ tts_voice:'',
+ };
+ [
+ ['tts_voice','hermes-tts-voice'],
+ ].forEach(([settingKey,storageKey])=>localStorage.setItem(storageKey,s[settingKey]));
+ return defaults;
+}
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/i18n.js b/testing/fixtures/hermes-webui-0.52.181/static/i18n.js
new file mode 100644
index 00000000..aa558ec1
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/static/i18n.js
@@ -0,0 +1,4 @@
+const EN = {
+ settings_label_tts_voice: 'Voice',
+ settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
+};
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html
new file mode 100644
index 00000000..b8ff3aca
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+