From 1048d2cb0cbf3800d8e2ad46aacd71bdb5c0862b Mon Sep 17 00:00:00 2001 From: wind-chant Date: Sat, 30 May 2026 23:13:00 +0800 Subject: [PATCH 1/3] feat: include CLI session usage in WebUI Insights The Insights page () previously only counted WebUI-native sessions from its own session index. This adds a query to the Hermes state.db so CLI and gateway sessions (Telegram, Discord, etc.) are also included in the aggregated token counts, costs, model breakdown, and daily activity charts. The state.db query is best-effort: if the file is missing or unreadable, the existing WebUI-only data is returned as before. --- api/routes.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/api/routes.py b/api/routes.py index 90ca74c2..94aae9c2 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3539,6 +3539,62 @@ def _handle_insights(handler, parsed) -> bool: except Exception: pass + # ── Also include CLI sessions from Hermes state.db ───────────────────── + try: + from api.models import _active_state_db_path + db_path = _active_state_db_path() + if db_path and db_path.exists(): + with closing(sqlite3.connect(str(db_path))) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(""" + SELECT id, model, message_count, input_tokens, output_tokens, + estimated_cost_usd, started_at, ended_at + FROM sessions + WHERE (started_at >= ? OR ended_at >= ?) + """, (cutoff, cutoff)) + for row in cur.fetchall(): + _input = _safe_usage_int(row["input_tokens"]) + _output = _safe_usage_int(row["output_tokens"]) + _cost = _safe_cost_float(row["estimated_cost_usd"]) + _msgs = _safe_usage_int(row["message_count"]) + total_sessions += 1 + total_messages += _msgs + total_input_tokens += _input + total_output_tokens += _output + total_cost += _cost + + _model = row["model"] or "unknown" + bucket = model_stats.setdefault(_model, { + "sessions": 0, + "input_tokens": 0, + "output_tokens": 0, + "cost": 0.0, + }) + bucket["sessions"] += 1 + bucket["input_tokens"] += _input + bucket["output_tokens"] += _output + bucket["cost"] += _cost + + _ts = row["started_at"] or row["ended_at"] or 0 + if _ts: + _dt = _time.localtime(_ts) + _day_key = _time.strftime("%Y-%m-%d", _dt) + _daily = daily_tokens.setdefault(_day_key, { + "input_tokens": 0, + "output_tokens": 0, + "sessions": 0, + "cost": 0.0, + }) + _daily["input_tokens"] += _input + _daily["output_tokens"] += _output + _daily["sessions"] += 1 + _daily["cost"] += _cost + dow_activity[_dt.tm_wday] += 1 + hod_activity[_dt.tm_hour] += 1 + except Exception: + logger.debug("Failed to include CLI sessions in insights", exc_info=True) + # Build model breakdown total_tokens = total_input_tokens + total_output_tokens models_breakdown = [] From 06842f6e9f40b2c57264964780c8cebd6d523e57 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 30 May 2026 18:58:20 +0000 Subject: [PATCH 2/3] fix(insights): exclude source='webui' from CLI session pass + add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new CLI/gateway insights pass counted ALL state.db sessions, but WebUI-native sessions are persisted to state.db with source='webui' AND already counted from the sidecar _index.json first pass — double-counting them in totals, model breakdown, and daily charts. Add `AND COALESCE(source, '') != 'webui'` so only CLI/gateway/cron/tui rows are added by the second pass. Adds regression tests proving (a) CLI + Telegram sessions appear in totals and (b) a webui-source state.db row is not double-counted against its _index.json entry. Co-authored-by: wind-chant --- api/routes.py | 1 + tests/test_insights.py | 93 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/api/routes.py b/api/routes.py index 94aae9c2..4118a3a9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3552,6 +3552,7 @@ def _handle_insights(handler, parsed) -> bool: estimated_cost_usd, started_at, ended_at FROM sessions WHERE (started_at >= ? OR ended_at >= ?) + AND COALESCE(source, '') != 'webui' """, (cutoff, cutoff)) for row in cur.fetchall(): _input = _safe_usage_int(row["input_tokens"]) diff --git a/tests/test_insights.py b/tests/test_insights.py index 913d0439..69921b94 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -318,3 +318,96 @@ def test_insights_mobile_models_table_has_contained_overflow(): section_block = STYLE_CSS[section_start:section_end] assert 'overflow-x' in section_block, 'Mobile rule should include overflow-x handling' assert 'insights-model-table' in section_block or 'insights-card' in section_block + + +# ── #3189: CLI/gateway sessions in Insights + webui double-count guard ────── + + +def _call_insights_with_state_db(monkeypatch, tmp_path, entries, state_rows, days="7", now=None): + """Like _call_insights but also seeds an agent state.db with `sessions` rows + and points _active_state_db_path at it, so the CLI second-pass is exercised.""" + import sqlite3 + import api.routes as routes + import api.models as models + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + (session_dir / "_index.json").write_text(json.dumps(entries), encoding="utf-8") + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + if now is not None: + monkeypatch.setattr(time, "time", lambda: now) + + db_path = tmp_path / "state.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + """CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT, model TEXT, message_count INTEGER, + input_tokens INTEGER, output_tokens INTEGER, estimated_cost_usd REAL, + started_at REAL, ended_at REAL + )""" + ) + for r in state_rows: + conn.execute( + "INSERT INTO sessions (id, source, model, message_count, input_tokens, " + "output_tokens, estimated_cost_usd, started_at, ended_at) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (r["id"], r.get("source"), r.get("model"), r.get("message_count", 0), + r.get("input_tokens", 0), r.get("output_tokens", 0), + r.get("estimated_cost_usd", 0.0), r.get("started_at"), r.get("ended_at")), + ) + conn.commit() + conn.close() + # _handle_insights does `from api.models import _active_state_db_path`, so patch on the module. + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + + handler = _FakeHandler() + parsed = SimpleNamespace(query=f"days={days}") + routes._handle_insights(handler, parsed) + assert handler.status == 200 + return handler.json_body() + + +def test_insights_includes_cli_and_gateway_sessions(monkeypatch, tmp_path): + """CLI / Telegram / Discord sessions in state.db should appear in Insights totals.""" + now = time.mktime((2026, 5, 30, 12, 0, 0, 0, 0, -1)) + webui_entries = [ + {"session_id": "w1", "updated_at": now, "created_at": now, "message_count": 2, + "input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.01, "model": "gpt-5.5"}, + ] + state_rows = [ + {"id": "cli1", "source": "cli", "model": "gpt-5.5", "message_count": 3, + "input_tokens": 200, "output_tokens": 80, "estimated_cost_usd": 0.02, + "started_at": now, "ended_at": now}, + {"id": "tg1", "source": "telegram", "model": "gpt-5.5", "message_count": 1, + "input_tokens": 60, "output_tokens": 20, "estimated_cost_usd": 0.005, + "started_at": now, "ended_at": now}, + ] + data = _call_insights_with_state_db(monkeypatch, tmp_path, webui_entries, state_rows, days="7", now=now) + # 1 webui + 2 cli/gateway = 3 sessions counted + assert data["total_sessions"] == 3 + # input tokens summed across all three: 100 + 200 + 60 = 360 + assert data["total_input_tokens"] == 360 + + +def test_insights_does_not_double_count_webui_state_db_rows(monkeypatch, tmp_path): + """WebUI sessions persist to BOTH _index.json AND state.db (source='webui'). + The CLI second-pass must exclude source='webui' so they aren't counted twice.""" + now = time.mktime((2026, 5, 30, 12, 0, 0, 0, 0, -1)) + webui_entries = [ + {"session_id": "w1", "updated_at": now, "created_at": now, "message_count": 2, + "input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.01, "model": "gpt-5.5"}, + ] + # The SAME webui session also has a state.db row (source='webui') + one real cli row. + state_rows = [ + {"id": "w1", "source": "webui", "model": "gpt-5.5", "message_count": 2, + "input_tokens": 100, "output_tokens": 50, "estimated_cost_usd": 0.01, + "started_at": now, "ended_at": now}, + {"id": "cli1", "source": "cli", "model": "gpt-5.5", "message_count": 3, + "input_tokens": 200, "output_tokens": 80, "estimated_cost_usd": 0.02, + "started_at": now, "ended_at": now}, + ] + data = _call_insights_with_state_db(monkeypatch, tmp_path, webui_entries, state_rows, days="7", now=now) + # webui session counted once (from _index.json), cli once = 2, NOT 3. + assert data["total_sessions"] == 2 + # webui tokens counted once (100) + cli (200) = 300, NOT 400. + assert data["total_input_tokens"] == 300 From 7ec16ba30c2d916a144f4251383864f5f935df74 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sat, 30 May 2026 19:08:08 +0000 Subject: [PATCH 3/3] docs(changelog): v0.51.174 release header (stage-batch56) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec520a0..1bd8d979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.174] — 2026-05-30 — Release ET (stage-batch56 — CLI/gateway session usage in Insights) + +### Added + +- The Insights page now includes CLI and gateway sessions (Telegram, Discord, cron, TUI) from the Hermes agent's `state.db` in usage totals, model breakdown, and daily activity — not just WebUI-native sessions (#3189). WebUI sessions are de-duplicated so they are counted once, not double-counted against their `state.db` row. + ## [v0.51.173] — 2026-05-30 — Release ES (stage-batch55 — Windows path/journal safety + pin-quota snapshot fix + tool-card paging anchor + sidebar dedupe + quieter tool cards) ### Fixed