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 diff --git a/api/routes.py b/api/routes.py index 90ca74c2..4118a3a9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3539,6 +3539,63 @@ 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 >= ?) + AND COALESCE(source, '') != 'webui' + """, (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 = [] 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