diff --git a/plugins/session-report/skills/session-report/analyze-sessions.mjs b/plugins/session-report/skills/session-report/analyze-sessions.mjs index 7ea712a..1a5e593 100644 --- a/plugins/session-report/skills/session-report/analyze-sessions.mjs +++ b/plugins/session-report/skills/session-report/analyze-sessions.mjs @@ -166,6 +166,7 @@ const toolUseIdToPrompt = new Map() // tool_use id -> promptKey (Agent spawned d 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 +const sessionSpans = new Map() // sessionId -> {project, firstTs, lastTs, tokens} function promptRecord(key, init) { let r = prompts.get(key) @@ -333,11 +334,29 @@ async function processFile(p, info, buckets) { } } + // session span (for by_day timeline) — subagent files roll into parent sessionId + let span = sessionSpans.get(info.sessionId) + if (!span) { + span = { project: info.project, firstTs: null, lastTs: null, tokens: 0 } + sessionSpans.set(info.sessionId, span) + } + if (firstTs !== null) { + if (span.firstTs === null || firstTs < span.firstTs) span.firstTs = firstTs + if (span.lastTs === null || lastTs > span.lastTs) span.lastTs = lastTs + } + // commit API calls for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) { if (key && seenRequestIds.has(key)) continue seenRequestIds.add(key) + const tot = + (usage.input_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + + (usage.output_tokens || 0) + span.tokens += tot + const targets = [overall, project] if (subagent) targets.push(subagent) if (skill && skillStats) { @@ -359,11 +378,6 @@ async function processFile(p, info, buckets) { // 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 @@ -656,10 +670,55 @@ function printJson({ overall, perProject, perSubagent, perSkill }) { [...perSkill].map(([k, v]) => [k, summarize(v)]), ), top_prompts: topPrompts(100), + by_day: buildByDay(), } process.stdout.write(JSON.stringify(out, null, 2) + '\n') } +// Group sessions into local-date buckets for the timeline view. A session is +// placed on the day its first message landed; tokens for that session (incl. +// subagents) count toward that day even if it ran past midnight. +function buildByDay() { + const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + const days = new Map() // yyyy-mm-dd -> {date, dow, tokens, sessions:[]} + for (const [id, s] of sessionSpans) { + if (s.firstTs === null || s.tokens === 0) continue + const d0 = new Date(s.firstTs) + const key = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-${String(d0.getDate()).padStart(2, '0')}` + let day = days.get(key) + if (!day) { + day = { date: key, dow: DOW[d0.getDay()], tokens: 0, sessions: [] } + days.set(key, day) + } + const base = new Date( + d0.getFullYear(), + d0.getMonth(), + d0.getDate(), + ).getTime() + day.tokens += s.tokens + day.sessions.push({ + id, + project: s.project, + tokens: s.tokens, + start_min: Math.max(0, Math.round((s.firstTs - base) / 60000)), + end_min: Math.max(1, Math.round((s.lastTs - base) / 60000)), + }) + } + for (const d of days.values()) { + // peak concurrency via 10-min buckets, capped at 24h for display + const b = new Array(144).fill(0) + for (const s of d.sessions) { + const lo = Math.min(143, Math.floor(s.start_min / 10)) + const hi = Math.min(144, Math.ceil(Math.min(s.end_min, 1440) / 10)) + for (let i = lo; i < hi; i++) b[i]++ + } + d.peak = Math.max(0, ...b) + d.peak_at_min = d.peak > 0 ? b.indexOf(d.peak) * 10 : 0 + d.sessions.sort((a, b) => a.start_min - b.start_min) + } + return [...days.values()].sort((a, b) => a.date.localeCompare(b.date)) +} + function promptTotal(r) { return ( r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens diff --git a/plugins/session-report/skills/session-report/template.html b/plugins/session-report/skills/session-report/template.html index 7a89e97..98a9910 100644 --- a/plugins/session-report/skills/session-report/template.html +++ b/plugins/session-report/skills/session-report/template.html @@ -102,6 +102,42 @@ color: var(--dim); margin: 6px 0; } .callout b, .callout code { color: var(--term-fg); } + /* ——— day pills + session gantt ——— */ + .days { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } + .dpill { flex: 1; min-width: 84px; max-width: 140px; background: none; + border: 1px solid var(--subtle); border-radius: 4px; + padding: 9px 6px; font: inherit; color: var(--dim); + cursor: pointer; text-align: center; } + .dpill:hover { border-color: var(--dim); background: var(--hover); } + .dpill .dow { font-size: 10px; color: var(--subtle); display: block; } + .dpill .date { font-size: 11px; color: var(--term-fg); font-weight: 500; + display: block; margin: 2px 0 4px; } + .dpill .pct { font-size: 16px; font-weight: 700; color: var(--term-fg); display: block; } + .dpill .ns { font-size: 10px; color: var(--subtle); display: block; margin-top: 2px; } + .dpill.heaviest .pct { color: var(--clay); } + .dpill.sel { border-color: var(--clay); background: rgba(217,119,87,0.10); } + .gantt-hd { display: flex; justify-content: space-between; align-items: baseline; + margin-bottom: 6px; } + .gantt-hd .day { color: var(--term-fg); font-weight: 500; } + .gantt-hd .stats { font-size: 11px; color: var(--dim); } + .gantt-hd .stats b { color: var(--clay); } + .gantt { position: relative; border-top: 1px solid var(--outline); + border-bottom: 1px solid var(--outline); min-height: 32px; } + .lane { position: relative; height: 16px; + border-bottom: 1px dashed rgba(255,255,255,0.04); } + .seg { position: absolute; top: 2px; height: 12px; border-radius: 2px; + opacity: .85; cursor: crosshair; } + .seg:hover { opacity: 1; outline: 1px solid var(--term-fg); z-index: 2; } + .gantt-rule { position: absolute; top: 0; bottom: 0; width: 0; + border-left: 1px dashed var(--subtle); opacity: .4; + pointer-events: none; } + .gantt-axis { display: flex; justify-content: space-between; + font-size: 10px; color: var(--subtle); padding: 4px 0; } + .gantt-leg { font-size: 10px; color: var(--subtle); margin-top: 8px; + display: flex; gap: 14px; flex-wrap: wrap; } + .gantt-leg .sw { display: inline-block; width: 14px; height: 10px; + border-radius: 2px; vertical-align: middle; margin-right: 4px; } + /* ——— block-char bars ——— */ .bar { display: grid; grid-template-columns: 26ch 1fr 8ch; gap: 14px; padding: 2px 0; align-items: center; } @@ -231,6 +267,21 @@
+
+
+

session timeline by dayclick a day · ←/→ to navigate

+
+
+
+ + +
+
00:0006:0012:0018:0024:00
+
+
+
+
+

most expensive promptsclick to expand context

@@ -335,6 +386,65 @@ `
${typeof v==='number'&&v>=1e4?fmt(v):v}
`+ (d?`
${d}
`:'')+``).join(''); + // session timeline by day + (function() { + const days = (DATA.by_day||[]).slice(-14); + if (!days.length) { $('timeline-section').style.display='none'; return; } + const PCOL = ['rgb(177,185,249)','rgb(78,186,101)','#D97757','rgb(255,193,7)', + 'rgb(255,107,128)','#9b8cff','#6ec1d6','#c792ea']; + const dayTotal = days.reduce((a,d)=>a+d.tokens,0) || 1; + const tokMax = Math.max(...days.map(d=>d.tokens)); + const projects = [...new Set(days.flatMap(d=>d.sessions.map(s=>s.project)))]; + const colorOf = p => PCOL[projects.indexOf(p)%PCOL.length]; + const hhmm = m => (m>=1440?`+${Math.floor(m/1440)}d `:'') + + `${String(Math.floor(m/60)%24).padStart(2,'0')}:${String(m%60).padStart(2,'0')}`; + const md = iso => { const [,mo,da]=iso.split('-'); return `${MON[+mo-1]} ${+da}`; }; + let sel = days.findIndex(d=>d.tokens===tokMax); + + function pills() { + $('day-pills').innerHTML = days.map((d,i)=> + `` + ).join(''); + $('day-pills').querySelectorAll('.dpill').forEach(el=> + el.onclick=()=>{sel=+el.dataset.i;pills();gantt();}); + } + function gantt() { + const d = days[sel], DAY = 1440; + $('g-day').textContent = `${d.dow} ${md(d.date)}`; + $('g-stats').innerHTML = `${d.sessions.length} sessions · ${fmt(d.tokens)} tokens`+ + ` · peak ${d.peak} concurrent at ${hhmm(d.peak_at_min)}`; + const lanes = []; + for (const s of d.sessions) { + let placed = false; + for (const L of lanes) if (L[L.length-1].end_min <= s.start_min) { L.push(s); placed=true; break; } + if (!placed) lanes.push([s]); + } + let h = ''; + for (let t=0;t<=24;t+=6) h += `
`; + h += lanes.map(L=>`
${L.map(s=>{ + const end = Math.min(s.end_min, DAY); + const w = Math.max(0.15, 100*(end-s.start_min)/DAY); + const tip = `folder: ${short(s.project)}\n`+ + `${hhmm(s.start_min)}–${hhmm(s.end_min)} · ${fmt(s.tokens)} tokens\n`+ + `session ${s.id}`; + return ``; + }).join('')}
`).join(''); + $('gantt').innerHTML = h || '
no sessions
'; + } + document.addEventListener('keydown',e=>{ + if (e.key==='ArrowRight'&&sel0){sel--;pills();gantt();e.preventDefault();} + }); + $('gantt-leg').innerHTML = projects.slice(0,12).map(p=> + `${esc(short(p))}`).join(''); + pills(); gantt(); + })(); + // block-char project bars (function() { const W = 48; @@ -366,57 +476,52 @@ return h + ''; } - // top prompts — share of grand total - (function() { - const ps = (DATA.top_prompts||[]).slice(0,100); + // expandable drill-down list with "show N more" toggle + function drillList(hostId, items, rowFn, empty) { const SHOW = 5; - const row = p => { - const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read; - return `
`+ - `${share(p.total_tokens)}`+ - `${esc(p.text)}`+ - `${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`+ - `
`+ - renderContext(p.context)+ - `
session ${esc(p.session)}
`+ - `
in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+ - `cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}
`+ - `
`; - }; - const head = ps.slice(0,SHOW).map(row).join(''); - const rest = ps.slice(SHOW).map(row).join(''); - $('top-prompts').innerHTML = ps.length - ? head + (rest - ? ``+ - `` - : '') - : '
No prompts in range.
'; - const btn = $('tp-more'); + const host = $(hostId); + if (!items.length) { host.innerHTML = `
${empty}
`; return; } + const head = items.slice(0,SHOW).map(rowFn).join(''); + const rest = items.slice(SHOW).map(rowFn).join(''); + host.innerHTML = head + (rest + ? `` + : ''); + const btn = host.querySelector('.more-btn'); if (btn) btn.onclick = () => { - const r = $('tp-rest'); r.hidden = !r.hidden; - btn.textContent = r.hidden ? `show ${ps.length-SHOW} more` : 'show less'; + const r = btn.previousElementSibling; r.hidden = !r.hidden; + btn.textContent = r.hidden ? `show ${items.length-SHOW} more` : 'show less'; }; - })(); + } - // cache breaks - (function() { - const bs = (DATA.cache_breaks||[]).slice(0,100); - $('cache-breaks').innerHTML = bs.map(b => - `
`+ - `${fmt(b.uncached)}`+ - `${esc(short(b.project))} · `+ - `${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}`+ - `${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached`+ + drillList('top-prompts', (DATA.top_prompts||[]).slice(0,100), p => { + const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read; + return `
`+ + `${share(p.total_tokens)}`+ + `${esc(p.text)}`+ + `${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`+ `
`+ - renderContext(b.context, - `
${fmt(b.uncached)} uncached `+ - `(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here
`)+ - `
session ${esc(b.session)}
`+ - `
` - ).join('') || '
No cache breaks over threshold.
'; - })(); + renderContext(p.context)+ + `
session ${esc(p.session)}
`+ + `
in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+ + `cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}
`+ + `
`; + }, 'No prompts in range.'); + + drillList('cache-breaks', (DATA.cache_breaks||[]).slice(0,100), b => + `
`+ + `${fmt(b.uncached)}`+ + `${esc(short(b.project))} · `+ + `${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}`+ + `${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached`+ + `
`+ + renderContext(b.context, + `
${fmt(b.uncached)} uncached `+ + `(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here
`)+ + `
session ${esc(b.session)}
`+ + `
`, + 'No cache breaks over threshold.'); // sortable table function table(el, cols, rows) {