Commit Graph

22116 Commits

Author SHA1 Message Date
teknium1 490056d6d3 perf: lazy mcp SDK import + tool-discovery mtime cache + browser_tool import diet
Three cold-start cuts, measured with .venv python, PYTHONPATH=worktree,
median of 3-5 fresh subprocesses:

1. tools/mcp_oauth.py — availability now via importlib.util.find_spec("mcp");
   SDK classes (OAuthClientProvider et al.) import lazily on first use via
   _ensure_sdk_loaded(). Module-level names kept as None placeholders so the
   test-patch surface (patch.object(mcp_oauth, "OAuthClientProvider", ...))
   still works. import tools.mcp_oauth: 242ms -> 56ms (mcp SDK no longer
   loaded at import time).

2. tools/registry.py — discover_builtin_tools AST scan memoized in an
   mtime_ns+size-keyed disk cache at ~/.hermes/cache/tool_discovery_cache.json
   (atomic write via utils.atomic_json_write, best-effort/never raises;
   corrupt or missing cache -> full rescan + rewrite; per-file stat mismatch
   -> rescan just that file). Scan of 100 files: 158ms cold -> 3ms warm.

3. tools/browser_tool.py — top-level `import requests` and
   agent.auxiliary_client.call_llm moved to lazy first-use (PEP 562
   __getattr__ preserves patch("tools.browser_tool.requests.get") and
   patch("tools.browser_tool.call_llm") surfaces; internal call sites go
   through _lazy_call_llm which reads module globals so patches are honored).

Entry-point imports (median ms, before -> after):
  import model_tools   392 -> 245   (warm discovery cache)
  import cli           152 -> 151   (unchanged; cli doesn't hit these paths)
  import gateway.run   234 -> 230

Functional verification:
- get_tool_definitions(quiet_mode=True) under temp HERMES_HOME: identical
  sorted 30-tool name set before vs after (empty diff).
- Discovery cache: delete cache -> 158ms, second run -> 3ms; corrupt cache
  -> clean full rescan; touching one file -> single-file rescan (12ms).
- Tests green: tests/tools/test_registry.py, all test_mcp_oauth*,
  test_mcp_dashboard_oauth, test_mcp_tool_401_handling, all
  tests/tools/test_browser*.py, tests/hermes_cli/test_mcp_{config,startup,
  dashboard_oauth}.py, test_skills_tool_discovery_cache.py.
2026-07-29 10:02:03 -07:00
Brooklyn Nicholson 8ce8b70dca feat(desktop): pins sync between apps sharing a gateway
The pin bridge only ever pushed: localStorage to the backend, never
back. Two apps on the same gateway each kept their own localStorage, so
a pin made on the Mac never appeared on the Windows app.

Now that a pinned row is guaranteed to be in the page, its absence says
nothing about its pin state — which makes the server row authoritative.
`pullRemotePins` adopts pins this app hasn't seen and drops local pins
the server says are gone, keying on the durable lineage root so a pin
survives compression tip rotation. Adopted pins are recorded as already
mirrored rather than echoed back as a redundant write.

A list request in flight when we PATCH still carries the old value, so
honouring it would silently undo the pin the user just made. Writes are
guarded for the lifetime of their own request — no timers, no wall-clock
windows. A runtime predating the flag sends no `pinned` at all; that's
treated as no opinion and leaves the local set alone.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Co-authored-by: aman-merchant <274313970+aman-merchant@users.noreply.github.com>
Co-authored-by: rerdi92 <76791321+rerdi92@users.noreply.github.com>
2026-07-29 12:00:41 -05:00
Brooklyn Nicholson 3290807e60 feat(api): session lists carry the pin flag and never drop a pinned row
The three list endpoints (`/api/sessions`, `/api/profiles/sessions`, and
the batched sidebar route) now request the pinned back-fill and expose
`pinned` as a real JSON boolean alongside `archived`.

Both merge paths re-window rows after sorting, which would have thrown
away exactly what the back-fill fetched, so pinned rows survive the cap.
The sidebar's "load more" signal discounts them — they arrive past the
LIMIT by design and would otherwise fake a full page on a short list.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Co-authored-by: konsisumer <11262660+konsisumer@users.noreply.github.com>
Co-authored-by: eason2026 <209090628+eason2026@users.noreply.github.com>
2026-07-29 12:00:31 -05:00
Brooklyn Nicholson 1b317d23fb fix(sessions): a pinned conversation can't be paged out of the list
`list_sessions_rich` returns one recency-ordered window, so a pinned
conversation that hadn't been touched in a while simply wasn't in the
payload. The desktop's Pinned section resolves pins against the loaded
rows, so the pin rendered as nothing until something dragged the row
back onto the page.

A pin is a "this must always be reachable" statement, which makes
falling off the page a bug rather than a paging outcome. `include_pinned`
adds one bounded query for the rows carrying `pinned = 1` that the
window missed, reusing the page's own WHERE clause — an archived or
filtered-out conversation stays out, and a pin is never a filter bypass.
It runs before compression projection, so a back-filled root surfaces
under its live tip exactly like a row that made the page on its own.

Co-authored-by: hrnbld <260600092+hrnbld@users.noreply.github.com>
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Co-authored-by: Tamaz-sujashvili <56168197+Tamaz-sujashvili@users.noreply.github.com>
Co-authored-by: ferminquant <14808645+ferminquant@users.noreply.github.com>
2026-07-29 12:00:21 -05:00
Teknium 3334db67a4 docs: fold in remaining live fixes from overnight-sweep PR cluster
Salvaged from the same PR family (#50691, #59335, #67832 by @virtuadex):

- sessions.md: gateway routing index is the gateway_routing table in
  state.db; sessions.json is a legacy mirror behind
  gateway.write_sessions_json (from #59335)
- mcp.md: MCP server reads state.db first, sessions.json fallback
  (from #59335)
- slash-commands.md: /model flags --once/--session/--refresh/--provider
  + persist_switch_by_default semantics (from #67832); /reasoning
  messaging row gains level list + --global; /history timestamps note
  (from #50691)
- environment-variables.md: TERMINAL_DOCKER_ENV,
  TERMINAL_DOCKER_EXTRA_ARGS (from #50691); MEM0_MODE/HOST/USER_ID/
  AGENT_ID rewritten for the current tri-mode plugin
- cron-script-only.md: interpreter table row matches bash-from-PATH
2026-07-29 09:45:11 -07:00
virtuadex 782d0219a0 docs: sync overnight sweep with registry, gateway, curator, cron
Salvaged from #72422 by @virtuadex (conflicts resolved against current
main; superseded slash-command hunks dropped):

- SECURITY.md + SECURITY.es.md: gateway adapters live under
  plugins/platforms/<name>/, registry in gateway/platform_registry.py
- gateway-internals.md (EN + zh-Hans): key-files table rows for
  platform_registry.py and plugins/platforms/, deferred-loading section
- slash-commands.md: /reasoning full level list (max/ultra) + --global;
  CLI-only notes list gains /prompt, /pet, /hatch, /timestamps
- cron.md + cron-script-only.md: script runner accuracy — bash resolved
  from PATH with /bin/bash fallback, script paths confined to
  ~/.hermes/scripts/, provider credentials stripped via
  _sanitize_subprocess_env
- curator.md: cron-referenced skills protected from auto-archive,
  never-used grace floor
2026-07-29 09:45:11 -07:00
Teknium c6f98b0cba docs: teach providers: dict as the canonical custom-provider format
Fixes #67278. Since config v12 (see #8776), custom_providers: list is
legacy — the migration converts it to the providers: dict and the
resolver reads the dict first (runtime_provider.py). Docs still taught
the legacy list as primary.

- integrations/providers.md: all 8 YAML examples converted to
  providers: dict (field mapping code-verified: api, default_model,
  transport), one consolidated legacy-format note
- configuring-models.md, configuration.md, credential-pools.md,
  migrate-from-openclaw.md, provider-runtime.md, faq.md: examples and
  prose flipped to dict-first; legacy list noted as still-read
- adding-providers.md untouched (sole mention is a literal test
  filename)
2026-07-29 09:44:59 -07:00
teknium1 1fe06115d1 refactor: extract DEFAULT_CONFIG + OPTIONAL_ENV_VARS to config_defaults.py (pure data move) 2026-07-29 09:34:18 -07:00
teknium1 19055492aa fix: route stray HERMES_HOME hardcodes through get_hermes_home() (profile + native-Windows safety) 2026-07-29 09:33:48 -07:00
Austin Pickett b8ceba97ed fix(web): make PTY resume sanitizer work against real PTY output
Review follow-up on the salvaged #47772 work. Three defects made the
filter a no-op or actively harmful in production, plus both Copilot
review items.

1. The blank-line burst filter never fired. pty_bridge.py spawns via
   ptyprocess.PtyProcess.spawn() and never calls setraw(), so the PTY
   line discipline runs with ONLCR: every LF the child writes reaches
   xterm as CRLF. The /\n{50,}/ pattern requires consecutive LF, so a
   real 1000-row burst matched nothing (verified against a live PTY:
   b"A"+b"\n"*5+b"B" is read back as b"A\r\n\r\n\r\n\r\n\r\nB").
   Now matches /(?:\r?\n){50,}/.

2. Bursts split across WebSocket frames were not collapsed. bridge.read()
   does os.read(fd, 65536) per drain tick and each read is forwarded as
   its own frame, so a burst spans frames and each fragment fell under
   the 50 threshold (3000 rows survived in a 40-byte-read simulation).
   The sanitizer now holds back a trailing newline run — including a lone
   trailing CR, since a frame can split a CRLF pair — and resolves it on
   the next frame or on flush.

3. Erase-code stripping was permanent, not resume-scoped. resumeParam is
   the durable session identity and is never cleared after connect, so
   every spinner/progress/status redraw in a resumed session lost its
   ESC[K and left stale glyphs. Suppression is now bounded to
   PTY_RESUME_SANITIZE_WINDOW_MS (30s) after connect; burst collapsing
   still applies for the life of the socket.

Copilot review items:
- flush() no longer writes a buffered partial CSI into xterm. #pending
  only ever holds an incomplete sequence, and emitting one leaves the
  parser in an in-escape state that swallows output after reconnect. A
  buffered newline run is still emitted (collapsed).
- Test expectations updated accordingly.

Tests: 29 cases (was 18), now using CRLF fixtures that match real PTY
output, plus cross-frame burst reassembly, CRLF-pair frame splits, and
post-window erase preservation. Full web suite 135 passing.
2026-07-29 12:21:48 -04:00
灵越羽毛 88e67508bc fix(web): replace stateless PTY regex with tested PtyResumeSanitizer stream helper
Addresses sweeper review feedback:

- Stateful buffer guards against CSI sequences split across WebSocket frames
- Newline threshold raised to \n{50,} — only targets Ink's pathological bursts
- Streaming TextDecoder with {stream: true} handles split UTF-8 bytes
- onclose flush drains any buffered partial escape
- Extract into web/src/lib/pty-resume-sanitizer.ts for independent testing

Added 17 vitest cases covering:
- applyPtyFilters: pass-through, burst collapse, short newlines, erase-line,
  erase-char, SGR preservation, empty string, mixed content
- PtyResumeSanitizer: complete chunks, 2/3-frame CSI splits, bare \x1b,
  \x1b[digit prefix, empty-chunk buffer integrity, instance isolation,
  onclose flush
2026-07-29 12:21:48 -04:00
灵越羽毛 5e37c94906 fix(web): gate ANSI filtering behind resumeParam and reuse TextDecoder
Addresses Copilot review:
- Reuse a single TextDecoder instance instead of allocating per message
- Only filter erase codes during session resume (resumeParam != null)
- Extract filter chain into named helper sanitizeResumeOutput()

Refs #47313
2026-07-29 12:21:48 -04:00
灵越羽毛 4b6e1e440e fix(web): strip ANSI erase codes and blank-line bursts from PTY stream
Ink two-pass virtual scrolling during session resume floods the
PTY output with \x1b[K (erase-line), \x1b[NX (erase-char), and
thousand-line \n bursts. In the Dashboard INLINE mode, xterm
main scrollback buffer absorbs these as blank rows.

Filter all three in ws.onmessage before they reach xterm.

Refs #47313.
2026-07-29 12:21:48 -04:00
Teknium aff380ddbe Merge pull request #74176 from NousResearch/fix/cua-driver-refresh-pin-confirmed-version
fix: pin cua-driver refresh to the release check-update confirmed
2026-07-29 09:19:52 -07:00
Teknium 5fdc9d2a9d fix: pin cua-driver refresh to the release check-update confirmed
The upstream cua-driver installer scripts on trycua/cua@main carry a
baked default version that Release Please bumps in the release PR
*before* the release assets are published. During that window an
unpinned installer run 404s on the asset download and the
`hermes update` cua-driver refresh fails with:

    error: download failed: The remote server returned an error: (404) Not Found.
    ⚠ cua-driver refreshing did not complete. Re-run manually: ...

Observed live 2026-07-29: baked version 0.14.0 vs latest published
release 0.13.1 — every `hermes update` run with an out-of-date driver
hit the warning until upstream publishes the assets.

We already know the correct version: `cua-driver check-update --json`
returns `latest_version` straight from the GitHub Releases API, whose
entries by definition have published assets. When the check positively
confirms an update, export that version as CUA_DRIVER_RS_VERSION into
the installer child env (both install.sh and install.ps1 honour it over
their baked default), so the refresh downloads the release that
actually exists instead of racing the upstream release pipeline.

Malformed / missing latest_version values fall back to the previous
unpinned behaviour. The explicit `hermes computer-use install
--upgrade` force path and fresh installs are unchanged.
2026-07-29 09:02:19 -07:00
Teknium 7de33cc57e Merge pull request #64536 from victor-kyriazakos/feat/gateway-health-diagnostics
feat(monitoring): gateway health & diagnostics OTLP export
2026-07-29 08:58:33 -07:00
Teknium 43874d1a96 docs: accuracy sweep + coverage for 2 months of shipped features
Accuracy pass (all 373 pages audited against code, 13 parallel audits):
- configuration.md: 12 stale defaults/keys (file-sync rewrite, clarify
  timeout, streaming knobs, iteration budget, TTS/STT enums)
- reference/: commands/env-vars/toolsets/tools synced with
  COMMAND_REGISTRY, argparse tree, OPTIONAL_ENV_VARS, TOOLSETS
  (28 env vars added, 3 phantom removed, mcp__ naming, webhook
  platform restricted toolset)
- features/, messaging/, developer-guide/, guides/: ~60 factual fixes
  (web_extract truncation, dashboard auth fail-closed, delegation
  blocked tools, adapter signatures, session schema v23, phantom
  Matrix env vars, hermes setup tts, auth spotify, webhook --skills)
- zh-Hans: explicit heading IDs fix 2 broken WSL2 anchors

New coverage for features shipped in the last 2 months (verified
against code before writing):
- compression.in_place, verify-on-stop (+v31/v32 migration reality),
  ${env:VAR} SecretRef, display.timestamp_format, session:compress
  hook + thread_id/chat_type fields
- /journey learning timeline, per-channel model/system-prompt
  overrides, /sessions search, clarify multi-select, -z --usage-file,
  uninstall --dry-run, config get/unset
- MCP elicitation, extra_headers, discover_models, api-server run cap,
  Bedrock cachePoint, Discord reasoning_style, Google Chat clarify
  cards, vibe reactions, resume cwd restore, Yuanbao forwarded
  messages, api_content sidecar, roaming pet, tool_progress log mode,
  WhatsApp polls/locations, kanban per-task model + lifecycle hooks
2026-07-29 08:48:05 -07:00
Teknium 068d7812a4 docs: document Hermes Relay and the desktop feature wave
- New page user-guide/messaging/relay.md: what Relay is, enrollment
  (hermes gateway enroll), capability handshake (media, native
  approval/clarify, threads, typing), config keys, troubleshooting —
  derived from gateway/relay/ + gateway_enroll.py (PRs #48147 #48242
  #71300 #71363 #71404 #71624 #69721).
- desktop.md: artifacts viewer, timeline rail, find-in-page,
  multi-window, git review/worktrees, multi-terminal + persistence,
  theme import, rebindable shortcuts, quick entry, context-usage
  popover, keep-awake, command palette, Hermes Cloud mode, memory
  graph — all verified against apps/desktop/ source.
2026-07-29 08:48:05 -07:00
Teknium 355b376225 docs(skills): regenerate skills catalog; fix generator sentence truncation
- Fix generate-skill-docs.py short-description truncation: split on
  sentence boundary (dot + space/end) instead of the first dot, which
  mangled descriptions containing dotted paths ('.hermes/plans/') or
  abbreviations.
- Regenerate all 181 skill pages + both catalog indexes + sidebar:
  adds missing pages for inspecting-hermes-desktop-dom, tldraw-offline
  (fixes broken catalog link), and pinecone-research.
- Delete orphaned kanban-codex-lane page (skill removed in #39028).
2026-07-29 08:48:05 -07:00
Teknium c19fd5c505 fix(cli): wire hermes import-agent parser into main argparse tree
The import-agent subcommand (24c3c27ba8) shipped with its parser builder
and handler logic but build_import_agent_parser() was never called from
main.py, making the documented and unit-tested command uninvocable.
Register it alongside the other subcommand builders.
2026-07-29 08:48:05 -07:00
Teknium 707f316687 chore: map contributor email shag@agentmail.to -> sg-shag 2026-07-29 08:42:16 -07:00
Teknium 64beb25a35 fix(cli): stop hard-wrapping streamed paragraphs; prefer OSC 52 over SSH
Streamed responses no longer insert real newlines at terminal width —
logical lines are emitted whole and the terminal soft-wraps them, so
highlight-copy rejoins the full line (emulators only keep linebreaks
the app actually printed). This is the CLI equivalent of the TUI's
selection copy, which reads logical source lines from its screen
buffer. TTFT perception is preserved by mirroring the unfinished
line's tail into the spinner status text instead of chunk-printing.

/copy now prefers OSC 52 when running over SSH (SSH_CONNECTION /
SSH_TTY / SSH_CLIENT) — native tools there write the REMOTE clipboard,
which is never what the user wants. The CLI's OSC 52 writer also gains
tmux/screen DCS passthrough wrapping, mirroring the TUI's
wrapForMultiplexer. Fixes #31528 for the CLI surface.

Sabotage-verified: restoring the old chunk emitter fails 3 of the new
tests (hard-wrap detection, spinner mirror, unbreakable-run split).
2026-07-29 08:42:16 -07:00
Shag 644590369f fix(tui): keep local tmux native copy fallback 2026-07-29 08:42:16 -07:00
Shag 8736ecac37 fix(tui): prefer OSC52 for copy in remote tmux sessions 2026-07-29 08:42:16 -07:00
victor-kyriazakos 1773752c8c Merge remote-tracking branch 'origin/main' into feat/gateway-health-diagnostics-monitoring
# Conflicts:
#	uv.lock
2026-07-29 15:37:14 +00:00
Teknium b6729ba905 test: importorskip numpy in thinking-sound tests (lazy voice dep, hermetic CI) 2026-07-29 08:24:00 -07:00
Teknium d042264310 fix: guard _voice_tts_done access for bare-constructed test CLIs (getattr pattern, skill pitfall #17) 2026-07-29 08:24:00 -07:00
Teknium b0734b0aa5 chore: contributor mapping for beardedeagle (#71083 salvage) 2026-07-29 08:24:00 -07:00
Teknium c15f9b71a5 fix(voice): spoken barge-in works on every TTS playback path (CLI + gateway/desktop backends)
Premise check on live main: barge-in machinery EXISTS for the per-turn
STREAMING pipeline only — cli.py chat() arms _voice_barge_in_monitor and
tui_gateway _tts_stream_begin arms _tts_stream_barge_in_monitor. What was
actually broken for spoken interruptions:

1. CLI whole-file fallback (_voice_speak_response_async — used whenever
   streaming TTS cannot start: sounddevice missing, requirement probe
   fails): NO monitor was ever armed, so talking over the reply did
   nothing. Now arms _voice_barge_in_monitor in continuous voice mode.
2. Gateway fallback speak (tts_queue None → speak_text thread) and the
   voice.tts RPC (desktop-triggered speech): speak_text ran bare, and
   its internal streaming dispatch created a PRIVATE stop event nothing
   could reach — uninterruptible even by stop_playback(). New
   _speak_text_with_barge() runs the same barge monitor beside the speak
   thread; hermes_cli.voice.speak_text/_speak_text_streaming accept an
   external stop_event so a barge cuts the streaming pipeline too.
   Stop-phrase handling and voice.transcript submission are inherited
   from the shared monitor (merged #73933 behavior preserved).
3. False barge during TTS (the reason interruption "worked" then
   self-cancelled or fired randomly): salvaged PR #71083 by @beardedeagle
   (previous commit, kept authorship) — rolling-window VAD floor, 8x
   multiplier, 4000-RMS trigger ceiling, barge_in_grace_seconds (2s)
   before the mic opens, min-floor clamp. barge_in_grace_seconds is now
   documented in DEFAULT_CONFIG.

Desktop spoken barge (renderer mic via voice-barge-in.ts) already covers
both its live-stream and fallback speech paths — verified, no change.
2026-07-29 08:24:00 -07:00
Teknium df093bf33c feat(voice): calm ambient "thinking" sound while the agent works in voice chat
Long thinking/tool stretches in a voice conversation are dead air — the
user cannot tell whether the agent is alive. New: quiet, repeating soft
bubble blips while the agent works and no speech audio is flowing.

- tools/voice_mode.py: numpy-synthesized blips (no binary assets) — two
  alternating low pitches (G4/E4) with pitch glide + smooth attack/decay
  envelopes, ~0.8-1.2s randomized spacing, volume = voice.beep_volume * 0.5.
  start_thinking_sound(should_play=...) / stop_thinking_sound() daemon-loop
  lifecycle; macOS-TCC-safe (sounddevice output gated there → silent skip,
  no per-second afplay churn). New mark_audio_output_active()/
  is_audio_output_active() ref-count wraps play_audio_file and the
  streaming OutputStream sentence writes so "audio is flowing" is accurate.
- Config: voice.thinking_sound (default true) off-switch.
- cli.py: starts when a voice-mode turn begins, per-blip gate skips while
  TTS speaks / mic records / barge capture owns the mic; stopped in the
  chat() finally on every exit path.
- tui_gateway/server.py: same lifecycle around _run_prompt_submit turns
  (voice mode on), gated on is_audio_output_active + continuous capture.
- Desktop: renderer owns voice-conversation audio, so a matching WebAudio
  implementation (src/lib/thinking-sound.ts, same envelope/pitches) runs
  while conversation status === "thinking"; honors voice.thinking_sound
  (via config store) and the shared sound-mute toggle; stops instantly on
  speaking/listening/end.
2026-07-29 08:24:00 -07:00
Teknium 6fdfdc1597 feat(voice): "Say <stop-phrase> to end the voice chat" notice on voice-mode start (CLI/TUI/desktop, i18n)
One owner for the wording: voice_stop_hint() in tools/voice_mode.py —
sources the phrase from voice.stop_phrases (first entry) so a custom
phrase renders correctly, and returns "" when the feature is disabled
(stop_phrases: []) so no surface shows a hint.

- CLI: printed in /voice on output (style-matched dim notice).
- TUI: voice.toggle action=on now carries stop_hint; the Ink client
  renders it in the "Voice mode enabled" block (older gateways omit
  the field — no hint, no crash).
- Desktop: the renderer voice loop never touches tools/voice_mode.py,
  so the phrase is read from config (voice.stop_phrases → $voiceStopPhrase
  store, seeded in use-hermes-config) and shown as an info toast when a
  voice conversation starts. i18n: en/ja/zh/zh-hant/ar.
2026-07-29 08:24:00 -07:00
Teknium 9e5f1b619c fix(voice): silent cycles never end the chat while the agent is busy or TTS is playing
The continuous-voice no-speech counter (3 strikes -> voice off) counted
every silent capture cycle unconditionally. During a long agent turn
(thinking/tool-calling for minutes) or while TTS is speaking, the user
is CORRECTLY silent — those cycles ended the voice chat under them.

- hermes_cli/voice.py: new set_voice_busy_probe() seam + _voice_activity_held()
  (TTS-playing via the existing _tts_playing Event, agent-busy via the
  registered probe). Both the continuous-loop strike path and the
  force-transcribe single-shot strike path skip counting while held.
  Fail-open: a broken probe counts cycles as before.
- tui_gateway/server.py: registers _any_session_running() as the probe
  on voice.record start (voice is process-global; any running session holds).
- cli.py: classic CLI strike path skips counting while _agent_running
  or TTS playback is in flight.

Stop phrase and barge-in still work during the hold (own paths).
Includes a fixture fix for the #71083 cherry-pick: the fake tools.tts_tool
module needs _load_tts_config (main's tts_streaming imports it).
2026-07-29 08:24:00 -07:00
beardedeagle be424703c4 fix(voice): rolling-window VAD, duplicate render suppression, TUI gateway mirror
Replace one-shot VAD calibration with a rolling deque window that
continuously recalibrates the noise floor throughout TTS playback,
preventing false barge-in triggers from stale calibration. Add a
grace period before VAD activates so TTS playback establishes first.
Suppress duplicate text rendering when token streaming is enabled.
Mirror the barge-in and TTS stream stop logic to the TUI gateway path
so both CLI and gateway use the same VAD semantics.

Rolling-window VAD:
- 90th percentile of rolling window (~3s) for noise floor
- 8x multiplier (was 5x) for TTS volume variation headroom
- 4000 RMS trigger ceiling so genuine speech can still trip
- min_floor clamped to SILENCE_RMS_THRESHOLD * 2
- sustained_ms=1000, calibration_ms=800

Barge-in grace period (barge_in_grace_seconds, default 2.0s):
Delays VAD activation so TTS playback establishes before the mic opens.

Duplicate render suppression:
When streaming_enabled, pass display_callback=None to
stream_tts_to_speaker so the token stream is the sole display path.

TUI gateway mirror:
Mirror _tts_stream_stop and _tts_stream_barge_in_monitor changes to
tui_gateway/server.py so both code paths use the same VAD parameters,
grace period, and TTS CUT diagnostic logging.

Profile-scoped session DB and MoA progress events in tui_gateway/server.py
were necessitated by the TTS pipeline changes affecting session state
and event routing.

Normal-exit flag and TTS CUT diagnostic logging at all cut paths.

Regression tests:
- test_quiet_then_loud_playback_does_not_trip
- test_8x_multiplier_absorbs_tts_volume_spikes
- test_trigger_ceiling_lets_genuine_speech_trip
- test_silence_calibration_does_not_false_trip_on_tts
- test_tts_stream_stop_latches_interruption_for_next_turn
- test_tts_stream_stop_after_natural_finish_does_not_latch
- Profile-scoped session DB tests (10 tests)
2026-07-29 08:24:00 -07:00
kshitij 27b9e13ebe Merge pull request #74138 from kshitijk4poor/chore/contributor-email-mattezell
chore: map ezell.matt@gmail.com -> mattezell for contributor attribution
2026-07-29 20:52:32 +05:30
Ben Barclay e327eaa2a0 feat(sync): default the sync plane to production
Skill Sync had no default base URL, so a user with no `sync.base_url` in
config.yaml and no HERMES_SYNC_BASE_URL got:

    sync inert: no sync base URL configured (config.yaml sync.base_url
    or HERMES_SYNC_BASE_URL).

Every sync command was unusable out of the box. The URL was left unset
because the plane did not exist yet when the client was written; it does now.

- Adds DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com" and
  returns it as the last step of resolve_sync_base_url().
- Resolution order is unchanged otherwise: HERMES_SYNC_BASE_URL ->
  config.yaml sync.base_url -> production default. The env var and config key
  now exist to point a dev/staging build at another plane rather than to make
  the feature work at all.
- Follows the existing precedent for production endpoints in this codebase
  (DEFAULT_NOUS_PORTAL_URL in hermes_cli/auth.py, HERMES_DIAGNOSTICS_BASE_URL
  in diagnostics_upload.py): a module constant with env/config override.

The "no sync base URL configured" guards are kept — they are now unreachable
in practice but remain correct if the default is ever blanked.

Tests: 3 new — the default is returned when nothing is configured, config
still overrides it, and the constant is a bare https origin (no trailing
slash, no path) since the client appends /v1/sync/. 2349 passed / 0 failed
across 56 suites via scripts/run_tests.sh.

Verified against a temp HERMES_HOME with no config: resolves to the
production plane; HERMES_SYNC_BASE_URL and sync.base_url both still win, and
trailing slashes are stripped.
2026-07-29 08:14:03 -07:00
kshitijk4poor cff9728587 fix(cron): record failure for BaseException escapes and leave a diagnostic when removing wedged one-shots
Fixes #73973.

A finite one-shot whose dispatch was claimed (claim_dispatch increments
repeat.completed BEFORE execution) but whose run died before mark_job_run
was left permanently wedged: completed==times, last_run_at null, state
'scheduled'. The run-claim TTL blocked re-dispatch, and once it expired
the dispatch-limit guard silently removed the job with no output and no
error.

Two complementary fixes:

- cron/scheduler.py run_one_job: the outer handler now catches
  BaseException, not just Exception. The inner run_job handler re-raises
  CancelledError/KeyboardInterrupt/SystemExit after agent teardown, and
  none of those are Exception subclasses, so the outer 'except Exception'
  missed them and mark_job_run(False) was never called. Failures are now
  recorded first (mark_job_run + finish_execution, each independently
  guarded), then non-Exception BaseExceptions are re-raised to preserve
  teardown semantics. Plain Exceptions keep the existing behavior
  (recorded, return False, no re-raise). Empty str(e) (bare
  CancelledError) falls back to the exception class name.

- cron/jobs.py: when either removal site (claim_dispatch or the
  get_due_jobs dispatch-limit guard) drops a one-shot whose claimed run
  never completed (last_run_at null), _write_wedged_oneshot_diagnostic
  now writes an operator-visible .md into cron/output/<job_id>/ instead
  of vanishing silently. Best-effort: diagnostics can never break the
  removal. No diagnostic when last_run_at is set (normal completion
  race).
2026-07-29 20:16:57 +05:30
kshitijk4poor 97fa2603d9 chore: map ezell.matt@gmail.com -> mattezell for contributor attribution 2026-07-29 19:43:43 +05:00
kshitijk4poor 41a07f5b84 test(cron): lock the #65773 env-injected credential contract at the cron layer
Issue #65773: run_one_job installs a <home>/.env secret scope around every
job; before c758ded6d (#69057) an installed scope was authoritative even
with multiplexing off, so provider keys injected only via the process
environment (container env vars, systemd Environment=) resolved to empty
inside cron and every provider call went out with the no-key-required
placeholder -> HTTP 401, while interactive turns kept working.

The fix landed in agent/secret_scope.py (scope-miss fallthrough to
os.environ when multiplex is off) with unit tests at that layer only.
These two tests pin the end-to-end contract where the bug actually
surfaced - cron's run_one_job:

- env-injected key resolves during run_job with multiplex OFF (fails on
  pre-fix code, verified by mutation against c758ded6d~1)
- .env value still wins when both sources define the key (precedence)

Implementation-agnostic: passes whether the fix is the get_secret
fallthrough (main today) or a multiplex guard at the installation site
(the approach in #65801/#65802/#73037).
2026-07-29 17:44:39 +05:30
yoniebans a8ec50f681 fix(sessions): always stamp owning profile so cross-profile open works toward default
Session rows served without ?profile= carried no profile field, so in
multi-profile desktops the default profile's sessions circulated unowned:
resolveStoredSession cached them profile-less, resolveSessionProfile returned
undefined, and session.resume targeted whichever gateway was active -- opening
a default-profile session from a non-default window failed with
"session can't be found" while the reverse direction worked (#67603 family).

Server: GET /api/sessions/{id} and GET /api/sessions now stamp profile/
is_default_profile unconditionally -- the serving profile is always known
(_cron_default_profile() when the request is unscoped).

Renderer: resolveStoredSession treats a profile-less $sessions cache hit as
unresolved when >1 profile exists (falls through to the stamped by-id ladder)
and back-fills the active profile on bare by-id hits from older backends, so
unowned rows are never re-cached.

Verified via CDP against a live 4-profile renderer: bare by-id GET returned
hasProfileField:false and the stale cache rows matched; with the fix both
lookups return the owning profile and the resume routes to the right backend.
2026-07-29 13:54:12 +02:00
Enough1122 5cc5c58e01 fix(gateway): flush pending memory writes before session teardown (#73297)
The gateway's /reset cleanup path called shutdown_memory_provider without
first draining the memory manager's serialized background write worker.
shutdown_all only gives that worker a bounded (~5s) drain and abandons
whatever is still queued past it, so a /reset could silently drop writes
the session had already handed off -- the next session then loaded
stale MEMORY.md.

The CLI exit path already drains via MemoryManager.flush_pending before
shutdown; this PR pins the same contract on the gateway cleanup path.

Cleanup now calls agent._memory_manager.flush_pending(timeout=10) before
the existing shutdown_memory_provider step. The flush is best-effort:
a flush failure must never block teardown, so it is wrapped in
try/except and the existing shutdown path remains the fallback.

Closes #73297
2026-07-29 17:21:55 +05:30
kshitijk4poor 222ea2b6c9 refactor: fold simplify-code review findings
- extract _commit_registry/_note_refresh_failure shared by the background
  worker and foreground stage-4 (identical 4-step success + failure paths
  were duplicated); worker now commits under _models_dev_fetch_lock so a
  failing background refresh can never re-arm the backoff immediately
  after a successful force_refresh committed (unsynchronized-write race)
- add should_clear_context_pin_async to hermes_cli/route_identity.py
  (matching the get_model_context_length_async precedent) and use it at
  the 4 async gateway sites instead of inline asyncio.to_thread wraps;
  the sync _format_session_info site keeps the sync call (already
  off-loop via its callers' to_thread)
- test the background-refresh success path (the PR's primary new
  behavior): disk saved, mem cache swapped, backoff cleared, in_flight
  reset — mutation-checked
- replace the race-prone spin-wait on _models_dev_refresh_in_flight with
  a named-thread join in the backoff test
2026-07-29 17:13:48 +05:30
kshitijk4poor ccf7129ed0 fix: restore zero-arg fetch_models_dev call on default paths
The branched call shape in get_provider_info/get_provider is deliberate:
~69 test sites across tests/hermes_cli and tests/gateway monkeypatch
fetch_models_dev (and get_provider_info) with zero/single-arg lambdas.
Passing allow_network= unconditionally broke 5 tests in CI slices 2/3/7.
Documented the constraint inline.
2026-07-29 17:13:48 +05:30
kshitijk4poor 11ca7eedf0 fix: follow-up hardening for salvaged #73621 + #35853
- _mark_stale_cache_grace only moves cache_time forward so a completed
  background refresh is never rewound to a 5-minute grace window
- clear _models_dev_refresh_in_flight if Thread.start() raises so a
  one-off thread-exhaustion failure doesn't disable refresh forever
- move empty-registry validation into _fetch_models_dev_from_network
  (was duplicated in the background worker and the foreground fetch)
- pass allow_network through as a plain kwarg in get_provider_info and
  hermes_cli.providers.get_provider instead of the branched call shape
- refresh the stale module docstring (no bundled snapshot exists; the
  resolution order now describes stale-serve + background refresh)
2026-07-29 17:13:48 +05:30
zapabob a479a1599f fix(models): use stale cache before models.dev refresh 2026-07-29 17:13:48 +05:30
StellarisW 8c50aaceb6 fix(gateway): keep models.dev refreshes off event loop 2026-07-29 17:13:48 +05:30
kshitijk4poor 9d6b9f44f2 chore: map megusta52@proton.me -> DonutsDelivery for attribution audit 2026-07-29 17:11:30 +05:30
kshitijk4poor 24a56f027c fix(lsp): expose lsp.idle_timeout in config, harden reaper, log reaps
Follow-ups on top of @DonutsDelivery's salvaged reaper commit (#64141):

- create_from_config() now parses lsp.idle_timeout (invalid values fall
  back to DEFAULT_IDLE_TIMEOUT) — previously the constructor knob was
  unreachable from config.yaml (config exposure adapted from #36892 by
  @0xbWy and #68091 by @9miya20)
- canonical default declared in hermes_cli DEFAULT_CONFIG so config
  discovery surfaces the knob (per sweeper review note on #64980)
- reaper loop survives transient sweep errors instead of dying and
  silently re-opening the leak (gap flagged in #68091 review)
- eventlog.log_reaped(): one INFO line per sweep + clears the
  log_active announce cache so respawns re-announce at INFO
- docs: replace the stale 'no idle-timeout reaper' paragraph with the
  new lifecycle description + config reference
- tests: reuse-refresh protection (the regression teknium's sweeper
  requested on #64141), reaper-survives-error, config propagation,
  invalid-value fallback, DEFAULT_CONFIG/manager-constant sync
2026-07-29 17:11:30 +05:30
DonutsDelivery d7578018c5 fix(lsp): reap idle language servers 2026-07-29 17:11:30 +05:30
kshitijk4poor 472658d014 fix(desktop): pass shell to serve probe; bound Windows discovery probes
Two residual gaps from the #72707/#72632 probe-hardening series:

- backendSupportsServe never forwarded backend.shell to the serve
  --help probe. A .cmd/.bat shim backend (which carries shell: true in
  its step-4 descriptor) makes execFileSync throw EINVAL on modern
  Node; the bare catch then caches supported=false for the process
  lifetime, permanently routing that backend through the legacy
  dashboard form. Forward the flag.

- The Windows python-discovery probes ran with no timeout at all:
  reg query (registry read) and py.exe -c 'import sys;...' (bare
  interpreter startup) are both synchronous execs on the boot path;
  a wedged reg.exe or python.exe would hang the resolver forever.
  Bound reg query at 5s and the py.exe probe at the shared
  PROBE_TIMEOUT_MS budget.
2026-07-29 17:09:25 +05:30
hermes-seaeye[bot] 022a175e0a fmt(js): npm run fix on merge (#74057)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-29 11:32:21 +00:00