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) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-04 20:07:05 -07:00
committed by GitHub
parent e0d52f39dc
commit bb595afde9
6 changed files with 134 additions and 1 deletions

View File

@@ -652,6 +652,7 @@ _SETTINGS_DEFAULTS = {
'send_key': 'enter', # 'enter' or 'ctrl+enter' 'send_key': 'enter', # 'enter' or 'ctrl+enter'
'show_token_usage': False, # show input/output token badge below assistant messages '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 '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 '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 = { _SETTINGS_ENUM_VALUES = {
'send_key': {'enter', 'ctrl+enter'}, '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: def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys.""" """Save settings to disk. Returns the merged settings. Ignores unknown keys."""

View File

@@ -995,6 +995,20 @@ def _handle_chat_sync(handler, body):
else: os.environ['HERMES_SESSION_KEY'] = old_session_key else: os.environ['HERMES_SESSION_KEY'] = old_session_key
s.messages = result.get('messages') or s.messages s.messages = result.get('messages') or s.messages
s.title = title_from(s.messages, s.title); s.save() 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, { return j(handler, {
'answer': result.get('final_response') or '', 'answer': result.get('final_response') or '',
'status': 'done' if result.get('completed', True) else 'partial', 'status': 'done' if result.get('completed', True) else 'partial',

93
api/state_sync.py Normal file
View File

@@ -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

View File

@@ -309,6 +309,21 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
m['attachments'] = attachments m['attachments'] = attachments
break break
s.save() 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} 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 # Include context window data from the agent's compressor for the UI indicator
_cc = getattr(agent, 'context_compressor', None) _cc = getattr(agent, 'context_compressor', None)

View File

@@ -343,6 +343,13 @@
</label> </label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div> <div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
</div> </div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
Sync usage to /insights
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword">Access Password</label> <label for="settingsPassword">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div> <div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>

View File

@@ -962,6 +962,8 @@ async function loadSettingsPanel(){
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage; if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
const showCliCb=$('settingsShowCliSessions'); const showCliCb=$('settingsShowCliSessions');
if(showCliCb) showCliCb.checked=!!settings.show_cli_sessions; 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) // Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword'); const pwField=$('settingsPassword');
if(pwField) pwField.value=''; if(pwField) pwField.value='';
@@ -992,6 +994,7 @@ async function saveSettings(){
if(sendKey) body.send_key=sendKey; if(sendKey) body.send_key=sendKey;
body.show_token_usage=showTokenUsage; body.show_token_usage=showTokenUsage;
body.show_cli_sessions=showCliSessions; body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
// Password: only act if the field has content; blank = leave auth unchanged // Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){ if(pw && pw.trim()){
try{ try{