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 @@
hermes /insights includes browser session data. Off by default.