5dcd4844bd4c78ef57ea50d650c032d9b792c2aa
4588 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
988d471479 | style(ts): sort imports/exports the way perfectionist wants after the rebase | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
(
|
||
|
|
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) |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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).
|
||
|
|
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. |
||
|
|
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`.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
d46f79233e | fix: sort imports in markdown-text.tsx per perfectionist rule | ||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
a318772f51 | fix(test): raise mock model context window | ||
|
|
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. |
||
|
|
6f8d975fe7 | fix(desktop): use valid LC_CTYPE on Linux terminals | ||
|
|
3e09e5a15f |
fmt(js): npm run fix on merge (#109094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
28326a6ccd |
fmt(js): npm run fix on merge (#108902)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
99d7f17a1c | docs(desktop): explain automatic tutorial cutoff in settings | ||
|
|
995e79afe4 | feat(desktop): retire tutorials after the first month | ||
|
|
a84a2223f8 |
fmt(js): npm run fix on merge (#108829)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
f7cd8bf084 |
fmt(js): npm run fix on merge (#108814)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
0f1668ef76 |
fmt(js): npm run fix on merge (#108753)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
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> |
||
|
|
2f21d29f44 |
fmt(js): npm run fix on merge (#108749)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
794981e3d4 |
fmt(js): npm run fix on merge (#108745)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
7469c0f2a5 |
fmt(js): npm run fix on merge (#108522)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
4e865d42d1 | fix(desktop): restore flat glass surfaces without text bleed | ||
|
|
698ab88ab8 |
Merge pull request #108429 from NousResearch/fix/local-recommendation-policy
fix(local-models): keep automatic recommendations GPU-resident |