Commit Graph

3587 Commits

Author SHA1 Message Date
Teknium bf8004e3a8 fix(stt): kill faster-whisper silence hallucinations at the source
Local faster-whisper called model.transcribe with bare {'beam_size': 5}:
no VAD, cross-window conditioning on, no confidence filtering. Pure
silence produced hallucinated tokens (E2E: 5s anullsrc WAV -> 'You',
no_speech_prob=0.705) and noisy clips could produce runs of junk, often
in other languages.

Three-layer class fix, one shared owner for every local-whisper call
site (build_local_transcribe_kwargs):

1. Silero VAD filter (bundled with faster-whisper) on by default —
   silence never reaches the model. stt.local.vad: false restores the
   raw behavior for music/ambient transcription.
   stt.local.vad_min_silence_ms tunes chunk splitting (default 500).
2. condition_on_previous_text=False — one hallucinated token can no
   longer seed a self-reinforcing run; negligible cost for
   voice-note-length audio.
3. Segment confidence gate (_join_confident_segments): drop a segment
   only when no_speech_prob > 0.6 AND avg_logprob < -1.0 (openai-whisper's
   own heuristic shape; both must hit so quiet-but-real speech survives).
   Config: stt.local.no_speech_prob_threshold / logprob_threshold.

The WHISPER_HALLUCINATIONS blocklist in voice_mode.py stays as
last-resort defense but should now almost never fire.

E2E (real faster-whisper 'base', CPU int8):
  silence.wav  before 'You'                        -> after ''
  noise.wav    before ''                           -> after ''
  speech.wav   before/after 'Hello World, this is a test of the
               transcription system.' (unchanged)

Docs (EN + zh-Hans), DEFAULT_CONFIG, cli-config.yaml.example updated;
19 unit tests (kwargs contract, off-switch, confidence gate incl.
quiet-speech survival, _transcribe_local wiring), sabotage-verified.
2026-07-29 00:01:33 -07:00
Teknium ba13132298 fix(voice): bare stop phrase ends the voice chat on every surface, spoken or typed
Saying OR typing a configured stop phrase (voice.stop_phrases, default
"stop") now ends the voice chat everywhere, not just classic CLI PTT:

- hermes_cli/voice.py: new explicit on_stop_phrase callback through
  start_continuous/stop_continuous. The force-transcribe path previously
  DISCARDED the stop phrase silently — with auto_restart=False the client
  re-arms the next capture, so the conversation never ended. Both halt
  paths now fire on_stop_phrase (fallback: on_silent_limit for legacy
  callers) as user intent, distinct from the no-speech timeout.
- tui_gateway/server.py: voice.record wires on_stop_phrase and emits
  voice.transcript {stop_phrase: true} after flipping HERMES_VOICE(_TTS)
  off and stopping streaming TTS — same teardown as /voice off. The TTS
  barge-in monitor stop-checks its transcript too. prompt.submit consumes
  a TYPED bare stop phrase at the server-side choke point when voice mode
  is on (returns {voice_stopped: true}, no turn starts).
- ui-tui: voice.transcript {stop_phrase} ends voice mode with a clear
  'voice chat ended' notice (distinct from the no-speech-limit message);
  submitPrompt releases the busy latch on a consumed voice_stopped reply.
- cli.py: _typed_voice_stop in process_loop — typing a bare stop phrase
  while voice mode/continuous is active ends voice mode instead of
  sending 'stop' to the agent; typed 'stop' outside voice mode is
  unchanged. Voice transcripts skip the check (already stop-checked).
- desktop: interceptsTypedVoiceStop — the composer's onSubmit ends the
  live voice conversation (same path as clicking end on the pill) when a
  bare stop command is typed with no attachments; renderer-owned loop, so
  handled client-side like the existing spoken isVoiceStopCommand.
- tools/voice_mode.py: transcribe_recording never lets the Whisper
  hallucination filter swallow a configured stop phrase (e.g. 'bye'
  configured as a stop phrase is both a hallucination-blocklist entry and
  a stop phrase — stop-phrase check now wins).

Tests: continuous-loop signal (sabotage-verified), force-transcribe stop
signal + legacy fallback, hallucination-filter ordering, typed-stop CLI
unit tests (voice on/off/longer text), prompt.submit typed-stop gateway
tests, TUI vitest for stop_phrase event handling, desktop vitest for the
typed-stop interceptor.
2026-07-29 00:01:06 -07:00
Teknium 158e9a9977 refactor: remove the claude-marketplace skill source (redundant Marketplace hub tab)
The Skills Hub 'Marketplace' tab showed a single useless entry: Anthropic
changed .claude-plugin/marketplace.json to bundle-shaped plugins whose
source is './', so all plugins collapsed to one identifier pointing at the
repo root, and the second marketplace repo (aiskillstore/marketplace) is
gone (404). Everything in anthropics/skills is already surfaced by the
GitHub tap as the Anthropic tab, making this source fully redundant.

Removes ClaudeMarketplaceSource and all wiring: source router, index
builder (crawl + floors + sort order + rate-limit messaging), extract
labels/install/URL mapping, hub UI tab, web server labels, CLI limits,
docs (en + zh), the legacy index-cache snapshot, and test fixtures.

Stale skills-index entries with source 'claude-marketplace' still install
fine: HermesIndexSource fetches via resolved GitHub paths generically.
2026-07-28 23:16:27 -07:00
Teknium 805c1c340c feat(stt): support OpenAI gpt-transcribe transcription model
Adds gpt-transcribe (OpenAI's new file-transcription model, $0.0045/min)
to the OpenAI STT provider:

- OPENAI_MODELS set: gpt-transcribe is recognized so provider
  auto-correction keeps it on OpenAI and rejects it on Groq
- Language hint wiring: gpt-transcribe replaces the singular
  'language' field with a 'languages' list; the API rejects the legacy
  field, so the hint is sent via extra_body {languages: [..]}
- Config comment (DEFAULT_CONFIG), cli-config.yaml.example, desktop
  settings enum, and docs (en + zh-Hans) updated
- Tests: model pass-through, languages-list hint shape, legacy singular
  hint preserved for gpt-4o-transcribe, Groq auto-correction

gpt-live-transcribe (realtime WebSocket, $0.017/min) is NOT wired here:
the file-based STT pipeline has no realtime session path; it belongs in
a future realtime/voice-mode integration.
2026-07-28 22:40:45 -07:00
Shannon Sands 8bbd77f368 fix: route memory-provider dep installs through lazy_deps durable target
Installing a memory provider (Honcho, mem0, hindsight, ...) from the
dashboard Plugins page failed on hosted deployments with a permission
error: the setup endpoint shelled out to
`uv pip install --python sys.executable`, which targets the sealed
read-only venv under /opt/hermes (immutable hosted image, NS-579/#49113).

The correct mechanism already exists: tools/lazy_deps.py redirects
installs to the writable durable target on the data volume
(HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages) when the venv is
sealed (HERMES_DISABLE_LAZY_INSTALLS=1), appends the target to the END
of sys.path (core venv always wins collisions), and constrains shared
deps to core-venv versions. The dashboard installer simply never used
it.

Fix:
- tools/lazy_deps.py: new public install_specs() — installs arbitrary
  manifest-declared pip specs through the same environment routing as
  ensure(): venv-scoped by default, durable-target on sealed images,
  refused with an actionable reason when gated off (config kill switch
  or sealed venv without a target — never surfaces raw EROFS/EACCES).
  Specs are validated with _spec_is_safe(); post-install it invalidates
  import/metadata caches so availability rechecks in the same process
  see the new packages without a restart. Never raises.
- hermes_cli/web_server.py: _install_memory_provider_pip_dependencies
  now calls install_specs() instead of building its own uv/pip
  subprocess. Blocked installs surface the gate reason in the setup
  results; the response's status block reflects post-install
  availability (stale 'missing deps' state clears immediately).
- hermes_cli/memory_setup.py, plugins/memory/honcho/cli.py,
  plugins/memory/mem0/_setup.py: CLI setup wizards routed through
  install_specs() too — same sealed-venv failure mode, same fix.

No hosted setup path writes to /opt/hermes anymore; provider discovery
and installation now use the same environment (sys.path activation is
shared with the lazy-install bootstrap in hermes_bootstrap).

Tests:
- tests/tools/test_lazy_deps.py: TestInstallSpecs — gating matrix
  (sealed+no-target blocked with immutable-deployment reason, config
  kill switch, sealed+target proceeds), spec-safety rejection before
  any subprocess, venv-scoped vs --target command display, failure
  stderr passthrough, never-raises contract.
- tests/hermes_cli/test_web_server.py: setup endpoint routes pip
  through lazy_deps (regression guard asserts no direct 'pip install'
  subprocess), blocked-reason surfacing, same-response availability
  recheck clears stale missing state.

Fixes NS-605 (Plain T-1111).
2026-07-28 22:40:33 -07:00
Teknium 7800bb1a29 fix(tts): route streaming-provider secrets through resolve_provider_secret; bound per-sentence stream bodies at 16 MiB
Follow-up integration for the #47588 salvage, aligning the new streamers
with the post-campaign invariants:

- All streaming key lookups go through _resolve_key -> tts_tool.
  _resolve_provider_key -> resolve_provider_secret (config > env/.env >
  credential pool, profile-scoped) — never bare get_env_value. xAI
  resolves via resolve_xai_http_credentials so OAuth users stream too.
- _capped(): every provider's chunk iterator is bounded at 16 MiB per
  sentence, mirroring _read_tts_response_bytes' bounded-upstream-body
  invariant on the sync paths.
- Tests updated for the resolver contract + new coverage for credential
  routing and the cap.
2026-07-28 22:31:40 -07:00
Carlos Diosdado bc4dcb1b02 feat(tts): Gemini SSE + xAI WebSocket streaming providers, tts.streaming.provider knob, docs + E2E tests
Salvaged from PR #47588 and rebased onto the post-campaign streaming core:
the StreamingTTSProvider ABC/registry and the ElevenLabs/OpenAI streamers
already live on main (tools/tts_streaming.py), so this ports the pieces
main lacked:

- GeminiStreamer: streamGenerateContent?alt=sse -> base64 PCM chunks
  (24 kHz mono int16), reusing main's DEFAULT_GEMINI_TTS_* constants.
- XAIStreamer: WebSocket wss://api.x.ai/v1/tts -> binary PCM frames,
  async->sync bridged via the _collect_async test seam.
- tts.streaming.provider config knob: pin one streamer, or 'auto' to
  walk the priority list elevenlabs -> gemini -> openai -> xai. Unset
  keeps the never-swap-the-user's-voice default.
- docs/streaming-tts.md: architecture, capability matrix, how to add
  a provider.
- Unit tests for the knob, SSE parsing, and the WS bridge; key-gated
  E2E tests (skipped without credentials).

Refs: #47588
2026-07-28 22:31:40 -07:00
Teknium 0f64557c06 fix(wake): coerce dead onnx->tflite on macOS ARM64; clear stale voice turn-timeout
Two follow-ups from the voice PR (#70509).

1. macOS ARM64 onnx migration. Existing users who pinned
   openwakeword.inference_framework=onnx before the tflite fix landed kept a
   wake word that arms but never fires (ONNX's embedding model is broken on
   Apple Silicon, upstream #336). New resolve_inference_framework() honors an
   explicit framework everywhere ONNX actually works, but coerces the one
   provably-dead combination (explicit onnx + macOS ARM64) to tflite with a
   one-time warning. No config mutation; empty still falls back to the platform
   default. Both read sites (engine init + requirements check) route through
   the shared resolver.

2. Voice turn-timeout leak. Each listen cycle reassigned turnTimeoutRef
   without clearing the prior 60s timer, so a stale timer from an earlier
   cycle could fire handleTurn() mid-way through a later listen — after enough
   idle re-listens this wedged the loop into a non-re-arming state (the
   'voice chat deactivates after ~a minute' report). Clear before re-arm.

Tests: 64 wake tests (added onnx-coercion / intel-kept / tflite-kept /
empty-default cases; updated the stale 'explicit onnx kept on ARM64' test
that encoded the old broken behavior), 39 desktop voice/wake vitest, tsc +
eslint clean.
2026-07-28 19:40:25 -07:00
Teknium f041c95b7f fix(send_message): pass photon DM chat GUIDs through as explicit targets
'photon:any;-;+1555...' targets matched no parser pattern, so
_handle_send bounced them off the channel directory and failed
resolution even though the adapter accepts the GUID verbatim (the
react handler already passed them through). Recognize the DM chat
GUID shape (mirrors the adapter's _DM_CHAT_GUID_RE) in
_parse_target_ref for photon only.
2026-07-28 18:21:01 -07:00
Teknium 050461ec83 fix: hoist STT credential read guard to the public transcribe_audio entry point
The rebase onto #73510's prepare/dispatch split left the guard inside
_transcribe_prepared_audio, where source validation ran first and a
blocked .env surfaced a format error instead of the read-block message.
Guard now fires before any validation/preprocessing.
2026-07-28 18:12:26 -07:00
Teknium e251e78df9 feat(tools): env_passthrough allowlist for command-provider secret scrub
Command providers legitimately reference their own API keys in shell
templates (curl one-liners). The #70342 scrub removes ALL provider keys,
which would break such setups. Add a per-provider env_passthrough list
(TTS + STT) that copies named variables back from the parent env, plus
docs and tests. Scrub stays the default; passthrough is explicit opt-in.
2026-07-28 18:12:26 -07:00
Teknium fc26e965bb fix(tools): apply idle timeout to command STT runner (class fix for #50081)
Port the progress-based idle-timeout pattern from _run_command_tts
(PR #50087, @CleanDev-Fix) to _run_command_stt: the timeout resets on
any stdout/stderr output, so a slow-but-alive STT provider survives
while a silently stalled one is killed. Stuck detection stays
progress-based, never wall-clock.
2026-07-28 18:12:26 -07:00
Frowtek 38bb193f38 fix(stt): route transcription inputs through the shared read guard
`transcribe_audio` reads a local file and hands it to the configured STT
provider — for the hosted providers (Groq, OpenAI, Mistral, xAI, ElevenLabs)
that ships the file's bytes to a third-party API. The same local-input read
guard was added to image-gen (587be5b5b) and xAI video-gen (104232979) to keep
the agent from feeding credential/secret stores to a provider, but STT was
missed.

Call `get_read_block_error(file_path)` at the top of `transcribe_audio`, before
validation/dispatch, so a `.env`, `auth.json`, `.anthropic_oauth.json`,
`mcp-tokens/`, etc. is refused up front instead of being transcribed (and, for
hosted providers, exfiltrated). This is defense-in-depth, not a security
boundary — the guard's own message says so — but it restores parity with the
image/video-gen tools.

Regression test: a `.env` file is refused with the shared read-guard message
before any provider dispatch (mutation-verified).
2026-07-28 18:12:26 -07:00
dsad 37d0b6c81a fix(tts): block output to protected paths 2026-07-28 18:12:26 -07:00
Eugeniusz Gilewski b76acacbb9 fix(tools): execute local STT templates without a shell
HERMES_LOCAL_STT_COMMAND rendered quoted placeholders into a
user-configured template and passed the result to shell=True. Shell
metacharacters in the template therefore remained executable syntax even
though the placeholder values themselves were quoted.

Tokenize the rendered template and invoke it as an argv list while
preserving the existing timeout, closed stdin, and Windows creation flags.
Lock the invocation contract with metacharacter regression coverage and
document explicit shell wrapping for trusted templates that need it.

Salvages #32694

Co-authored-by: Ernest Hysa <takis312@hotmail.com>
2026-07-28 18:12:26 -07:00
Sherman 273b986fd9 feat(tools): expand command TTS output_format allowlist (m4a/aac/amr/opus)
Command-type TTS providers validated output_format against a hardcoded
{mp3,wav,ogg,flac} set; any other value was silently coerced back to mp3,
which then mismatched the output path the post-run check expects. This
blocked common ffmpeg-producible containers/codecs — notably m4a (AAC),
the portable choice for WeChat/iOS/mobile voice files — with no
config-only path (only a local source patch, lost on every update).

Widen COMMAND_TTS_OUTPUT_FORMATS to add m4a, aac, amr, opus. This only
permits a command provider to declare these; the user's command still
produces the file (e.g. via ffmpeg). No built-in provider behavior
changes and no new required config.

Update the two tests that pinned the old set, and add a positive case
covering the new formats. Document the supported output_format values.
2026-07-28 18:12:26 -07:00
峯岸 亮 3ae25e0fbd fix(security): scrub credentials from voice playback subprocesses
## Summary
- Spawn system audio players (`ffplay` / `afplay` / `aplay`) with `hermes_subprocess_env(inherit_credentials=False)`.
- Prevent gateway tokens and provider API keys from leaking into OS media helpers.
- Add a regression test asserting scrubbed env on `Popen`.

## Salvage / credit
Sibling of #70342 / incomplete #56332 (TTS/STT command scrub) on the voice-mode playback path.
2026-07-28 18:12:26 -07:00
峯岸 亮 24a6fb6448 fix(security): scrub Hermes secrets from voice command subprocess env
Salvage incomplete #56332: route command TTS/STT through hermes_subprocess_env
while preserving delegated-child lineage, and close the sibling local-whisper
subprocess.run path that still inherited the full process environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:12:26 -07:00
CleanDev-Fix 4e8a66dace fix(tools): keep command TTS deadline through exit 2026-07-28 18:12:26 -07:00
CleanDev-Fix 1b97e3efc5 fix(tools): chunk command TTS stream reads 2026-07-28 18:12:26 -07:00
CleanDev-Fix 3884f078bd fix(tools): use idle timeout for command TTS 2026-07-28 18:12:26 -07:00
Teknium 7e7f7d3059 Merge pull request #70509 from NousResearch/hermes/hermes-29661bf6
feat(voice): on-device wake words with open-vocabulary phrases and multi-profile voice routing
2026-07-28 17:58:33 -07:00
Atakan 20de37d409 fix: reject ambiguous MCP tool name collisions 2026-07-28 15:02:52 -07:00
Ben Barclay f9c4d835f9 refactor(sync): name the access gate for what it is (Nous admin)
The client called its gate "the DEV-PHASE gate (tool_gateway_admin)", which
reads as though Skill Sync is gated on an unrelated service's admin right.
It isn't. NAS populates that claim from Permissions.ADMIN_ACCESS — the global
portal admin permission that guards /admin/* — so the gate is "is this user a
Nous admin?". The claim is simply named for its first consumer, the tool
gateway.

Renamed on this side to say what it means, while keeping the wire string
(other services read it):

- DEV_GATE_CLAIM -> NOUS_ADMIN_CLAIM (value unchanged: "tool_gateway_admin",
  with a comment recording why the wire name differs).
- identity/status key dev_gate_ok -> nous_admin, across the client, the CLI
  consumers, and the tests.
- The module docstring now states where the claim comes from, that the wire
  name is misleading, and that this gate is pre-launch containment rather
  than the shipping entitlement — admin status conflates "may administer
  Nous" with "has Skill Sync enabled" and has no middle setting for a beta
  cohort. Choosing the real entitlement is left as a separate decision.

Naming only — no behaviour change, and no change to which accounts can sync.
The user-facing messages stay deliberately vague ("not enabled for your
account yet") rather than telling users they need portal admin.

Verified: 2346 passed / 0 failed across 56 suites via scripts/run_tests.sh;
`hermes sync status` against a live token reports "nous_admin": true. Zero
stale dev_gate_ok / DEV_GATE_CLAIM references remain.
2026-07-29 07:57:25 +10:00
Ben Barclay 4f990ec09e refactor(sync): put every Skill Sync verb under hermes sync; drop HSP naming
Encapsulates the feature behind one command for launch, and adopts the
official product name.

One command:
- `propose` moves from `hermes skills propose` to `hermes sync propose`, so
  the whole feature is one command to learn and one to document. Its handler
  moves from cmd_skills to cmd_sync accordingly.
- The `hermes sync` parser now documents both halves plainly: personal sync
  across your devices, and sharing with your organisation. Added an examples
  epilog; rewrote the verb help in user language ("Include a skill in your
  sync" rather than "Opt a skill into sync").
- Every user-facing string that pointed at `hermes skills propose` now points
  at `hermes sync propose` (8 sites, including the agent-visible guidance
  returned by skill_manage and the org provenance header).

This also clears the way for #39343, which adds its own top-level `sync` for
git-repo profile backup — that feature nests under `skills`, this one owns
`sync`.

Naming:
- HSP / "Hermes Sync Protocol" is gone from prose, docstrings, and comments.
  The feature is "Skill Sync".
- Public identifiers renamed: HSPClient -> SyncClient, HSPError -> SyncError,
  HSPConflict -> SyncConflict, hsp_address -> wire_address, HSP_VERSION ->
  WIRE_VERSION.
- The WIRE names are deliberately NOT renamed: the `hsp_version` capability
  field and the `x-hsp-object-type` response header are set by the deployed
  gateway-gateway sync plane (verified in src/sync/syncRouter.ts), so
  renaming them client-side would break sync against a live server. A comment
  at the version constant records why they differ from the product name.
- The version-mismatch error is now actionable ("this server speaks sync
  version X, but this Hermes speaks Y — update Hermes to sync with it")
  instead of leaking the protocol acronym.

Also fixes a wiring gap found on the way: the gateway housekeeping tick
pulled personal skills but never org skills — the same defect already fixed
for the CLI. Org pull now runs there too, gated on real org membership.

Tests: the jargon guard now also fails on a bare "HSP". The two tests that
asserted the old cross-command structure are replaced by three asserting the
new one (propose IS under sync, propose is NOT under skills, sync usage
lists it). 2294 passed / 0 failed across all 51 suites that import the
changed modules, via scripts/run_tests.sh.

Verified by running the real CLI: `hermes sync --help` lists all eight verbs,
`hermes skills --help` no longer mentions propose, `hermes sync propose
--help` parses, and `hermes sync status` still reports live org state.
2026-07-29 07:47:06 +10:00
Atakan 9dcb44a219 fix(schema): preserve dependentRequired property names 2026-07-28 14:37:19 -07:00
Teknium 8151670712 fix(mcp): widen discovery lock wait to cover real discovery durations
The bounded lock wait was 10 x 0.2s = 2s, but the concurrent-discovery
scenario this lock exists for (#62771: hermes serve + gateway spawning
every MCP server twice) reports 40-60s discovery rounds. A 2s budget
guarantees the loser times out and runs unguarded exactly when the guard
matters. Widen to 240 x 0.5s = 120s; fail-soft unguarded fallback and
test overrides unchanged.
2026-07-28 14:31:24 -07:00
Atakan 362fc21577 fix(mcp): guard discovery with cross-process lock 2026-07-28 14:31:24 -07:00
Atakan 56bda4529b fix(computer_use): revive ended cua-driver sessions once 2026-07-28 14:24:30 -07:00
atakan g 3d30232eba fix(delegate): isolate async batches from parent interrupts
Detached background delegation batches (_batch_runner) no longer honor
the foreground parent's interrupt flag — a busy-submit interrupt in the
TUI/desktop previously fabricated 'interrupted' results for background
children that should outlive the turn. Explicit cancellation still works
via _batch_interrupt.

Rebased onto current main from PR #65040; both interrupt-suppression
regression tests aligned with the current _session test helper.

Original work by @AtakanGs in #65040.
2026-07-28 14:20:10 -07:00
atakan g 7b18de5f40 fix: scope private URL policy per profile 2026-07-28 14:17:45 -07:00
William Reed 946ed96785 fix(voice): reconcile NeuTTS backbone/codec GPU device strings
llama_cpp's GGUF backbone loader only enables n_gpu_layers for the
literal device string "gpu"; torch's codec only accepts "cuda". A
single --device value passed straight through can't satisfy both,
so config.yaml's documented tts.neutts.device: cuda silently ran the
backbone on CPU while the codec ran on GPU.

Map cuda -> gpu for the backbone only; leave the codec on the
torch-native string. Measured 24.9s -> 13.1s per synthesis call on
an RTX 5070 Ti (neutts-air-q4-gguf) with this fix.
2026-07-28 14:07:21 -07:00
Que0x 555d4e10ad fix(tts): close temp WAV handle before playback in streaming fallback
`_play_via_tempfile` passes the NamedTemporaryFile *object* to `wave.open()`.
`wave.open()` flushes but does not close a file it did not open itself (by
name), so the OS handle to the temp WAV stays open. On Windows that open write
handle blocks the system player from reading the file and blocks the
`os.unlink()` cleanup (WinError 32, silently swallowed by `except OSError:
pass`), leaving orphaned temp .wav files piling up. The fallback runs whenever
no sounddevice output stream is available (sounddevice not installed / no audio
device), which is common on headless and Windows setups.

Close the temp file handle before invoking the player and again in the finally
block (idempotent), so the player can read the file and `os.unlink()` can
remove it.

Regression test drives `stream_tts_to_speaker` with the sounddevice path forced
unavailable and asserts the temp handle is closed before `play_audio_file` is
called (mutation-verified: reverting the close leaves the handle open at play
time and the test fails). Cross-platform.
2026-07-28 14:07:21 -07:00
3ASiC 45f7030786 fix(tts): bind MiniMax credentials to their region endpoint 2026-07-28 14:07:21 -07:00
Chaz Dinkle 7b0a575c82 fix(tts): use microsecond timestamp to prevent concurrent output path collision
Concurrent TTS requests within the same second (e.g. from voice prefetch)
generated identical tts_YYYYMMDD_HHMMSS.ogg paths, causing a race where
one request would unlink the file while another was still writing — resulting
in 'produced no output' errors.

Adding %f (microseconds) to the strftime format makes collisions practically
impossible. Registered as PATCH-006.
2026-07-28 14:07:21 -07:00
Vivaan Dhawan 1c30c57f11 fix(whatsapp): preserve voice notes when STT fails 2026-07-28 14:06:56 -07:00
Florian Valade 5277065260 feat(stt): add CAF format support with WAV conversion for cloud providers
Cloud STT APIs (Groq, OpenAI, etc.) cannot parse Apple CAF containers.
Add .caf to SUPPORTED_FORMATS and _convert_caf_to_wav() which tries
ffmpeg (cross-platform) then afconvert (macOS built-in).
2026-07-28 14:06:56 -07:00
Teknium 0cf58de85e Merge remote-tracking branch 'origin/main' into wake-toggle-config
# Conflicts:
#	tests/test_tui_gateway_server.py
#	tui_gateway/server.py
2026-07-28 12:37:35 -07:00
Teknium e6b0344052 test: adapt salvaged voice tests to current main + lint fix
- _FakeProc gains returncode (main's player loop checks proc.returncode)
- WSL gate tests clear SSH_* env vars (main hard-warns over SSH without
  forwarded audio) and accept the merged #37346 forwarded-sound-server
  notice wording
- test_tts_macos_output stubs resolve_streaming_provider so the
  OutputStream setup path actually runs on main's chunked-streamer code
- voice CLI integration tests unwrap the _VoiceInputMessage sentinel
- _is_wsl: explicit encoding + drop unreachable return (ruff PLW1514)
2026-07-28 11:57:37 -07:00
Teknium fe3dc29009 fix(voice): honor quoted beep_enabled strings + correct PortAudio OSError hint
Two in-house micro-fixes from issue triage:

- #49883: voice.beep_enabled gates in cli.py and hermes_cli/voice.py used
  bool() on the config value, so a quoted YAML string like "false" or
  "off" kept beeps on. Route through utils.is_truthy_value.
- #18432: AudioRecorder.start() collapsed OSError from _import_audio into
  the 'pip install sounddevice numpy' hint — but OSError means the
  PortAudio SHARED LIBRARY is missing, which pip cannot fix. Mirror
  detect_audio_environment's system-package hint (libportaudio2 /
  brew portaudio / Termux pkg install portaudio) on that path.

Fixes #49883
Fixes #18432
2026-07-28 11:57:37 -07:00
RedClaus 1605cb5fe4 fix(voice): point pip install hint at venv pip instead of system Python
When Hermes runs inside its bundled venv (~/.hermes/profiles/<name>/
hermes-agent/venv/), the bare 'pip install sounddevice numpy' hint in
_voice_capture_install_hint() told users to install into whichever
Python their shell resolves first — on macOS that is often the system
Python under Rosetta, a completely separate site-packages tree with
incompatible wheel arches.

Detect venv via sys.prefix != sys.base_prefix and return the full path
to the venv's pip binary (Path(sys.prefix)/bin/pip) when available.
Falls back to the bare hint outside a venv (e.g. bare-metal installs).

Also adds 'from pathlib import Path' which was the only new import.
2026-07-28 11:57:37 -07:00
simonmmafs a1bc12f191 voice: one macOS output policy across WAV, beeps, streaming TTS + tests
Addresses review on #62601. Applies a single rule — no sounddevice for
audio OUTPUT on macOS (PortAudio/CoreAudio init triggers a
kTCCServiceMediaLibrary prompt) — consistently at all three output sites:

- play_audio_file: WAV playback (already routed to afplay) now uses the
  shared _sounddevice_output_allowed() helper.
- play_beep: synthesize the tone with numpy only, then on macOS play it
  via a temp WAV through afplay instead of sounddevice.
- stream_tts_to_speaker (tts_tool): on macOS, skip the sounddevice
  OutputStream so playback falls through to the existing tempfile/afplay path.

Audio INPUT (recording) is untouched — it legitimately needs mic permission.

Tests: TestMacOSAudioOutputPolicy (voice_mode) proves WAV + beep routing
does not import sounddevice on Darwin and still uses it off Darwin;
test_tts_macos_output proves streaming TTS skips the OutputStream on Darwin.
Existing test_play_wav_via_sounddevice pinned to non-Darwin for determinism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:57:37 -07:00
Simon 0179ff9738 fix(voice): skip sounddevice on macOS to avoid TCC media-library prompt
On macOS, initializing PortAudio/CoreAudio via sounddevice triggers a
kTCCServiceMediaLibrary permission dialog even when no media-library
access is needed. afplay already handles WAV (and every other format)
natively, so route macOS playback straight to it and keep sounddevice
for other platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:57:37 -07:00
xxxigm e3f4dc2103 fix(voice-mode): make Termux:API detection robust against pm probe failures
\`pm list packages com.termux.api\` is the canonical way to detect the
Termux:API Android app, but on some devices it gives a false negative
even when the app is installed and \`termux-microphone-record\` runs
fine — the symptom reported in #31015 (\`/voice on\` complaining
"Termux:API Android app is not installed").

Replace the single probe with a graded strategy:

1. Try \`pm list packages com.termux.api\` (current behaviour).
2. If \`pm\` isn't on PATH or returns non-zero, fall back to
   \`cmd package list packages com.termux.api\` — the modern Android
   API 28+ equivalent that's present on devices where \`pm\` is gone.
3. If both probes are inconclusive (binary missing, permission
   denied, timeout, or non-zero exit) and \`termux-microphone-record\`
   is on PATH, trust the binary.  The CLI ships in the \`termux-api\`
   package which is essentially only useful with the Android app
   installed; users who installed it deliberately almost always have
   the app too.

Polarity matters: a false negative on this gate blocks \`/voice on\`
entirely (the user-reported symptom), while a false positive only
surfaces a precise runtime error from the binary itself when it
tries to talk to the missing app — strictly more actionable.

The clean-probe-but-no-package case still returns False, so the
existing "Termux:API CLI installed without the app" warning still
fires when the package manager *can* tell us the app is missing.

Refs: NousResearch/hermes-agent#31015
2026-07-28 11:57:37 -07:00
ygd58 0560f52047 fix(voice): add WSL2 PowerShell audio fallback for TTS playback
Ports #63768 forward onto current main per teknium1's review.

On WSL2 without a PulseAudio bridge, ffplay and aplay have no audio
device and TTS playback silently fails (issue #17608). When
powershell.exe and ffmpeg are available, convert the audio to a
uniquely-named WAV in the Windows %TEMP% directory and play it via
Media.SoundPlayer.

Per review, this fixes two gaps in the original port:

1. Exit-status masking: the cleanup subshell was
   '( ffmpeg && powershell ); rm -f wav' -- the shell's exit status is
   the LAST command's (rm -f, which is always 0), so a real
   ffmpeg/PowerShell failure could never be detected by the rc-checking
   fallback logic added to the player loop. Now captures the real
   status before cleanup and re-exits with it:
   '( ffmpeg && powershell ); rc=0; rm -f wav; exit '.

2. The no-Pulse WSL gate in detect_audio_environment() still hard-blocked
   voice mode entirely (input AND output) even when the PowerShell
   fallback made TTS output viable. Added _wsl_powershell_tts_available()
   and use it to downgrade the WSL-without-Pulse case from a hard
   'warnings' block to a non-blocking 'notices' entry when the fallback
   is available -- the same PulseAudio-bridge recording guidance is still
   surfaced (mic capture genuinely still needs it), it just no longer
   blocks /voice on for TTS-only usage. cli.py's existing
   env_check['available'] gate needed no changes since it already
   respects this flag.

Also fixed the flaky uniqueness test (the original asserted
len(filenames) >= 2, which passed trivially on zero captured
filenames) and added a real fallback-triggering regression test for
the exit-status fix.

10 new/fixed tests pass in TestWSL2PowerShellFallback and the new
TestWSLAudioEnvironmentGate; 80/80 in the full tests/tools/test_voice_mode.py file.
2026-07-28 11:57:37 -07:00
Ivan Kharitonov ec7a46a6fe fix(voice): add WSL audio warmup to eliminate RDP crackling
WSLg RDP audio has two issues causing crackling:
1. systemd-timesyncd clock adjustments jitter PulseAudio timing
   (microsoft/wslg#1257) — user action: stop the service
2. Cold-start RDP connection drops first ~100ms of audio packets
   before the virtual channel stabilises

Fix (automated, WSL-only):
- Detect WSL via /proc/version 'microsoft' marker
- Prepend 100ms silence + apply 100ms fade-in to audio
- Append 50ms silence tail for clean stream teardown
- Set blocksize=4096 (default auto ~1024 is too small for RDP)
- All in a single continuous sd.play() buffer

Non-WSL paths unchanged.

Closes #38893
2026-07-28 11:57:37 -07:00
Slippy87 ef686c3878 fix(voice): honor forwarded audio (PIPEWIRE_REMOTE) in WSL detection 2026-07-28 11:57:37 -07:00
Brice 2a75664c0c fix(voice): capture at the input device's native sample rate
AudioRecorder hard-codes SAMPLE_RATE (16 kHz) when opening the input
stream, but some capture devices (e.g. USB microphones exposed through
ALSA hw) reject 16 kHz outright — sd.InputStream fails with
PaErrorCode -9997 (Invalid sample rate) and voice recording is broken.

Query the default input device for its native default_samplerate at
recording start and open the stream / write the WAV at that rate,
falling back to the Whisper-friendly 16 kHz constant when the backend
does not expose a usable rate. STT providers accept standard WAV rates,
so downstream transcription is unaffected.
2026-07-28 11:57:37 -07:00
Kewe63 f98952b267 feat(voice): make beep notification volume configurable from config.yaml
Closes #55908. The CLI voice-mode beep amplitude is hardcoded at 0.3 inside
tools.voice_mode:play_beep(), which makes the record start/stop cues too
quiet on low-volume systems and headphones. Users couldn't adjust it
without editing source.

Move the literal into a configurable voice.beep_volume setting (clamped to
0.0-1.0, default 0.3 to preserve prior behaviour). The new
_get_beep_volume() helper reads via the same load_config() pattern used by
cli.py's _voice_beeps_enabled() and hermes_cli/voice.py's _beeps_enabled(),
keeps bools / out-of-range / non-numeric / NaN values safely on the default,
and falls back silently if config can't load so the audio cue never breaks
the voice loop on a degenerate config.yaml.

Covered by tests/tools/test_voice_mode.py:
- TestGetBeepVolume (12 cases: missing key, custom value, boundary 0.0/1.0,
  out-of-range clamp, type coercion, bool guard, NaN guard, exception
  guard, dict-typed voice section)
- TestPlayBeepVolumeWiring (guards against re-introducing a hardcoded 0.3
  literal in play_beep)

Docs: website/docs/user-guide/configuration.md mentions the new key.
Other locale translations (zh-Hans etc.) intentionally untouched —
handled by the regular i18n sync pipeline as a separate change.

No change in default behaviour: existing users hear exactly the same beep.
2026-07-28 11:57:37 -07:00
Solitud1nem fd213a82f4 fix(voice): enforce voice.max_recording_seconds (was dead config) 2026-07-28 11:57:37 -07:00