mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-04-17 13:12:42 +00:00
Compare commits
4 Commits
add-zoom-p
...
unpin-sona
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f1195ce5 | ||
|
|
62f2063abc | ||
|
|
66ca8fc540 | ||
|
|
147ddf8ee3 |
@@ -1179,6 +1179,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.",
|
||||
@@ -1206,8 +1217,7 @@
|
||||
"category": "security",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/SonarSource/sonarqube-agent-plugins.git",
|
||||
"sha": "0cae644cee9318e6245b62ca779abdc60e6daa49"
|
||||
"url": "https://github.com/SonarSource/sonarqube-agent-plugins.git"
|
||||
},
|
||||
"homepage": "https://github.com/SonarSource/sonarqube-agent-plugins"
|
||||
},
|
||||
|
||||
42
plugins/session-report/skills/session-report/SKILL.md
Normal file
42
plugins/session-report/skills/session-report/SKILL.md
Normal 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 **3–5 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 1–4 `<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).
|
||||
@@ -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)
|
||||
})
|
||||
464
plugins/session-report/skills/session-report/template.html
Normal file
464
plugins/session-report/skills/session-report/template.html
Normal 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">></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. 3–5 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">>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 & 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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"'}[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 ? '> 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>
|
||||
Reference in New Issue
Block a user