Compare commits

...

4 Commits

Author SHA1 Message Date
tobin
c97c4d88f3 Add expo plugin to official marketplace
Promotes expo from community to official. Uses git-subdir source
(expo/skills @ plugins/expo, ref main) without a SHA pin so it
tracks upstream. Description cleaned to remove the reviewer-note
preamble that leaked into the community entry.

Ref: DIR-77, DIR-79
2026-04-08 21:23:23 +00:00
Thariq Shihipar
62f2063abc Merge pull request #1293 from anthropics/thariq/session-report-plugin
Add session-report plugin
2026-04-08 09:35:14 -07:00
Thariq Shihipar
66ca8fc540 Sort session-report plugin into marketplace order 2026-04-08 09:31:35 -07:00
Thariq Shihipar
147ddf8ee3 Add session-report plugin
Generates an explorable HTML report of Claude Code session usage from
local ~/.claude/projects transcripts: total tokens, cache efficiency,
per-project/subagent/skill breakdowns, most expensive prompts with
transcript context, and cache breaks. Terminal-styled, single-file
output with sortable tables and expandable drill-downs.
2026-04-07 17:40:13 -07:00
4 changed files with 1345 additions and 0 deletions

View File

@@ -384,6 +384,18 @@
"category": "learning",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/explanatory-output-style"
},
{
"name": "expo",
"description": "Official Expo skills for building, deploying, upgrading, and debugging React Native apps with Expo. Covers UI development with Expo Router, SwiftUI and Jetpack Compose components, Tailwind CSS setup, API routes, data fetching, CI/CD workflows, App Store and Play Store deployment, SDK upgrades, DOM components, and dev client distribution.",
"category": "development",
"source": {
"source": "git-subdir",
"url": "expo/skills",
"path": "plugins/expo",
"ref": "main"
},
"homepage": "https://github.com/expo/skills/blob/main/plugins/expo/README.md"
},
{
"name": "fakechat",
"description": "Localhost web chat for testing the channel notification flow. No tokens, no access control, no third-party service.",
@@ -1179,6 +1191,17 @@
"community-managed"
]
},
{
"name": "session-report",
"description": "Generate an explorable HTML report of Claude Code session usage — tokens, cache efficiency, subagents, skills, and the most expensive prompts — from local ~/.claude/projects transcripts.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/session-report",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/session-report"
},
{
"name": "skill-creator",
"description": "Create new skills, improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, or benchmark skill performance with variance analysis.",

View File

@@ -0,0 +1,42 @@
---
name: session-report
description: Generate an explorable HTML report of Claude Code session usage (tokens, cache, subagents, skills, expensive prompts) from ~/.claude/projects transcripts.
---
# Session Report
Produce a self-contained HTML report of Claude Code usage and save it to the current working directory.
## Steps
1. **Get data.** Run the bundled analyzer (default window: last 7 days; honor a different range if the user passed one, e.g. `24h`, `30d`, or `all`). The script `analyze-sessions.mjs` lives in the same directory as this SKILL.md — use its absolute path:
```sh
node <skill-dir>/analyze-sessions.mjs --json --since 7d > /tmp/session-report.json
```
For all-time, omit `--since`.
2. **Read** `/tmp/session-report.json`. Skim `overall`, `by_project`, `by_subagent_type`, `by_skill`, `cache_breaks`, `top_prompts`.
3. **Copy the template** (also bundled alongside this SKILL.md) to the output path in the current working directory:
```sh
cp <skill-dir>/template.html ./session-report-$(date +%Y%m%d-%H%M).html
```
4. **Edit the output file** (use Edit, not Write — preserve the template's JS/CSS):
- Replace the contents of `<script id="report-data" type="application/json">` with the full JSON from step 1. The page's JS renders the hero total, all tables, bars, and drill-downs from this blob automatically.
- Fill the `<!-- AGENT: anomalies -->` block with **35 one-line findings**. Express figures as a **% of total tokens** wherever possible (total = `overall.input_tokens.total + overall.output_tokens`). One line per finding, exact markup:
```html
<div class="take bad"><div class="fig">41.2%</div><div class="txt"><b>cc-monitor</b> consumed 41% of the week across just 3 sessions</div></div>
```
Classes: `.take bad` for waste/anomalies (red), `.take good` for healthy signals (green), `.take info` for neutral facts (blue). The `.fig` is one short number (a %, a count, or a multiplier like `12×`). The `.txt` is one plain-English sentence naming the project/skill/prompt; wrap the subject in `<b>`. Look for: a project or skill eating a disproportionate share, cache-hit <85%, a single prompt >2% of total, subagent types averaging >1M tokens/call, cache breaks clustering.
- Fill the `<!-- AGENT: optimizations -->` block (at the **bottom** of the page) with 14 `<div class="callout">` suggestions tied to specific rows (e.g. "`/weekly-status` spawned 7 subagents for 8.1% of total — scope it to fewer parallel agents").
- Do not restructure existing sections.
5. **Report** the saved file path to the user. Do not open it or render it.
## Notes
- The template is the source of interactivity (sorting, expand/collapse, block-char bars). Your job is data + narrative, not markup.
- Keep commentary terse and specific — reference actual project names, numbers, timestamps from the JSON.
- `top_prompts` already includes subagent tokens and rolls task-notification continuations into the originating prompt.
- If the JSON is >2MB, trim `top_prompts` to 100 entries and `cache_breaks` to 100 before embedding (they should already be capped).

View File

@@ -0,0 +1,816 @@
#!/usr/bin/env node
/* eslint-disable */
/**
* analyze-sessions.js
*
* Scans ~/.claude/projects/**.jsonl transcript files and reports token usage,
* message counts, runtime, cache breaks, subagent and skill activity.
*
* Output is human-readable text by default; pass --json for machine-readable.
*
* Usage:
* node scripts/analyze-sessions.js [--dir <projects-dir>] [--json] [--since <ISO|7d|24h>] [--top N]
*
* Notes on JSONL structure (discovered empirically):
* - One API response is split into MULTIPLE `type:"assistant"` entries (one per
* content block). They share the same `requestId` / `message.id`, and only the
* LAST one carries the final `output_tokens`. We dedupe by requestId and keep
* the max output_tokens to avoid 3-10x overcounting.
* - `type:"user"` entries include tool_result messages, interrupt markers,
* compact summaries and meta-injected text. A "human" message is one where
* isSidechain/isMeta/isCompactSummary are falsy and the content is a plain
* string (or text block) that isn't a tool_result or interrupt marker.
* - Subagent transcripts live in <project>/<sessionId>/subagents/*.jsonl with a
* sibling *.meta.json containing {agentType}. When meta is absent we fall back
* to the filename label (`agent-a<label>-<hash>.jsonl` → label) or "fork".
* - Resumed sessions can re-serialize prior entries into a new file; we dedupe
* globally by entry `uuid` so replayed history isn't double-counted.
*/
import fs from 'fs'
import os from 'os'
import path from 'path'
import readline from 'readline'
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2)
function flag(name, dflt) {
const i = argv.indexOf(name)
if (i === -1) return dflt
const v = argv[i + 1]
return v === undefined || v.startsWith('--') ? true : v
}
const ROOT = flag('--dir', path.join(os.homedir(), '.claude', 'projects'))
const AS_JSON = argv.includes('--json')
const TOP_N = parseInt(flag('--top', '15'), 10)
const SINCE = parseSince(flag('--since', null))
const CACHE_BREAK_THRESHOLD = parseInt(flag('--cache-break', '100000'), 10)
const IDLE_GAP_MS = 5 * 60 * 1000 // gaps >5min don't count toward "active" time
function parseSince(s) {
if (!s) return null
const m = /^(\d+)([dh])$/.exec(s)
if (m) {
const ms = m[2] === 'd' ? 86400000 : 3600000
return new Date(Date.now() - parseInt(m[1], 10) * ms)
}
const d = new Date(s)
return isNaN(d) ? null : d
}
// ---------------------------------------------------------------------------
// Stats container
// ---------------------------------------------------------------------------
function newStats() {
return {
sessions: new Set(),
apiCalls: 0,
inputUncached: 0, // usage.input_tokens
inputCacheCreate: 0, // usage.cache_creation_input_tokens
inputCacheRead: 0, // usage.cache_read_input_tokens
outputTokens: 0,
humanMessages: 0,
wallClockMs: 0,
activeMs: 0,
cacheBreaks: [], // [{ts, session, project, uncached, total}]
subagentCalls: 0,
subagentTokens: 0, // total (in+out) inside subagent transcripts
skillInvocations: {}, // name -> count
firstTs: null,
lastTs: null,
}
}
function addUsage(s, u) {
s.apiCalls++
s.inputUncached += u.input_tokens || 0
s.inputCacheCreate += u.cache_creation_input_tokens || 0
s.inputCacheRead += u.cache_read_input_tokens || 0
s.outputTokens += u.output_tokens || 0
}
// ---------------------------------------------------------------------------
// File discovery
// ---------------------------------------------------------------------------
function* walk(dir) {
let ents
try {
ents = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const e of ents) {
const p = path.join(dir, e.name)
if (e.isDirectory()) yield* walk(p)
else if (e.isFile() && e.name.endsWith('.jsonl')) yield p
}
}
function classifyFile(p) {
// returns { project, sessionId, kind, agentId?, agentTypeHint? }
// agentTypeHint is from meta.json or filename label; final type is resolved
// in main() after the parent-transcript map is built.
const rel = path.relative(ROOT, p)
const parts = rel.split(path.sep)
const project = parts[0]
const subIdx = parts.indexOf('subagents')
if (subIdx !== -1) {
const sessionId = parts[subIdx - 1]
const base = path.basename(p, '.jsonl')
const agentId = base.replace(/^agent-/, '')
return {
project,
sessionId,
kind: 'subagent',
agentId,
agentTypeHint:
inferAgentTypeFromMeta(p) || inferAgentTypeFromFilename(base),
}
}
if (parts.includes('workflows')) {
const sessionId = parts[1]
return { project, sessionId, kind: 'subagent', agentTypeHint: 'workflow' }
}
const sessionId = path.basename(p, '.jsonl')
return { project, sessionId, kind: 'main' }
}
function inferAgentTypeFromMeta(jsonlPath) {
const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json')
try {
const m = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
if (m && typeof m.agentType === 'string') return m.agentType
} catch {
/* no meta */
}
return null
}
function inferAgentTypeFromFilename(base) {
// agentId = 'a' + hex16 OR 'a' + label + '-' + hex16 (src/utils/uuid.ts)
const m = /^agent-a([a-zA-Z_][\w-]*?)-[0-9a-f]{6,}$/.exec(base)
if (m) return m[1] // internal background fork label
return null // unlabeled — resolve via agentIdToType map or default to 'fork'
}
// ---------------------------------------------------------------------------
// Per-file streaming parse
// ---------------------------------------------------------------------------
const seenUuids = new Set() // global dedupe across resumed sessions
const seenRequestIds = new Set() // global dedupe for usage accounting
const toolUseIdToType = new Map() // tool_use id -> subagent_type (from Agent/Task tool_use)
const agentIdToType = new Map() // agentId -> subagent_type (linked via tool_result)
const toolUseIdToPrompt = new Map() // tool_use id -> promptKey (Agent spawned during this prompt)
const agentIdToPrompt = new Map() // agentId -> promptKey
const prompts = new Map() // promptKey -> { text, ts, project, sessionId, ...usage }
const sessionTurns = new Map() // sessionId -> [promptKey, ...] in transcript order
function promptRecord(key, init) {
let r = prompts.get(key)
if (!r) {
r = {
text: init.text,
ts: init.ts,
project: init.project,
sessionId: init.sessionId,
apiCalls: 0,
subagentCalls: 0,
inputUncached: 0,
inputCacheCreate: 0,
inputCacheRead: 0,
outputTokens: 0,
}
prompts.set(key, r)
}
return r
}
async function processFile(p, info, buckets) {
const rl = readline.createInterface({
input: fs.createReadStream(p, { encoding: 'utf8' }),
crlfDelay: Infinity,
})
// Per-file: dedupe API calls by requestId, keep the one with max output_tokens.
// We collect first, then commit, because earlier blocks have stale output counts.
const fileApiCalls = new Map() // key -> {usage, ts}
let firstTs = null
let lastTs = null
let prevTs = null
let activeMs = 0
let currentSkill = null // skill attribution for this turn
// Prompt attribution: in main files this is set on each human message; in
// subagent files it's inherited from the spawning prompt (via agentIdToPrompt).
let currentPrompt =
info.kind === 'subagent' && info.agentId
? agentIdToPrompt.get(info.agentId) || null
: null
const project = buckets.project
const overall = buckets.overall
const subagent = buckets.subagent // may be null
const skillStats = buckets.skillStats // map name -> stats
for await (const line of rl) {
if (!line) continue
let e
try {
e = JSON.parse(line)
} catch {
continue
}
// global uuid dedupe (resumed sessions replay history)
if (e.uuid) {
if (seenUuids.has(e.uuid)) continue
seenUuids.add(e.uuid)
}
// timestamp tracking
if (e.timestamp) {
const ts = Date.parse(e.timestamp)
if (!isNaN(ts)) {
if (SINCE && ts < SINCE.getTime()) continue
if (firstTs === null) firstTs = ts
if (prevTs !== null) {
const gap = ts - prevTs
if (gap > 0 && gap < IDLE_GAP_MS) activeMs += gap
}
prevTs = ts
lastTs = ts
}
}
if (e.type === 'user') {
// Link Agent tool_result -> agentId for type + prompt attribution.
const tur = e.toolUseResult
if (tur && tur.agentId) {
const c0 = Array.isArray(e.message?.content)
? e.message.content[0]
: null
const tuid = c0 && c0.tool_use_id
if (tuid) {
const st = toolUseIdToType.get(tuid)
if (st) agentIdToType.set(tur.agentId, st)
const pk = toolUseIdToPrompt.get(tuid)
if (pk) {
agentIdToPrompt.set(tur.agentId, pk)
const r = prompts.get(pk)
if (r) r.subagentCalls++
}
}
}
handleUser(
e,
info,
{ project, overall, subagent },
v => {
currentSkill = v
},
pk => {
currentPrompt = pk
},
)
continue
}
if (e.type === 'assistant') {
const msg = e.message || {}
const usage = msg.usage
// detect Skill / Agent tool calls in content
if (Array.isArray(msg.content)) {
for (const c of msg.content) {
if (c && c.type === 'tool_use') {
if (c.name === 'Skill' && c.input && c.input.skill) {
const sk = String(c.input.skill)
bumpSkill(overall, sk)
bumpSkill(project, sk)
if (subagent) bumpSkill(subagent, sk)
currentSkill = sk
}
if (c.name === 'Agent' || c.name === 'Task') {
if (c.input && c.input.subagent_type) {
toolUseIdToType.set(c.id, String(c.input.subagent_type))
}
if (currentPrompt) toolUseIdToPrompt.set(c.id, currentPrompt)
}
}
}
}
if (!usage) continue
const key =
e.requestId ||
(msg.id && msg.id.startsWith('msg_0') && msg.id.length > 10
? msg.id
: null) ||
`${p}:${e.uuid || ''}`
const prev = fileApiCalls.get(key)
if (
!prev ||
(usage.output_tokens || 0) >= (prev.usage.output_tokens || 0)
) {
fileApiCalls.set(key, {
usage,
ts: e.timestamp,
skill: currentSkill,
prompt: currentPrompt,
})
}
continue
}
}
// commit timestamps
if (firstTs !== null && lastTs !== null) {
const wall = lastTs - firstTs
for (const s of [overall, project, subagent].filter(Boolean)) {
s.wallClockMs += wall
s.activeMs += activeMs
if (!s.firstTs || firstTs < s.firstTs) s.firstTs = firstTs
if (!s.lastTs || lastTs > s.lastTs) s.lastTs = lastTs
}
}
// commit API calls
for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) {
if (key && seenRequestIds.has(key)) continue
seenRequestIds.add(key)
const targets = [overall, project]
if (subagent) targets.push(subagent)
if (skill && skillStats) {
if (!skillStats.has(skill)) skillStats.set(skill, newStats())
targets.push(skillStats.get(skill))
}
for (const s of targets) addUsage(s, usage)
if (prompt) {
const r = prompts.get(prompt)
if (r) {
r.apiCalls++
r.inputUncached += usage.input_tokens || 0
r.inputCacheCreate += usage.cache_creation_input_tokens || 0
r.inputCacheRead += usage.cache_read_input_tokens || 0
r.outputTokens += usage.output_tokens || 0
}
}
// subagent token accounting on parent buckets
if (info.kind === 'subagent') {
const tot =
(usage.input_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0) +
(usage.output_tokens || 0)
overall.subagentTokens += tot
project.subagentTokens += tot
if (subagent) subagent.subagentTokens += tot
}
// cache break detection
const uncached =
(usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
if (uncached > CACHE_BREAK_THRESHOLD) {
const total = uncached + (usage.cache_read_input_tokens || 0)
const cb = {
ts,
session: info.sessionId,
project: info.project,
uncached,
total,
kind: info.kind,
agentType: info.agentType,
prompt,
}
overall.cacheBreaks.push(cb)
project.cacheBreaks.push(cb)
if (subagent) subagent.cacheBreaks.push(cb)
}
}
// only count this file toward session/subagent tallies if it had in-range entries
if (firstTs !== null || fileApiCalls.size > 0) {
for (const s of [overall, project, subagent].filter(Boolean)) {
s.sessions.add(info.sessionId)
}
if (info.kind === 'subagent') {
overall.subagentCalls++
project.subagentCalls++
if (subagent) subagent.subagentCalls++
}
}
}
function handleUser(
e,
info,
{ project, overall, subagent },
setSkill,
setPrompt,
) {
if (e.isMeta || e.isCompactSummary) return
const content = e.message && e.message.content
let isToolResult = false
let text = null
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
const first = content[0]
if (first && first.type === 'tool_result') isToolResult = true
else if (first && first.type === 'text') text = first.text || ''
}
if (isToolResult) return
let slashCmd = null
if (text) {
// Auto-continuations (task notifications, scheduled wakeups) are not new
// human prompts; keep attributing to the previously active prompt.
if (
text.startsWith('<task-notification') ||
text.startsWith('<scheduled-wakeup') ||
text.startsWith('<background-task')
) {
return
}
const m = /<command-(?:name|message)>\/?([^<]+)<\/command-/.exec(text)
if (m) {
slashCmd = m[1].trim()
bumpSkill(overall, slashCmd)
bumpSkill(project, slashCmd)
if (subagent) bumpSkill(subagent, slashCmd)
setSkill(slashCmd)
} else {
setSkill(null) // plain human message resets skill attribution
}
if (text.startsWith('[Request interrupted')) return
}
// Only count as human message / start a prompt in main (non-sidechain) transcripts
if (info.kind === 'main' && !e.isSidechain) {
overall.humanMessages++
project.humanMessages++
const pk = e.uuid || `${info.sessionId}:${e.timestamp}`
promptRecord(pk, {
text: promptPreview(text, slashCmd),
ts: e.timestamp,
project: info.project,
sessionId: info.sessionId,
})
setPrompt(pk)
let turns = sessionTurns.get(info.sessionId)
if (!turns) sessionTurns.set(info.sessionId, (turns = []))
turns.push(pk)
}
}
function promptPreview(text, slashCmd) {
if (slashCmd) return `/${slashCmd}`
if (!text) return '(non-text)'
const t = text
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return t.length > 240 ? t.slice(0, 237) + '…' : t
}
// ±2 user messages around a given prompt, with the api-call count that
// followed each one. Used for drill-down in the HTML report.
function buildContext(pk) {
const r = prompts.get(pk)
if (!r) return null
const turns = sessionTurns.get(r.sessionId)
if (!turns) return null
const i = turns.indexOf(pk)
if (i === -1) return null
const lo = Math.max(0, i - 2)
const hi = Math.min(turns.length, i + 3)
return turns.slice(lo, hi).map((k, j) => {
const t = prompts.get(k) || {}
return {
text: t.text || '',
ts: t.ts || null,
calls: t.apiCalls || 0,
here: lo + j === i,
}
})
}
function bumpSkill(s, name) {
s.skillInvocations[name] = (s.skillInvocations[name] || 0) + 1
}
const _btCache = new Map()
function birthtime(p) {
let t = _btCache.get(p)
if (t === undefined) {
try {
t = fs.statSync(p).birthtimeMs
} catch {
t = 0
}
_btCache.set(p, t)
}
return t
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const overall = newStats()
const perProject = new Map() // project -> stats
const perSubagent = new Map() // agentType -> stats
const perSkill = new Map() // skill -> stats (token-attributed)
// Classify, then sort main files before subagent files. Fork-style subagents
// replay parent entries with identical uuids; processing parents first ensures
// the global uuid-dedupe attributes those entries to the parent, not the fork.
// Among subagents, sort by birthtime so a parent subagent is processed before
// any nested children it spawned (needed for prompt-attribution propagation).
const files = [...walk(ROOT)]
.map(p => ({ p, info: classifyFile(p) }))
.sort((a, b) => {
const ka = a.info.kind === 'main' ? 0 : 1
const kb = b.info.kind === 'main' ? 0 : 1
if (ka !== kb) return ka - kb
if (ka === 1) return birthtime(a.p) - birthtime(b.p)
return 0
})
let n = 0
for (const { p, info } of files) {
if (!perProject.has(info.project)) perProject.set(info.project, newStats())
const project = perProject.get(info.project)
let subagent = null
if (info.kind === 'subagent') {
// Resolve agent type: meta.json/filename hint > parent-transcript map > 'fork'
const at =
info.agentTypeHint ||
(info.agentId && agentIdToType.get(info.agentId)) ||
'fork'
info.agentType = at
if (!perSubagent.has(at)) perSubagent.set(at, newStats())
subagent = perSubagent.get(at)
}
await processFile(p, info, {
overall,
project,
subagent,
skillStats: perSkill,
})
n++
if (!AS_JSON && n % 200 === 0) {
process.stderr.write(`\r scanned ${n}/${files.length} files…`)
}
}
if (!AS_JSON)
process.stderr.write(`\r scanned ${n}/${files.length} files.\n`)
// Drop empty buckets (created for files that had no in-range entries under --since)
for (const m of [perProject, perSubagent, perSkill]) {
for (const [k, v] of m) {
if (v.apiCalls === 0 && v.sessions.size === 0) m.delete(k)
}
}
if (AS_JSON) {
printJson({ overall, perProject, perSubagent, perSkill })
} else {
printText({ overall, perProject, perSubagent, perSkill })
}
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
function fmt(n) {
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
return String(n)
}
function pct(a, b) {
return b > 0 ? ((100 * a) / b).toFixed(1) + '%' : '—'
}
function hrs(ms) {
return (ms / 3600000).toFixed(1)
}
function summarize(s) {
const inTotal = s.inputUncached + s.inputCacheCreate + s.inputCacheRead
return {
sessions: s.sessions.size,
api_calls: s.apiCalls,
input_tokens: {
uncached: s.inputUncached,
cache_create: s.inputCacheCreate,
cache_read: s.inputCacheRead,
total: inTotal,
pct_cached:
inTotal > 0 ? +((100 * s.inputCacheRead) / inTotal).toFixed(1) : 0,
},
output_tokens: s.outputTokens,
human_messages: s.humanMessages,
hours: { wall_clock: +hrs(s.wallClockMs), active: +hrs(s.activeMs) },
cache_breaks_over_100k: s.cacheBreaks.length,
subagent: {
calls: s.subagentCalls,
total_tokens: s.subagentTokens,
avg_tokens_per_call:
s.subagentCalls > 0
? Math.round(s.subagentTokens / s.subagentCalls)
: 0,
},
skill_invocations: s.skillInvocations,
span: s.firstTs
? {
from: new Date(s.firstTs).toISOString(),
to: new Date(s.lastTs).toISOString(),
}
: null,
}
}
function printJson({ overall, perProject, perSubagent, perSkill }) {
const out = {
root: ROOT,
generated_at: new Date().toISOString(),
overall: summarize(overall),
cache_breaks: overall.cacheBreaks
.sort((a, b) => b.uncached - a.uncached)
.slice(0, 100)
.map(({ prompt, ...b }) => ({
...b,
context: prompt ? buildContext(prompt) : null,
})),
by_project: Object.fromEntries(
[...perProject].map(([k, v]) => [k, summarize(v)]),
),
by_subagent_type: Object.fromEntries(
[...perSubagent].map(([k, v]) => [k, summarize(v)]),
),
by_skill: Object.fromEntries(
[...perSkill].map(([k, v]) => [k, summarize(v)]),
),
top_prompts: topPrompts(100),
}
process.stdout.write(JSON.stringify(out, null, 2) + '\n')
}
function promptTotal(r) {
return (
r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens
)
}
function topPrompts(n) {
return [...prompts.entries()]
.filter(([, r]) => r.apiCalls > 0)
.sort((a, b) => promptTotal(b[1]) - promptTotal(a[1]))
.slice(0, n)
.map(([pk, r]) => ({
ts: r.ts,
project: r.project,
session: r.sessionId,
text: r.text,
api_calls: r.apiCalls,
subagent_calls: r.subagentCalls,
total_tokens: promptTotal(r),
input: {
uncached: r.inputUncached,
cache_create: r.inputCacheCreate,
cache_read: r.inputCacheRead,
},
output: r.outputTokens,
context: buildContext(pk),
}))
}
function printText({ overall, perProject, perSubagent, perSkill }) {
const line = (...a) => console.log(...a)
const hr = () => line('─'.repeat(78))
line()
line(`Claude Code session analysis — ${ROOT}`)
if (SINCE) line(`(since ${SINCE.toISOString()})`)
hr()
printBlock('OVERALL', overall)
hr()
line(
`CACHE BREAKS (>${fmt(CACHE_BREAK_THRESHOLD)} uncached input on a single call)`,
)
const breaks = overall.cacheBreaks
.sort((a, b) => b.uncached - a.uncached)
.slice(0, TOP_N)
if (breaks.length === 0) line(' none')
for (const b of breaks) {
line(
` ${fmt(b.uncached).padStart(8)} uncached / ${fmt(b.total).padStart(8)} total ` +
`${(b.ts || '').slice(0, 19)} ${b.project}` +
(b.kind === 'subagent' ? ` [${b.agentType}]` : ''),
)
}
if (overall.cacheBreaks.length > TOP_N)
line(`${overall.cacheBreaks.length - TOP_N} more`)
hr()
line(
'MOST EXPENSIVE PROMPTS (total tokens incl. subagents spawned during the turn)',
)
const top = topPrompts(TOP_N)
if (top.length === 0) line(' none')
for (const r of top) {
const inTot = r.input.uncached + r.input.cache_create + r.input.cache_read
line(
` ${fmt(r.total_tokens).padStart(8)} ` +
`(in ${fmt(inTot)} ${pct(r.input.cache_read, inTot)} cached, out ${fmt(r.output)}) ` +
`${r.api_calls} calls` +
(r.subagent_calls ? `, ${r.subagent_calls} subagents` : '') +
` ${(r.ts || '').slice(0, 16)} ${r.project}`,
)
line(` "${r.text}"`)
}
line(
' (note: internal background forks like task_summary/compact are not attributed to a prompt)',
)
hr()
line('BY PROJECT (top by total input tokens)')
const projects = [...perProject.entries()].sort(
(a, b) => totalIn(b[1]) - totalIn(a[1]),
)
for (const [name, s] of projects.slice(0, TOP_N)) {
printBlock(name, s, ' ')
line()
}
if (projects.length > TOP_N)
line(`${projects.length - TOP_N} more projects`)
hr()
line('BY SUBAGENT TYPE')
const agents = [...perSubagent.entries()].sort(
(a, b) => totalIn(b[1]) - totalIn(a[1]),
)
for (const [name, s] of agents) {
printBlock(name, s, ' ')
line()
}
hr()
line(
'BY SKILL / SLASH COMMAND (tokens attributed = from invocation until next human msg)',
)
const skills = [...perSkill.entries()].sort(
(a, b) => totalIn(b[1]) - totalIn(a[1]),
)
for (const [name, s] of skills.slice(0, TOP_N)) {
printBlock(name, s, ' ')
line()
}
if (skills.length > TOP_N) line(`${skills.length - TOP_N} more`)
line()
}
function totalIn(s) {
return s.inputUncached + s.inputCacheCreate + s.inputCacheRead
}
function printBlock(title, s, indent = '') {
const inTotal = totalIn(s)
console.log(`${indent}${title}`)
console.log(
`${indent} sessions: ${s.sessions.size} api calls: ${s.apiCalls} human msgs: ${s.humanMessages}`,
)
console.log(
`${indent} input: ${fmt(inTotal)} total ` +
`(uncached ${fmt(s.inputUncached)}, cache-create ${fmt(s.inputCacheCreate)}, cache-read ${fmt(s.inputCacheRead)} = ${pct(s.inputCacheRead, inTotal)} cached)`,
)
console.log(`${indent} output: ${fmt(s.outputTokens)}`)
console.log(
`${indent} hours: ${hrs(s.wallClockMs)} wall-clock, ${hrs(s.activeMs)} active (gaps >5m excluded)`,
)
console.log(
`${indent} cache breaks >${fmt(CACHE_BREAK_THRESHOLD)}: ${s.cacheBreaks.length}`,
)
console.log(
`${indent} subagents: ${s.subagentCalls} calls, ${fmt(s.subagentTokens)} tokens, avg ${fmt(
s.subagentCalls ? Math.round(s.subagentTokens / s.subagentCalls) : 0,
)}/call`,
)
const topSkills = Object.entries(s.skillInvocations)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
if (topSkills.length)
console.log(
`${indent} skills: ${topSkills.map(([k, v]) => `${k}×${v}`).join(', ')}`,
)
}
main().catch(e => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,464 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>claude usage</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
/* anthropic / claude-code palette */
--ivory: #FAF9F5;
--term-bg: #1a1918;
--term-fg: #d1cfc5;
--titlebar: #252321;
--outline: rgba(255,255,255,0.08);
--hover: rgba(255,255,255,0.035);
--clay: #D97757;
--dim: rgb(136,136,136);
--subtle: rgb(80,80,80);
--green: rgb(78,186,101);
--red: rgb(255,107,128);
--blue: rgb(177,185,249);
--yellow: rgb(255,193,7);
--mono: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Monaco, monospace;
}
* { box-sizing: border-box; }
html { background: var(--ivory); }
body {
margin: 0; padding: 48px 24px 80px;
font: 13px/1.55 var(--mono);
font-variant-numeric: tabular-nums;
color: var(--term-fg);
}
/* ——— terminal window chrome ——— */
.term {
max-width: 1180px; margin: 0 auto;
background: var(--term-bg);
border-radius: 8px;
outline: 1px solid var(--outline);
box-shadow: 0 20px 60px rgba(20,20,19,0.22);
}
.titlebar {
background: var(--titlebar);
border-radius: 8px 8px 0 0;
border-bottom: 1px solid var(--outline);
padding: 11px 14px;
display: flex; align-items: center; gap: 7px;
}
.titlebar .dot { width: 11px; height: 11px; border-radius: 50%; background: #3a3836; }
.titlebar .path { margin-left: 14px; color: var(--dim); font-size: 11px; }
.term-body { padding: 22px 30px 30px; }
/* ——— command + hero ——— */
.cmd { color: var(--dim); margin-bottom: 6px; }
.cmd .prompt { color: var(--clay); }
.cmd .flag { color: var(--blue); }
#meta-line { color: var(--subtle); font-size: 11px; }
#hero { margin: 14px 0 6px; }
#hero-total { font-size: 56px; font-weight: 700; line-height: 1; }
#hero-total .unit { color: var(--clay); }
#hero-total .label { font-size: 18px; font-weight: 400; color: var(--dim); margin-left: 8px; }
#hero-split { color: var(--dim); margin-top: 8px; }
#hero-split b { color: var(--term-fg); font-weight: 500; }
#hero-split .ok { color: var(--green); }
#hero-split .warn { color: var(--yellow); }
/* ——— sections ——— */
section { margin-top: 26px; }
.hr { color: var(--subtle); overflow: hidden; white-space: nowrap;
user-select: none; margin-bottom: 8px; }
.hr::after { content: '────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────'; }
h2 { all: unset; display: block; color: var(--clay); font-weight: 500; }
h2::before { content: '▸ '; }
h2 .hint { color: var(--subtle); font-size: 11px; font-weight: 400; margin-left: 10px; }
.section-body { margin-top: 10px; }
/* ——— overall stat grid ——— */
#overall-grid { display: grid; gap: 4px 28px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.stat { padding: 4px 0; }
.stat .label { font-size: 11px; color: var(--dim); }
.stat .val { font-size: 20px; font-weight: 500; }
.stat .detail { font-size: 11px; color: var(--subtle); }
/* ——— takeaways ——— */
.take { display: grid; grid-template-columns: 9ch 1fr; gap: 18px;
padding: 6px 0; align-items: baseline; }
.take .fig { text-align: right; font-weight: 700; font-size: 15px; }
.take .txt { color: var(--dim); }
.take .txt b { color: var(--term-fg); font-weight: 500; }
.take.bad .fig { color: var(--red); }
.take.good .fig { color: var(--green); }
.take.info .fig { color: var(--blue); }
/* ——— callouts (recommendations) ——— */
.callout { padding: 6px 0 6px 14px; border-left: 2px solid var(--subtle);
color: var(--dim); margin: 6px 0; }
.callout b, .callout code { color: var(--term-fg); }
/* ——— block-char bars ——— */
.bar { display: grid; grid-template-columns: 26ch 1fr 8ch; gap: 14px;
padding: 2px 0; align-items: center; }
.bar .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar .blocks { color: var(--clay); white-space: pre; overflow: hidden; }
.bar .blocks .empty { color: var(--subtle); }
.bar .pct { text-align: right; color: var(--dim); }
.bar:hover .name { color: var(--clay); }
/* ——— drill-down lists (top prompts, cache breaks) ——— */
.drill details { border-top: 1px solid var(--outline); }
.drill details:last-of-type { border-bottom: 1px solid var(--outline); }
.drill summary { list-style: none; cursor: pointer;
display: grid; grid-template-columns: 8ch 1fr; gap: 16px;
padding: 9px 4px; align-items: baseline; }
.drill summary::-webkit-details-marker { display: none; }
.drill summary:hover { background: var(--hover); }
.drill .amt { font-weight: 700; text-align: right; color: var(--clay); }
.drill .desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drill .meta { grid-column: 2; font-size: 11px; color: var(--subtle); }
.drill details[open] summary .desc { white-space: normal; }
.drill .body { padding: 4px 4px 14px calc(8ch + 20px); font-size: 12px; color: var(--dim); }
/* ——— transcript context (±2 user msgs) — light inset for legibility ——— */
.ctx { margin: 8px 0 12px; padding: 10px 14px;
background: #F0EEE6; color: #1a1918;
border-radius: 6px; font-size: 12px; }
.ctx-msg { padding: 4px 0; white-space: pre-wrap; }
.ctx-msg .who { color: #87867F; font-size: 11px; }
.ctx-msg .ts { color: #87867F; font-size: 10px; margin-left: 8px; }
.ctx-msg.here { margin: 2px -14px; padding: 6px 11px 6px 14px;
border-left: 3px solid var(--clay);
background: rgba(217,119,87,0.10); }
.ctx-msg.here .who { color: var(--clay); font-weight: 500; }
.ctx-gap { color: #87867F; font-size: 11px; padding: 3px 0 3px 7ch; }
.ctx-gap::before { content: '⟨ '; }
.ctx-gap::after { content: ' ⟩'; }
.ctx-break { margin: 2px -14px; padding: 8px 11px 8px 14px;
border-left: 3px solid #BD5E6D;
background: rgba(189,94,109,0.12); color: #A63244; }
.ctx-break b { color: #1a1918; margin-right: 6px; }
.more-btn { display: block; width: 100%; margin-top: 10px; padding: 9px;
background: none; border: 1px dashed var(--subtle); cursor: pointer;
font: 500 11px/1 var(--mono); letter-spacing: 0.06em;
color: var(--dim); }
.more-btn:hover { border-color: var(--dim); color: var(--term-fg); }
/* ——— tables ——— */
.scroll { max-height: 440px; overflow: auto;
border-top: 1px solid var(--outline); border-bottom: 1px solid var(--outline); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { text-align: left; padding: 6px 10px; }
td { border-top: 1px solid rgba(255,255,255,0.04); }
th { position: sticky; top: 0; background: var(--term-bg); z-index: 1;
font-weight: 500; font-size: 11px; color: var(--subtle);
cursor: pointer; user-select: none;
border-bottom: 1px solid var(--outline); }
th:hover { color: var(--dim); }
th.sorted { color: var(--clay); }
th.sorted::after { content: ' ↓'; }
th.sorted.asc::after { content: ' ↑'; }
td.num, th.num { text-align: right; }
tbody tr:hover td { background: var(--hover); }
footer { margin-top: 28px; color: var(--subtle); font-size: 11px;
display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
code { color: var(--blue); }
a { color: var(--clay); }
::selection { background: var(--clay); color: var(--term-bg); }
@media (max-width: 760px) {
body { padding: 20px 12px 48px; }
.term-body { padding: 16px 16px 24px; }
#hero-total { font-size: 40px; }
.bar { grid-template-columns: 14ch 1fr 7ch; gap: 8px; }
.drill summary { grid-template-columns: 6ch 1fr; }
.drill .body { padding-left: 12px; }
.take { grid-template-columns: 7ch 1fr; gap: 12px; }
}
</style>
</head>
<body>
<div class="term">
<div class="titlebar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="path" id="title-path">~/.claude — session-report</span>
</div>
<div class="term-body">
<div class="cmd"><span class="prompt">&gt;</span> claude usage <span id="cmd-flags"></span></div>
<div id="meta-line">loading…</div>
<div id="hero">
<div id="hero-total"></div>
<div id="hero-split"></div>
</div>
<!-- ====================================================================
FINDINGS — agent fills this. 35 one-line takeaways. Use .bad for
waste/anomalies, .good for healthy signals, .info for neutral.
==================================================================== -->
<section>
<div class="hr"></div>
<h2>findings</h2>
<div class="section-body" id="takeaways">
<!-- AGENT: anomalies -->
<div class="take"><div class="fig"></div><div class="txt">No findings generated yet.</div></div>
<!-- /AGENT -->
</div>
</section>
<!-- ====================================================================
Everything below renders automatically from #report-data.
==================================================================== -->
<section>
<div class="hr"></div>
<h2>summary</h2>
<div class="section-body" id="overall-grid"></div>
</section>
<section>
<div class="hr"></div>
<h2>tokens by project<span class="hint">share of total</span></h2>
<div class="section-body" id="project-bars"></div>
</section>
<section>
<div class="hr"></div>
<h2>most expensive prompts<span class="hint">click to expand context</span></h2>
<div class="section-body drill" id="top-prompts"></div>
</section>
<section>
<div class="hr"></div>
<h2>cache breaks<span class="hint">&gt;100k uncached · click for context</span></h2>
<div class="section-body drill" id="cache-breaks"></div>
</section>
<section>
<div class="hr"></div>
<h2>projects</h2>
<div class="section-body scroll"><table id="tbl-projects"></table></div>
</section>
<section>
<div class="hr"></div>
<h2>subagent types</h2>
<div class="section-body scroll"><table id="tbl-subagents"></table></div>
</section>
<section>
<div class="hr"></div>
<h2>skills &amp; slash commands</h2>
<div class="section-body scroll"><table id="tbl-skills"></table></div>
</section>
<section>
<div class="hr"></div>
<h2>recommendations</h2>
<div class="section-body">
<!-- AGENT: optimizations -->
<div class="callout">No suggestions generated yet.</div>
<!-- /AGENT -->
</div>
</section>
<footer>
<span id="foot-gen"></span>
<span id="foot-stats"></span>
</footer>
</div>
</div>
<!-- ========================================================================
DATA — agent replaces the {} below with the full --json output.
======================================================================== -->
<script id="report-data" type="application/json">{}</script>
<script>
(function() {
const DATA = JSON.parse(document.getElementById('report-data').textContent || '{}');
const $ = id => document.getElementById(id);
const fmt = n => n>=1e9 ? (n/1e9).toFixed(2)+'B' : n>=1e6 ? (n/1e6).toFixed(2)+'M'
: n>=1e3 ? (n/1e3).toFixed(1)+'k' : String(n);
const pct = (a,b) => b>0 ? ((100*a/b).toFixed(1)+'%') : '—';
const esc = s => String(s).replace(/[&<>"]/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const short = p => String(p||'').replace(/^-Users-[^-]+-/,'').replace(/^code-/,'');
const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const niceDate = iso => { const d = new Date(iso); return isNaN(d) ? '' :
`${MON[d.getMonth()]} ${d.getDate()} · ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; };
if (!DATA.overall) { $('meta-line').textContent = 'no data — embed JSON in #report-data.'; return; }
// header + hero
const o = DATA.overall, it = o.input_tokens;
const GRAND = it.total + o.output_tokens;
const share = n => GRAND>0 ? (100*n/GRAND).toFixed(1)+'%' : '—';
const span = o.span;
$('cmd-flags').innerHTML = DATA.since
? `<span class="flag">--since</span> ${esc(DATA.since)}` : '';
$('meta-line').textContent =
(span ? `${span.from.slice(0,10)}${span.to.slice(0,10)}` : '') +
` · ${DATA.root || ''}`;
$('foot-gen').textContent = `generated ${DATA.generated_at?.slice(0,16).replace('T',' ') || ''}`;
$('foot-stats').textContent =
`${o.sessions} sessions · ${o.api_calls} api calls · ${o.human_messages} prompts`;
const num = fmt(GRAND), m = num.match(/^([\d.]+)([A-Za-z]*)$/);
$('hero-total').innerHTML =
`${m?m[1]:num}<span class="unit">${m?m[2]:''}</span><span class="label">tokens</span>`;
const cacheCls = it.pct_cached>=85 ? 'ok' : 'warn';
$('hero-split').innerHTML =
`<b>${fmt(it.total)}</b> input <span class="${cacheCls}">(${it.pct_cached}% cache-read)</span> · `+
`<b>${fmt(o.output_tokens)}</b> output · all figures below are % of this total`;
// overall stat grid
$('overall-grid').innerHTML = [
['sessions', o.sessions],
['api calls', o.api_calls],
['human msgs', o.human_messages],
['active hours', o.hours.active, `${o.hours.wall_clock} wall-clock`],
['cache breaks', o.cache_breaks_over_100k, '>100k uncached'],
['subagent calls', o.subagent.calls, `avg ${fmt(o.subagent.avg_tokens_per_call)}`],
].map(([l,v,d]) =>
`<div class="stat"><div class="label">${l}</div>`+
`<div class="val">${typeof v==='number'&&v>=1e4?fmt(v):v}</div>`+
(d?`<div class="detail">${d}</div>`:'')+`</div>`).join('');
// block-char project bars
(function() {
const W = 48;
const rows = Object.entries(DATA.by_project||{})
.map(([k,v]) => [k, v.input_tokens.total + v.output_tokens])
.sort((a,b)=>b[1]-a[1]).slice(0,15);
const max = rows[0]?.[1] || 1;
$('project-bars').innerHTML = rows.map(([k,v]) => {
const n = Math.max(1, Math.round(W*v/max));
return `<div class="bar"><span class="name" title="${esc(k)}${fmt(v)}">${esc(short(k))}</span>`+
`<span class="blocks">${'█'.repeat(n)}<span class="empty">${'░'.repeat(W-n)}</span></span>`+
`<span class="pct">${share(v)}</span></div>`;
}).join('');
})();
// ±2 user-message transcript context
function renderContext(ctx, mark) {
if (!ctx || !ctx.length) return '<div class="ctx-gap">no transcript context available</div>';
let h = '<div class="ctx">';
ctx.forEach((m, i) => {
const who = m.here ? '&gt; user' : ' user';
h += `<div class="ctx-msg${m.here?' here':''}">`+
`<span class="who">${who}</span> ${esc(m.text||'(non-text)')}`+
`<span class="ts">${niceDate(m.ts)}</span></div>`;
if (m.here && mark) h += mark;
if (i < ctx.length-1 || m.here)
h += `<div class="ctx-gap">${m.calls} api call${m.calls===1?'':'s'}</div>`;
});
return h + '</div>';
}
// top prompts — share of grand total
(function() {
const ps = (DATA.top_prompts||[]).slice(0,100);
const SHOW = 5;
const row = p => {
const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read;
return `<details><summary>`+
`<span class="amt">${share(p.total_tokens)}</span>`+
`<span class="desc">${esc(p.text)}</span>`+
`<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+
(p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+
` · ${pct(p.input.cache_read,inTot)} cached</span>`+
`</summary><div class="body">`+
renderContext(p.context)+
`<div>session <code>${esc(p.session)}</code></div>`+
`<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+
`cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+
`</div></details>`;
};
const head = ps.slice(0,SHOW).map(row).join('');
const rest = ps.slice(SHOW).map(row).join('');
$('top-prompts').innerHTML = ps.length
? head + (rest
? `<div id="tp-rest" hidden>${rest}</div>`+
`<button id="tp-more" class="more-btn">show ${ps.length-SHOW} more</button>`
: '')
: '<div class="callout">No prompts in range.</div>';
const btn = $('tp-more');
if (btn) btn.onclick = () => {
const r = $('tp-rest'); r.hidden = !r.hidden;
btn.textContent = r.hidden ? `show ${ps.length-SHOW} more` : 'show less';
};
})();
// cache breaks
(function() {
const bs = (DATA.cache_breaks||[]).slice(0,100);
$('cache-breaks').innerHTML = bs.map(b =>
`<details><summary>`+
`<span class="amt">${fmt(b.uncached)}</span>`+
`<span class="desc">${esc(short(b.project))} · `+
`${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+
`<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+
`</summary><div class="body">`+
renderContext(b.context,
`<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+
`(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+
`<div>session <code>${esc(b.session)}</code></div>`+
`</div></details>`
).join('') || '<div class="callout">No cache breaks over threshold.</div>';
})();
// sortable table
function table(el, cols, rows) {
let sortIdx = cols.findIndex(c=>c.sort), asc = false;
function render() {
const sorted = rows.slice().sort((a,b)=>{
const va=a[sortIdx], vb=b[sortIdx];
return (asc?1:-1)*(typeof va==='number' ? va-vb : String(va).localeCompare(String(vb)));
});
el.innerHTML = `<thead><tr>${cols.map((c,i)=>
`<th class="${c.num?'num':''} ${i===sortIdx?'sorted'+(asc?' asc':''):''}" data-i="${i}">${c.h}</th>`
).join('')}</tr></thead><tbody>${sorted.map(r=>
`<tr>${r.map((v,i)=>`<td class="${cols[i].num?'num':''}">${
cols[i].fmt?cols[i].fmt(v):esc(v)}</td>`).join('')}</tr>`
).join('')}</tbody>`;
el.querySelectorAll('th').forEach(th=>th.onclick=()=>{
const i=+th.dataset.i; if(i===sortIdx)asc=!asc; else{sortIdx=i;asc=false;} render();
});
}
render();
}
function statRows(obj) {
return Object.entries(obj||{}).map(([k,v])=>[
short(k), GRAND>0 ? 100*(v.input_tokens.total+v.output_tokens)/GRAND : 0,
v.sessions, v.api_calls, v.human_messages,
v.input_tokens.total, v.input_tokens.pct_cached, v.output_tokens,
v.hours.active, v.cache_breaks_over_100k,
v.subagent.calls, v.subagent.avg_tokens_per_call,
]);
}
const statCols = [
{h:'name'},{h:'% total',num:1,sort:1,fmt:v=>v.toFixed(1)+'%'},
{h:'sess',num:1},{h:'calls',num:1},{h:'msgs',num:1},
{h:'input',num:1,fmt:fmt},{h:'%cached',num:1,fmt:v=>v+'%'},
{h:'output',num:1,fmt:fmt},{h:'active h',num:1},{h:'breaks',num:1},
{h:'subagents',num:1},{h:'avg sub tok',num:1,fmt:fmt},
];
table($('tbl-projects'), statCols, statRows(DATA.by_project));
table($('tbl-subagents'), statCols, statRows(DATA.by_subagent_type));
table($('tbl-skills'), statCols, statRows(DATA.by_skill));
})();
</script>
</body>
</html>