Commit Graph

4588 Commits

Author SHA1 Message Date
teknium1 988d471479 style(ts): sort imports/exports the way perfectionist wants after the rebase 2026-09-13 06:50:57 -07:00
teknium1 c3edad29ba docs(shared): declare M as compactNumber's top rung
The 1e9 → '1000M' test row read as a snapshot of a missing rung; the
formatter comment now states the cap (token/cost figures stay well under a
billion; a B suffix would collide with the bytes reading) and the row cites it.
2026-09-13 06:50:57 -07:00
teknium1 c764d6d354 fix(shared): ensureContrast keeps the desktop's 0.2-step ladder; TUI chain opts into 0.05
The shared ensureContrast shipped the TUI's fine 0.05×20 ladder, which
changed --dt-primary-solid for 7 of 15 desktop presets (nous #3b6acb →
#3f70d8, cyberpunk #00661a → #008021, slate #505457 → #6f7377) while the PR
body said no preset VALUE changed. The ladder is now the desktop's original
algorithm exactly — pole by luminance < 0.5, accumulating 0.2 steps up to
1.0001, re-mixed from the source colour — with `step` as a parameter. The
only pre-refactor TUI caller (ColorChain.ensureContrast) passes 0.05, so
the terminal palette is byte-identical too.

Test: apps/desktop context.test.tsx iterates every builtin preset × mode,
paints it through ThemeProvider and asserts --dt-primary-solid equals the
value a reference copy of the old desktop algorithm computes. Sabotage
(default step 0.05): 11/30 rows fail. Docs: the SDK table now lists
contrastRatio as `number | null` under sRGB measures, not OKLCH.
2026-09-13 06:50:57 -07:00
teknium1 3f02259518 fix(shared): fuzzyRank folds [-_.] to space on both sides like the desktop picker did
The desktop model picker moved from foldIncludes (searchFold: lower-case +
`[-_.]` → space on text AND query) to the shared fuzzyRank, which only
lower-cased. `gpt.4o`, `claude_3` and `qwen3-8` returned zero rows where
main listed gpt-4o / claude-3-opus / qwen3.8-flash, while HighlightMatches
still folded — filter and highlight disagreed. fuzzyScore now folds both
sides with a length-preserving fold, so positions still index the original
target and all three surfaces rank a separator variant identically.

Tests: three separator rows in fuzzy.test.ts and in the desktop picker
test. Sabotage (lower-case only): all six fail.
2026-09-13 06:50:57 -07:00
teknium1 458595a20b refactor(desktop): slash block-list derives from the Python command registry; only 5 TS-only names stay hand-typed
34 of the 46 `NO_DESKTOP_SURFACE` rows in desktop-slash-commands.ts were a
byte-for-byte copy of `desktop=` on the matching CommandDef in
hermes_cli/commands.py (7 more were aliases of those rows). The live
`commands.catalog` already carries that metadata; the static list was the
offline fallback and would silently drift on the next registry edit.

Now `hermes_cli/commands.py::desktop_surface_registry()` is the one author
of `/name -> desktop` (aliases included). `scripts/dump_desktop_slash_registry.py`
writes it to apps/desktop/src/lib/desktop-slash-registry.json, which the
desktop imports as its offline fallback (`registryUnavailableSpecs`). Five
names the Python registry has never heard of stay in an explicit
`TS_ONLY_NO_DESKTOP_SURFACE` with the reason WHY: `/density /details /logs
/mouse` are Ink-process-local display toggles (handled in
ui-tui/src/app/slash/commands/core.ts; advertised via `_TUI_EXTRA`), and
`/pets` is the plural typo of the desktop's own `/pet` action. `/switch`
was never a block-list row (it is a `/resume` alias) — not a finding.

Cross-language contract: tests/hermes_cli/test_desktop_slash_registry.py
asserts the committed JSON == desktop_surface_registry() and that every
alias carries its canonical value; desktop-slash-commands.test.ts asserts
every dumped row is unavailable/unsuggested offline with the dumped reason
and that the TS-only set is disjoint from the dump. Both sides fail on
drift (sabotage: flipping one `desktop=` in commands.py -> Python test
"stale"; adding `/clear` to the TS-only list -> vitest disjointness fails;
dropping `registryUnavailableSpecs()` -> 4 existing vitest cases fail).

Behavior change: none for users. `/model` (`desktop="hidden"`) keeps its
local picker spec; `hidden` is a popover flag read from the live catalog.

Sites: apps/desktop/src/lib/desktop-slash-commands.ts::NO_DESKTOP_SURFACE
(46 rows) -> hermes_cli/commands.py::desktop_surface_registry (41 rows via
the dump) + TS_ONLY_NO_DESKTOP_SURFACE (5 rows). apps/desktop/src/AGENTS.md
updated.
2026-09-13 06:50:57 -07:00
teknium1 057c2c85fc refactor(slash): delete the dead web slash re-implementation; one slash parser + command.dispatch narrowing in @hermes/shared
web/src/lib/slashExec.ts and web/src/components/SlashPopover.tsx had zero
importers since the React composer was replaced by the PTY-embedded TUI
(f49afd3122) — exactly what web/AGENTS.md forbids, now orphaned. Their
parseSlash still carried the `(.*)` newline bug and lacked the `prefill`
variant. Desktop and the TUI each hand-rolled the same slash split and the
same command.dispatch narrowing; the multi-line fix (#41323, #55510) had to
be applied to each copy separately.

Sites:
  web/src/lib/slashExec.ts::executeSlash/parseSlash/parseCommandDispatch  -> deleted
  web/src/components/SlashPopover.tsx::SlashPopover                        -> deleted
  apps/desktop/src/lib/chat-runtime.ts::parseSlashCommand                  -> apps/shared/src/slash.ts::parseSlashCommand
  apps/desktop/src/lib/chat-runtime.ts::parseCommandDispatch               -> apps/shared/src/slash.ts::parseCommandDispatch
  apps/desktop/src/lib/chat-runtime.ts::SLASH_COMMAND_RE                   -> apps/shared/src/slash.ts::SLASH_COMMAND_RE
  apps/desktop/src/app/types.ts::*CommandDispatchResponse (5 interfaces)   -> apps/shared/src/slash.ts
  ui-tui/src/domain/slash.ts::parseSlashCommand/looksLikeSlashCommand      -> apps/shared/src/slash.ts
  ui-tui/src/lib/rpc.ts::asCommandDispatch                                 -> apps/shared/src/slash.ts::parseCommandDispatch
  ui-tui/src/gatewayTypes.ts::CommandDispatchResponse                      -> apps/shared/src/slash.ts
  9 desktop importers + 3 TUI importers repointed.

Behavior change: desktop `parseSlashCommand` now lower-cases the command
name like the TUI, backend `resolve_command` and `slash.exec` already do
(`/Help` resolved before via the case-insensitive backend; local desktop
action lookups were case-sensitive). TUI's parsed result no longer carries
the redundant `cmd` echo (no consumer read it).

Tests: apps/shared/src/slash.test.ts (parseSlashCommand multi-line /
newline-boundary / degenerate cases; parseCommandDispatch every variant +
malformed rejection). Sabotage: restoring `(.*)` in SLASH_PARTS_RE fails
2 tests; restored -> 7 pass. Desktop chat-runtime.test.ts and TUI
asCommandDispatch.test.ts cases moved here; slashParity.test.ts repointed.
2026-09-13 06:50:57 -07:00
teknium1 35022e02ed refactor(themes): one sRGB color-math module in @hermes/shared; measured readableOn + fine ensureContrast ladder on both surfaces
ui-tui/src/lib/color.ts called itself "the twin of the desktop app's
src/themes/color.ts" and the two had already drifted: the desktop measured
readableOn but used a coarse 0.2x5 ensureContrast ladder and returned 0 for
unparseable luminance; the TUI had the fine 0.05x20 ladder and null-for-garbage
but a luminance>0.5 threshold readableOn. Both now import the primitives from
apps/shared/src/color.ts (`@hermes/shared/color`, also exported from the root
index); each surface keeps only what is specific to it. No palette / preset /
skin VALUE changes anywhere — only math.

Sites (path::symbol → canonical):
  apps/desktop/src/themes/color.ts::hexToRgb           → @hermes/shared/color::parseColor (deleted)
  apps/desktop/src/themes/color.ts::rgbToHex           → @hermes/shared/color::toHex (deleted)
  apps/desktop/src/themes/color.ts::mix                → @hermes/shared/color::mix
  apps/desktop/src/themes/color.ts::relativeLuminance  → @hermes/shared/color::relativeLuminance
  apps/desktop/src/themes/color.ts::contrastRatio      → @hermes/shared/color::contrastRatio
  apps/desktop/src/themes/color.ts::readableOn         → @hermes/shared/color::readableOn (desktop wrapper readableInk pins ['#161616','#ffffff'])
  apps/desktop/src/themes/color.ts::ensureContrast     → @hermes/shared/color::ensureContrast
  ui-tui/src/lib/color.ts::{Rgb,parseColor,toHex,mix,relativeLuminance,contrastRatio,readableOn,ensureContrast,lighten,darken}
                                                       → @hermes/shared/color (same names)
  Stays desktop-only (apps/desktop/src/themes/color.ts): luminance, normalizeHex, readableInk, OKLCH set
    (hexToOklch, oklchToHex, oklchToSrgb255, maxChroma, hueDelta, harmonize, mixOklab, withHue, ensureContrastOklch).
  Stays TUI-only (ui-tui/src/lib/color.ts): liftForContrast, grayOf, desaturate, toHsl, fromHsl, retone,
    boostSaturation, color()/ColorChain.
  Importers repointed (17): apps/desktop/src/{sdk/index.ts, themes/context.tsx, themes/retint.ts,
    themes/retint.test.ts, themes/skin.ts, themes/vscode.ts, themes/vscode.test.ts};
    ui-tui/src/{theme.ts, sdk/index.ts, sdk/apps/weather.tsx, app/createGatewayEventHandler.ts,
    components/agentsPanel.tsx, components/branding.tsx, components/loaders.tsx,
    components/overlayPrimitives.tsx, lib/color.ts, lib/color.test.ts}.
  Wiring: apps/shared/package.json exports './color'; apps/shared/src/index.ts re-exports;
    apps/desktop/tsconfig.json paths + vite.config.ts alias for '@hermes/shared/color'
    (ui-tui resolves the subpath via the workspace package exports, like './billing').

Behavior change (1): relativeLuminance / contrastRatio return null for unparseable
  input on the desktop too (previously 0, which made garbage measure like pure
  black). Desktop SDK export `contrastRatio` therefore widens to `number | null`.
  Only ensureContrastOklch relied on the number: it now treats null as "already
  passing / can't measure" and returns the input unchanged. Every other desktop
  caller passes 6-digit hex.

Behavior change (2): readableOn MEASURES both candidate inks and returns the one
  with the higher contrast ratio (desktop semantics; the threshold version got
  mid-lightness accents wrong: white on #4f9e5e is 3.29:1 vs near-black 5.50:1).
  Signature is readableOn(bg, inks = ['#000000', '#ffffff']); the desktop passes
  its own pair via `readableInk` so desktop output is byte-identical. The TUI
  switches from the luminance>0.5 threshold to measurement: over the 185 distinct
  hexes in ui-tui/src/theme.ts (DARK/LIGHT seeds + built palettes) and
  hermes_cli/skin_engine.py, 56 flip from '#ffffff' to '#000000' — all
  mid-lightness accents (L 0.18–0.49, e.g. #cd7f32, #4caf50, #ef5350, #ffa726,
  #4dabf7) where black measures 4.6–10.8:1 against white's 1.9–4.5:1. Note the
  TUI never called readableOn directly; it only reaches ensureContrast's pole
  choice (below), and ensureContrast is only reachable via the color() chain and
  the theme.ts re-export (no production caller today).

Behavior change (3): ensureContrast steps 0.05 x 20 from the ORIGINAL color toward
  the measured readableOn pole (TUI semantics). The desktop previously stepped
  0.2 x 5 toward a threshold-chosen pole, so desktop-derived accents that needed a
  lift (skin/VS Code imports whose accent fails 4.5:1 on the sidebar, and
  --dt-primary-solid) may now land up to 0.15 closer to their original hue —
  they stop at the first passing rung. Palette VALUES are unchanged; only
  synthesized colors move.

Also: parseColor accepts #rgb shorthand where desktop hexToRgb rejected it —
  strictly more permissive; the only desktop path fed raw user hex is
  normalizeHex, which already expands shorthand itself.

Tests: apps/shared/src/color.test.ts (moved TUI parse/mix/contrast cases +
  two invariants):
  - "readableOn(%s) returns the ink with the higher measured contrast" — computes
    contrastRatio for each candidate in the test and asserts the returned ink is
    the max (a contract, not a hardcoded hex) over #4f9e5e (both ink pairs),
    #cba6f7, #ffffff, #101014.
    Sabotage: reverted readableOn to the luminance threshold → 3 red
    (#4f9e5e x2, #cba6f7); restored → green.
  - "ensureContrast(%s on %s) clears %s" — 5 failing pairs end ≥ min; plus
    "leaves passing and unparseable colors byte-identical".
    Sabotage: truncated the ladder to 3 rungs → 5 red; restored → green.
  ui-tui/src/lib/color.test.ts keeps only the color() chain case.

Validation:
  apps/shared: npx tsc -p . --noEmit (0) && npx vitest run → 3 files, 30 tests passed; npm run lint clean
  apps/desktop: npx tsc -p . --noEmit (0); npx vitest run --project ui → 798/800 files, 7563/7572 tests;
    the 9 failures (src/app/messaging/index.test.tsx x8 12s-timeouts, src/lib/markdown-blocks.test.ts
    property fuzz 36s) are load-induced flakes under the full parallel run: both files pass in
    isolation on this branch (16/16) and on origin/main; neither imports color math. npm run lint 0 errors
  ui-tui: npm run build:ink; npx tsc -p . --noEmit (0) && npx vitest run → 168 files, 1764 tests passed; npm run lint 0 errors
  git diff --check clean; no new gitignored .d.ts.

Handoff: desktop vs web preset palettes diverge for the four shared ids
  (web presets carry a 3-slot palette {background, midground, foreground(alpha 0)}
  + warmGlow, not the desktop's 24-slot set, so only the comparable slots are
  listed; web `foreground` is #ffffff alpha 0 on all four — a glow/overlay
  slot, not text ink). Design call for Teknium; nothing changed here.

    preset     slot        desktop                     web
    cyberpunk  background  #000a00                     #040608
    cyberpunk  accent      #00ff41 (primary/ring/mid)  #9bffcf (midground)
    cyberpunk  foreground  #00ff41                     #ffffff (alpha 0)
    ember      background  #160800                     #1a0a06
    ember      accent      #d97316 (ring/midground)    #ffd8b0 (midground = desktop fg/primary)
    ember      foreground  #ffd8b0                     #ffffff (alpha 0)
    midnight   background  #08081c                     #0a0a1f
    midnight   accent      #8b80e8 (ring/midground)    #d4c8ff (midground)
    midnight   foreground  #ddd6ff                     #ffffff (alpha 0)
    mono       background  #0e0e0e                     #0e0e0e  (match)
    mono       accent      #9a9a9a (ring/midground)    #eaeaea (midground = desktop fg/primary)
    mono       foreground  #eaeaea                     #ffffff (alpha 0)
2026-09-13 06:50:57 -07:00
teknium1 65ca7eac5f refactor(i18n): shared define-locale/RTL/endonym scaffolding in @hermes/shared; desktop+web forward to it
Desktop and web each re-implemented the same locale plumbing: the
TranslationOverride<T> partial-catalog type, isRecord (four copies across
the two apps), mergeTranslations, the RTL_LOCALES={'ar'} set with the
documentElement.lang/dir effect, and the endonym table for the language
picker (6 entries on desktop, 17 on web, overlapping and hand-synced).

The generic parts now live once in apps/shared/src/i18n.ts (exported from
the root index and the `@hermes/shared/i18n` subpath). It is generic over
the catalog type — no Translations, no `en` — so translation catalogs stay
per-app (content decision, deliberately not merged here).

Sites (path::symbol → canonical):
  apps/desktop/src/i18n/define-locale.ts::TranslationOverride, isRecord,
      mergeTranslations → @hermes/shared/i18n; defineLocale is a one-liner
  web/src/i18n/define-locale.ts::TranslationOverride, isRecord,
      mergeTranslations → @hermes/shared/i18n; defineLocale is a one-liner
  apps/desktop/src/i18n/runtime.ts::isRecord → shared isRecord
  apps/desktop/src/i18n/context.tsx::isRecord, RTL_LOCALES,
      applyDocumentLocale → shared isRecord / applyDocumentLocale
  web/src/i18n/context.tsx::RTL_LOCALES + inline lang/dir effect
      → shared applyDocumentLocale
  web/src/i18n/context.tsx::LOCALE_META literal (17 names)
      → derived from shared LOCALE_ENDONYMS (same exported shape)
  apps/desktop/src/i18n/languages.ts::LOCALE_OPTIONS.name (6 names)
      → LOCALE_ENDONYMS.<id>; englishName/configValue columns stay

The six desktop endonyms were byte-identical to web's before the move.

Tests: apps/shared/src/i18n.test.ts — mergeTranslations keeps untouched
sibling keys under a nested partial override and replaces functions/arrays
wholesale without mutating the base; RTL_LOCALES ⊆ keys(LOCALE_ENDONYMS);
applyDocumentLocale is a no-op without a document. The existing desktop
context.test.tsx RTL/lang assertions keep covering the effect.

Behavior change: none.
2026-09-13 06:50:57 -07:00
teknium1 a3d259019b refactor(ts): one stripAnsi in @hermes/shared (TUI's OSC/DCS/partial-CSI coverage); desktop adopts it
Three TS surfaces each carried their own ANSI stripper with different
coverage. The TUI's (OSC, DCS/SOS/PM/APC strings, complete and truncated
CSI, multi-byte non-CSI ESC sequences, stray ESC, C0 controls) is now the
single implementation at apps/shared/src/ansi.ts, exported from the root
index and the new `@hermes/shared/ansi` subpath (ui-tui has no DOM lib, so
it imports the subpath like it does for billing/skin).

Sites (path::symbol → canonical):
  ui-tui/src/lib/text.ts::stripAnsi, sanitizeAnsiForRender, hasAnsi
      → moved to apps/shared/src/ansi.ts (text.ts now imports stripAnsi
        from '@hermes/shared/ansi' for its own trail helpers)
  ui-tui: 13 importers repointed from '../lib/text.js' to
      '@hermes/shared/ansi' (createGatewayEventHandler.ts,
      components/messageLine.tsx, 11 __tests__ files)
  apps/desktop/src/lib/ansi.ts::stripAnsi (2 regexes) → deleted;
      parseAnsi/ansiColorClass/hasAnsiCodes stay (styled-segment parser)
  apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
      → imports stripAnsi from '@hermes/shared/ansi'
  apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
      private SGR-only stripAnsi → deleted; imports the shared one

Tests: the TUI 'ANSI sanitizers' cases move from
ui-tui/src/__tests__/text.test.ts to apps/shared/src/ansi.test.ts, plus
one invariant: an OSC-8 hyperlink + DCS string + SGR + partial CSI tail
strips to exactly the visible text with no ESC/BEL left.

Behavior change: desktop chat system messages (use-prompt-actions) and
inline-diff chrome (stripInlineDiffChrome) now also lose OSC hyperlink
payloads, DCS strings, truncated CSI tails and C0 control bytes that the
weaker regexes let through. TUI behavior is unchanged.
2026-09-13 06:50:57 -07:00
teknium1 172b2a722b refactor(ts): one compactNumber and one reasoning-effort value set in @hermes/shared
Three hand-rolled compact-number formatters and two mirrored copies of the
reasoning-effort value set collapse into apps/shared/src/format.ts and
apps/shared/src/reasoning-effort.ts, exported from the package root and as
the subpaths `@hermes/shared/format` / `@hermes/shared/reasoning-effort`
(the TUI compiles with lib ES2023 and imports subpaths only). Surfaces keep
their own label maps and UI helpers. No re-export shims remain.

Convention for compactNumber (desktop's implementation, moved verbatim):
lowercase 'k', uppercase 'M', promotion-guarded thresholds (>= 999.5 -> k,
>= 999_950 -> M) so rounding can never print "1000k", trailing ".0"
stripped, non-finite / <= 0 -> "0".

Sites (path::symbol -> canonical):

  apps/desktop/src/lib/format.ts::compactNumber          -> apps/shared/src/format.ts::compactNumber (moved; file deleted)
  web/src/lib/format.ts::formatTokenCount                -> deleted
  ui-tui/src/lib/text.ts::fmtK                           -> deleted (text.ts's own callers use compactNumber)
  apps/desktop/src/app/agents/index.tsx                  -> @hermes/shared
  apps/desktop/src/app/chat/sidebar/chrome.tsx           -> @hermes/shared
  apps/desktop/src/app/chat/sidebar/session-row.tsx      -> @hermes/shared
  apps/desktop/src/app/command-center/index.tsx          -> @hermes/shared
  apps/desktop/src/app/shell/context-usage-panel.tsx     -> @hermes/shared
  apps/desktop/src/app/shell/titlebar-controls.tsx       -> @hermes/shared
  apps/desktop/src/app/skills/index.tsx                  -> @hermes/shared
  apps/desktop/src/app/skills/mcp-tab.tsx                -> @hermes/shared
  apps/desktop/src/components/ui/tab-dropdown.tsx        -> @hermes/shared
  apps/desktop/src/lib/statusbar.tsx                     -> @hermes/shared
  apps/desktop/src/sdk/index.ts::compactNumber           -> re-exported from @hermes/shared (plugin SDK surface unchanged)
  apps/desktop/src/plugins/kanban/{board,drawer}.tsx     -> unchanged (import via @hermes/plugin-sdk)
  web/src/components/ModelInfoCard.tsx::formatTokenCount -> @hermes/shared::compactNumber
  web/src/pages/ModelsPage.tsx::formatTokenCount         -> @hermes/shared::compactNumber
  ui-tui/src/components/appChrome.tsx::fmtK              -> @hermes/shared/format::compactNumber
  ui-tui/src/components/thinking.tsx::fmtK               -> @hermes/shared/format::compactNumber
  ui-tui/src/app/slash/commands/session.ts::fmtK         -> @hermes/shared/format::compactNumber
  ui-tui/src/__tests__/text.test.ts::fmtK suite          -> apps/shared/src/format.test.ts (table incl. promotion guard)

  apps/desktop/src/lib/reasoning-effort.ts::REASONING_EFFORTS/REASONING_EFFORT_VALUES/
      DEFAULT_REASONING_EFFORT/ReasoningEffort/isReasoningEffort  -> apps/shared/src/reasoning-effort.ts
      (SHORT_LABELS, reasoningEffortLabel, isThinkingEnabled, resolveReasoningEffort stay local)
  apps/desktop/src/app/settings/constants.ts             -> @hermes/shared
  apps/desktop/src/app/settings/model-settings.tsx       -> @hermes/shared
  apps/desktop/src/app/shell/model-catalog-menu.tsx      -> @hermes/shared (+ local reasoningEffortLabel)
  apps/desktop/src/app/shell/model-edit-submenu.tsx      -> @hermes/shared (+ local UI helpers)
  apps/desktop/src/app/shell/model-menu-panel.tsx        -> @hermes/shared
  apps/desktop/src/lib/model-status-label.ts             -> @hermes/shared (+ local reasoningEffortLabel)
  apps/desktop/src/sdk/index.ts                          -> value set re-exported from @hermes/shared; label helper stays from '@/lib/reasoning-effort'
  apps/desktop/src/lib/reasoning-effort.test.ts          -> value-set + isReasoningEffort cases moved to apps/shared/src/reasoning-effort.test.ts
  web/src/lib/reasoning-effort.ts::EFFORT_OPTIONS        -> labels mapped over shared REASONING_EFFORT_VALUES (same order: none, then 7 levels)
  web/src/lib/reasoning-effort.ts::VALID_EFFORTS         -> Set(REASONING_EFFORT_VALUES); normalizeEffort falls back to DEFAULT_REASONING_EFFORT

Semantics kept: web `none` is selectable; desktop `none` resolves to ''
(thinking off); desktop isReasoningEffort still trims + lowercases.

Behavior change:
  - web: token counts on the Models page and ModelInfoCard now print a
    lowercase 'k' and are promotion-guarded: 128_000 "128K" -> "128k",
    999_999 "1000.0K" -> "1M", 1_500 "1.5K" -> "1.5k". 'M' is unchanged.
  - TUI: fmtK used Intl compact notation; compactNumber differs only in
    suffix case and the guard: 1_000_000 "1m" -> "1M", and billions no
    longer get a 'b' suffix (1_000_000_000 "1b" -> "1000M"). Sub-million
    values are identical ("999", "1k", "1.5k"). Non-positive values now
    print "0" instead of "-1k".
  - desktop: none (its formatter moved verbatim).

Tests: apps/shared/src/format.test.ts::"compactNumber" (table incl.
999_999 -> "1M", 999_949 -> "999.9k"; fails when the promotion guard is
removed) and apps/shared/src/reasoning-effort.test.ts::"reasoning-effort"
(no duplicate values, `none` is the only non-level, default is a member;
fails on a duplicated level or a `none`-accepting isReasoningEffort).
2026-09-13 06:50:57 -07:00
teknium1 a2ae8f229d refactor(ts): one fuzzy + model-search-text helper in @hermes/shared; desktop picker ranks with fuzzyRank
Three byte-identical (modulo prettier and a "keep in sync" header comment)
copies of model-search-text.ts and two of fuzzy.ts collapse into one copy
each under apps/shared/src, exported from the package root and as the
subpaths `@hermes/shared/fuzzy` / `@hermes/shared/model-search-text` (the
TUI compiles with lib ES2023 and imports subpaths, never the DOM-typed
root). The vitest suites move with the code; no re-export shims remain.

Sites (path::symbol -> canonical):

  ui-tui/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank   -> apps/shared/src/fuzzy.ts (moved)
  web/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank      -> deleted
  ui-tui/src/lib/model-search-text.ts::modelSearchText            -> apps/shared/src/model-search-text.ts (moved)
  web/src/lib/model-search-text.ts::modelSearchText               -> deleted
  apps/desktop/src/lib/model-search-text.ts::modelSearchText      -> deleted
  ui-tui/src/lib/fuzzy.test.ts                                    -> apps/shared/src/fuzzy.test.ts (moved)
  ui-tui/src/lib/model-search-text.test.ts                        -> apps/shared/src/model-search-text.test.ts (moved)
  ui-tui/src/components/modelPicker.tsx::fuzzyRank, modelSearchText     -> @hermes/shared/fuzzy, @hermes/shared/model-search-text
  web/src/components/ModelPickerDialog.tsx::fuzzyRank, modelSearchText  -> @hermes/shared
  web/src/lib/model-picker-filter.ts::fuzzyScoreMulti                   -> @hermes/shared
  apps/desktop/src/components/model-picker.tsx::modelSearchText         -> @hermes/shared (+ fuzzyRank, see below)

The header comment now names only the cross-language twin
(hermes_cli/model_search.py) as the thing to keep in sync.

Behavior change (desktop only): the desktop model picker used to filter
model rows with `foldIncludes` substring matching and keep the curated
order; it now ranks them with the same `fuzzyRank(models, query,
modelSearchText)` the web and TUI pickers use. What a user sees
differently while typing a query:

  - subsequence queries match: "g4o" now finds "gpt-4o" (previously only
    a literal substring such as "gpt-4" or "4o" matched);
  - the best match floats to the top instead of rows staying in curated
    order (exact > prefix > word-boundary > contiguous > scattered);
  - a query that matches the provider name/slug still shows that
    provider's full curated list in order, exactly as before;
  - an empty query still shows the curated list verbatim.

The in-row highlight is unchanged (substring emphasis via HighlightMatches),
so a fuzzy-only hit renders without emphasis rather than mis-highlighting.

Tests: apps/desktop/src/components/model-picker.test.tsx::"orders model
rows exactly as the shared fuzzyRank does" asserts the rendered row order
equals the shared fuzzyRank order for the same inputs (fails on both the
old substring filter and a reversed ranking).
2026-09-13 06:50:57 -07:00
teknium1 b05a47b9d2 feat(desktop): reasoning effort gets its own composer pill
The composer showed "<model> · Med" in one truncating pill and the only way
to change the effort was to open the model menu, find the active model's
row, and hover it for the per-row options submenu. Users read the pill as
"this model is medium only" and never found the submenu.

- New `ReasoningPill` next to the model pill: shows the active model's live
  effort (session value, else the profile default) and opens the same
  Thinking / Fast / Effort rows the catalog submenu offers, for the active
  model only. Hidden when the catalog reports `reasoning: false`; stays
  while capabilities are unknown so it never flickers during the fetch.
  Folds away with the model pill in the compact composer stages.
- `useModelMenuController` (shell sibling) now owns the session write /
  preset / optimistic-store / rollback logic that lived inside
  `ModelMenuPanel`; the model menu and the new `ReasoningMenuPanel` share it
  so an edit from either surface is one code path. Tiles get their own
  pill bound to their SessionView, primary or tile — never the globals.
- `ModelOptionsContent` (the submenu body) is exported container-free so
  the pill's top-level menu renders it without a Radix Sub wrapper.
- The model pill drops the effort suffix (`formatModelPillLabel`: name +
  Fast); `formatModelStatusLabel` had no other caller and is removed.
- `currentModelCapabilities()` in lib/model-options resolves the active
  pick's caps through `catalogProviderMatches` (aliases, custom slugs).

Live (headless Electron + worktree `hermes serve`, CDP): before — one
pill "Deepseek V4 Flash · Low", no effort control; after — "Deepseek V4
Flash" + "Low" pill; pick High → `config.get reasoning` on the live
session returns high; a `reasoning:false` cap unmounts the pill; the
catalog row submenu still writes through and the pill mirrors it.

Credit: the dedicated-pill direction was proposed independently in
composer selector on current main with the shared-controller shape.
2026-09-13 06:11:49 -07:00
teknium1 c1e0fd83f9 fix(shared): GatewayEventMap drops phantom keys and types child_session_id
Re-verified against the tui_gateway emitters:
- SubagentEventPayload.cost_usd / .iteration: not in
  tool_progress.py::_SUBAGENT_FIELDS, never emitted → removed; the TUI's
  turnController no longer copies them (its SubagentProgress keeps the
  fields for spawn-history persistence).
- SubagentEventPayload.child_session_id: emitted (in _SUBAGENT_FIELDS, read
  by agent_callbacks.py::_mirror_subagent_to_child) but untyped → added.
- ToolCompletePayload.error: _on_tool_complete never sets it → removed;
  the TUI's completeTool drops its dead `error` parameter and renders the
  trail line as non-error (which is what it always did on the wire).
- ToolStartPayload.todos: not on the wire either, but the TUI handler and
  its fixtures exercise recordTodos from tool.start; kept with a comment
  saying so rather than churning the handler.
- MessageCompletePayload.failure_reason: prompt_turn.py passes
  result.get("failure_reason") through → `string | null`.
2026-09-13 05:42:31 -07:00
teknium1 2435131573 fix(shared): JSON-RPC channel ignores non-object frames and keeps the TUI's pong-based liveness
handleFrame guarded JSON.parse but then read `frame.id` on whatever came
back, so a stdout line of `null`/`42`/`"str"` threw a TypeError out of the
readline handler — an uncaughtException in the Ink process, where main's
TUI had caught and logged it. Non-object frames now return null (the owner
logs a protocol error, as for non-JSON).

The shared heartbeat counted ANY inbound frame as liveness, silently
dropping the TUI's original contract (fail on an unanswered gateway.ping):
a backend whose request loop is wedged but still streams deltas never
tripped the deadline. `heartbeatLiveness` now selects the contract:
'response' (default, TUI) — only a pong or a response to our own request
resets the deadline; 'any-inbound' — the desktop/web WebSocket client's
original behaviour, which JsonRpcGatewayClient passes explicitly so that
surface is unchanged. The dead 'error' branch comment in connect()'s
onClose is corrected to describe the onSocketClose-intercept case it
actually serves.

Tests: it.each over 'null'/'42'/'"str"'/'true' asserts no throw and null;
'response' mode: pongs and request responses keep it alive, streaming
deltas with unanswered pings fire onHeartbeatFailure. Sabotage (remove the
object check + count any inbound): 5 tests fail with the original TypeError.
2026-09-13 05:42:31 -07:00
teknium1 bab5cece78 refactor(ts): one reconnect backoff in apps/shared; web events feed rides the shared client and survives reconnects
Four backoff formulas (ui-tui 1000/30s, desktop 300/15s jittered, web events
1000/30s, web PTY inline 250/3s cap 5 — untested) collapse into
apps/shared/src/reconnect-backoff.ts::reconnectBackoffDelayMs(attempt,
{baseDelayMs, capMs, jitter}). Every caller keeps its own parameters
(table in the PR body); the PTY ladder gains a test.

web/src/components/ChatSidebar.tsx hand-rolled a third WebSocket frame
dispatcher (`new WebSocket` + JSON.parse + `frame.method === "event"` switch
+ a private RpcEnvelope re-declaring shared JsonRpcFrame) for /api/events.
That socket now goes through EventsFeedClient, a notification-only subclass
of the shared JsonRpcGatewayClient (replay off, heartbeat off, connect
timeout covering ticket minting); the effect keeps only the retry ladder and
the banner. Both sidebar clients are now created once per component instead
of per `version` bump, so the shared client's seq watermarks survive a drop
and its `session.events.since` gap replay can actually fire for web
(previously the client was rebuilt on every reconnect and replay never ran).

Behavior change: web sidecar reconnects reuse the same JsonRpcGatewayClient
(gap replay now runs); the events feed's handshake `error`+`close` pair is one
`closed` transition (one retry timer, as before); no parameter of any
backoff ladder changed.
2026-09-13 05:42:31 -07:00
teknium1 6b406f1c89 refactor(ts): ui-tui rides apps/shared's JSON-RPC request channel; one pending map, one heartbeat, typed RPC errors
Two independent JSON-RPC client cores existed for one backend: apps/shared's
JsonRpcGatewayClient (desktop, web) and ui-tui/src/gatewayClient.ts, which
re-implemented request ids, the pending map with timeouts, response->error
mapping, event decoding and the gateway.ping heartbeat (~200 LOC, drifted).

Split the transport-agnostic half out of the shared client into
JsonRpcRequestChannel (apps/shared/src/json-rpc-channel.ts): the owner binds a
JsonRpcTransport { send(text) } per connection generation and feeds inbound
text through handleFrame(). JsonRpcGatewayClient keeps only the WebSocket
lifecycle, seq replay and the typed event hub on top of it; the Ink TUI keeps
only its two transports (spawned child stdio, attached socket) and its
mount-order event buffering, and delegates everything else.

Behavior change:
- TUI RPC errors now carry the JSON-RPC `code` / `data` (JsonRpcGatewayError)
  instead of a bare Error(message); the TUI's timeout text is now the shared
  "request timed out after Ns: <method>" (was "timeout: <method>", matched by
  no caller) and callers may pass a per-call timeout.
- TUI heartbeat liveness counts any inbound frame (shared semantics) rather
  than tracking one in-flight ping id; the interval/deadline are unchanged
  and pings no longer carry the unread `last_activity_ms` param.
- Desktop isMissingRpcMethod reads the -32601 code first and only regexes the
  message for code-less (IPC-flattened) errors, so a tool result that merely
  mentions "unknown method" no longer reads as a capability verdict.
- Shared connect() now settles on a `close` during the handshake (auth-gate
  4401/4403) instead of waiting out the 15s connect timeout, and
  invalidate()/close() drop the socket generation before calling close() so a
  synchronous close event cannot run the closed-path twice.
2026-09-13 05:42:31 -07:00
teknium1 36773e0d78 refactor(ts): one GatewayEventMap in apps/shared typed from tui_gateway emitters; drop never-emitted tool.progress
Three TypeScript clients each declared their own copy of the tui_gateway wire
types and had drifted apart: apps/shared had a partial GatewayEventName union
with a `(string & {})` escape hatch, ui-tui/gatewayTypes.ts a 150-line
discriminated union, and apps/desktop an `RpcEvent<T>` that was field-for-field
the shared GatewayEvent with `type: string`. None matched the emitter:
message.complete lacked warning/status/error/recoverable/error_surface,
tool.start/tool.complete lacked args/result, SessionResumeResponse lacked
session_key/messages_omitted/hydrating/auto_continue/todo_state, three
different ModelOptionProvider shapes disagreed on fields, and all three unions
handled a `tool.progress` event that no Python emitter has ever produced.

Now:

* `apps/shared/src/gateway-events.ts` is the single home: payload interfaces
  typed from the Python emitters (file::symbol cited per interface),
  `BackendGatewayEventMap` (89 backend names) + `ClientLocalGatewayEventMap`
  (5 TUI-synthetic transport events, clearly marked, excluded from the
  contract) merged into `GatewayEventMap`; `GatewayEvent<K>` is discriminated
  on `type` with `seq` typed. RPC shapes shared by 2+ surfaces live beside it
  (ModelOptionProvider = union of every field hermes_cli/inventory.py sets,
  incl. pricing_pending/free_tier_pending; SessionResumeResponse<Info>;
  SessionListItem with resolved_id; Usage).
* `JsonRpcGatewayClient.on<K>` is keyed by event name; the gateway.ready
  heartbeat/replay_epoch and per-frame `seq` reads are typed instead of cast.
* ui-tui and apps/desktop import the shared names; their local duplicates are
  deleted (no re-export shims — importers are repointed; the desktop plugin
  SDK barrel keeps its public `RpcEvent` name as an alias of GatewayEvent).
  web/src repoints ModelOptionProvider/ModelOptionsResponse.
* `tool.progress` handling is removed from the TUI handler/turnController,
  desktop event sets/tools handler, shared union, tests, and two docs
  (`grep '"tool.progress"' tui_gateway/` = 0 hits; the `display.tool_progress`
  config mode is unrelated and untouched).
* `message.complete.warning` (history-commit note from
  prompt_turn.py::_complete_turn_payload) is typed and surfaced on both
  surfaces through their existing notice paths (TUI pushActivity 'warn',
  desktop notify kind 'warning').

Contract: `apps/shared/src/gateway-events.json` is the sorted list of
backend-emitted names. `tests/tui_gateway/test_gateway_event_contract.py`
collects names from the Python emitter side (emit-helper literals, the
`.request → .expire` table, change-watcher table, child delta mirror,
subagent relay, desktop_ui tool emitters, gateway.ready/setup.ready/
browser-controller frames) and asserts emitted == JSON in both directions.
`apps/shared/src/gateway-events.test.ts` asserts BACKEND_EVENT_NAMES (which
the map type is `satisfies`-checked against) == JSON. Sabotage-verified: a
fake JSON name fails both tests; a fake TS name fails tsc + vitest; a fake
Python `_emit("...")` fails pytest.
2026-09-13 05:42:31 -07:00
Teknium d5774ad880 fix(tests): pay heavy view imports at collection, not the first test's budget
Three CI-load flakes from the same class — a fixed per-test timeout billed
for one-time module-transform/env-init cost:

- apps/desktop messaging/index.test.tsx: `await import('./index')` ran inside
  renderMessaging(), so the FIRST test paid the whole MessagingView transform.
  On loaded runners that alone blew the 15s testTimeout and cascade-failed all
  subsequent tests in the file (unmounted DOM). Red on main runs 34599517793,
  34600757569, 34601269252 (green file takes 15.7s on a green main run —
  already over the first test's budget when billed there). Import moved to
  module scope, where vitest bills it to collection.
- apps/desktop skills/index.test.tsx: same pattern, 9 call sites; the file ran
  18.6s on a green main run. Deduplicated to one module-scope import (the
  existing 60s describe-timeout stays for the legitimately slow tests).
- web SessionsPage.test.tsx: the web vitest project still ran on vitest's 5s
  default while its per-row routing test legitimately takes 3.6-4.6s on GREEN
  runs; run 34600757569 tipped it to 5079ms. Gave web/vitest.config.ts the
  same 15s testTimeout the desktop project already carries, with the same
  rationale comment.

Validation: both desktop files 5x consecutive green + green pinned to 1 CPU
core (worst-case contention); SessionsPage 3x green; full desktop ui project
(801 files / 7622 tests) green; tsc + eslint clean on touched files.
2026-09-12 21:34:17 -07:00
Teknium d46f79233e fix: sort imports in markdown-text.tsx per perfectionist rule 2026-09-12 21:17:02 -07:00
Teknium e1c05ffa32 feat(desktop): persist video playback speed across transcript videos
Port from block/buzz#7336: a playback rate picked in any transcript
video's native controls persists as a device-level preference, so a
viewer who watches at 2x doesn't re-select it for every clip. New
players (and other open windows, via the persistentAtom storage sync)
start at the saved rate; out-of-range or malformed stored values fall
back to 1x, and returning to 1x removes the stored key.

Adapted from Buzz's hand-rolled localStorage module + custom player to
our persistentAtom store and the single <video> render site in
markdown-text.tsx (MediaAttachment), wrapped as TranscriptVideo.
2026-09-12 21:17:02 -07:00
teknium1 d595e636c8 fix(model): a selected model id is never rewritten to a catalog neighbour
A user who picked `deepseek-v4.1-flash` on their own custom endpoint kept
landing on `deepseek-v4-flash-0731`. Three sites each "helped" by diffing
the pick against a catalog and moving it:

- hermes_cli/models_validate.py: the shared catalog matcher auto-corrected
  any id within difflib ratio 0.9 of a listed one (`corrected_model`), and
  model_switch applied it. Version bumps, dated snapshots and qualifiers
  all sit inside 0.9 of a sibling, so a newer release the listing lacked
  was swapped for the older one under the user's label. The matcher now
  does exact membership -> suggestion text only; the id goes to the wire
  verbatim and a genuine typo is refused with the listed siblings named.
  Every branch that carried the correction (live listing, static catalog,
  curated fallback, MiniMax, Anthropic, custom, OpenRouter preset base)
  loses it in one place.

- hermes_cli/model_switch.py: a `providers.<key>` endpoint reached by its
  bare key (the slug Desktop picker rows carry) validated as a built-in
  and hit the hard-rejecting live-listing branch; the same endpoint as
  `custom:<key>` soft-accepted. Both spellings now validate as the user's
  custom endpoint.

- apps/desktop: `manualPickRemoved` (composer reseed) and
  `reconcileSelectionAfterCatalogRefresh` (Refresh Models) retargeted a
  sticky pick to the profile default / the row's first model whenever the
  provider row did not list it. Rows are hints (discovered, curated,
  capped); the gateway's switch result is the only authority on a pick.
  Both helpers are removed; the pick stays put.

Tests: change-detectors pinning the swap are rewritten as invariants
(never `corrected_model`; unlisted id on a user endpoint is kept and
warned; typo is refused with a suggestion); proven red on origin/main.
2026-09-12 14:05:36 -07:00
teknium1 6a66a5d481 fix(desktop,dashboard): served profile's api_server/webhook read connected with their /p/<profile>/ URL; shared-gateway restart asks first
Under gateway.multiplex_profiles a secondary's api_server and webhook are never built as
adapters (run_adapters skips SHARED_LISTENER_MIRROR_PLATFORMS: the default's listener answers
/p/<profile>/...). The multiplexer record therefore has no `<profile>:api_server` entry,
profile_platforms_from_multiplexer() returned {} for them and both /api/messaging/platforms
and /api/status?profile= fell through to `pending_restart`: the Desktop Messaging card and
Command Center said "Restart needed" forever for a platform that was answering.

- gateway.status.shared_listener_mirror_platforms projects the default's LIVE api_server /
  webhook entry onto every served secondary with `ingress_url` = `<listener>/p/<profile>/v1`
  (`.../webhooks/<route>`); a dead default listener is not mirrored. The api_server / webhook
  adapters stamp the listener they actually bound (`listener_base`) on connect so the URL is
  the real one, not a config guess. `hermes status` lists those URLs beside the other
  shared-ingress platforms.
- /api/status?profile= reports `gateway_shared_with` (every profile the multiplexer carries)
  when the served rung answered; null for a standalone gateway.
- Desktop: the messaging card shows the URL line; "Restart gateway" from a served profile
  (statusbar menu, Cmd+K, messaging/webhooks banners, Command Center) confirms "Restart the
  shared gateway? All bots on this device reconnect: default, alpha, beta" (Restart all /
  Cancel) and toasts "Shared gateway restarted (3 bots)". Standalone keeps the silent path.
- Dashboard: same confirm + toast on the System page and the sidebar restart; the 409 from
  start/stop on a served profile renders as an inline notice instead of a raw error toast.
2026-09-12 12:52:19 -07:00
teknium1 d1dbb0ac9e feat(gateway): multiplexer hot-serves profiles created while it runs, unroutes deleted ones
A `gateway.multiplex_profiles` gateway enumerated `profiles/` once at boot, so a profile
created afterwards (CLI, dashboard, Desktop, TUI) was never served until `hermes gateway
restart`; Desktop and the dashboard gave no reminder, so a new profile's bot simply never
connected.

The served set is now reconciled at runtime (`gateway/run_profile_reconcile.py`):
- `hermes_cli/profiles.py` create/delete ping the multiplexer over its control socket
  (new `rescan-profiles` verb); a supervised watcher rescans every 30s as the safety net.
- A new profile gets its adapters under its own runtime scope from its config/.env
  (`_start_one_profile_adapters`, same duplicate-credential guard as boot, now seeded
  with the LIVE secondaries' claims), `served_profiles` in gateway_state.json is
  updated, MCP discovery + log routing run for it. Other profiles' adapters are never
  touched.
- A served profile whose config.yaml/.env changed is re-scanned so a token added after
  create builds the adapter; already-live/queued platforms are skipped (no second poller).
- A deleted profile (tombstone) has its reconnects cancelled, adapters torn down,
  pairing/busy bookkeeping and cached agents dropped, and this process's SQLite /
  memory-store handles released so the deleter's rmtree succeeds.
- The in-process cron ticker takes a live enumerator so new profiles' jobs fire.
- PUT /api/messaging/platforms/<id>?profile=X returns `hot_served` when a live
  multiplexer rebuilt X's adapters; Desktop/dashboard skip the restart banner then.
- `hermes profile create` confirms hot-serve; the restart reminder stays for a gateway
  that did not pick the profile up (older build / signal failed).
2026-09-12 08:49:16 -07:00
Bartok9 88d84beede fix(desktop): treat an explicit empty MCP include list as block-all
`isToolEnabled` used `include?.length`, so `tools.include: []` (the block-all
whitelist the runtime honours) rendered every tool as enabled, and toggling the
last include entry off deleted the key, silently flipping the server back to
"all tools". Keep the empty list.

Salvaged from #52874 (desktop portion) by @Bartok9. Part of #12865.
2026-09-12 08:34:43 -07:00
Kuxin a318772f51 fix(test): raise mock model context window 2026-09-12 08:30:27 -07:00
teknium1 5b0103ffdc refactor(desktop): extract terminalLcCtype so the Linux locale rule is testable
The picked fix inlined the platform ternary inside terminalShellEnv(), which
reads process.platform and can only be exercised by booting Electron. Lift it
into a pure terminalLcCtype(env, platform) that takes the platform as data
(root AGENTS.md: never fake the host OS) and pin the contract with one vitest:
Linux reuses LANG, falls back to C.UTF-8, respects an explicit LC_CTYPE; macOS
keeps the bare "UTF-8" it accepts. Red on origin/main (helper absent), green here.
2026-09-12 08:18:50 -07:00
NaviElLay 6f8d975fe7 fix(desktop): use valid LC_CTYPE on Linux terminals 2026-09-12 08:18:50 -07:00
hermes-seaeye[bot] 3e09e5a15f fmt(js): npm run fix on merge (#109094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 13:20:20 +00:00
Teknium b0c383cdf7 fix(desktop): served profiles show running and route lifecycle to the multiplexer from a pooled local backend
Electron sends a local sub-profile's REST to its pooled `hermes --profile X serve` without
?profile=; inside that process the unscoped branches never reached the multiplexer rung, so a
profile served by the default multiplexer read as 'Messaging gateway stopped' on the system and
messaging pages, start/stop spawned a child that exited 78 while the UI reported success, and
restart ran `gateway restart` under X's HOME (same exit 78). Remote-backend topology was already
correct because its requests carry ?profile=.

Unscoped liveness/status/messaging now take the multiplexer rung for the process's own home;
lifecycle verbs resolve the own profile, refuse start/stop with 409 and restart the multiplexer via
-p default; Electron routes POST /api/gateway/{restart,start,stop} through the primary with
?profile= so the action lives on the backend the status poll asks and outside the pooled
backend's shutdown SIGTERM.
2026-09-12 06:13:44 -07:00
Teknium a9cfcf70c1 fix(desktop): forward optional composer handlers through the latest-actions adapter
latestChatActions rebuilds the ChatView handler bag field by field, so an
optional handler added to ChatActions but not to the adapter is silently
dropped before it reaches ChatView. Live CDP probe on a built Desktop: the
wiring controller had onAttachPastedText, ChatView received undefined, and
a 4,500-char paste stayed inline. onAttachPrCommentUrl and onSteerHidden
(already on main) were dropped the same way on the main chat surface; the
session-tile path passes them directly and was unaffected.

Forward all three via latestOptional and pin the class with one invariant
test: every handler present on the actions bag is present on the adapted
bag (red on the previous adapter).
2026-09-12 05:09:01 -07:00
Teknium ab7f03049e refactor(desktop): large-paste writer in its own electron module, 3k threshold
Move writeComposerPaste out of electron/main.ts into composer-paste.ts
(placement gate: no new behaviour appended to the facade). Lower the
conversion threshold from 10k to 3k characters so a pasted stack trace or
log excerpt already becomes a chip. Trim the policy tests to two
invariants (strict threshold boundary; chip size label is byte-based) and
drop vendor references from code comments.
2026-09-12 05:09:01 -07:00
Teknium 22e3888543 Inspired by ChatGPT Work: convert large pastes into .txt attachments in the Desktop composer
Pasting more than 10k characters of plain text into the Desktop composer
now converts the content into a 'Pasted content (NN KB)' .txt attachment
chip instead of flooding the input, mirroring ChatGPT's large-paste
handling (OpenAI release notes, Aug 4 2026). Short pastes stay inline;
the exact text is preserved byte-for-byte in a Hermes-managed
composer-pastes file and rides the existing @file: attachment pipeline.
If the desktop bridge is missing or the write fails, the paste falls
back to inline insertion so nothing is ever lost.

Implements #66622.
2026-09-12 05:09:01 -07:00
hermes-seaeye[bot] 28326a6ccd fmt(js): npm run fix on merge (#108902)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 07:09:15 +00:00
brooklyn! 99d7f17a1c docs(desktop): explain automatic tutorial cutoff in settings 2026-09-12 01:35:33 -05:00
brooklyn! 995e79afe4 feat(desktop): retire tutorials after the first month 2026-09-12 01:35:33 -05:00
hermes-seaeye[bot] a84a2223f8 fmt(js): npm run fix on merge (#108829)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 04:42:23 +00:00
hermes-seaeye[bot] f7cd8bf084 fmt(js): npm run fix on merge (#108814)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 04:36:01 +00:00
Siddharth Balyan cbe9e5b294 chore(desktop): literal comments across the onboarding flow (#108438)
* chore(desktop): literal comments in the guide script and runbooks

Comment-only change to onboarding-script.ts and setup-profile.ts. The module
headers now state the purpose and the constraints that shaped each file. The
notes beside the runbook strings keep one fact per sentence, or are deleted
when the string beside them says the same thing. The runbook text, the
persona, the option pills and the SOUL text are unchanged.

Both versions transpile to identical output with comments removed.

* chore(desktop): literal comments in the guide chat cards and stores

Comment-only change to the guided chat's cards, directive dispatcher, option
catalog, assembly module and chip. Metaphor and personification are replaced
by the name of the atom, effect or CSS property they stood for. Comments that
restate the code are deleted. Two stale facts are corrected in place: the
mini layout trees point at app/contrib/layout-presets.ts, and the skip button
sets the onboarding phase to skipped rather than done.

One comment line in cards/frame.tsx from bb/connector-ui-e2e-v2 loses a
metaphor and an em dash; its fact is unchanged.

* chore(desktop): literal comments in the handoff and first build

Comment-only change to the handoff wiring, the kickoff, the receipt store,
the first-build check-ins, the handoff tour, the connector rows and the
machine profile store. Every kept comment names the caller, the constraint or
the defect it prevents. The claim that the tour never throws is removed: the
function can reject and its caller does not catch.

Five comment blocks in connector-tool.tsx written on bb/connector-ui-e2e-v2
lose personification, dramatic capitals and em dashes. Every fact in them
stays, and no block moves.

* chore(desktop): literal comments in the intro reveal

Comment-only change to the intro reveal's clock, timeline, cube renderer,
sound, scenes, store and README. Animation comments now name the actual
ramp, easing or offset with its number. Four comments that contradicted the
code are corrected: the first texture slot opens at 3700 ms, the tear settles
from 1 to 0 over 460 ms, the typing weight delays the character it sits on,
and INTRO_EXIT_MS is wall time in index.tsx but score time in the overlay.

* chore(desktop): literal comments in the Electron onboarding windows

Comment-only change to the window growth geometry and the two onboarding
windows. The 768 px floor keeps its one fact: the floor uses Math.ceil where
the deltas round, because rounding 906.24 DIP down leaves the media query
false. The comment that placed the CSS-pixel to DIP conversion at getBounds
now points at growWindowBounds, where it happens.

* chore(gateway): literal docstrings in the onboarding RPCs and the tour tool

Docstring and comment-only change. The module summaries state what each
module does and where authorization comes from, without contrast pairs. The
tool descriptions the model reads are unchanged. Two words in the tour tool's
module docstring lose personification; the rest of that docstring is as it
was.

ast.dump of both versions, with docstrings stripped, is identical for all
three files.

* chore(desktop): literal punctuation in the relaunch and film-end notes

Comment-only change to four lines that bb/connector-ui-e2e-v2 added to the
boot gate, the gate store and the intro gate. Each em dash becomes a colon, a
full stop or a pair of parentheses; one emphasis capital is lowercased. The
facts in the notes are unchanged.
2026-09-12 10:00:08 +05:30
Siddharth Balyan 474a7f8b2a Guided first launch: the first build connects the picked apps before it starts (NS-859) (#108317)
* feat(desktop): the first build connects the picked apps before it starts

When the user picked apps during setup, the build session's runbook now opens with one batched manage_connections connect for every pick, ends the turn, and waits for the app's "links opened" note before calling wait with a 180 s budget. The task starts the moment the wait returns or the user says to start, with whatever connected; pending apps are named once and skipped, not asked about. The no-account rule stays only for builds with no picks and for machine setup, which needs no account. Account data comes from the connected apps and the web only; credentials that happen to be on the machine are off limits to a first task. The deliverable is a page the user can open plus one real reading or action through a connected app.

The picks are gateway slugs from here on, so the runbook names each one with its title and the model never calls status to match them.

* feat(desktop): the onboarding picker shows only the lead-order apps the gateway carries

The picker reads the live catalog through useConnectorCatalog (#108292) and
offers a pick only when the gateway carries it. This commit narrows what it
shows to CONNECTOR_LEAD_ORDER: the everyday apps, in that order, per D89. The
rest of the catalog stays available to the agent; the first-run card does not
list it. Reverting to the full catalog with search is the one filter clause.

* feat(desktop): mark the first build session at handoff

Persist the build stored id with its created receipt so connection automation stays scoped to that session.

* feat(desktop): open first build connection links as one batch

Claim each tool call before opening its links and send one hidden setup note. Share row state by stored session and retain clickable links when the browser bridge is unavailable.

* feat(desktop): reconcile first build rows during the model wait

Poll the owning gateway while the newest wait is pending and preserve settled or interrupted results. Show connection states without the ordinary offer controls.

* feat(desktop): let the first build start with connected apps

Read the shared connection rows in the composer and submit a visible first-person start message. Persist use of the pill so it cannot return for the same session.

* feat(desktop): add connect first setup copy

Use the connector locale keys for catalog checks, sign-in states, and the start action. Other locales inherit these additions from English.

* feat(desktop): the first build waits 120 s, then asks; signed-in tools are fair

The wait budget drops from 180 to 120 seconds. When it runs out with apps still pending, the model stops and asks in one line whether to continue without them or connect again, instead of deciding alone. Tools already signed in on the machine, such as a logged-in gh, are fair to use for the task when it helps, said in one line; the earlier rule against them goes.

* fix(desktop): an untargeted connector status renders as a tool line, not a card

A manage_connections status call with no connectors list describes the whole catalog. The card rendered that as one Connect row per app the gateway knows, 59 of them, right after the user had connected the one they wanted. Such a part now takes the collapsed tool line like any other tool call, and resolves no owner and polls nothing. A status call that names apps keeps its rows.

* feat(desktop): a three-step tour explains the profile switch at handoff

The one-step signpost becomes a three-step tour when the first build's session is on screen: the profile rail, where the task runs on default and the welcome chat lives on the setup profile; the sessions list, which belongs to the selected profile; the rail again, one click from Hermes. The copy is in the locale files. The app runs it, not the guide: the tour bridge answers only the session the user is looking at, and the guide is a background session by then. Its own note tells it not to describe the tour. The tour no longer skips users who declined the look around, since the tour beat itself is mandatory in the next cut.

* fix(desktop): a rejected hidden submit never lands in the composer draft

A submit with displayKind hidden is machine text, a setup note the user never typed. When the gateway rejected one because the model's turn was still running, the composer restored it into the draft like any rejected message, and the user saw "[setup] links opened for gmail, googlecalendar" sitting in their input box. A rejected hidden submit is now dropped.

* fix(desktop): hold the links-opened note until the build session is idle

The card sent the hidden "[setup] links opened" note the moment it had opened the links, while the model's connect turn was still running; the gateway rejected it. In the live run the model had already called wait in that same turn, so the note had nothing left to say. The card now holds the note as pending on the session's state and delivers it only when the session is idle and the connect is still the newest connector part. When a newer part exists the note is dropped. The runbook says the same from the model's side: call wait right after connect; if it bounces as just minted, end the turn and treat the note as the cue to wait again; a note that arrives after a wait needs no reply.

* fix(desktop): the Start pill says one app, not one apps

* fix(desktop): parse connector dictionaries in wait results

Read connected connector slugs from dictionary and string results, excluding entries explicitly marked disconnected. Use the gateway response shape in the wait fixture so completed connections update their rows.

* fix(desktop): end first-build mode when the handshake settles

Persist a completed connection handshake after a settled wait or an accepted start message. Later connector parts use the ordinary card, and completed sessions cannot auto-open links or send a held setup note.

* fix(desktop): route external submits through busy handling

Steer external visible prompts during a running turn and queue them when steering is unavailable or rejected. Preserve hidden submit dispatch and leave the current draft intact so the start pill can hand delivery to the composer.

* fix(desktop): refresh first-build rows before the wait call

Poll connector status while the newest connect result contains initiated links, using the same watcher as waits. Check the active part before each request so replacing the card retires its earlier poll.

* fix(desktop): bound first-build connector polling

Stop polling after 150 seconds, a start decision, a completed handshake or three consecutive request failures. Ignore in-flight answers after those conditions so an expired watcher cannot overwrite the final rows.

* fix(desktop): preserve connected rows on unavailable status

Keep a confirmed connection when the catalog omits or disables its row or reports itself unavailable. Only unresolved rows become unavailable so a transient catalog answer does not reduce the start count.

* fix(desktop): treat an omitted connector action as status

Apply the gateway default when deciding whether a connector part is an untargeted status check. These catalog answers render as tool lines and do not resolve an owner or start a connection flow.

* fix(desktop): keep catalog checks from retiring live connectors

Exclude untargeted status parts when finding the latest connector action, including omitted actions. The card, poll and start pill now remain attached to the last actionable connector part.

* fix(desktop): connector catalog loading recovers, and disabled toolkits stay out of the picker

useConnectorCatalog (#108292) started in `loading` and returned early when
the session ids were missing, so the connectors card could sit on its
skeleton with Continue disabled and no request in flight. It now starts
`unavailable` until both ids exist, probes when they arrive, and gives the
gateway 15 s before it reports unavailable.

orderConnectorPicks drops rows the gateway marks `enabled: false`: a toolkit
the deployment turned off is not something the build chat can connect.

* fix(desktop): clarify first-build connection instructions

Filter connector picks against the offered apps before building the task runbook. Explain when active connections, early start messages, and skipped apps let the task begin so the model follows the card’s handshake.

* fix(desktop): show handoff steps only for visible panes

Require positive bounds and a visible pane before the handoff tour starts. Keep the two profile rail steps when the sessions list stays hidden so a collapsed sidebar does not suppress the tour.

* style(desktop): prettier on the connect-first files

Format the renderer TypeScript files in the branch comparison with Prettier and clear spacing warnings in the edited connector files. Keep the connection behavior changes in their individual commits so each defect remains reviewable.

* test(desktop): hold the connect-first tests back until the onboarding test pass

The onboarding tests return in one pass after the guide script rewrite (NS-853), not piecemeal in each behaviour PR. The eight files added on this branch are removed here and come back then; they are intact at 980172b9dd for that pass. The one existing test file this branch edits, the composer submit test, keeps its changes because the behaviour it covers changed.

* test(desktop): a busy hidden request steers like a visible one

After the stack onto #108292 the composer has one busy rule for every external request, hidden or visible: steer the live turn, queue when the steer is refused. The test that asserted the earlier rule (dispatch a hidden request while busy) now asserts the merged one, and a second case keeps the idle path: a hidden request the gateway rejects is dropped, never restored into the draft.

* fix(desktop): a hidden note mid-turn rides session.steer, never a user turn

A hidden request that landed while the model's turn ran went through the redirect path, which records a real user turn on the gateway and paints one in the transcript. The base branch's "[connectors] The user clicked Connect …" nudge showed up as the user's own bubble in a live run, and our "[setup] links opened" note would have done the same. The gateway already has the right primitive: session.steer injects text into the model's next tool result with no user turn. Both composers now expose it as onSteerHidden, and the composer's busy rule sends hidden requests through it. When the steer is refused the note queues with its hidden kind, the queue panel shows "Setup note" instead of the text, and the drain resubmits it hidden. Visible messages a button sends still redirect the turn as before.
2026-09-12 10:00:07 +05:30
brooklyn! 4f1966edac feat(desktop): connector cards that wait for the sign-in, and a guided first launch that holds together (#108292)
* feat(desktop): give Button a loading prop that swaps label for spinner without layout shift

The label stays in the box, invisible, and the spinner is absolutely
centred over it, so a Connect or Approve button keeps its width while it
works instead of collapsing to a spinner. The approval bar had the same
thrash and moves onto it.

* refactor(desktop): one consent card for connectors and MCP setup

McpSetupTool rendered its own copy of the connector card's markup. It now
renders ConnectorCard for the pending question and ConnectorSummary once
settled, and the card gains what MCP needed: keyboard accelerators, a
source line, a question heading. The card also gets an avatar variant
(40px mark in the left gutter, text and buttons on one column) and a
collapseWhenSettled switch so a connector can stay a full card with a
green Connected pill in the action slot while MCP keeps its one-line
summary. Brand marks for Gmail, Calendar, Drive, Discord, Telegram and
Spotify; Slack via Tabler because simple-icons dropped the mark.

* feat(desktop): connector card drives the agent through manage_connections wait

The offer used to end in a Continue in chat button, and the agent, seeing
an unconnected status, would improvise around the app. Now the card does
what the TUI does. Clicking Connect opens the browser and sends one hidden
line telling the agent to park in manage_connections action=wait for that
slug and to never call connect again (a second link cancels the one being
signed into). Not now sends its own line. A hidden request that lands
while the turn is busy steers it, or queues if the turn just ended.

Which call owns the live card changes too: consecutive calls naming the
same apps are one exchange (connect, the wait, the status that follows),
and the first of the last exchange is the card, so the agent's wait no
longer demotes the card mid-authorization and mints a fresh one below it.
A targeted ask renders one or two bare cards; only a real catalog gets the
header, search and refresh.

* feat(desktop): onboarding connects apps in chat and keeps tasks finishable without them

The welcome chat knew connectors only as preferences to pick and wire up
later, so asked to connect Gmail it invented a Settings page that does not
exist. Both scripts now carry one rule set: status once, one batched
connect for every app named, the card is the ask so write a line and end
the turn, never route around a declined app with another client or
credential. The build handoff checks real connection status instead of
asserting none are connected, and the first task must be finishable, not
free of, the apps they picked. The connectors card explains what
connecting means and reports the count on its Continue button.

* fix(tools): resolve the Nous identity for share_auth profiles in the connector gate

A profile created with share_auth has no auth.json of its own and signs
in through the root store. Every other credential reader falls back to
the global root; the connector gate read HERMES_HOME/auth.json directly,
saw nothing, and stripped manage_connections from the profile's tool
list, so the welcome chat's agent truthfully reported the tool missing.
The gate now goes through get_provider_auth_state.

* fix(agent): name a provider retry backoff on the live status line

The retry status is buffered and replays only when every retry fails, so
during a 60s backoff after a 5xx the user saw a bare spinner. Right after
a connector sign-in landed this read as the agent going silent. The
backoff now also rewrites the live wait notice, which the desktop already
renders in the thread status row; it is transient and clears on recovery.

* test(desktop): connector rehearsal launcher and flagged connector spec

connector-rehearsal.mjs starts the real desktop and backend under a fresh
HERMES_HOME with no copied credentials, a fixed Vite port and CDP on 9344,
so the onboarding connector flow can be driven end to end by hand or from
outside. The Playwright spec covers the flagged connector step.

* fix(desktop): send the agent back into wait when the user keeps waiting after a timeout

The card's Keep waiting re-entered the poll but the agent's own wait had
timed out too and nothing told it to go back in, so it would start
talking mid-authorization. keepWaiting now fires onWaiting like connect
does. Tests also pin that an expired or revoked grant asks the gateway
for reconnect, not connect.

* style(desktop): blank lines in connector-flow test per lint

* feat(desktop): HERMES_SKIP_INTRO=1 / --skip-intro skips the first-run film

The intro is a one-time reveal, so anyone rehearsing the guided chat behind
it sits through it on every fresh HERMES_HOME. The flag rides the existing
launch-flags path (main → preload → renderer) next to guestOnboarding and
only gates isIntroRevealEnabled; the backend never sees it. The rehearsal
launcher sets it.

* fix(desktop): onboarding card Continue stays Done after the transcript rebuilds

The card kept its Done flag in component state. The hidden submit and the
turn-end hydrate both rebuild the message list, so the card remounted with
the flag false and Continue came back live, letting a step be answered
twice. The committed steps now live with the other onboarding answers,
keyed by step, and the first-build chip pick rides the same store.
remember_onboarding projects by key, so the new field never reaches USER.md.

* fix(desktop): no provider picker or free-tier chip over the guided first launch

Two sign-in surfaces leaked into the guide. A credential probe on the
setup profile (a free-tier token mid refresh, a session before its runtime
settled) hit requestDesktopOnboarding and dropped the provider picker over
the chat the user was in; and the statusbar free-tier chip sat there
offering a second sign-in the whole time. Both now yield while the gate
phase is cinematic, guided or handoff. The free tier is the provider for
those phases, and the guide offers sign-in on its own ready screen.

* fix(desktop): onboarding connector picks are real catalog slugs

The picker offered Spotify, GitHub and Stripe, none of which the deployed
connector catalog carries, and spelled Calendar and Drive with hyphens the
gateway does not use. A pick the build chat could not honour ended as
"Spotify isn't in the connector list" after the user had been told to
expect it. The list is now twelve slugs from the live status catalog,
spelled as the gateway spells them; GitHub is out (the terminal has git
and gh), chat channels stay on Messaging. Marks for the new entries; the
Google marks answer both spellings. The build runbook offers the picked
connections in its first turn rather than after the work is underway.

* fix(desktop): the free-tier ready screen never interrupts the guided chat

A readiness round fires when the layout pick assembles the window, and it
raised the free-tier ready screen over the conversation: the user was
dropped into the main app, dismissed it, and came back to a card they had
already answered. The guide is the introduction. The ready screen now
yields while the gate is cinematic, guided or handoff, and the notice is
acked the moment the guided chat takes the screen, not only when the film
does, so a skipped film no longer leaves it pending.

* feat(desktop): tour options that lead to building, and a fork that follows the tour

"Just the basics" and "Show me around" read as a click-through with no
exit; "I'll figure it out" read as declining help. Now Quick tour, Show me
everything, and Skip, let's build something. The script also folds the
fork into the same turn as the tour, so when the user closes the overlay
the next ask is already waiting instead of a transcript that ends on the
tour call.

* feat(desktop): the onboarding connector picker reads the live catalog

A hardcoded list, however carefully copied from today's catalog, is the
next drift. The picker now asks connectors.list through the same
session-owned RPC the connector cards use and offers exactly what the
gateway carries: a curated lead order puts the everyday apps first, chat
channels stay on Messaging, everything else is reachable by search. The
picks are gateway slugs, handed straight to manage_connections. No
catalog (toolset off, gateway unreachable) ends the step honestly with
Skip instead of inventing apps.

* test(desktop): the guided first launch never forces a sign-in

The acceptance criterion the guided onboarding was built to, as a test:
while the gate is cinematic, guided or handoff, the provider picker does
not open and a credential warning is dropped rather than deferred to the
next send. Outside the guide the picker opens as before. Red against the
tree before the guards landed (6 of 9).

* fix(desktop): a relaunch mid-guide resumes the guide, in the guide's shape

Closing the app during the guided first launch and reopening it booted the
normal shell around the persisted solo layout: the connecting splash, the
stock composer and model picker, a small window whose sidebars would not
open, while the gate still read guided. The gate now queues a kickoff for
the guided phase too (the kickoff adopts the existing guide chat by title),
takes the solo shape before the gateway opens rather than after, and the
connecting overlay yields to the guide's own opening. A typed reply in the
composer now closes an ask card and the first-build chips the same way a
click does; the layout card's Continue comes back Done.

* style(desktop): one answeredAfter helper for the ask card and first-build chips

* fix(desktop): the guide takes its shape on the tick the film ends, not after the window shows

Between the film and the greeting the full-size shell painted for a beat:
finishIntroReveal showed the main window, then the kickoff shrank it once
the setup profile answered. The listener on the intro's hidden edge now
takes the guide's shape (solo layout + small centred window) synchronously,
so the window is already the guide when it is shown. One takeGuideShape
owns the pair; kickoff and the boot gate call it idempotently.

* style(desktop): the 'nothing connects yet' line reads first on the connectors card
2026-09-12 10:00:06 +05:30
hermes-seaeye[bot] 0f1668ef76 fmt(js): npm run fix on merge (#108753)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 02:33:53 +00:00
Teknium 9c021a6bde fix(desktop): Bot Mode pet picker selects locally hatched pets via pet.thumb
Selecting a self-generated pet in the Bot avatar picker always failed with
"Could not load that pet — try another", and its tile showed only a name.
Locally hatched pets have no petdex manifest entry, so pet.gallery reports an
empty spritesheetUrl; the picker cropped frame 0 client-side from that URL and
bailed on the empty string. The same raw CDN fetch also lacked a User-Agent,
which the petdex CDN rejects with 403 (#90465), so manifest pets could fail on
the same path.

Route tile rendering and selection through the gateway's pet.thumb RPC, which
already backs the Settings pet picker: it crops frame 0 server-side from the
installed sheet on disk (or the host-validated CDN URL for uninstalled pets)
and returns a same-origin PNG data URI. The client cache is keyed by slug,
evicts failures so a blip never poisons a tile, and races a 15s deadline so a
hung RPC cannot park a pending promise forever.

Based on analysis from PR #90931, whose target (plugin.js) has since been
decomposed into pet.tsx.

Co-authored-by: m1k3s0 <44042865+m1k3s0@users.noreply.github.com>
2026-09-11 19:28:29 -07:00
hermes-seaeye[bot] 2f21d29f44 fmt(js): npm run fix on merge (#108749)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 02:24:53 +00:00
hermes-seaeye[bot] 794981e3d4 fmt(js): npm run fix on merge (#108745)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-12 02:19:48 +00:00
Teknium 20816c13cd feat(voice): pick the voice chat engine from the composer
Switching to GPT-Live meant Settings → Voice → Voice Chat Mode, which is not
where you are when you want to talk. The composer now offers the choice where
the voice button is:

- folded layout (HUD / narrow): a "Voice chat engine" radio group in the
  existing voice menu
- unfolded layout: a small chevron beside the start-voice button opening the
  same rows; the button tooltip names the engine that will mount

Rows are hidden until the backend reports a mode (older gateway = no switch
that would 4002); GPT-Live is disabled with the backend's reason when no
OpenAI key resolves. Selecting writes `voice.voice_chat_mode` through
`config.set` on the LIVE gateway (local, SSH, cloud alike) and re-reads the
resolved status; it applies to the next conversation and never touches a
running one.

Backend: `voice.voice_chat_mode` joins the `config.set` word setters
(chained|gpt-live).

Live: headed desktop, chained → menu → GPT-Live → start = RTCPeerConnection
connected; menu → chained → start = no peer connection, chained controls.
2026-09-11 19:14:24 -07:00
Teknium f923faa0b8 feat(voice): GPT-Live voice chat mode — a full-duplex voice frontend that delegates to Hermes (Desktop)
`voice.voice_chat_mode: gpt-live` swaps the desktop's chained STT → turn → TTS
loop for OpenAI's gpt-live-1: one voice model that listens while it speaks and
has no tools of its own. Every real request it hears becomes a normal Hermes
turn on the open chat — any model/provider the session selected, full toolset,
memory, approvals — and the voice paraphrases the reply aloud.

Backend
- tools/voice_live.py: mode/credential/persona resolution and the one server-side
  step the API needs — POST /v1/live/sessions exchanging the renderer's SDP offer,
  pinned to client delegation; the OpenAI key never reaches the renderer. Voice
  persona follows the vendor prompting guide (role, style, labelled delegation
  policy describing Hermes as the backend). VOICE_LIVE_TURN_NOTE is the per-turn
  model-input note (transcript in, speakable prose out).
- REST: GET /api/audio/voice-live/status (mode + readiness, non-secret),
  POST /api/audio/voice-live/session (SDP exchange). The offer is passed
  byte-exact: a stripped trailing CRLF is a vendor 400 "unmarshal SDP: EOF".
- prompt.submit accepts surface=voice-live (+ voice_context) beside hud; the
  note rides the model input via the existing _prepend_note seam, the persisted
  user row stays the user's words, the system prompt stays byte-stable.
- config_defaults: voice.voice_chat_mode (chained|gpt-live), voice.gpt_live.*.

Desktop
- lib/voice-live.ts: RTCPeerConnection + oai-events data channel owner, transcript
  accumulation, session.commentary/thinking/instructions appends (500-token
  chunking), mute, graceful close waiting for session.closed.
- hooks/use-voice-live-conversation.ts: same public shape as useVoiceConversation;
  delegation → prompt.submit(surface=voice-live); tool activity → quiet thinking
  appends; reply streamed back per sentence; spoken stop phrase ends the chat;
  a newer delegation interrupts an in-flight turn.
- use-composer-voice mounts both engines and latches one at conversation start
  from the backend-resolved status; gpt-live without a key falls back to chained
  with a notice. Settings → Voice gets the mode dropdown, voice picker, persona.

Live-verified on the worktree desktop build (headless Electron, CDP, synthetic
mic): "what is 17 times 23 and which model are you on" → delegation → Hermes
(Claude Sonnet 4.5 via OpenRouter) → spoken "391 … Claude Sonnet 4.5 through
OpenRouter"; follow-up "double that" resolved from the spoken context → 782;
"run uname -r" ran the terminal tool with "Hermes is working: terminal" fed as
quiet context → spoken kernel version; "stop" closed the session
(reason=close_requested). Chained mode creates no RTCPeerConnection.
2026-09-11 19:14:24 -07:00
hermes-seaeye[bot] 7469c0f2a5 fmt(js): npm run fix on merge (#108522)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-11 19:00:50 +00:00
Teknium 0dcadf6f41 revert: remove Collective Wisdom V1 (#94266)
Reverts the in-tree org skill-marketplace: hermes_wisdom package, three
model tools, CLI/gateway/desktop/dashboard/Telegram/Slack surfaces.

Later non-Wisdom work on shared files (guest onboarding i18n, dashboard
startup schema, Slack adapter, tui_gateway) is kept; Wisdom-only call
sites and config were stripped from those files.
2026-09-11 11:54:49 -07:00
emozilla 4e865d42d1 fix(desktop): restore flat glass surfaces without text bleed 2026-09-11 13:41:05 -05:00
Jeffrey Quesnelle 698ab88ab8 Merge pull request #108429 from NousResearch/fix/local-recommendation-policy
fix(local-models): keep automatic recommendations GPU-resident
2026-09-11 13:15:00 -04:00