Skip to content

fix(hermes): command provider keeps the serving profile's voice and bot - #138

Merged
adrianwedd merged 11 commits into
mainfrom
fix/hermes-profile-voice-identity
Oct 24, 2026
Merged

adrianwedd merged 11 commits into
mainfrom
fix/hermes-profile-voice-identity

Conversation

@adrianwedd

@adrianwedd adrianwedd commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

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_speech tool, /voice auto replies, voice-input auto-TTS) ends in scripts/afterwords-tts-command.sh. That script dropped the one identity Hermes gives it, HERMES_HOME:

  • if {voice} was empty, it re-resolved the voice from .afterwords under a hardcoded hermes key, so the vixen profile spoke as seven-of-nine (server log 12:55:59 and 13:05:36);
  • it archived every artifact into ~/.hermes/tts-archive, which tts-feed-send.py relays with the default profile's bot. As a result, Vixen's voice notes were sent a second time by @failurefirstai_bot.

Fix (one boundary)

  • The voice is the serving profile's tts.providers.afterwords.voice passed 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.
  • Archives stay in the serving profile's scope. Gateway-delivered audio goes to tts-archive/gateway-delivered/, which the feed doesn't relay.
  • Every request appends one evidence line to $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:

  • interleaved concurrent requests from two profiles;
  • missing and unknown voice;
  • relay isolation, checked through the real feed poller;
  • the installed Hermes 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

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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T02:54:47.921575Z f7f69a5 New commits
🔒 Security Review ✅ Completed 2026-10-15T07:18:48.103377Z 8a042d3 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines 85 to +87
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +84 to +85
if [ "${_HERMES_GATEWAY:-}" = "1" ]; then
DELIVERY="gateway"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@adrianwedd

Copy link
Copy Markdown
Owner Author

Live end-to-end results (gateways restarted 17:46 under launchd; Vixen PID 55603, default/Hermes PID 55905; Hermes e06df9c). The voice-input cases were real Telegram voice messages, delivered as forwarded msg.voice:

input bot evidence ($HERMES_HOME/logs/afterwords-command.log) server voice=
text r1 18:07 @failurefirstai_bot profile=default voice=seven-of-nine seven-of-nine
text r1 18:07 @Vixen_Hermes_bot profile=vixen voice=seydoux seydoux
text r2 18:15 @failurefirstai_bot profile=default voice=seven-of-nine seven-of-nine
text r2 18:27 @Vixen_Hermes_bot profile=vixen voice=seydoux platform=telegram/gateway seydoux
voice r1 18:22 @failurefirstai_bot profile=default voice=seven-of-nine platform=cli → relayed twice by the feed (found the 2nd bug, fixed in 3d96308) seven-of-nine
voice r1 18:26 @Vixen_Hermes_bot profile=vixen voice=seydoux platform=/gateway seydoux
voice r2 18:27 @failurefirstai_bot profile=default voice=seven-of-nine platform=/gateway, no relay seven-of-nine
voice r2 18:32 @Vixen_Hermes_bot profile=vixen voice=seydoux platform=/gateway seydoux

The feed has relayed nothing since 18:22. CI shows the same 10 pre-existing failures as main at 47baf36; the new tests pass there.

https://claude.ai/code/session_01KAY8zUE3UJTZPfLN4bLH76

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +346 to +348
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread scripts/afterwords-tts-command.sh Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread scripts/afterwords-tts-command.sh Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedhydra-core@​1.3.3 ⏵ 1.3.69785 +6100100100

View full report

@adrianwedd
adrianwedd merged commit 50e609d into main Oct 24, 2026
6 checks passed
@adrianwedd
adrianwedd deleted the fix/hermes-profile-voice-identity branch October 24, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant