Repository navigation
fix(hermes): command provider keeps the serving profile's voice and bot - #138
Conversation
Root cause: afterwords-tts-command.sh — the one boundary every Hermes speech
path ends in (text_to_speech tool, /voice auto replies, voice-input auto-TTS) —
discarded the only identity Hermes gives it, HERMES_HOME:
- an empty {voice} was re-resolved from .afterwords under a hardcoded
`hermes` key, so the vixen profile spoke as seven-of-nine (12:55, 13:05);
- every artifact was archived into ~/.hermes/tts-archive, which
tts-feed-send.py relays with the DEFAULT profile's bot, so Vixen's voice
notes were re-delivered by @failurefirstai_bot.
Now:
- voice = the profile's tts.providers.afterwords.voice ({voice}) or exit 2
naming the profile; no .afterwords / server-default fallback; HTTP 400
(unknown voice) is surfaced with profile+voice, never substituted;
- archives stay in the serving profile's scope; gateway-delivered artifacts
go to tts-archive/gateway-delivered/ (not relayed);
- every request logs profile/voice/platform/sha/outcome to
$HERMES_HOME/logs/afterwords-command.log.
Tests: two independent profiles, interleaved concurrent turns (messaging +
CLI), fail-visibly on missing/unknown voice, relay isolation via the real
feed poller, and the installed Hermes text_to_speech_tool + voice-input
_synthesize_auto_tts across fresh processes (restart persistence).
Claude-Session: https://claude.ai/code/session_01KAY8zUE3UJTZPfLN4bLH76
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8a042d37e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if [ -z "$VOICE" ]; then | ||
| # Try project .afterwords | ||
| AW_FILE="" | ||
| CWD="$(pwd)" | ||
| if [ -f "$CWD/.afterwords" ]; then | ||
| AW_FILE="$CWD/.afterwords" | ||
| elif [ -f "$HOME/.afterwords" ]; then | ||
| AW_FILE="$HOME/.afterwords" | ||
| fi | ||
|
|
||
| if [ -n "$AW_FILE" ]; then | ||
| if grep -q ':' "$AW_FILE" 2>/dev/null; then | ||
| # Mapping mode. Split on the final colon so keys may contain colons. | ||
| VOICE=$(awk -v agent="hermes" ' | ||
| function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s } | ||
| /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } | ||
| { | ||
| pos = 0 | ||
| for (i = 1; i <= length($0); i++) { | ||
| if (substr($0, i, 1) == ":") pos = i | ||
| } | ||
| if (!pos) next | ||
| key = trim(substr($0, 1, pos - 1)) | ||
| val = trim(substr($0, pos + 1)) | ||
| if (key == agent) { print val; found = 1; exit } | ||
| if (key == "default" && fallback == "") fallback = val | ||
| } | ||
| END { if (!found && fallback != "") print fallback } | ||
| ' "$AW_FILE" 2>/dev/null) | ||
| else | ||
| # Simple mode: first non-empty non-comment line | ||
| VOICE=$(grep -v '^[[:space:]]*$\|^[[:space:]]*#' "$AW_FILE" | head -1 | tr -d '[:space:]' 2>/dev/null || true) | ||
| fi | ||
| fi | ||
| printf 'afterwords: no voice configured for Hermes profile "%s" (%s): set tts.providers.afterwords.voice and keep {voice} in the command template — refusing to fall back to another voice\n' \ | ||
| "$PROFILE" "$PROFILE_HOME/config.yaml" >&2 |
There was a problem hiding this comment.
Preserve fallback for existing Hermes voice configs
For existing Hermes installations created from the repository's README or skill examples, the provider command contains {voice} but the provider block has no voice: setting, so Hermes expands this argument to empty. This new branch then exits 2 even when the project's or global .afterwords correctly defines hermes:/default:, disabling text_to_speech, /voice, and automatic voice replies immediately after an upgrade. Either migrate existing profile configs before enforcing this requirement or retain a profile-aware .afterwords fallback.
AGENTS.md reference: AGENTS.md:L103-L115
Useful? React with 👍 / 👎.
Live e2e (18:22): the default bot's reply to a Telegram voice note reached the command provider with HERMES_SESSION_PLATFORM cleared (the gateway clears it before the post-handler voice-input auto-TTS), so it ran in CLI mode — local afplay, archived into ~/.hermes/tts-archive, and re-sent by tts-feed-send.py. For the vixen profile the same path would play Telegram replies on the Mac. Delivery is now classified once from the gateway process marker (_HERMES_GATEWAY=1, inherited by the command) or a messaging platform. Tests drive the platform-cleared voice-input shape, both through the script and the installed Hermes _synthesize_auto_tts. Claude-Session: https://claude.ai/code/session_01KAY8zUE3UJTZPfLN4bLH76
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d96308e5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if [ "${_HERMES_GATEWAY:-}" = "1" ]; then | ||
| DELIVERY="gateway" |
There was a problem hiding this comment.
Distinguish inherited gateway markers from gateway delivery
When a CLI/TUI session is spawned beneath the Hermes gateway or Desktop host, it can inherit _HERMES_GATEWAY=1 even though its speech is a local CLI delivery; this inheritance behavior is documented in upstream issue #92560. This unconditional override classifies that session as gateway, so the later branch skips afplay and archives under gateway-delivered, leaving the local user without the playback the command-provider contract promises. Use the session source/platform or another turn-scoped gateway identity rather than the inherited process marker alone.
AGENTS.md reference: AGENTS.md:L88-L88
Useful? React with 👍 / 👎.
|
Live end-to-end results (gateways restarted 17:46 under launchd; Vixen PID 55603, default/Hermes PID 55905; Hermes
The feed has relayed nothing since 18:22. CI shows the same 10 pre-existing failures as |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90162f7363
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| When Hermes native `/voice` consumes command-provider artifacts, use | ||
| `env AFTERWORDS_PLAYBACK_OWNER=hermes bash /path/to/afterwords-tts-command.sh | ||
| {input_path} {output_path} voice-name` in that profile's command. Hermes plays |
There was a problem hiding this comment.
Scope Hermes playback ownership to native voice mode
Do not place this environment override in the profile-wide provider command: it applies to every command-provider call, not only native /voice. A normal CLI text_to_speech invocation will therefore suppress the provider's afplay and archive outside the feed relay even though that route has no native sentence consumer, leaving the generated artifact without audible delivery. Pass the override only from the native sentence pipeline or otherwise convey caller-specific ownership.
AGENTS.md reference: AGENTS.md:L88-L88
Useful? React with 👍 / 👎.
| fi | ||
| if [ "$PREV_SIZE" -gt 1000 ] && $PLAY_OK; then | ||
| [ -f "$MUTE_FILE" ] || afplay "$PREV_WAV" 2>/dev/null | ||
| [ "$PLAYBACK_OWNER" = "hermes" ] || [ -f "$MUTE_FILE" ] || afplay "$PREV_WAV" 2>/dev/null |
There was a problem hiding this comment.
Hold the shared lock during Hermes-owned playback
Preserve the play lock around the actual consumer playback when AFTERWORDS_PLAYBACK_OWNER=hermes. In this mode the provider acquires the lock during synthesis, skips afplay here, and releases the lock on exit before Hermes plays the returned artifact, so Claude, Codex, or another Afterwords integration can acquire the lock and play concurrently with Hermes. The ownership handoff needs to keep or reacquire the shared lock for the native playback itself.
AGENTS.md reference: AGENTS.md:L99-L99
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c59d08df6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| fi | ||
| if [ "$PREV_SIZE" -gt 1000 ] && $PLAY_OK; then | ||
| [ -f "$MUTE_FILE" ] || afplay "$PREV_WAV" 2>/dev/null | ||
| [ "$PLAYBACK_OWNER" = "hermes" ] || [ -f "$MUTE_FILE" ] || afplay "$PREV_WAV" 2>/dev/null |
There was a problem hiding this comment.
Preserve mute when handing playback to Hermes
When native /voice runs with AFTERWORDS_PLAYBACK_OWNER=hermes while /tmp/afterwords-muted exists, this condition skips the provider's only mute-guarded playback and returns the nonempty artifact for Hermes to play without consulting the mute flag. Consequently, afterwords mute still allows automatic native responses to sound; the Hermes-owned consumer must honor the flag while allowing synthesis and archiving to continue, or playback must remain in the guarded provider path.
AGENTS.md reference: AGENTS.md:L101-L101
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 62211109da
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| When Hermes native `/voice` consumes command-provider artifacts, use | ||
| `env AFTERWORDS_PLAYBACK_OWNER=hermes bash /path/to/afterwords-tts-command.sh | ||
| {input_path} {output_path} voice-name` in that profile's command. Hermes plays |
There was a problem hiding this comment.
Pass the profile voice through the native command
When a profile follows this native-playback command, the final argument is a literal voice-name rather than Hermes's {voice} placeholder. Because the provider treats that argument as authoritative, tts.providers.afterwords.voice is ignored; copying the example verbatim produces an unknown-voice error, while substituting a current slug causes later profile voice changes to keep using the stale slug. Keep the playback-owner override but pass {voice} here.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c91e2fe2d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if config.get('provider') != 'afterwords' or provider.get('type') != 'command': | ||
| parser.error('configured Afterwords command provider required') | ||
| args.out.mkdir(parents=True) | ||
| profile = home.name |
There was a problem hiding this comment.
Map the default Hermes home to the default profile
When this probe runs against the standard default installation (HERMES_HOME=~/.hermes), home.name produces .hermes rather than Hermes's default profile identity. The runner then installs the capture adapter under a fabricated .hermes secondary profile while its actual primary/default adapter is the deliberately failing DefaultAdapter, so the probe can report success without exercising the real default-profile delivery route. Normalize the default home to default and configure the capture adapter as the primary adapter for that case.
Useful? React with 👍 / 👎.
…ture
- afterwords-tts-command.sh: chunked synthesis recorded no HTTP status, so a
server 400 (`unknown voice: <name>`) surfaced as "incomplete audio (0/1
chunks usable)". Each chunk now records its status; any 4xx fails the
request with the server's reason, logs failed:http-<code>, and is never
retried.
- strip_markdown.py: the marker-cleanup whitespace collapse (`\s{2,}`) also
ate newlines, flattening headings, bullets and tables before line parsing
(heading pause cues lost, table pipes spoken). Collapse spaces/tabs only.
This was failing on main as well.
- tests: the partial-delivery assertion had encoded the flattened text; the
dedup hook tests now use a private play lock instead of the live
/tmp/afterwords-play.lock, which they timed out waiting on during speech.
Claude-Session: https://claude.ai/code/session_012RARMGD7VE9vTYELtnZQPJ
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
2026-10-18: Source retained and integrated in #141 at
39a852976c532b34195ca00ad4ad0160f353d933. This PR remains open pending approval and merge of the combined candidate; do not merge it independently. Canonical source/runtime transition has not occurred. See #140 and #141 for current tests, the native consumer compatibility dependency and rollback.Historical workstream description and evidence
Root cause
Every Hermes speech path (the
text_to_speechtool,/voiceauto replies, voice-input auto-TTS) ends inscripts/afterwords-tts-command.sh. That script dropped the one identity Hermes gives it,HERMES_HOME:{voice}was empty, it re-resolved the voice from.afterwordsunder a hardcodedhermeskey, so the vixen profile spoke as seven-of-nine (server log 12:55:59 and 13:05:36);~/.hermes/tts-archive, whichtts-feed-send.pyrelays with the default profile's bot. As a result, Vixen's voice notes were sent a second time by@failurefirstai_bot.Fix (one boundary)
tts.providers.afterwords.voicepassed as{voice}. If it's missing, the script exits 2 and names the profile. An unknown voice returns the server's 400 to the caller and is never swapped for another voice.tts-archive/gateway-delivered/, which the feed doesn't relay.$HERMES_HOME/logs/afterwords-command.log.Tests
tests/test_hermes_profile_voice.py(9 tests). 8 of them fail against the old script; the restart guard passes on both. They cover:text_to_speech_tool(text-input path) and_synthesize_auto_tts(voice-input path), run in fresh processes per call to check restart persistence.There are pre-existing failures that this PR doesn't change. Running the suite on a clean HEAD worktree gives the same set: test_hermes_dedup, the strip-cue tests, and
test_partial_delivery_exits_nonzero_and_keeps_the_survivors.https://claude.ai/code/session_01KAY8zUE3UJTZPfLN4bLH76