* feat: /personality slash command with backend integration
Add /personality command to switch the agent's system prompt personality.
Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md.
Backend:
- GET /api/personalities: lists available personalities from the active
profile's personalities directory (reads first line of SOUL.md for desc)
- POST /api/personality/set: sets active personality on the session, reads
and validates the SOUL.md file exists, returns the prompt text
- streaming.py: injects personality prompt (SOUL.md content) as prefix to
the system_message when run_conversation is called
Frontend (commands.js):
- /personality with no args: lists available personalities as a local message
- /personality <name>: sets the personality with a toast confirmation
- /personality none|default|clear: removes the active personality
Session model: new 'personality' field (backward-compatible, defaults to None)
Closes#139
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: path traversal in personality name + case sensitivity
Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
in both routes.py (POST /api/personality/set) and streaming.py (system
prompt injection). Defense-in-depth: resolve().relative_to() check ensures
the path stays inside the personalities directory even if regex is bypassed.
Also: removed toLowerCase() from frontend command handler so personality
names are case-preserved (filesystem may be case-sensitive).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: /personality command — hardened, compact() fix, tests
Fixes on top of original PR:
- compact() was missing 'personality' field — UI couldn't know active
personality after page load. Added to Session.compact().
- GET /api/personalities: add symlink guard (is_symlink() skip) and
resolve() check — prevents reading SOUL.md from symlink targets
outside personalities dir.
- POST /api/personality/set: require() only checks session_id (not name)
so clearing with name='' works correctly instead of 400.
- POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to
prevent unbounded context window consumption.
- POST /api/personality/set: return personality:null (not '') when cleared.
- streaming.py: same MAX_FILE_BYTES guard before prepending to system msg.
Added tests/test_sprint28.py: 11 tests for API round-trip, listing,
symlink guard, path traversal rejection, clear, size cap, persistence.
Tests pass in isolation; full-suite run has a test-isolation interaction
with shared server state across sprint tests (tracked as follow-up).
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prefix non-default provider model IDs for correct routing
When multiple providers are configured, models from non-default providers
(e.g. MiniMax when Anthropic is default) were sent as bare names without
provider context. resolve_model_provider() couldn't determine the target
provider and routed them to the default provider's API, which failed.
Fix: get_available_models() now prefixes model IDs with the provider name
(e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config
provider. The default provider's models keep bare names for direct API
routing. This matches the existing pattern for OpenRouter models.
Added 2 tests to test_model_resolver.py for cross-provider routing.
Closes#138
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: model prefix — null-guard, case normalization, mutation safety, tests
Four fixes on top of original PR:
- active_provider=None guard: without a confirmed provider all models were
being prefixed. Only prefix when active_provider is set.
- Case normalisation: compare pid against active_provider.lower() so
config.yaml entries like 'Anthropic' match pid 'anthropic'.
- Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid];
the default_model injector later calls list.insert() on that reference,
permanently mutating the shared constant. Both branches now use a copy.
- Already-prefixed model IDs pass through as-is (no double-prefix).
Added 3 tests for get_available_models() prefix behaviour:
- Non-default provider models are prefixed
- Active provider's own entries remain bare
- No double-prefix when active_provider is absent
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: tool call cards persist correctly after page reload
Root cause: the insertion logic looked for the NEXT assistant row to
insert BEFORE, but when the triggering assistant message contained only
tool_use blocks (no text), it was filtered from the DOM by msgContent()
and no anchor row existed. Cards were silently dropped.
Fix: find the row AT the assistant_msg_idx first (exact match), fall
back to the nearest preceding visible row, then insert AFTER it (not
before the next). This handles both text+tool and tool-only assistant
messages correctly.
Closes#140
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: tool card ordering — role-filter fallback anchor, preserve order for same-anchor groups
Two bugs in the initial PR:
- Fallback 'nearest row' had no role filter, could anchor to a user message
row causing cards to appear after a user bubble. Fixed: check role==='assistant'
in the fallback loop.
- Back-to-back tool-card groups sharing a filtered anchor were inserted in
reverse order because each group re-read anchorRow.nextSibling from the live
DOM. Fixed: track the last inserted node per anchor via anchorInsertAfter Map,
so each subsequent group appends after the previous one.
Also restores the 'Collect card elements before they get moved to DOM' comment
explaining why Array.from() is used on frag before DOM insertion.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move `import html` to module top (was inside function body)
- Fix IndexError crash in /login when bot_name is empty string;
use `or 'Hermes'` fallback instead of .get() default which
doesn't guard against stored empty string
- Add server-side sanitization in POST /api/settings: strip + default
empty/whitespace bot_name to 'Hermes' before persisting
- Escape _bn initial char in ui.js innerHTML (esc() consistency)
- Add maxlength=64 to #settingsBotName input field
- Add tests/test_sprint27.py: 9 tests covering API round-trip,
empty/whitespace defaults, login page rendering, and XSS escaping
When config has provider=openrouter and model=openrouter/free,
resolve_model_provider() stripped the 'openrouter/' prefix because
prefix == config_provider. This sent 'free' to OpenRouter's API,
which returned 404 (model not found).
OpenRouter always needs the full provider/model path (e.g.
openrouter/free, anthropic/claude-sonnet-4.6). The prefix-stripping
logic is only correct for direct-API providers.
Fix: skip prefix stripping entirely when config_provider is 'openrouter'.
Return the full model_id with provider='openrouter'.
Added 7 unit tests for resolve_model_provider() covering:
- openrouter/free keeps full path (the bug)
- openrouter cross-provider models keep full path
- direct API providers still strip prefix correctly
- cross-provider routing to openrouter
- bare model names use config provider
- empty model returns defaults
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The login form used 'return doLogin(event)' in onsubmit, but doLogin is
async so it returns a Promise (truthy), which some browsers interpret as
'proceed with native form submit'. Changed to 'doLogin(event);return false'
and added an explicit onkeydown Enter handler on the password input as
belt-and-suspenders.
Closes#124
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- api/updates.py: add _apply_lock to prevent concurrent stash/pull/pop
- static/boot.js: set check_for_updates:false on settings fetch failure
- static/panels.js: remove dead settingsWorkspace references (element removed from HTML)
- api/routes.py + static/boot.js: add ?test_updates=1 URL param for testing banner
without being behind on git (localhost-only simulate endpoint)
Shows a blue banner when the webui or hermes-agent git repos are behind
their upstream branches. One-click 'Update Now' button does stash, pull
--ff-only, stash pop, then reloads the page.
Backend (api/updates.py):
- _check_repo(): git fetch + rev-list count with 15s timeout
- check_for_updates(): 30-min server-side cache, thread-safe, skips
Docker (no .git dir)
- apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache
Routes:
- GET /api/updates/check -- returns cached {webui, agent} with behind count
- POST /api/updates/apply -- {target: 'webui'|'agent'}
Frontend:
- Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now'
- Non-blocking boot check via fire-and-forget .then(), once per tab session
- sessionStorage guards prevent re-checking and re-showing after dismiss
Settings:
- 'Check for updates' checkbox (default: on) -- when off, no git operations
- Removed 'Default Workspace' dropdown to keep settings panel compact
Performance:
- Server cache: git fetch at most 2x/hour regardless of client count
- sessionStorage: one check per browser tab session
- _check_in_progress flag prevents concurrent fetch storms
- Fire-and-forget: does NOT block the boot sequence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related bugs in get_available_models():
1. cfg_base_url undefined for string model configs (#117):
cfg_base_url was defined inside 'elif isinstance(model_cfg, dict)'
but referenced unconditionally at line 506. If model config was a
plain string, NameError crashed model detection. Fix: initialize
cfg_base_url='' before the conditional.
2. Configured default_model missing from dropdown (#116):
The OpenRouter branch substituted _FALLBACK_MODELS without checking
if the user's model.default was in the list. Models like
'openrouter/free' or custom local models were invisible. Fix: after
building all groups, check if default_model is present. If not,
inject it at the top of the matching provider group.
Closes#116, closes#117
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* security: fix four audit findings -- env race, signing key, upload traversal, password hash
1. Race condition in os.environ (HIGH): Per-session _agent_lock didn't
prevent cross-session env writes from interleaving. Added global
_ENV_LOCK in streaming.py that serializes the entire env save/restore
block across all sessions.
2. Predictable signing key (MEDIUM): sha256(STATE_DIR) was deterministic.
Now generates a random 32-byte key on first startup and persists it to
STATE_DIR/.signing_key (chmod 600). Existing sessions invalidated on
first restart (acceptable for a security fix).
3. Upload path traversal (MEDIUM): Filename '..' survived the regex
sanitization (dots are allowed chars). Added explicit rejection of
dot-only names and safe_resolve_ws() check to verify the resolved
path stays within the workspace.
4. Weak password hashing (MEDIUM): Replaced bare SHA-256 with PBKDF2-
SHA256 (600k iterations per OWASP). Uses stdlib hashlib.pbkdf2_hmac,
no new dependencies. Note: existing passwords must be re-set after
this change (hash format changed).
Closes#106
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use random signing key as PBKDF2 salt (replaces predictable STATE_DIR salt)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: light theme sidebar, roles, chips, active states -- full polish
Comprehensive light theme overrides for every remaining hardcoded
dark-theme element:
Sidebar:
- Session items: warm dark text instead of faint muted gray
- Active session: blue accent (matching --blue) instead of washed-out gold
- Pin stars/headers: deep gold #996b15 instead of bright yellow #f5c542
- Session actions gradient: light bg instead of dark overlay
- Search input: dark borders, proper focus ring
Role labels:
- You: solid #2d6fa3 blue instead of faint rgba(124,185,255,0.65)
- Hermes: solid #8a6520 gold instead of faint rgba(201,168,76,0.6)
- Role icons: proper bg/border contrast for light backgrounds
Chips and interactive elements:
- Project chips: dark borders, dark hover states
- Model chip: blue accent matching theme
- New chat button: blue accent borders
- All hover states: rgba(0,0,0,.XX) instead of rgba(255,255,255,.XX)
Other surfaces:
- Composer box borders and focus ring
- Tool cards, cron items, suggestions
- File tree hover, preview badges
- Profile/workspace dropdown hovers
- Settings, nav tooltips
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: update THEMES.md with all current CSS variables
Added typography variables (--strong, --em, --code-text, --code-inline-bg,
--pre-text) to the custom theme guide. Added note about light theme
selector overrides needed for hover/border contrast.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added 5 new CSS variables to every theme block:
--strong, --em, --code-text, --code-inline-bg, --pre-text
Light theme: dark brown text, warm gray italics, saddle brown code on
subtle bg. All previously invisible text is now readable.
All themes get palette-appropriate values matching their design language
(Solarized orange, Monokai yellow, Nord green, etc).
Also fixed: remaining white borders to var(--border), light scrollbar,
code-bg contrast, settings overlay, approval card text.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: topbar, dropdowns, toast, approval card, tooltips, main area,
inputs, and hover states all used hardcoded rgba(22,33,62), #1a2535, etc.
These only looked correct on the Dark theme — all other themes showed
jarring dark-navy elements on non-navy backgrounds.
New CSS variables added to every theme block:
- --surface: dropdowns, popups, toast, approval card
- --topbar-bg: topbar background
- --main-bg: main chat area background
- --input-bg: subtle input/button backgrounds
- --hover-bg: hover state backgrounds
- --focus-ring / --focus-glow: focus border and box-shadow
Light theme now has proper light-colored surfaces, inputs, and hover
states instead of invisible white-on-white.
THEMES.md updated with all new variables documented.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unsaved-changes guard:
- _closeSettingsPanel() intercepts all three close paths (X button, overlay
click, Escape key) and checks _settingsDirty before closing
- If dirty: shows inline 'Unsaved changes' bar with Save & Close / Discard
- Discard reverts the live theme preview to what it was when panel opened
- _markSettingsDirty() wired to all inputs via addEventListener in loadSettingsPanel()
- saveSettings() now resets dirty flag and hides the bar on successful save
Theme improvements:
- Add 'Slate' theme: warm charcoal (#2b2d30 bg), a softer/lighter dark option
that sits between Dark and the full light themes
- Rework 'Light' theme: replace pure white (#f5f5f7) with warm off-white
(#f0ede8) -- warmer, lower contrast, less harsh on most displays
- Update /theme command to include 'slate' in valid list
- Add test_settings_set_theme_slate() to test_sprint26.py
- Sprint 12 and 13 headers: add missing (COMPLETED) labels
- Sprint 23 header: corrected from 'Profile/Workspace/Model Coherence' to
'Agentic Transparency + Context Visibility' (what it actually shipped)
- Sprint 24 Track C: removed stale self-referential cleanup items that are now done
- Sprint 26 added: full plan for pluggable UI themes (light/dark/solarized/monokai/nord)
including CSS variable architecture, flicker prevention, /theme slash command,
settings picker with live preview, and test spec
- ROADMAP.md: add v0.32/v0.33 to sprint history table, add Sprint 25/26 to feature checklist
- SPRINTS.md footer: add horizon sprint line
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Three bugs found during review:
1. Class is SessionDB not HermesState -- would silently no-op on every install
2. SessionDB.__init__ takes Path not str -- would crash with AttributeError
3. _execute_write() takes a callable not SQL+params -- wrong signature.
Replaced with public set_session_title() API.
4. Each call opened a persistent SQLite connection and never closed it.
Added try/finally db.close() to prevent WAL leak under sustained load.
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
WebUI sessions were invisible to 'hermes /insights' because the WebUI
bypasses the gateway and calls AIAgent.run_conversation() directly,
never writing to state.db.
New 'Sync usage to /insights' setting (default: off) that mirrors
WebUI session metadata (tokens, cost, model, title) into state.db
after each turn. Uses absolute token counts to avoid double-counting.
Components:
- api/state_sync.py: bridge module with sync_session_start() and
sync_session_usage(). Uses ensure_session() (idempotent) and
update_token_counts(absolute=True). All wrapped in try/except.
- api/config.py: new 'sync_to_insights' boolean setting
- api/streaming.py: calls sync_session_usage() after s.save()
- api/routes.py: same for the non-streaming chat path
- Settings UI: checkbox toggle with description
Default off because:
- Writing to state.db while CLI/gateway also writes could cause
WAL lock contention on busy systems
- Some users may not want WebUI sessions in /insights stats
Closes#92
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>