Skip to content

Commit 1057d02

Browse files
authored
Merge pull request #1333 from anthropics/thariq/session-report-timeline
session-report: add per-day timeline and collapse cache-breaks
2 parents 6e43e87 + 9dc3809 commit 1057d02

2 files changed

Lines changed: 215 additions & 51 deletions

File tree

plugins/session-report/skills/session-report/analyze-sessions.mjs

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ const toolUseIdToPrompt = new Map() // tool_use id -> promptKey (Agent spawned d
166166
const agentIdToPrompt = new Map() // agentId -> promptKey
167167
const prompts = new Map() // promptKey -> { text, ts, project, sessionId, ...usage }
168168
const sessionTurns = new Map() // sessionId -> [promptKey, ...] in transcript order
169+
const sessionSpans = new Map() // sessionId -> {project, firstTs, lastTs, tokens}
169170

170171
function promptRecord(key, init) {
171172
let r = prompts.get(key)
@@ -333,11 +334,29 @@ async function processFile(p, info, buckets) {
333334
}
334335
}
335336

337+
// session span (for by_day timeline) — subagent files roll into parent sessionId
338+
let span = sessionSpans.get(info.sessionId)
339+
if (!span) {
340+
span = { project: info.project, firstTs: null, lastTs: null, tokens: 0 }
341+
sessionSpans.set(info.sessionId, span)
342+
}
343+
if (firstTs !== null) {
344+
if (span.firstTs === null || firstTs < span.firstTs) span.firstTs = firstTs
345+
if (span.lastTs === null || lastTs > span.lastTs) span.lastTs = lastTs
346+
}
347+
336348
// commit API calls
337349
for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) {
338350
if (key && seenRequestIds.has(key)) continue
339351
seenRequestIds.add(key)
340352

353+
const tot =
354+
(usage.input_tokens || 0) +
355+
(usage.cache_creation_input_tokens || 0) +
356+
(usage.cache_read_input_tokens || 0) +
357+
(usage.output_tokens || 0)
358+
span.tokens += tot
359+
341360
const targets = [overall, project]
342361
if (subagent) targets.push(subagent)
343362
if (skill && skillStats) {
@@ -359,11 +378,6 @@ async function processFile(p, info, buckets) {
359378

360379
// subagent token accounting on parent buckets
361380
if (info.kind === 'subagent') {
362-
const tot =
363-
(usage.input_tokens || 0) +
364-
(usage.cache_creation_input_tokens || 0) +
365-
(usage.cache_read_input_tokens || 0) +
366-
(usage.output_tokens || 0)
367381
overall.subagentTokens += tot
368382
project.subagentTokens += tot
369383
if (subagent) subagent.subagentTokens += tot
@@ -656,10 +670,55 @@ function printJson({ overall, perProject, perSubagent, perSkill }) {
656670
[...perSkill].map(([k, v]) => [k, summarize(v)]),
657671
),
658672
top_prompts: topPrompts(100),
673+
by_day: buildByDay(),
659674
}
660675
process.stdout.write(JSON.stringify(out, null, 2) + '\n')
661676
}
662677

678+
// Group sessions into local-date buckets for the timeline view. A session is
679+
// placed on the day its first message landed; tokens for that session (incl.
680+
// subagents) count toward that day even if it ran past midnight.
681+
function buildByDay() {
682+
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
683+
const days = new Map() // yyyy-mm-dd -> {date, dow, tokens, sessions:[]}
684+
for (const [id, s] of sessionSpans) {
685+
if (s.firstTs === null || s.tokens === 0) continue
686+
const d0 = new Date(s.firstTs)
687+
const key = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-${String(d0.getDate()).padStart(2, '0')}`
688+
let day = days.get(key)
689+
if (!day) {
690+
day = { date: key, dow: DOW[d0.getDay()], tokens: 0, sessions: [] }
691+
days.set(key, day)
692+
}
693+
const base = new Date(
694+
d0.getFullYear(),
695+
d0.getMonth(),
696+
d0.getDate(),
697+
).getTime()
698+
day.tokens += s.tokens
699+
day.sessions.push({
700+
id,
701+
project: s.project,
702+
tokens: s.tokens,
703+
start_min: Math.max(0, Math.round((s.firstTs - base) / 60000)),
704+
end_min: Math.max(1, Math.round((s.lastTs - base) / 60000)),
705+
})
706+
}
707+
for (const d of days.values()) {
708+
// peak concurrency via 10-min buckets, capped at 24h for display
709+
const b = new Array(144).fill(0)
710+
for (const s of d.sessions) {
711+
const lo = Math.min(143, Math.floor(s.start_min / 10))
712+
const hi = Math.min(144, Math.ceil(Math.min(s.end_min, 1440) / 10))
713+
for (let i = lo; i < hi; i++) b[i]++
714+
}
715+
d.peak = Math.max(0, ...b)
716+
d.peak_at_min = d.peak > 0 ? b.indexOf(d.peak) * 10 : 0
717+
d.sessions.sort((a, b) => a.start_min - b.start_min)
718+
}
719+
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date))
720+
}
721+
663722
function promptTotal(r) {
664723
return (
665724
r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens

plugins/session-report/skills/session-report/template.html

Lines changed: 151 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,42 @@
102102
color: var(--dim); margin: 6px 0; }
103103
.callout b, .callout code { color: var(--term-fg); }
104104

105+
/* ——— day pills + session gantt ——— */
106+
.days { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
107+
.dpill { flex: 1; min-width: 84px; max-width: 140px; background: none;
108+
border: 1px solid var(--subtle); border-radius: 4px;
109+
padding: 9px 6px; font: inherit; color: var(--dim);
110+
cursor: pointer; text-align: center; }
111+
.dpill:hover { border-color: var(--dim); background: var(--hover); }
112+
.dpill .dow { font-size: 10px; color: var(--subtle); display: block; }
113+
.dpill .date { font-size: 11px; color: var(--term-fg); font-weight: 500;
114+
display: block; margin: 2px 0 4px; }
115+
.dpill .pct { font-size: 16px; font-weight: 700; color: var(--term-fg); display: block; }
116+
.dpill .ns { font-size: 10px; color: var(--subtle); display: block; margin-top: 2px; }
117+
.dpill.heaviest .pct { color: var(--clay); }
118+
.dpill.sel { border-color: var(--clay); background: rgba(217,119,87,0.10); }
119+
.gantt-hd { display: flex; justify-content: space-between; align-items: baseline;
120+
margin-bottom: 6px; }
121+
.gantt-hd .day { color: var(--term-fg); font-weight: 500; }
122+
.gantt-hd .stats { font-size: 11px; color: var(--dim); }
123+
.gantt-hd .stats b { color: var(--clay); }
124+
.gantt { position: relative; border-top: 1px solid var(--outline);
125+
border-bottom: 1px solid var(--outline); min-height: 32px; }
126+
.lane { position: relative; height: 16px;
127+
border-bottom: 1px dashed rgba(255,255,255,0.04); }
128+
.seg { position: absolute; top: 2px; height: 12px; border-radius: 2px;
129+
opacity: .85; cursor: crosshair; }
130+
.seg:hover { opacity: 1; outline: 1px solid var(--term-fg); z-index: 2; }
131+
.gantt-rule { position: absolute; top: 0; bottom: 0; width: 0;
132+
border-left: 1px dashed var(--subtle); opacity: .4;
133+
pointer-events: none; }
134+
.gantt-axis { display: flex; justify-content: space-between;
135+
font-size: 10px; color: var(--subtle); padding: 4px 0; }
136+
.gantt-leg { font-size: 10px; color: var(--subtle); margin-top: 8px;
137+
display: flex; gap: 14px; flex-wrap: wrap; }
138+
.gantt-leg .sw { display: inline-block; width: 14px; height: 10px;
139+
border-radius: 2px; vertical-align: middle; margin-right: 4px; }
140+
105141
/* ——— block-char bars ——— */
106142
.bar { display: grid; grid-template-columns: 26ch 1fr 8ch; gap: 14px;
107143
padding: 2px 0; align-items: center; }
@@ -231,6 +267,21 @@ <h2>tokens by project<span class="hint">share of total</span></h2>
231267
<div class="section-body" id="project-bars"></div>
232268
</section>
233269

270+
<section id="timeline-section">
271+
<div class="hr"></div>
272+
<h2>session timeline by day<span class="hint">click a day · ←/→ to navigate</span></h2>
273+
<div class="section-body">
274+
<div class="days" id="day-pills"></div>
275+
<div class="gantt-hd">
276+
<span class="day" id="g-day"></span>
277+
<span class="stats" id="g-stats"></span>
278+
</div>
279+
<div class="gantt-axis"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
280+
<div class="gantt" id="gantt"></div>
281+
<div class="gantt-leg" id="gantt-leg"></div>
282+
</div>
283+
</section>
284+
234285
<section>
235286
<div class="hr"></div>
236287
<h2>most expensive prompts<span class="hint">click to expand context</span></h2>
@@ -335,6 +386,65 @@ <h2>recommendations</h2>
335386
`<div class="val">${typeof v==='number'&&v>=1e4?fmt(v):v}</div>`+
336387
(d?`<div class="detail">${d}</div>`:'')+`</div>`).join('');
337388

389+
// session timeline by day
390+
(function() {
391+
const days = (DATA.by_day||[]).slice(-14);
392+
if (!days.length) { $('timeline-section').style.display='none'; return; }
393+
const PCOL = ['rgb(177,185,249)','rgb(78,186,101)','#D97757','rgb(255,193,7)',
394+
'rgb(255,107,128)','#9b8cff','#6ec1d6','#c792ea'];
395+
const dayTotal = days.reduce((a,d)=>a+d.tokens,0) || 1;
396+
const tokMax = Math.max(...days.map(d=>d.tokens));
397+
const projects = [...new Set(days.flatMap(d=>d.sessions.map(s=>s.project)))];
398+
const colorOf = p => PCOL[projects.indexOf(p)%PCOL.length];
399+
const hhmm = m => (m>=1440?`+${Math.floor(m/1440)}d `:'') +
400+
`${String(Math.floor(m/60)%24).padStart(2,'0')}:${String(m%60).padStart(2,'0')}`;
401+
const md = iso => { const [,mo,da]=iso.split('-'); return `${MON[+mo-1]} ${+da}`; };
402+
let sel = days.findIndex(d=>d.tokens===tokMax);
403+
404+
function pills() {
405+
$('day-pills').innerHTML = days.map((d,i)=>
406+
`<button class="dpill${d.tokens===tokMax?' heaviest':''}${i===sel?' sel':''}" data-i="${i}">`+
407+
`<span class="dow">${esc(d.dow)}</span>`+
408+
`<span class="date">${esc(md(d.date))}</span>`+
409+
`<span class="pct">${(100*d.tokens/dayTotal).toFixed(1)}%</span>`+
410+
`<span class="ns">${d.sessions.length} sess</span></button>`
411+
).join('');
412+
$('day-pills').querySelectorAll('.dpill').forEach(el=>
413+
el.onclick=()=>{sel=+el.dataset.i;pills();gantt();});
414+
}
415+
function gantt() {
416+
const d = days[sel], DAY = 1440;
417+
$('g-day').textContent = `${d.dow} ${md(d.date)}`;
418+
$('g-stats').innerHTML = `${d.sessions.length} sessions · ${fmt(d.tokens)} tokens`+
419+
` · peak <b>${d.peak}</b> concurrent at <b>${hhmm(d.peak_at_min)}</b>`;
420+
const lanes = [];
421+
for (const s of d.sessions) {
422+
let placed = false;
423+
for (const L of lanes) if (L[L.length-1].end_min <= s.start_min) { L.push(s); placed=true; break; }
424+
if (!placed) lanes.push([s]);
425+
}
426+
let h = '';
427+
for (let t=0;t<=24;t+=6) h += `<div class="gantt-rule" style="left:${100*t/24}%"></div>`;
428+
h += lanes.map(L=>`<div class="lane">${L.map(s=>{
429+
const end = Math.min(s.end_min, DAY);
430+
const w = Math.max(0.15, 100*(end-s.start_min)/DAY);
431+
const tip = `folder: ${short(s.project)}\n`+
432+
`${hhmm(s.start_min)}${hhmm(s.end_min)} · ${fmt(s.tokens)} tokens\n`+
433+
`session ${s.id}`;
434+
return `<span class="seg" style="left:${100*s.start_min/DAY}%;width:${w}%;`+
435+
`background:${colorOf(s.project)}" title="${esc(tip)}"></span>`;
436+
}).join('')}</div>`).join('');
437+
$('gantt').innerHTML = h || '<div class="callout">no sessions</div>';
438+
}
439+
document.addEventListener('keydown',e=>{
440+
if (e.key==='ArrowRight'&&sel<days.length-1){sel++;pills();gantt();e.preventDefault();}
441+
if (e.key==='ArrowLeft'&&sel>0){sel--;pills();gantt();e.preventDefault();}
442+
});
443+
$('gantt-leg').innerHTML = projects.slice(0,12).map(p=>
444+
`<span><span class="sw" style="background:${colorOf(p)}"></span>${esc(short(p))}</span>`).join('');
445+
pills(); gantt();
446+
})();
447+
338448
// block-char project bars
339449
(function() {
340450
const W = 48;
@@ -366,57 +476,52 @@ <h2>recommendations</h2>
366476
return h + '</div>';
367477
}
368478

369-
// top prompts — share of grand total
370-
(function() {
371-
const ps = (DATA.top_prompts||[]).slice(0,100);
479+
// expandable drill-down list with "show N more" toggle
480+
function drillList(hostId, items, rowFn, empty) {
372481
const SHOW = 5;
373-
const row = p => {
374-
const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read;
375-
return `<details><summary>`+
376-
`<span class="amt">${share(p.total_tokens)}</span>`+
377-
`<span class="desc">${esc(p.text)}</span>`+
378-
`<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+
379-
(p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+
380-
` · ${pct(p.input.cache_read,inTot)} cached</span>`+
381-
`</summary><div class="body">`+
382-
renderContext(p.context)+
383-
`<div>session <code>${esc(p.session)}</code></div>`+
384-
`<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+
385-
`cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+
386-
`</div></details>`;
387-
};
388-
const head = ps.slice(0,SHOW).map(row).join('');
389-
const rest = ps.slice(SHOW).map(row).join('');
390-
$('top-prompts').innerHTML = ps.length
391-
? head + (rest
392-
? `<div id="tp-rest" hidden>${rest}</div>`+
393-
`<button id="tp-more" class="more-btn">show ${ps.length-SHOW} more</button>`
394-
: '')
395-
: '<div class="callout">No prompts in range.</div>';
396-
const btn = $('tp-more');
482+
const host = $(hostId);
483+
if (!items.length) { host.innerHTML = `<div class="callout">${empty}</div>`; return; }
484+
const head = items.slice(0,SHOW).map(rowFn).join('');
485+
const rest = items.slice(SHOW).map(rowFn).join('');
486+
host.innerHTML = head + (rest
487+
? `<div hidden>${rest}</div><button class="more-btn">show ${items.length-SHOW} more</button>`
488+
: '');
489+
const btn = host.querySelector('.more-btn');
397490
if (btn) btn.onclick = () => {
398-
const r = $('tp-rest'); r.hidden = !r.hidden;
399-
btn.textContent = r.hidden ? `show ${ps.length-SHOW} more` : 'show less';
491+
const r = btn.previousElementSibling; r.hidden = !r.hidden;
492+
btn.textContent = r.hidden ? `show ${items.length-SHOW} more` : 'show less';
400493
};
401-
})();
494+
}
402495

403-
// cache breaks
404-
(function() {
405-
const bs = (DATA.cache_breaks||[]).slice(0,100);
406-
$('cache-breaks').innerHTML = bs.map(b =>
407-
`<details><summary>`+
408-
`<span class="amt">${fmt(b.uncached)}</span>`+
409-
`<span class="desc">${esc(short(b.project))} · `+
410-
`${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+
411-
`<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+
496+
drillList('top-prompts', (DATA.top_prompts||[]).slice(0,100), p => {
497+
const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read;
498+
return `<details><summary>`+
499+
`<span class="amt">${share(p.total_tokens)}</span>`+
500+
`<span class="desc">${esc(p.text)}</span>`+
501+
`<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+
502+
(p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+
503+
` · ${pct(p.input.cache_read,inTot)} cached</span>`+
412504
`</summary><div class="body">`+
413-
renderContext(b.context,
414-
`<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+
415-
`(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+
416-
`<div>session <code>${esc(b.session)}</code></div>`+
417-
`</div></details>`
418-
).join('') || '<div class="callout">No cache breaks over threshold.</div>';
419-
})();
505+
renderContext(p.context)+
506+
`<div>session <code>${esc(p.session)}</code></div>`+
507+
`<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+
508+
`cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+
509+
`</div></details>`;
510+
}, 'No prompts in range.');
511+
512+
drillList('cache-breaks', (DATA.cache_breaks||[]).slice(0,100), b =>
513+
`<details><summary>`+
514+
`<span class="amt">${fmt(b.uncached)}</span>`+
515+
`<span class="desc">${esc(short(b.project))} · `+
516+
`${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+
517+
`<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+
518+
`</summary><div class="body">`+
519+
renderContext(b.context,
520+
`<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+
521+
`(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+
522+
`<div>session <code>${esc(b.session)}</code></div>`+
523+
`</div></details>`,
524+
'No cache breaks over threshold.');
420525

421526
// sortable table
422527
function table(el, cols, rows) {

0 commit comments

Comments
 (0)