Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a5a4f6f
feat(transcript): locate raw Claude session files by id
plind-junior Jul 10, 2026
9bedd1b
feat(transcript): parse Claude JSONL into normalized blocks
plind-junior Jul 10, 2026
d51109c
feat(transcript): orchestrate load with observation fallback
plind-junior Jul 10, 2026
97e41a9
feat(transcript): expose kb.session_transcript across all surfaces
plind-junior Jul 10, 2026
7dde100
feat(webapp): transcript client types and fetch
plind-junior Jul 10, 2026
ff99043
feat(webapp): thinking, diff, and code block renderers
plind-junior Jul 10, 2026
0eee247
feat(webapp): tool block with per-tool rendering and diffs
plind-junior Jul 10, 2026
47ae354
feat(webapp): sessions tab with full transcript viewer
plind-junior Jul 10, 2026
b1d10b7
feat(transcript): parse Codex rollouts into normalized blocks
plind-junior Jul 10, 2026
b496ce8
test(webapp): cover degraded render and subagent drill-down
plind-junior Jul 10, 2026
7adc267
test(webapp): e2e smoke for the sessions transcript tab
plind-junior Jul 10, 2026
7c39f74
docs(transcript): session transcript viewer design and plan
plind-junior Jul 10, 2026
ac6dd0a
refactor(webapp): merge session transcript into Review, drop Sessions…
plind-junior Jul 10, 2026
776bffb
docs(transcript): note the merge of the viewer into Review
plind-junior Jul 10, 2026
b7ea9f5
feat(activity): expose kb.activity audit buckets across all surfaces
plind-junior Jul 10, 2026
bc75fcb
feat(webapp): dashboard view of kb activity, landing on /
plind-junior Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Added
- `kb.activity` read method (+ `vouch activity` CLI mirror): audit-log
activity buckets for dashboards — per-day counts with proposal/decision
breakdowns, an hour-of-week matrix, and actor/event histograms. windowed
in viewer-local calendar days (IANA `tz` or a fixed utc offset), scope-
filtered like `kb.audit`.
- console Dashboard view: 12-month activity calendar, last-30-days bars,
hour-of-week heatmap, top actors and event mix, driven by `kb.activity`.

## [1.2.2] — 2026-07-07

### Packaging
Expand Down
1,745 changes: 1,745 additions & 0 deletions docs/superpowers/plans/2026-07-10-session-transcript-viewer.md

Large diffs are not rendered by default.

352 changes: 352 additions & 0 deletions docs/superpowers/specs/2026-07-10-session-transcript-viewer-design.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"kb.capabilities",
"kb.status",
"kb.stats",
"kb.activity",
"kb.digest",
"kb.search",
"kb.neighbors",
Expand Down Expand Up @@ -68,6 +69,7 @@
"kb.session_start",
"kb.session_end",
"kb.list_sessions",
"kb.session_transcript",
"kb.volunteer_context",
"kb.crystallize",
"kb.summarize_session",
Expand Down
53 changes: 53 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,59 @@ def stats(days: int, as_json: bool) -> None:
_echo(f" invalid: {cites['invalid_claim']}, broken: {cites['broken_citation']}")


@cli.command()
@click.option(
"--days",
default=365,
show_default=True,
type=click.IntRange(min=0),
help="Window (local calendar days). Use 0 for all-time.",
)
@click.option(
"--tz-offset-minutes",
default=0,
show_default=True,
type=int,
help="Viewer's UTC offset in minutes for local-time bucketing.",
)
@click.option("--tz", default=None, help="IANA zone for local-time bucketing (wins over offset).")
@click.option("--project", default=None, help="Viewer project for audit scope filtering.")
@click.option("--agent", default=None, help="Viewer agent for audit scope filtering.")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
def activity(
days: int,
tz_offset_minutes: int,
tz: str | None,
project: str | None,
agent: str | None,
as_json: bool,
) -> None:
"""Audit activity buckets: per-day counts, hour-of-week matrix, actors."""
from .scoping import viewer_from

store = _load_store()
viewer = viewer_from(
config_path=store.config_path,
project=project,
agent=agent,
)
body = stats_mod.collect_activity(
store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer,
)
if as_json:
_emit_json(body)
return
window = "all time" if body["window_days"] is None else f"last {body['window_days']}d"
_echo(
f"activity ({window}): {_style(str(body['total_events']), fg='cyan')} events "
f"on {body['active_days']} day(s)"
)
if body["first_event_day"]:
_echo(f" span: {body['first_event_day']} → {body['last_event_day']}")
for actor, count in list(body["by_actor"].items())[:8]:
_echo(f" {actor}: {count}")


@cli.command(name="digest")
@click.option(
"--since",
Expand Down
27 changes: 26 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
reject,
reject_auto_extracted,
)
from .stats import collect_stats
from .stats import collect_activity, collect_stats
from .storage import (
ArtifactNotFoundError,
KBNotFoundError,
Expand Down Expand Up @@ -102,6 +102,20 @@ def _h_stats(p: dict) -> dict:
return collect_stats(_store(), since_days=since)


def _h_activity(p: dict) -> dict:
from .scoping import viewer_from_params

s = _store()
viewer = viewer_from_params(s, p)
return collect_activity(
s,
days=int(p.get("days", 365)),
tz_offset_minutes=int(p.get("tz_offset_minutes", 0)),
tz=p.get("tz"),
viewer=viewer,
)


def _h_digest(p: dict) -> dict:
d = digest_mod.build(
_store(),
Expand Down Expand Up @@ -412,6 +426,15 @@ def _h_list_sessions(p: dict) -> dict:
return {"sessions": session_split.build_session_rows(_store())}


def _h_session_transcript(p: dict) -> dict:
from . import transcript
session_id = p["session_id"]
agent = p.get("agent")
if agent is not None and agent not in ("claude", "codex"):
raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')")
return transcript.load_transcript(_store(), session_id, agent=agent)


def _h_propose_entity(p: dict) -> dict:
pr = propose_entity(
_store(),
Expand Down Expand Up @@ -761,6 +784,7 @@ def _h_propose_theme(p: dict) -> dict:
"kb.capabilities": _h_capabilities,
"kb.status": _h_status,
"kb.stats": _h_stats,
"kb.activity": _h_activity,
"kb.digest": _h_digest,
"kb.search": _h_search,
"kb.neighbors": _h_neighbors,
Expand All @@ -785,6 +809,7 @@ def _h_propose_theme(p: dict) -> dict:
"kb.compile": _h_compile,
"kb.summarize_session": _h_summarize_session,
"kb.list_sessions": _h_list_sessions,
"kb.session_transcript": _h_session_transcript,
"kb.propose_entity": _h_propose_entity,
"kb.propose_relation": _h_propose_relation,
"kb.propose_delete": _h_propose_delete,
Expand Down
43 changes: 42 additions & 1 deletion src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
reject_auto_extracted,
)
from .scoping import filter_hits, scoped_fetch_limit, viewer_from
from .stats import collect_stats
from .stats import collect_activity, collect_stats
from .storage import (
ArtifactNotFoundError,
KBNotFoundError,
Expand Down Expand Up @@ -97,6 +97,32 @@ def kb_stats(*, days: int = 30) -> dict[str, Any]:
return collect_stats(_store(), since_days=since)


@mcp.tool()
def kb_activity(
*,
days: int = 365,
tz_offset_minutes: int = 0,
tz: str | None = None,
project: str | None = None,
agent: str | None = None,
) -> dict[str, Any]:
"""Audit activity buckets for dashboards: per-day counts, hour-of-week
matrix, actor and event histograms. Scope-filtered like kb.audit.

days: window in local calendar days; 0 means all-time.
tz: IANA zone for local-time bucketing; falls back to tz_offset_minutes.
"""
store = _store()
viewer = viewer_from(
config_path=store.config_path,
project=project,
agent=agent,
)
return collect_activity(
store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer,
)


@mcp.tool()
def kb_digest(
*,
Expand Down Expand Up @@ -556,6 +582,21 @@ def kb_list_sessions() -> dict[str, Any]:
return {"sessions": session_split.build_session_rows(_store())}


@mcp.tool()
def kb_session_transcript(session_id: str, agent: str | None = None) -> dict[str, Any]:
"""Render a captured session's full transcript from its raw agent JSONL.

Read-only. Locates the raw Claude Code / Codex file on disk and normalizes
it into message blocks (text, thinking, tool_use with paired results).
``agent`` restricts the search ("claude" | "codex"); omit to try both.
Degrades to compact capture observations when the raw file is unavailable.
"""
from . import transcript
if agent is not None and agent not in ("claude", "codex"):
raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')")
return transcript.load_transcript(_store(), session_id, agent=agent)


@mcp.tool()
def kb_propose_entity(
name: str,
Expand Down
104 changes: 103 additions & 1 deletion src/vouch/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@
from __future__ import annotations

import statistics
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from typing import Any
from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from . import audit, health
from .models import Proposal, ProposalStatus
from .proposals import EXPIRE_REASON
from .storage import KBStore, _yaml_load

if TYPE_CHECKING:
from .scoping import ViewerContext


def _utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
Expand Down Expand Up @@ -186,6 +191,103 @@ def bump(agent: str, bucket: str) -> None:
}


# Real-world UTC offsets span -12:00..+14:00; clamp so a bad client can't
# shift events into arbitrary buckets.
_MAX_TZ_OFFSET_MINUTES = 14 * 60


def _is_proposal_create(event: str) -> bool:
return event.startswith("proposal.") and event.endswith(".create")


def _local_clock(tz: str | None, offset_minutes: int) -> Callable[[datetime], datetime]:
"""Viewer-local conversion: an IANA zone when resolvable (DST-correct),
otherwise the fixed offset."""
if tz:
try:
zone = ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError):
pass
else:
return lambda dt: _utc(dt).astimezone(zone)
shift = timedelta(minutes=offset_minutes)
return lambda dt: _utc(dt) + shift


def collect_activity(
store: KBStore,
*,
days: int = 365,
tz_offset_minutes: int = 0,
tz: str | None = None,
viewer: ViewerContext | None = None,
) -> dict[str, Any]:
"""Bucket audit events for the console dashboard — `kb.activity`.

One pass over the audit log: per-day totals (with proposal/decision
breakdowns), an hour-of-week matrix, and actor/event histograms.
``days=0`` means all-time; otherwise the window is the last ``days``
viewer-local calendar days including today, so the oldest day in a
dashboard heatmap is never partially counted. ``tz`` (IANA name) wins
over ``tz_offset_minutes`` for local bucketing. ``viewer`` applies the
same scope filtering as `kb.audit`.
"""
if days < 0:
raise ValueError("days must be >= 0")
window = None if days == 0 else days
offset = max(-_MAX_TZ_OFFSET_MINUTES, min(_MAX_TZ_OFFSET_MINUTES, tz_offset_minutes))
to_local = _local_clock(tz, offset)
cutoff_day = (
None
if window is None
else to_local(datetime.now(UTC)).date() - timedelta(days=window - 1)
)

by_day: dict[str, dict[str, int]] = {}
# by_hour[weekday][hour], Monday = 0 — matches datetime.weekday().
by_hour = [[0] * 24 for _ in range(7)]
by_actor: dict[str, int] = {}
by_event: dict[str, int] = {}
total = 0

for ev in audit.read_events(store.kb_dir, store=store, viewer=viewer):
local = to_local(ev.created_at)
if cutoff_day is not None and local.date() < cutoff_day:
continue
day = by_day.setdefault(
local.date().isoformat(),
{"total": 0, "proposals": 0, "decisions": 0},
)
day["total"] += 1
if _is_proposal_create(ev.event):
day["proposals"] += 1
elif _audit_decision_kind(ev.event) is not None:
day["decisions"] += 1
by_hour[local.weekday()][local.hour] += 1
by_actor[ev.actor] = by_actor.get(ev.actor, 0) + 1
by_event[ev.event] = by_event.get(ev.event, 0) + 1
total += 1

days_seen = sorted(by_day)
return {
"generated_at": datetime.now(UTC).isoformat(),
"window_days": window,
"tz_offset_minutes": offset,
"viewer": {
"project": viewer.project if viewer else None,
"agent": viewer.agent if viewer else None,
},
"total_events": total,
"active_days": len(by_day),
"first_event_day": days_seen[0] if days_seen else None,
"last_event_day": days_seen[-1] if days_seen else None,
"by_day": {d: by_day[d] for d in days_seen},
"by_hour": by_hour,
"by_actor": dict(sorted(by_actor.items(), key=lambda kv: (-kv[1], kv[0]))),
"by_event": dict(sorted(by_event.items(), key=lambda kv: (-kv[1], kv[0]))),
}


def collect_stats(store: KBStore, *, since_days: int | None = 30) -> dict[str, Any]:
"""Aggregate observability metrics for the KB at ``store``."""
return {
Expand Down
Loading
Loading