From bb595afde9fb98a9f4d5147267e632c9411120d9 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 4 Apr 2026 20:07:05 -0700 Subject: [PATCH] feat: opt-in state.db sync for /insights visibility (#92) 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) --- api/config.py | 3 +- api/routes.py | 14 +++++++ api/state_sync.py | 93 +++++++++++++++++++++++++++++++++++++++++++++++ api/streaming.py | 15 ++++++++ static/index.html | 7 ++++ static/panels.js | 3 ++ 6 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 api/state_sync.py diff --git a/api/config.py b/api/config.py index 947f013..af45f3a 100644 --- a/api/config.py +++ b/api/config.py @@ -652,6 +652,7 @@ _SETTINGS_DEFAULTS = { 'send_key': 'enter', # 'enter' or 'ctrl+enter' 'show_token_usage': False, # show input/output token badge below assistant messages 'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar + 'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights 'password_hash': None, # SHA-256 hash; None = auth disabled } @@ -671,7 +672,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'} _SETTINGS_ENUM_VALUES = { 'send_key': {'enter', 'ctrl+enter'}, } -_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions'} +_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'} def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" diff --git a/api/routes.py b/api/routes.py index 67ad0e9..692c2c8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -995,6 +995,20 @@ def _handle_chat_sync(handler, body): else: os.environ['HERMES_SESSION_KEY'] = old_session_key s.messages = result.get('messages') or s.messages s.title = title_from(s.messages, s.title); s.save() + # Sync to state.db for /insights (opt-in setting) + try: + if load_settings().get('sync_to_insights'): + from api.state_sync import sync_session_usage + sync_session_usage( + session_id=s.session_id, + input_tokens=s.input_tokens or 0, + output_tokens=s.output_tokens or 0, + estimated_cost=s.estimated_cost, + model=s.model, + title=s.title, + ) + except Exception: + pass return j(handler, { 'answer': result.get('final_response') or '', 'status': 'done' if result.get('completed', True) else 'partial', diff --git a/api/state_sync.py b/api/state_sync.py new file mode 100644 index 0000000..db5868a --- /dev/null +++ b/api/state_sync.py @@ -0,0 +1,93 @@ +""" +Hermes Web UI -- Optional state.db sync bridge. + +Mirrors WebUI session metadata (token usage, title, model) into the +hermes-agent state.db so that /insights, session lists, and cost +tracking include WebUI activity. + +This is opt-in via the 'sync_to_insights' setting (default: off). +All operations are wrapped in try/except -- if state.db is unavailable, +locked, or the schema doesn't match, the WebUI continues normally. + +The bridge uses absolute token counts (not deltas) because the WebUI +Session object already accumulates totals across turns. This avoids +any double-counting risk. +""" +import os +from pathlib import Path + + +def _get_state_db(): + """Get a HermesState instance for the active profile's state.db. + Returns None if hermes_state is not importable or DB is unavailable. + """ + try: + from hermes_state import HermesState + except ImportError: + return None + + try: + from api.profiles import get_active_hermes_home + hermes_home = Path(get_active_hermes_home()).expanduser().resolve() + except Exception: + hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes'))) + + db_path = hermes_home / 'state.db' + if not db_path.exists(): + return None + + try: + return HermesState(str(db_path)) + except Exception: + return None + + +def sync_session_start(session_id, model=None): + """Register a WebUI session in state.db (idempotent). + Called when a session's first message is sent. + """ + try: + db = _get_state_db() + if not db: + return + db.ensure_session( + session_id=session_id, + source='webui', + model=model, + ) + except Exception: + pass # never crash the WebUI for sync failures + + +def sync_session_usage(session_id, input_tokens=0, output_tokens=0, + estimated_cost=None, model=None, title=None): + """Update token usage and title for a WebUI session in state.db. + Called after each turn completes. Uses absolute=True to set totals + (the WebUI Session already accumulates across turns). + """ + try: + db = _get_state_db() + if not db: + return + # Ensure session exists first (idempotent) + db.ensure_session(session_id=session_id, source='webui', model=model) + # Set absolute token counts + db.update_token_counts( + session_id=session_id, + input_tokens=input_tokens, + output_tokens=output_tokens, + estimated_cost_usd=estimated_cost, + model=model, + absolute=True, + ) + # Update title if we have one + if title: + try: + db._execute_write( + "UPDATE sessions SET title = ? WHERE id = ?", + (title, session_id), + ) + except Exception: + pass + except Exception: + pass # never crash the WebUI for sync failures diff --git a/api/streaming.py b/api/streaming.py index 6d7c480..c6d4ab0 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -309,6 +309,21 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta m['attachments'] = attachments break s.save() + # Sync to state.db for /insights (opt-in setting) + try: + from api.config import load_settings as _load_settings + if _load_settings().get('sync_to_insights'): + from api.state_sync import sync_session_usage + sync_session_usage( + session_id=s.session_id, + input_tokens=s.input_tokens or 0, + output_tokens=s.output_tokens or 0, + estimated_cost=s.estimated_cost, + model=model, + title=s.title, + ) + except Exception: + pass # never crash the stream for sync failures usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost} # Include context window data from the agent's compressor for the UI indicator _cc = getattr(agent, 'context_compressor', None) diff --git a/static/index.html b/static/index.html index a4baf4b..f6b0829 100644 --- a/static/index.html +++ b/static/index.html @@ -343,6 +343,13 @@
Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.
+
+ +
Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.
+
Enter a new password to set or change it. Leave blank to keep current setting.
diff --git a/static/panels.js b/static/panels.js index 043d6f1..ddc9066 100644 --- a/static/panels.js +++ b/static/panels.js @@ -962,6 +962,8 @@ async function loadSettingsPanel(){ if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage; const showCliCb=$('settingsShowCliSessions'); if(showCliCb) showCliCb.checked=!!settings.show_cli_sessions; + const syncCb=$('settingsSyncInsights'); + if(syncCb) syncCb.checked=!!settings.sync_to_insights; // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField) pwField.value=''; @@ -992,6 +994,7 @@ async function saveSettings(){ if(sendKey) body.send_key=sendKey; body.show_token_usage=showTokenUsage; body.show_cli_sessions=showCliSessions; + body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{