Skip to content
4 changes: 4 additions & 0 deletions backend/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
touch_conversation,
update_conversation,
)
from .queries.stats import add_generated_chars, get_generated_chars, get_global_stats
from .queries.interactive_fragments import (
create_interactive_fragment,
delete_interactive_fragment,
Expand Down Expand Up @@ -136,6 +137,7 @@
"SEED_MOOD_FRAGMENTS",
"SEED_PHRASE_BANK",
"add_conversation_log",
"add_generated_chars",
"add_message",
"add_phrase_group",
"create_character_card",
Expand Down Expand Up @@ -197,6 +199,8 @@
"get_workflow_state",
"get_world",
"get_world_by_name",
"get_generated_chars",
"get_global_stats",
"get_worlds",
"schema_safety_problems",
"init_db",
Expand Down
21 changes: 21 additions & 0 deletions backend/database/migrations/0029_generated_chars_counter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Migration 0029: lifetime generated-chars counter for homepage stats.

Adds ``settings.generated_chars``, the running total of characters the LLM has
generated (the homepage "~Tokens generated" stat divides it by the
CHARS_PER_TOKEN heuristic). NULL means "not yet seeded": the stats query layer
lazily initialises it from the existing assistant-message rows on first use,
then successful turns increment it -- so no backfill happens here, and a
restored backup without the column self-heals the same way.
"""

from __future__ import annotations

import sqlite3


def migrate(conn: sqlite3.Connection) -> None:
cols = {row[1] for row in conn.execute("PRAGMA table_info(settings)").fetchall()}
if "generated_chars" not in cols:
conn.execute("ALTER TABLE settings ADD COLUMN generated_chars INTEGER DEFAULT NULL")
conn.commit()
print("[migrations] 0029: added generated_chars column to settings")
1 change: 1 addition & 0 deletions backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class SettingsRow(_SettingsBase, total=False):
agent_endpoint_id: int | None
attachment_cache_budget_bytes: int
attachment_access_counter: int
generated_chars: int | None
# JSON columns, decoded to their in-memory shape by get_settings() on the
# SELECT * branch only (DEFAULT_SETTINGS omits them).
enabled_tools: dict[str, bool]
Expand Down
2 changes: 1 addition & 1 deletion backend/database/preset_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
# take from the file -- e.g. attachment-cache bookkeeping, not user-facing config.
# Maps ``table -> columns to leave untouched`` during the overwrite.
PRESERVED_COLUMNS: dict[str, tuple[str, ...]] = {
"settings": ("attachment_cache_budget_bytes", "attachment_access_counter"),
"settings": ("attachment_cache_budget_bytes", "attachment_access_counter", "generated_chars"),
}

# The tripwire behind the SECRET_COLUMNS check: any column whose name ends with one
Expand Down
153 changes: 153 additions & 0 deletions backend/database/queries/stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone

from ..connection import get_db

# Seeds settings.generated_chars from the rows that already exist the first
# time the counter is touched (NULL = never seeded). Assistant rows across ALL
# branches (swipes/regens are sibling rows) approximate what the LLM has
# generated historically; after this one-time backfill the counter only moves
# via add_generated_chars, so deleting conversations no longer shrinks it.
_SEED_GENERATED_CHARS_SQL = """
UPDATE settings
SET generated_chars = (
SELECT COALESCE(SUM(LENGTH(content)), 0) FROM messages WHERE role = 'assistant'
)
WHERE id = 1 AND generated_chars IS NULL
"""


async def get_generated_chars() -> int:
"""Lifetime characters generated by the LLM (callers derive tokens).

Seeds the counter from existing assistant messages on first use.
"""
async with get_db() as db:
await db.execute(_SEED_GENERATED_CHARS_SQL)
await db.commit()
rows = list(await db.execute_fetchall("SELECT generated_chars FROM settings WHERE id = 1"))
return int(rows[0][0]) if rows and rows[0][0] is not None else 0


async def add_generated_chars(chars: int) -> None:
"""Credit *chars* of freshly generated text to the lifetime counter.

Call AFTER the assistant message is persisted: if the counter was never
seeded, the seed scan already counts that new row, so the increment is
skipped for exactly that case (cur.rowcount == 1) to avoid double counting.
"""
if chars <= 0:
return
async with get_db() as db:
cur = await db.execute(_SEED_GENERATED_CHARS_SQL)
if cur.rowcount == 0:
await db.execute(
"UPDATE settings SET generated_chars = generated_chars + ? WHERE id = 1",
(chars,),
)
await db.commit()


async def get_global_stats() -> dict:
"""Aggregate usage stats across the whole database.

Token totals are NOT derived here -- they come from the persistent
``generated_chars`` counter via :func:`get_generated_chars`.
"""
async with get_db() as db:
conv_row = list(await db.execute_fetchall("SELECT COUNT(*) FROM conversations"))
total_conversations = conv_row[0][0] if conv_row else 0

# One full scan of messages: the count covers ALL branches (swipes/regens
# are sibling rows here), while user_chars filters to role='user' so
# "words written" reflects only what the user typed.
msg_row = list(
await db.execute_fetchall(
"""SELECT COUNT(*),
COALESCE(SUM(CASE WHEN role = 'user' THEN LENGTH(content) ELSE 0 END), 0)
FROM messages"""
)
)
total_messages = msg_row[0][0] if msg_row else 0
user_chars = msg_row[0][1] if msg_row else 0

# Favorite character = the one whose conversations hold the most messages.
# Group on character_name (not card id) so renamed/deleted cards still tally,
# skipping unnamed conversations.
fav_row = list(
await db.execute_fetchall(
"""SELECT c.character_name,
COUNT(*) AS msg_count,
COUNT(DISTINCT c.id) AS conv_count,
MAX(c.character_card_id) AS card_id
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.character_name != ''
GROUP BY c.character_name
ORDER BY msg_count DESC
LIMIT 1"""
)
)
favorite_character = (
{
"name": fav_row[0][0],
"messages": fav_row[0][1],
"conversations": fav_row[0][2],
"card_id": fav_row[0][3],
}
if fav_row
else None
)

# A random well-worn character (>100 messages) for the "misses you"
# spotlight theme. Same shape as the favorite, but excludes the favorite
# itself so the two themes stay distinct, and skips anyone talked to in
# the last 24h — they can't "miss you" if you just spoke. created_at is
# an ISO-8601 UTC string, so a string compare against the cutoff sorts
# correctly. RANDOM() picks the candidate; the endpoint flips the coin
# on which theme actually shows.
fav_name = favorite_character["name"] if favorite_character else ""
recent_cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
missed_row = list(
await db.execute_fetchall(
"""SELECT c.character_name,
COUNT(*) AS msg_count,
COUNT(DISTINCT c.id) AS conv_count,
MAX(c.character_card_id) AS card_id
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.character_name != '' AND c.character_name != ?
GROUP BY c.character_name
HAVING COUNT(*) > 100 AND MAX(m.created_at) < ?
ORDER BY RANDOM()
LIMIT 1""",
(fav_name, recent_cutoff),
)
)
missed_character = (
{
"name": missed_row[0][0],
"messages": missed_row[0][1],
"conversations": missed_row[0][2],
"card_id": missed_row[0][3],
}
if missed_row
else None
)

# > 0 (not just IS NOT NULL): turns with no LLM passes log 0, and
# averaging those in would understate true response time.
lat_row = list(
await db.execute_fetchall("SELECT AVG(agent_latency_ms) FROM conversation_logs WHERE agent_latency_ms > 0")
)
avg_latency = lat_row[0][0] if lat_row else None

return {
"total_conversations": total_conversations,
"total_messages": total_messages,
"user_chars": user_chars,
"favorite_character": favorite_character,
"missed_character": missed_character,
"avg_latency_ms": avg_latency,
}
3 changes: 2 additions & 1 deletion backend/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
inspector_open_states TEXT NOT NULL DEFAULT '{"reasoning":true,"tool_calls":false,"injection_block":false,"context_size":true}',
workflow_config TEXT NOT NULL DEFAULT '{}',
attachment_cache_budget_bytes INTEGER NOT NULL DEFAULT 524288000,
attachment_access_counter INTEGER NOT NULL DEFAULT 0
attachment_access_counter INTEGER NOT NULL DEFAULT 0,
generated_chars INTEGER DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS mood_fragments (
Expand Down
36 changes: 36 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
import hashlib
import json
import random
import re
import uuid
import logging
Expand Down Expand Up @@ -41,6 +42,8 @@
create_mood_fragment,
update_mood_fragment,
delete_mood_fragment,
get_generated_chars,
get_global_stats,
list_conversations,
get_conversation,
create_conversation,
Expand Down Expand Up @@ -964,6 +967,39 @@ async def api_reset(data: ResetConfirm):
# Conversations ──


@app.get("/api/stats")
async def api_global_stats():
"""Aggregate usage statistics for the homepage stat grid."""
s = await get_global_stats()
# Persistent lifetime counter: seeded from existing messages on first use,
# then incremented per successful generation -- not recomputed per call.
generated_chars = await get_generated_chars()
avg_latency = s["avg_latency_ms"]
# On-disk footprint: the main db plus its WAL/shared-memory sidecars, which
# hold not-yet-checkpointed pages and can be a sizable share of the total.
storage_bytes = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
# The hero slot shows one of several "story beat" themes, chosen uniformly
# among those with data (50/50 today, extensible by appending more themes).
themes = []
if s["favorite_character"]:
themes.append(("favorite", s["favorite_character"]))
if s["missed_character"]:
themes.append(("missed", s["missed_character"]))
spotlight = None
if themes:
theme, card = random.choice(themes)
spotlight = {"theme": theme, **card}
return {
"total_conversations": s["total_conversations"],
"total_messages": s["total_messages"],
"character_spotlight": spotlight,
"total_words": round(s["user_chars"] / 5),
"estimated_tokens": estimate_tokens(generated_chars) if generated_chars else 0,
"storage_bytes": storage_bytes,
"avg_latency_ms": round(avg_latency) if avg_latency is not None else None,
}


@app.get("/api/conversations")
async def api_list_conversations():
return await list_conversations()
Expand Down
14 changes: 14 additions & 0 deletions backend/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import asyncio
import logging
import time
from dataclasses import dataclass, field, fields
from types import MappingProxyType
from typing import Any, AsyncIterator, List, Mapping, Optional, Sequence
Expand Down Expand Up @@ -482,6 +483,7 @@ async def _run_pipeline(
cfg.length_guard,
)
resp_text = ""
writer_t0 = time.monotonic()
async for item in writer_pass(
cfg.writer_lane.client,
cfg.writer_lane.base,
Expand All @@ -499,6 +501,10 @@ async def _run_pipeline(
else:
resp_text += item["delta"]
yield {"event": "token", "data": item["delta"]}
# agent_latency_ms is the whole turn's wall time, so accumulate every pass:
# director (above) + writer (here) + editor (below). Timed in the orchestrator
# because the writer pass only streams tokens and reports no duration of its own.
latency += int((time.monotonic() - writer_t0) * 1000)

def _make_result(final_text: str, staged: list[dict], staged_state: dict | None = None) -> dict:
return {
Expand Down Expand Up @@ -578,6 +584,7 @@ def _make_result(final_text: str, staged: list[dict], staged_state: dict | None
"data": {"pass": "editor", "delta": event["delta"]},
}
elif event["type"] == "done":
latency += int(event.get("elapsed", 0) or 0)
refined_draft = event["draft"]
if refined_draft and refined_draft != resp_text:
resp_text = refined_draft
Expand Down Expand Up @@ -1448,6 +1455,13 @@ async def _persist_result(
"Failed to set active leaf to assistant message %s; row already committed",
asst_id,
)
# Lifetime "tokens generated" homepage stat. Must run after add_message:
# the counter's first-use seed scans existing assistant rows, and ordering
# it this way lets the seed absorb this turn's text without double counting.
try:
await db.add_generated_chars(len(resp_text))
except Exception:
logger.exception("Failed to update generated-chars counter; row already committed")
return asst_id, rejected
else:
logger.info("Skipping assistant message persistence: resp_text is empty (reasoning‑only output)")
Expand Down
6 changes: 6 additions & 0 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,12 @@ setLockStateChangeCallback((hasMultipleTabs) => {
});
initWorkflowMutationListener();

// On a fresh load with no conversation selected, render the JS empty state so
// the homepage stats grid appears (index.html ships a static empty state).
if (!S.activeConvId) {
renderMessages();
}

// Load data independently to prevent failures from blocking other loads
async function initAll() {
initMobileUi({ closeBurger });
Expand Down
Loading
Loading