| 1 | import { daySpan } from './util.js'; |
| 2 | |
| 3 | export function buildTree(sessions, nodes) { |
| 4 | const byUuid = new Map(); |
| 5 | for (const node of nodes) if (node.uuid) byUuid.set(node.uuid, node); |
| 6 | |
| 7 | const mainPaths = new Map(); |
| 8 | for (const session of sessions) { |
| 9 | const main = new Set(); |
| 10 | let cur = |
| 11 | (session.activeLeafUuid && session.index.has(session.activeLeafUuid) |
| 12 | ? session.activeLeafUuid |
| 13 | : session.leafUuid) || null; |
| 14 | let guard = 0; |
| 15 | while (cur && guard++ < 1_000_000) { |
| 16 | main.add(cur); |
| 17 | cur = session.index.get(cur)?.parentUuid || null; |
| 18 | } |
| 19 | mainPaths.set(session.sessionId, main); |
| 20 | } |
| 21 | |
| 22 | const sessionById = new Map(sessions.map((s) => [s.sessionId, s])); |
| 23 | for (const node of nodes) { |
| 24 | node.parent = null; |
| 25 | if (!node.uuid) continue; |
| 26 | const session = sessionById.get(node.sessionId); |
| 27 | if (!session) continue; |
| 28 | let cur = node.parentUuid; |
| 29 | let guard = 0; |
| 30 | while (cur && guard++ < 1_000_000) { |
| 31 | const hit = byUuid.get(cur); |
| 32 | if (hit) { |
| 33 | node.parent = hit; |
| 34 | break; |
| 35 | } |
| 36 | cur = session.index.get(cur)?.parentUuid || null; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | const ordered = [...sessions].sort((a, b) => |
| 41 | String(a.firstTs || '').localeCompare(String(b.firstTs || '')) |
| 42 | ); |
| 43 | const nodesBySession = new Map(); |
| 44 | for (const node of nodes) { |
| 45 | if (!nodesBySession.has(node.sessionId)) nodesBySession.set(node.sessionId, []); |
| 46 | nodesBySession.get(node.sessionId).push(node); |
| 47 | } |
| 48 | |
| 49 | let prevTail = null; |
| 50 | for (const session of ordered) { |
| 51 | const sNodes = nodesBySession.get(session.sessionId) || []; |
| 52 | if (!sNodes.length) continue; |
| 53 | for (let i = 0; i < sNodes.length; i++) { |
| 54 | const node = sNodes[i]; |
| 55 | if (!node.parent && i > 0) { |
| 56 | node.parent = sNodes[i - 1]; |
| 57 | } |
| 58 | } |
| 59 | if (!sNodes[0].parent && prevTail) { |
| 60 | sNodes[0].parent = prevTail; |
| 61 | sNodes[0].sessionBoundary = true; |
| 62 | } else if (!sNodes[0].parent) { |
| 63 | sNodes[0].sessionBoundary = true; |
| 64 | } |
| 65 | const main = mainPaths.get(session.sessionId) || new Set(); |
| 66 | const tail = [...sNodes].reverse().find((n) => !n.uuid || main.has(n.uuid)); |
| 67 | prevTail = tail || sNodes[sNodes.length - 1]; |
| 68 | } |
| 69 | |
| 70 | for (const node of nodes) { |
| 71 | if (!node.uuid) continue; |
| 72 | const main = mainPaths.get(node.sessionId); |
| 73 | const session = sessionById.get(node.sessionId); |
| 74 | if (!main || !main.size || !session || main.has(node.uuid)) continue; |
| 75 | let cur = node.parentUuid; |
| 76 | let guard = 0; |
| 77 | while (cur && guard++ < 1_000_000) { |
| 78 | if (main.has(cur)) { |
| 79 | node.status = 'abandoned'; |
| 80 | break; |
| 81 | } |
| 82 | cur = session.index.get(cur)?.parentUuid || null; |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | nodes.forEach((n, i) => { |
| 87 | n.id = `node_${String(i + 1).padStart(3, '0')}`; |
| 88 | n.children = []; |
| 89 | }); |
| 90 | const roots = []; |
| 91 | for (const node of nodes) { |
| 92 | if (node.parent) node.parent.children.push(node); |
| 93 | else roots.push(node); |
| 94 | } |
| 95 | |
| 96 | return { roots, nodes, sessions: ordered, stats: computeStats(ordered, nodes) }; |
| 97 | } |
| 98 | |
| 99 | function computeStats(sessions, nodes) { |
| 100 | const byKind = {}; |
| 101 | for (const node of nodes) byKind[node.kind] = (byKind[node.kind] || 0) + 1; |
| 102 | |
| 103 | const abandonedRoots = nodes.filter( |
| 104 | (n) => n.status === 'abandoned' && (!n.parent || n.parent.status !== 'abandoned') |
| 105 | ); |
| 106 | |
| 107 | const models = new Set(); |
| 108 | const filesTouched = new Set(); |
| 109 | let toolUses = 0; |
| 110 | let interruptions = 0; |
| 111 | let rejections = 0; |
| 112 | let inputTokens = 0; |
| 113 | let outputTokens = 0; |
| 114 | const rejectionsByKind = Object.create(null); |
| 115 | const timestamps = []; |
| 116 | for (const s of sessions) { |
| 117 | for (const m of s.stats.models) models.add(m); |
| 118 | for (const f of s.stats.filesTouched) filesTouched.add(f); |
| 119 | toolUses += s.stats.toolUses; |
| 120 | interruptions += s.stats.interruptions; |
| 121 | rejections += s.stats.rejections || 0; |
| 122 | inputTokens += s.stats.inputTokens || 0; |
| 123 | outputTokens += s.stats.outputTokens || 0; |
| 124 | if (s.stats.rejectionsByKind) { |
| 125 | for (const [k, v] of Object.entries(s.stats.rejectionsByKind)) { |
| 126 | rejectionsByKind[k] = (rejectionsByKind[k] || 0) + v; |
| 127 | } |
| 128 | } |
| 129 | if (s.firstTs) timestamps.push(s.firstTs); |
| 130 | if (s.lastTs) timestamps.push(s.lastTs); |
| 131 | } |
| 132 | |
| 133 | const sortedTs = timestamps.length ? [...timestamps].sort() : []; |
| 134 | return { |
| 135 | promptCount: nodes.length, |
| 136 | rawPromptCount: sessions.reduce((acc, s) => acc + (s.prompts ? s.prompts.length : 0), 0), |
| 137 | sessionCount: sessions.filter((s) => s.prompts.length).length, |
| 138 | byKind, |
| 139 | corrections: byKind['correction'] || 0, |
| 140 | scopeChanges: byKind['scope-change'] || 0, |
| 141 | checkpoints: byKind['checkpoint'] || 0, |
| 142 | rejections, |
| 143 | rejectionsByKind: { ...rejectionsByKind }, |
| 144 | abandonedBranches: abandonedRoots.length, |
| 145 | nudges: nodes.reduce((acc, n) => acc + n.nudges, 0), |
| 146 | interruptions, |
| 147 | toolUses, |
| 148 | inputTokens, |
| 149 | outputTokens, |
| 150 | filesTouched: filesTouched.size, |
| 151 | models: [...models], |
| 152 | days: daySpan(timestamps), |
| 153 | firstTs: sortedTs[0] ?? null, |
| 154 | lastTs: sortedTs.at(-1) ?? null, |
| 155 | }; |
| 156 | } |