Files
ai-agent/backend/indexer.py
Gabriel Vidal f90d9d27ff feat(dashboards): activity dashboard — contribution graph + 5-minute agent histogram
A third dashboard answering *when* the agents ran, next to the existing "what
they cost" pages:

- a GitHub-style contribution graph, a cell per day, intensity = conversations
  started, quartile-stepped so one outlier day can't flatten the rest;
- the selected day as stacked columns, one per 5-minute slot, a segment per
  model — the shape of a session day and where agents overlapped;
- per-model totals: runs, working time, tokens, cost.

Both charts are wider than a phone by design and scroll inside their own
container, so the page stays a single mobile-first column.

The per-turn work rides along with the existing parse: `ParserState` now stamps
each summary with `activeBins` (model id -> the 5-minute bins that run produced
a turn in), so `activity.py` rolls the whole archive up without re-reading a
transcript. Bins are epoch-absolute UTC and the caller passes its `tzOffset`, so
local days survive a DST change. PARSER_VERSION 25 -> 26 re-parses the archive
once to backfill.

Model hues move to the validated categorical palette: the existing 500 steps in
light, 600 in dark (which is what puts every hue inside the darker surface's
lightness band), plus qwen. Both sets pass the band / chroma / CVD-separation /
normal-vision checks; the light set sits just under 3:1 contrast, which the
panels' table twins cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:08:52 +02:00

429 lines
20 KiB
Python

"""
Background indexing for the ai-agent backend.
Two jobs, both run on a single daemon thread so they never block requests:
1. **Token + cost accounting** — scan the exposed context files, store their
metadata in SQLite, and compute the *real* Claude token count (via the
count_tokens endpoint) + dollar cost. To use the API sparingly we only count
a file when its content hash changed AND it has been stable for at least
``DEBOUNCE_SECS`` (so we don't burn a call on every keystroke of an edit).
2. **Skill-usage analytics** — mine the Claude Code transcripts (``*.jsonl``)
for ``Skill`` tool calls, counting invocations and last-used time per skill.
Parsing is incremental: a transcript is only re-read when its mtime/size
changed, and per-file contributions are summed into the ``skills`` table.
"""
import hashlib
import json
import math
import os
import pathlib
import shutil
import threading
import time
import conversations
import fsscan
from db import Store
# Bump when parse_conversation's stored summary shape changes (forces re-parse).
# v2: archive-backed indexing + byTool / projectsAuto fields in the summary.
# v3: lifecycleAuto (CI/CD stamps derived from transcript script/command usage).
# v4: doneMarker (last message ends with DONE → conversation finished).
# v5: memoriesRead (slugs of memories recalled into the transcript).
# v6: searchMsgs (ordered user/assistant text blocks for full-text search).
# v7: interruptedByUser (trailing "[Request interrupted by user]" → interrupted).
# v8: isSidechain/agentId (subagent transcripts flagged, hidden from lists,
# linked from the parent's Task/Agent card + usage rolled into the parent).
# v9: worktreeAuto (name of the git worktree mined from a new-worktree.sh call).
# v10: worktreesAuto (list of created/removed worktrees w/ create+remove dates).
# v11: worktree parse anchored to command position + name validation (drop noise).
# v12: projectsAuto/servicesAuto tag only projects a conversation *edited* (target
# path of Write/Edit/MultiEdit/NotebookEdit), not every cwd/read/bash mention.
# v13: byToolSub (second-level per-bucket usage breakdown).
# v14: pausedByUser (trailing "No response requested." → paused); interrupt +
# no-response markers hidden from the thread (rendered as compact tags).
# v15: models (every model that produced a turn, busiest first) → model tags on
# past conversations, not just the first model seen.
# v16: `<synthetic>` (Claude Code's placeholder model on messages it fabricates)
# never counts as a model the conversation ran on.
# v20: cost accumulates per assistant record — a pi record's runner-mirrored
# `costUSD` (real provider cost) beats the price-table estimate, and bare
# (local EVOX2) qwen ids price at 0.
# v21: first-turn context split — the first API call's input side divided into
# injected context (system prompt, CLAUDE.md, memories) vs the user's
# actual message: new top-level "context" activity bucket +
# firstContextTokens/firstMessageTokens + per-item turnContext/MessageTokens.
# v25: completedMarker (last message ends with COMPLETED → the post-task hook's
# own reply, distinct from the task's own DONE marker).
# v26: activeBins — per model, the 5-minute wall-clock bins the run worked in
# (the activity dashboard's histogram source).
PARSER_VERSION = "26"
MODEL = os.environ.get("TOKEN_MODEL", "claude-opus-4-8")
# Input-token price in USD per million tokens (Opus 4.8 = $5.00 / 1M).
INPUT_PRICE_PER_MTOK = float(os.environ.get("INPUT_PRICE_PER_MTOK", "5.0"))
DEBOUNCE_SECS = float(os.environ.get("TOKEN_DEBOUNCE_SECS", "30"))
SCAN_INTERVAL = float(os.environ.get("INDEX_INTERVAL_SECS", "60"))
# How often the workspace is re-walked to *discover* exposed files. The walk
# visits every project dir to find ~280 files, so it is far too expensive to
# redo each SCAN_INTERVAL; between walks the cached list is just re-stat'd
# (which is what catches edits). A save forces a walk, so the UI never waits.
WALK_INTERVAL_SECS = float(os.environ.get("WALK_INTERVAL_SECS", "120"))
RETRY_ESTIMATE_SECS = 300.0 # retry API-failed (estimated) files at most this often
MD_EXTS = {".md", ".markdown"}
def _price(tokens: int) -> float:
return tokens / 1_000_000 * INPUT_PRICE_PER_MTOK
def _estimate_tokens(text: str) -> int:
"""Cheap fallback used before/instead of an API count (~chars/4)."""
return math.ceil(len(text) / 4) if text else 0
class Indexer:
def __init__(self, store: Store, workspace: pathlib.Path,
source_dir,
archive_dir: pathlib.Path | None, iter_files, read_text):
self.store = store
self.workspace = workspace
# Live transcript sources (read-only mounts): the Claude Code projects
# dir, plus the pi runner's mirror dir. A single path or a list — both
# are imported into the same archive (session UUIDs keep them distinct).
if source_dir is None:
self.source_dirs = []
elif isinstance(source_dir, (list, tuple)):
self.source_dirs = [d for d in source_dir if d]
else:
self.source_dirs = [source_dir]
# Our persistent copy: transcripts are imported here so deleted ones
# survive. Indexing + the detail view read from the archive.
self.archive_dir = archive_dir
# Back-compat alias used by the conversation detail endpoint.
self.transcripts_dir = archive_dir or (
self.source_dirs[0] if self.source_dirs else None)
self._iter_files = iter_files # () -> yields (abs_path, rel_posix)
self._read_text = read_text # (abs_path) -> str
self._client = None
self._client_tried = False
self._stop = threading.Event()
# Resumable parser states for *growing* transcripts: abs path →
# (byte offset of the next unread line, ParserState). A live session's
# transcript changes every couple of seconds; feeding only the appended
# lines keeps the per-tick parse cost flat instead of O(file size).
# In-memory only — a restart simply re-parses once. LRU-capped: only a
# handful of transcripts grow concurrently.
self._live: dict[str, tuple[int, "conversations.ParserState"]] = {}
self._live_cap = 8
# Cached result of the workspace walk (see _exposed_files).
self._files_cache: list | None = None
self._walked_at = 0.0
# ── Anthropic client (lazy) ──────────────────────────────────────────
def _anthropic(self):
if self._client_tried:
return self._client
self._client_tried = True
if not os.environ.get("ANTHROPIC_API_KEY"):
return None
try:
import anthropic
self._client = anthropic.Anthropic()
except Exception:
self._client = None
return self._client
def _count_tokens_api(self, text: str) -> int | None:
if not text.strip():
return 0
client = self._anthropic()
if client is None:
return None
try:
r = client.messages.count_tokens(
model=MODEL,
messages=[{"role": "user", "content": text}],
)
return int(r.input_tokens)
except Exception:
return None
# ── file scan + token accounting ─────────────────────────────────────
def _exposed_files(self, force_walk: bool) -> list:
"""The exposed context files, from a cached directory walk.
Discovering them means walking the whole workspace (every project dir),
which is far more expensive than the ~280 files it finds — and it was
being redone on every scan interval. The file *list* changes only when
a file is added or removed, so the walk is cached and refreshed at most
every ``WALK_INTERVAL_SECS`` (or on demand, e.g. right after a save)."""
now = time.time()
if (self._files_cache is None or force_walk
or (now - self._walked_at) >= WALK_INTERVAL_SECS):
self._files_cache = list(self._iter_files())
self._walked_at = now
return self._files_cache
def scan_files(self, force_walk: bool = False) -> None:
"""Record on-disk metadata for every exposed file.
Stat-gated: a file whose (mtime, size) matches its stored row is
skipped without being read, so a scan costs one stat per exposed file
— not a read+sha256 of the whole context tree. Word and char counts are
stored alongside (recomputed only on change) so ``/api/bundle`` can be
served from the DB without touching disk."""
seen: set[str] = set()
rows = self.store.all_files()
for ap, rel in self._exposed_files(force_walk):
relstr = str(rel)
try:
st = ap.stat()
except OSError:
continue # vanished since the walk — let the next walk prune it
seen.add(relstr)
row = rows.get(relstr)
if (row is not None and row["mtime"] == st.st_mtime
and row["size"] == st.st_size
and row["words"] is not None):
continue # unchanged on disk — nothing to record
try:
data = ap.read_bytes()
except OSError:
continue
digest = hashlib.sha256(data).hexdigest()
text = data.decode("utf-8", "ignore")
self.store.upsert_file_meta(
relstr, digest, st.st_size, st.st_mtime,
rel.suffix.lower() in MD_EXTS,
words=len(text.split()), chars=len(text),
)
self.store.prune_files(seen)
def count_pending(self) -> int:
"""Compute real token counts for changed/stale files past the debounce
window. Returns how many files were (re)counted."""
now = time.time()
done = 0
for path, row in self.store.all_files().items():
stale = bool(row["estimated"]) or row["tokens"] is None
if not stale:
continue
# debounce: only spend a call once the file has settled
if row["mtime"] and (now - row["mtime"]) < DEBOUNCE_SECS:
continue
# don't hammer the API on files that keep failing
if (row["tokens"] is not None and row["counted_at"]
and (now - row["counted_at"]) < RETRY_ESTIMATE_SECS):
continue
ap = self.workspace / path
text = self._read_text(ap)
tokens = self._count_tokens_api(text)
if tokens is None:
tokens = _estimate_tokens(text)
self.store.set_tokens(path, tokens, _price(tokens), estimated=True)
else:
self.store.set_tokens(path, tokens, _price(tokens), estimated=False)
done += 1
return done
# ── transcript import (persist live transcripts before Claude prunes) ──
def _copy_in(self, src: pathlib.Path, rel: pathlib.PurePath,
mtime: float, size: int, changed: list[str]) -> None:
"""Copy one source transcript into the archive if it's new or grew."""
dst = self.archive_dir / rel
try:
d = dst.stat()
if d.st_size >= size and d.st_mtime >= mtime:
return
except OSError:
pass # not in the archive yet
try:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
changed.append(rel.as_posix())
except OSError:
pass
def import_transcripts(self, only: list[str] | None = None) -> list[str]:
"""Copy new/grown source transcripts into the archive. Append-only, so
we copy when the source is larger or newer; we never delete archived
copies whose source has vanished. Returns the rel paths that changed.
Also copies each subagent's tiny `agent-<id>.meta.json` sidecar (it
carries the `toolUseId` that links a subagent back to the parent's
Task/Agent card, so the detail endpoint can cross-link them).
``only`` restricts the import to the given source-relative paths — the
watcher already knows exactly which transcripts changed, so the live
path doesn't re-walk thousands of files every couple of seconds. A
sibling `.meta.json` is pulled in alongside each named `.jsonl`; the
periodic full pass (``only=None``) catches anything else."""
if not (self.source_dirs and self.archive_dir):
return []
changed: list[str] = []
for source_dir in self.source_dirs:
if not source_dir.is_dir():
continue
if only is None:
for suffix in (".jsonl", ".meta.json"):
for path, mtime, size in fsscan.walk_files(source_dir, suffix):
src = pathlib.Path(path)
self._copy_in(src, src.relative_to(source_dir),
mtime, size, changed)
continue
for rel in only:
relp = pathlib.PurePosixPath(rel)
cands = [relp]
if relp.suffix == ".jsonl": # its subagent metadata sidecar
cands.append(relp.with_suffix(".meta.json"))
for cand in cands:
src = source_dir / cand
try:
st = src.stat()
except OSError:
continue
self._copy_in(src, cand, st.st_mtime, st.st_size, changed)
return changed
# ── transcript analytics (skills + per-conversation summaries) ────────
@staticmethod
def _feed_lines(state, ap, offset: int) -> int | None:
"""Feed the complete JSONL lines from ``offset`` to EOF into ``state``.
Returns the offset of the next unread byte — a trailing partial line
(a record still being written) is left for the next pass. ``None`` on
read errors. Mirrors ``conversations._iter_records`` semantics."""
try:
with ap.open("rb") as fh:
fh.seek(offset)
buf = fh.read()
except OSError:
return None
end = buf.rfind(b"\n")
if end == -1:
return offset # no complete new line yet
for raw in buf[:end].split(b"\n"):
line = raw.decode("utf-8", "ignore").strip()
if not line or line[0] != "{":
continue
try:
state.feed(json.loads(line))
except ValueError:
continue
return offset + end + 1
def _parse_summary(self, ap, size: int) -> dict:
"""Summary of a transcript — incremental when we saw it grow before."""
relstr = str(ap)
live = self._live.pop(relstr, None) # pop = LRU re-insert on success
if live is not None:
offset, state = live
if size >= offset:
new_off = self._feed_lines(state, ap, offset)
if new_off is not None:
self._live[relstr] = (new_off, state)
return state.summary()
# shrank/rewritten or unreadable → fall through to a full parse
state = conversations.ParserState(ap, full=False)
off = self._feed_lines(state, ap, 0)
if off is None:
return conversations.parse_conversation(ap, full=False)
self._live[relstr] = (off, state)
while len(self._live) > self._live_cap: # evict least-recently-grown
self._live.pop(next(iter(self._live)))
return state.summary()
def scan_transcripts(self, only: list[str] | None = None) -> None:
"""Import + (re)parse transcripts into the store.
``only`` is the source-relative paths the watcher saw change: the live
path then imports and re-parses just those, instead of re-walking every
source dir and the whole archive on each 2-second tick. The indexer's
own periodic pass (``only=None``) still does the full sweep, which is
what discovers files the watcher missed and prunes deleted ones."""
self.import_transcripts(only)
if not self.transcripts_dir or not self.transcripts_dir.is_dir():
return
# Force a full re-parse when the parser changes shape (new fields).
if self.store.get_meta("parser_version") != PARSER_VERSION:
self.store.prune_transcripts(set())
self.store.set_meta("parser_version", PARSER_VERSION)
self._live.clear()
only = None # everything must be re-parsed
rows = self.store.all_transcripts()
if only is not None:
targets = []
for rel in only:
ap = self.transcripts_dir / rel
try:
st = ap.stat()
except OSError:
continue
targets.append((str(ap), st.st_mtime, st.st_size))
else:
targets = list(fsscan.walk_files(self.transcripts_dir, ".jsonl"))
seen: set[str] = set()
changed = False
for relstr, mtime, size in targets:
seen.add(relstr)
row = rows.get(relstr)
if row and row["mtime"] == mtime and row["size"] == size:
continue
summary = self._parse_summary(pathlib.Path(relstr), size)
skills = summary.pop("skills", {})
self.store.upsert_transcript(relstr, mtime, size, skills, summary)
changed = True
# Only a full sweep knows what's gone; a targeted pass must not prune.
if only is None and rows.keys() - seen:
self.store.prune_transcripts(seen)
changed = True
if changed:
self._aggregate_skills()
def _aggregate_skills(self) -> None:
agg: dict[str, dict] = {}
for skills_json in self.store.all_transcript_skills():
try:
contrib = json.loads(skills_json or "{}")
except ValueError:
continue
for name, d in contrib.items():
e = agg.setdefault(name, {"count": 0, "last_used": None})
e["count"] += d.get("count", 0)
last = d.get("last")
if last and (e["last_used"] is None or last > e["last_used"]):
e["last_used"] = last
self.store.replace_skills(agg)
# ── lifecycle ────────────────────────────────────────────────────────
def run_once(self) -> None:
"""One full sweep: re-stat the context files, count what's pending, and
do the complete transcript discovery + prune pass."""
self.scan_files()
self.count_pending()
self.scan_transcripts()
def _loop(self) -> None:
# The watcher already handles live transcript changes (targeted, every
# couple of seconds). This loop is the slower full sweep: it re-stats
# the context files for token counting and re-walks the transcript dirs
# to discover/prune — work that is wasted at a 10-second cadence.
while not self._stop.is_set():
try:
self.run_once()
except Exception:
pass
self._stop.wait(SCAN_INTERVAL)
def start(self) -> None:
threading.Thread(target=self._loop, daemon=True).start()
def stop(self) -> None:
self._stop.set()