Zion Boggan
repos/TreeTrace/src/tree.js
zionboggan.com ↗
156 lines · javascript
History for this file →
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
}