dsh-treecom 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,857 @@
1
+ /**
2
+ * treecom — Host half.
3
+ *
4
+ * A read-only index over the user's conversation family. It does not write to
5
+ * any session log: the only file it owns is the sidecar with branch labels.
6
+ *
7
+ * The index is a tree of TURNS, not of sessions. A DSH fork copies a prefix of
8
+ * the parent log verbatim, so a turn below `inheritedEventCount` is the same
9
+ * turn in every descendant. Canonicalising those turns to their owning session
10
+ * makes the shared trunk appear once and makes a fork a child of the turn it
11
+ * was cut from. That is the whole data model.
12
+ *
13
+ * The client asks for the family of one session, then either clicks a node
14
+ * (fork at that turn's closing seq) or opens an existing branch. Forking and
15
+ * archive/unarchive go through the normal Client session services; this route
16
+ * never creates or mutates a session.
17
+ */
18
+
19
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
20
+ import { homedir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ const ROUTE = '/treecom'
24
+ const STATE_VERSION = 1
25
+ const SNAPSHOT_TTL_MS = 15_000
26
+ const SNAPSHOT_CACHE_MAX = 64
27
+ const TREE_CACHE_MAX = 16
28
+ const LABEL_LIMIT = 120
29
+ const DETAIL_LIMIT = 24_000
30
+
31
+ export const name = 'treecom'
32
+ export const inject = ['webServer', 'sessionQuery', 'sessions']
33
+
34
+ export function apply(ctx) {
35
+ ctx.effect(() => ctx.webServer.register({
36
+ kind: 'exact',
37
+ path: ROUTE,
38
+ handler: (request, response) => handle(ctx, request, response),
39
+ }), 'treecom: index route')
40
+ }
41
+
42
+ /* --------------------------------------------------------------- route -- */
43
+
44
+ async function handle(ctx, request, response) {
45
+ try {
46
+ if (request.method === 'GET') {
47
+ const url = new URL(request.url ?? ROUTE, 'http://treecom.local')
48
+ const sessionId = url.searchParams.get('sessionId')
49
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
50
+ respond(response, 400, { error: 'sessionId 必须是非空字符串' })
51
+ return
52
+ }
53
+ const fresh = url.searchParams.get('fresh') === '1'
54
+ respond(response, 200, await buildTree(ctx, sessionId, fresh))
55
+ return
56
+ }
57
+ if (request.method === 'POST') {
58
+ const body = await readJson(request)
59
+ if (body !== null && typeof body === 'object' && body.action === 'label') {
60
+ const nodeId = typeof body.nodeId === 'string' ? body.nodeId : ''
61
+ if (nodeId.length === 0) {
62
+ respond(response, 400, { error: 'nodeId 必须是非空字符串' })
63
+ return
64
+ }
65
+ const label = normalizeLabel(body.label)
66
+ const state = await updateState((current) => {
67
+ if (label.length === 0) delete current.labels[nodeId]
68
+ else current.labels[nodeId] = label
69
+ return current
70
+ })
71
+ respond(response, 200, { ok: true, labels: state.labels })
72
+ return
73
+ }
74
+ if (body !== null && typeof body === 'object' && body.action === 'lineage') {
75
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
76
+ if (sessionId.length === 0) {
77
+ respond(response, 400, { error: 'sessionId 必须是非空字符串' })
78
+ return
79
+ }
80
+ respond(response, 200, await buildLineage(ctx, sessionId))
81
+ return
82
+ }
83
+ if (body !== null && typeof body === 'object' && body.action === 'forest') {
84
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
85
+ if (sessionId.length === 0) {
86
+ respond(response, 400, { error: 'sessionId 必须是非空字符串' })
87
+ return
88
+ }
89
+ respond(response, 200, await buildForest(ctx, sessionId))
90
+ return
91
+ }
92
+ if (body !== null && typeof body === 'object' && body.action === 'detail') {
93
+ const nodeId = typeof body.nodeId === 'string' ? body.nodeId : ''
94
+ if (nodeId.length === 0) {
95
+ respond(response, 400, { error: 'nodeId 必须是非空字符串' })
96
+ return
97
+ }
98
+ const detail = await buildDetail(ctx, nodeId)
99
+ if (detail === null) {
100
+ respond(response, 404, { error: '找不到这个节点' })
101
+ return
102
+ }
103
+ respond(response, 200, { ok: true, detail })
104
+ return
105
+ }
106
+ respond(response, 400, { error: '未知操作' })
107
+ return
108
+ }
109
+ response.writeHead(405)
110
+ response.end()
111
+ } catch (error) {
112
+ respond(response, 500, { error: error instanceof Error ? error.message : String(error) })
113
+ }
114
+ }
115
+
116
+ function respond(response, status, value) {
117
+ response.writeHead(status, {
118
+ 'content-type': 'application/json; charset=utf-8',
119
+ 'cache-control': 'no-store',
120
+ })
121
+ response.end(JSON.stringify(value))
122
+ }
123
+
124
+ function readJson(request) {
125
+ return new Promise((resolve, reject) => {
126
+ const chunks = []
127
+ request.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))))
128
+ request.on('end', () => {
129
+ try {
130
+ const text = Buffer.concat(chunks).toString('utf8')
131
+ resolve(text.length === 0 ? null : JSON.parse(text))
132
+ } catch (error) {
133
+ reject(new TypeError('请求体必须是 JSON'))
134
+ }
135
+ })
136
+ request.on('error', reject)
137
+ })
138
+ }
139
+
140
+ /* ---------------------------------------------------------------- tree -- */
141
+
142
+ /** Every catalogue session in the folder, with its root-session identity. */
143
+ async function buildLineage(ctx, sessionId) {
144
+ const records = await ctx.sessionQuery.listSessions()
145
+ const byId = new Map(records.map((record) => [record.header.id, record]))
146
+ let target = byId.get(sessionId)
147
+ if (target === undefined) {
148
+ const snapshot = await ctx.sessionQuery.readSession(sessionId)
149
+ target = { header: snapshot.session }
150
+ }
151
+ const cwd = target.header.cwd ?? null
152
+ const sessions = []
153
+ for (const record of records) {
154
+ const header = record.header
155
+ if (header.origin === 'subagent') continue
156
+ if (cwd !== null && header.cwd !== cwd) continue
157
+ const root = rootOf(record, byId)
158
+ sessions.push({
159
+ id: header.id,
160
+ parentId: header.parentSession ?? null,
161
+ rootId: root.header.id,
162
+ title: typeof record.title === 'string' && record.title.trim() !== ''
163
+ ? record.title.trim()
164
+ : (typeof header.title === 'string' && header.title.trim() !== '' ? header.title.trim() : shortId(header.id)),
165
+ })
166
+ }
167
+ return { ok: true, sessionId, cwd, sessions }
168
+ }
169
+
170
+ /**
171
+ * All root sessions that belong to the same folder (cwd) as `sessionId`.
172
+ * Trees are derived from session ancestry; a promoted tree is a client-side
173
+ * view root pointing at a node in one of these families.
174
+ */
175
+ async function buildForest(ctx, sessionId) {
176
+ const records = await ctx.sessionQuery.listSessions()
177
+ const byId = new Map(records.map((record) => [record.header.id, record]))
178
+ let target = byId.get(sessionId)
179
+ if (target === undefined) {
180
+ const snapshot = await ctx.sessionQuery.readSession(sessionId)
181
+ target = { header: snapshot.session }
182
+ }
183
+ const cwd = target.header.cwd ?? null
184
+ const roots = []
185
+ for (const record of records) {
186
+ const header = record.header
187
+ if (header.origin === 'subagent') continue
188
+ if (cwd !== null && header.cwd !== cwd) continue
189
+ const parent = header.parentSession
190
+ if (parent !== undefined && byId.has(parent)) continue
191
+ roots.push({
192
+ id: header.id,
193
+ title: typeof record.title === 'string' && record.title.trim() !== ''
194
+ ? record.title.trim()
195
+ : (typeof header.title === 'string' && header.title.trim() !== '' ? header.title.trim() : shortId(header.id)),
196
+ createdAt: header.createdAt ?? null,
197
+ active: header.id === sessionId,
198
+ })
199
+ }
200
+ roots.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
201
+ const idsByRoot = new Map()
202
+ for (const record of records) {
203
+ const header = record.header
204
+ if (header.origin === 'subagent') continue
205
+ if (cwd !== null && header.cwd !== cwd) continue
206
+ const owner = rootOf(record, byId).header.id
207
+ const list = idsByRoot.get(owner)
208
+ if (list === undefined) idsByRoot.set(owner, [header.id])
209
+ else list.push(header.id)
210
+ }
211
+ await Promise.all(roots.map(async (root) => {
212
+ root.sessionIds = idsByRoot.get(root.id) ?? [root.id]
213
+ root.sessionCount = root.sessionIds.length
214
+ const record = byId.get(root.id)
215
+ if (record === undefined) return
216
+ try {
217
+ const snapshot = await readSnapshot(ctx, record)
218
+ const turns = cachedTurns(root.id, snapshot.events)
219
+ root.title = titleOf(snapshot.events) || firstPrompt(turns) || shortId(root.id)
220
+ } catch {
221
+ /* keep the catalogue title / short id */
222
+ }
223
+ }))
224
+ return { ok: true, sessionId, cwd, roots }
225
+ }
226
+
227
+ function familyRevision(ctx, family, state, archived, sessionId, rootId) {
228
+ const sessions = family.map((record) => {
229
+ const live = ctx.sessions.get(record.header.id)
230
+ const updatedAt = record.updatedAt ?? record.header.updatedAt ?? ''
231
+ const title = record.title ?? record.header.title ?? ''
232
+ return `${record.header.id}:${updatedAt}:${title}:${live?.seq ?? ''}:${isRunning(ctx, record.header.id) ? 'r' : '-'}`
233
+ })
234
+ return `${sessionId}|${rootId}|${sessions.join('|')}|${JSON.stringify(state.labels)}|${[...archived].sort().join(',')}`
235
+ }
236
+
237
+ async function buildTree(ctx, sessionId, fresh = false) {
238
+ const records = await ctx.sessionQuery.listSessions()
239
+ const byId = new Map(records.map((record) => [record.header.id, record]))
240
+ let target = byId.get(sessionId)
241
+ if (target === undefined) {
242
+ // A freshly forked child can be readable before the catalogue lists it.
243
+ // Read it once and splice it into this request's family.
244
+ const snapshot = await ctx.sessionQuery.readSession(sessionId)
245
+ target = { header: snapshot.session, live: false, persisted: true }
246
+ records.push(target)
247
+ byId.set(sessionId, target)
248
+ }
249
+
250
+ const root = rootOf(target, byId)
251
+ const family = familyOf(root, records)
252
+ if (!family.some((record) => record.header.id === sessionId)) family.push(target)
253
+
254
+ const state = await readState()
255
+ const archived = new Set(ctx.get?.('workspaceRegistry')?.archivedSessionIds ?? [])
256
+ const rootId = root.header.id
257
+ const revision = familyRevision(ctx, family, state, archived, sessionId, rootId)
258
+ const reliable = family.every((record) => ctx.sessions.get(record.header.id) !== undefined
259
+ || (record.updatedAt ?? record.header.updatedAt) !== undefined)
260
+ const cacheTtl = reliable ? Infinity : SNAPSHOT_TTL_MS
261
+ const cachedTree = fresh ? undefined : treeCache.get(sessionId)
262
+ if (cachedTree !== undefined && cachedTree.key === revision && Date.now() - cachedTree.at < cachedTree.ttl) {
263
+ treeCache.delete(sessionId)
264
+ treeCache.set(sessionId, cachedTree)
265
+ return cachedTree.payload
266
+ }
267
+
268
+ const snapshots = new Map()
269
+ const errors = []
270
+ await Promise.all(family.map(async (record) => {
271
+ try {
272
+ const snapshot = await readSnapshot(ctx, record, fresh)
273
+ snapshots.set(record.header.id, snapshot)
274
+ } catch (error) {
275
+ errors.push({ sessionId: record.header.id, message: messageOf(error) })
276
+ }
277
+ }))
278
+ if (!snapshots.has(sessionId)) {
279
+ const detail = errors.find((entry) => entry.sessionId === sessionId)
280
+ throw new Error(detail === undefined ? `读不到会话 ${sessionId}` : `读不到会话 ${sessionId}: ${detail.message}`)
281
+ }
282
+
283
+ const turnsBySession = new Map()
284
+ for (const [id, snapshot] of snapshots) turnsBySession.set(id, cachedTurns(id, snapshot.events))
285
+
286
+ const rootSnapshot = snapshots.get(rootId)
287
+ const treeTitle = rootSnapshot === undefined
288
+ ? shortId(rootId)
289
+ : titleOf(rootSnapshot.events) || firstPrompt(turnsBySession.get(rootId) ?? []) || shortId(rootId)
290
+
291
+ const nodes = []
292
+ const nodeById = new Map()
293
+ const rootNode = {
294
+ id: `${rootId}:root`,
295
+ kind: 'root',
296
+ sessionId: rootId,
297
+ turn: 0,
298
+ startSeq: 0,
299
+ endSeq: 0,
300
+ parentId: null,
301
+ label: treeTitle,
302
+ prompt: '',
303
+ answer: '',
304
+ time: root.header.createdAt,
305
+ order: 0,
306
+ }
307
+ nodes.push(rootNode)
308
+ nodeById.set(rootNode.id, rootNode)
309
+
310
+ for (const record of family) {
311
+ const id = record.header.id
312
+ const snapshot = snapshots.get(id)
313
+ if (snapshot === undefined) continue
314
+ const turns = turnsBySession.get(id) ?? []
315
+ let previousId = rootNode.id
316
+ for (const turn of turns) {
317
+ const owner = ownerOf(id, turn.startSeq, byId, snapshots)
318
+ const nodeId = `${owner}:${turn.startSeq}`
319
+ let node = nodeById.get(nodeId)
320
+ if (node === undefined) {
321
+ node = {
322
+ id: nodeId,
323
+ kind: 'turn',
324
+ sessionId: owner,
325
+ turn: turn.turn,
326
+ startSeq: turn.startSeq,
327
+ endSeq: turn.endSeq,
328
+ parentId: previousId,
329
+ label: turn.prompt === '' ? `第 ${turn.turn} 轮` : turn.prompt,
330
+ prompt: turn.prompt,
331
+ answer: turn.answer,
332
+ contextTokens: turn.contextTokens ?? 0,
333
+ contextWindow: turn.contextWindow ?? 0,
334
+ time: turn.time,
335
+ order: turn.startSeq,
336
+ ...turn.open === true ? { open: true } : {},
337
+ }
338
+ nodes.push(node)
339
+ nodeById.set(nodeId, node)
340
+ } else {
341
+ // A running turn keeps growing; refresh the mutable fields instead of
342
+ // freezing the first snapshot the panel happened to see.
343
+ node.endSeq = turn.endSeq
344
+ node.label = turn.prompt === '' ? `第 ${turn.turn} 轮` : turn.prompt
345
+ node.prompt = turn.prompt
346
+ node.answer = turn.answer
347
+ node.contextTokens = turn.contextTokens ?? 0
348
+ node.contextWindow = turn.contextWindow ?? 0
349
+ node.time = turn.time
350
+ node.open = turn.open === true
351
+ }
352
+ previousId = nodeId
353
+ }
354
+ const ownTurns = turns.filter((turn) => turn.startSeq >= snapshot.inheritedEventCount)
355
+ if (ownTurns.length === 0 && id !== rootId) {
356
+ const stub = blankStub({ record, snapshot, nodes, byId, snapshots, rootNode })
357
+ if (stub !== null && !nodeById.has(stub.id)) {
358
+ nodes.push(stub)
359
+ nodeById.set(stub.id, stub)
360
+ }
361
+ }
362
+ }
363
+
364
+ for (const node of nodes) {
365
+ if (node.kind !== 'root' && typeof state.labels[node.id] === 'string') node.label = state.labels[node.id]
366
+ }
367
+
368
+ const sessions = family
369
+ .filter((record) => snapshots.has(record.header.id))
370
+ .map((record) => {
371
+ const snapshot = snapshots.get(record.header.id)
372
+ const turns = turnsBySession.get(record.header.id) ?? []
373
+ return {
374
+ id: record.header.id,
375
+ parentSessionId: record.header.parentSession,
376
+ title: titleOf(snapshot.events) || firstPrompt(turns) || shortId(record.header.id),
377
+ createdAt: record.header.createdAt,
378
+ inheritedEventCount: snapshot.inheritedEventCount,
379
+ turnCount: turns.length,
380
+ running: isRunning(ctx, record.header.id),
381
+ blank: turns.length === 0,
382
+ archived: archived.has(record.header.id),
383
+ active: record.header.id === sessionId,
384
+ }
385
+ })
386
+
387
+ const payload = {
388
+ ok: true,
389
+ sessionId,
390
+ rootId,
391
+ cwd: root.header.cwd ?? null,
392
+ treeTitle,
393
+ nodes,
394
+ sessions,
395
+ labels: state.labels,
396
+ archivedSessionIds: [...archived],
397
+ errors,
398
+ }
399
+ if (errors.length === 0) {
400
+ treeCache.set(sessionId, { key: revision, at: Date.now(), ttl: cacheTtl, payload })
401
+ while (treeCache.size > TREE_CACHE_MAX) {
402
+ const oldest = treeCache.keys().next().value
403
+ if (oldest === undefined) break
404
+ treeCache.delete(oldest)
405
+ }
406
+ }
407
+ return payload
408
+ }
409
+
410
+ /** The topmost surviving ancestor. A missing parent ends the walk at the last known session. */
411
+ function rootOf(target, byId) {
412
+ let current = target
413
+ const seen = new Set()
414
+ while (current.header.parentSession !== undefined) {
415
+ if (seen.has(current.header.id)) break
416
+ seen.add(current.header.id)
417
+ const parent = byId.get(current.header.parentSession)
418
+ if (parent === undefined) break
419
+ current = parent
420
+ }
421
+ return current
422
+ }
423
+
424
+ /** Root and every non-subagent descendant, parents before children. */
425
+ function familyOf(root, records) {
426
+ const children = new Map()
427
+ for (const record of records) {
428
+ const parent = record.header.parentSession
429
+ if (parent === undefined) continue
430
+ const list = children.get(parent) ?? []
431
+ list.push(record)
432
+ children.set(parent, list)
433
+ }
434
+ const family = []
435
+ const seen = new Set()
436
+ const queue = [root]
437
+ while (queue.length > 0) {
438
+ const record = queue.shift()
439
+ if (seen.has(record.header.id)) continue
440
+ seen.add(record.header.id)
441
+ if (record.header.origin === 'subagent') continue
442
+ family.push(record)
443
+ for (const child of children.get(record.header.id) ?? []) queue.push(child)
444
+ }
445
+ return family
446
+ }
447
+
448
+ /**
449
+ * The session that actually owns one event seq. Below `inheritedEventCount`
450
+ * the child's events are a verbatim copy of the parent's, so the owner is the
451
+ * first ancestor whose own log still contains that seq.
452
+ */
453
+ function ownerOf(sessionId, seq, byId, snapshots) {
454
+ let current = sessionId
455
+ const seen = new Set()
456
+ for (;;) {
457
+ if (seen.has(current)) return current
458
+ seen.add(current)
459
+ const snapshot = snapshots.get(current)
460
+ const parentId = byId.get(current)?.header.parentSession
461
+ if (snapshot === undefined || seq >= snapshot.inheritedEventCount) return current
462
+ if (parentId === undefined || !byId.has(parentId) || !snapshots.has(parentId)) return current
463
+ current = parentId
464
+ }
465
+ }
466
+
467
+ /**
468
+ * A forked child that has not asked anything yet is still a branch. Hang it
469
+ * under the turn it was cut from, so the panel can show "新分支 · 等待提问".
470
+ */
471
+ function blankStub({ record, snapshot, nodes, byId, snapshots, rootNode }) {
472
+ const cut = snapshot.inheritedEventCount
473
+ let parentNode = rootNode
474
+ for (let index = cut - 1; index >= 0; index--) {
475
+ const event = snapshot.events[index]
476
+ if (event?.type !== 'turn/end') continue
477
+ const owner = ownerOf(record.header.id, event.seq, byId, snapshots)
478
+ const candidate = nodes.find((node) => node.sessionId === owner && node.endSeq === event.seq)
479
+ if (candidate !== undefined) {
480
+ parentNode = candidate
481
+ break
482
+ }
483
+ }
484
+ const ownPrompt = firstOwnPrompt(snapshot.events, cut)
485
+ const last = snapshot.events.at(-1)
486
+ return {
487
+ id: `${record.header.id}:blank`,
488
+ kind: 'blank',
489
+ sessionId: record.header.id,
490
+ turn: 0,
491
+ startSeq: cut,
492
+ endSeq: typeof last?.seq === 'number' ? last.seq : cut,
493
+ parentId: parentNode.id,
494
+ label: ownPrompt === '' ? '新分支' : ownPrompt,
495
+ prompt: ownPrompt,
496
+ answer: '',
497
+ contextTokens: parentNode.contextTokens ?? 0,
498
+ contextWindow: parentNode.contextWindow ?? 0,
499
+ time: last?.time ?? record.header.createdAt,
500
+ order: typeof last?.seq === 'number' ? last.seq : cut,
501
+ blank: true,
502
+ ...ownPrompt === '' ? {} : { open: true },
503
+ }
504
+ }
505
+
506
+ const turnCache = new Map()
507
+
508
+ /** Parse once per log revision; the tree panel polls while a branch is open. */
509
+ function cachedTurns(sessionId, events) {
510
+ const last = events.at(-1)?.seq ?? -1
511
+ const hit = turnCache.get(sessionId)
512
+ if (hit !== undefined && hit.last === last) return hit.turns
513
+ const turns = turnsOf(events)
514
+ turnCache.set(sessionId, { last, turns })
515
+ return turns
516
+ }
517
+
518
+ /** Extract completed and open turns from one log. Prompts are human-authored only. */
519
+ function turnsOf(events) {
520
+ const turns = []
521
+ let current = null
522
+ let contextWindow = 0
523
+ let lastUsage = null
524
+ let lastUsageTokens = 0
525
+ for (const event of events) {
526
+ if (event.type === 'request/context') {
527
+ const value = Number(event.data?.contextWindow)
528
+ if (Number.isFinite(value) && value > 0) {
529
+ contextWindow = value
530
+ if (current !== null) current.contextWindow = value
531
+ }
532
+ continue
533
+ }
534
+ if (event.type === 'turn/start') {
535
+ current = {
536
+ turn: event.data.turn,
537
+ startSeq: event.seq,
538
+ endSeq: null,
539
+ time: event.time,
540
+ prompt: '',
541
+ promptFull: '',
542
+ answer: '',
543
+ answerFull: '',
544
+ usage: null,
545
+ contextTokens: lastUsageTokens,
546
+ contextWindow,
547
+ open: true,
548
+ }
549
+ continue
550
+ }
551
+ if (current === null) continue
552
+ if (event.type === 'user/message') {
553
+ if (current.prompt === '' && event.data?.source?.kind === 'user') {
554
+ const full = rawTextOf(event.data.content)
555
+ current.prompt = preview(full, 140)
556
+ current.promptFull = limitText(full, DETAIL_LIMIT)
557
+ }
558
+ continue
559
+ }
560
+ if (event.type === 'assistant/message') {
561
+ const usage = normalizeUsage(event.data?.usage ?? event.data?.message?.usage)
562
+ if (usage !== null) {
563
+ current.usage = usage
564
+ lastUsage = usage
565
+ lastUsageTokens = usage.totalTokens
566
+ current.contextTokens = usage.totalTokens
567
+ }
568
+ const full = rawTextOf(event.data?.message?.content)
569
+ if (full !== '') {
570
+ current.answer = preview(full, 220)
571
+ current.answerFull = limitText(full, DETAIL_LIMIT)
572
+ }
573
+ continue
574
+ }
575
+ if (event.type === 'turn/end' && event.data?.turn === current.turn) {
576
+ current.endSeq = event.seq
577
+ current.time = event.time
578
+ current.open = false
579
+ turns.push(current)
580
+ current = null
581
+ }
582
+ }
583
+ if (current !== null) {
584
+ current.endSeq = events.at(-1)?.seq ?? current.startSeq
585
+ turns.push(current)
586
+ }
587
+ if (contextWindow > 0) {
588
+ for (const turn of turns) {
589
+ if (!Number.isFinite(turn.contextWindow) || turn.contextWindow <= 0) turn.contextWindow = contextWindow
590
+ }
591
+ }
592
+ return turns
593
+ }
594
+
595
+ function normalizeUsage(value) {
596
+ if (value === null || typeof value !== 'object') return null
597
+ const inputTokens = Number(value.inputTokens)
598
+ const outputTokens = Number(value.outputTokens)
599
+ const cacheReadTokens = Number(value.cacheReadTokens)
600
+ const cacheWriteTokens = Number(value.cacheWriteTokens ?? 0)
601
+ const total = Number(value.totalTokens)
602
+ if (![inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens].every(Number.isFinite)) return null
603
+ return {
604
+ inputTokens,
605
+ outputTokens,
606
+ cacheReadTokens,
607
+ cacheWriteTokens,
608
+ totalTokens: Number.isFinite(total) && total > 0 ? total : inputTokens + outputTokens + cacheReadTokens,
609
+ }
610
+ }
611
+
612
+ function firstOwnPrompt(events, cut) {
613
+ for (const event of events) {
614
+ if (event.seq < cut || event.type !== 'user/message') continue
615
+ if (event.data?.source?.kind !== 'user') continue
616
+ const text = preview(textOf(event.data.content), 140)
617
+ if (text !== '') return text
618
+ }
619
+ return ''
620
+ }
621
+ function firstOwnPromptFull(events, cut) {
622
+ for (const event of events) {
623
+ if (event.seq < cut || event.type !== 'user/message') continue
624
+ if (event.data?.source?.kind !== 'user') continue
625
+ const text = limitText(rawTextOf(event.data.content), DETAIL_LIMIT)
626
+ if (text !== '') return text
627
+ }
628
+ return ''
629
+ }
630
+
631
+ async function readSessionView(ctx, sessionId) {
632
+ let record = null
633
+ try {
634
+ const records = await ctx.sessionQuery.listSessions()
635
+ record = records.find((candidate) => candidate.header.id === sessionId) ?? null
636
+ } catch {
637
+ record = null
638
+ }
639
+ if (record !== null) {
640
+ try {
641
+ return { record, snapshot: await readSnapshot(ctx, record) }
642
+ } catch {
643
+ /* fall through to the single-session read */
644
+ }
645
+ }
646
+ try {
647
+ const snapshot = await ctx.sessionQuery.readSession(sessionId)
648
+ return {
649
+ record: { header: snapshot.session, live: false, persisted: true },
650
+ snapshot,
651
+ }
652
+ } catch {
653
+ return null
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Full Q/A text for the hover card, read on demand. The tree payload only
659
+ * carries previews, so a long answer never inflates every poll.
660
+ */
661
+ async function buildDetail(ctx, nodeId) {
662
+ if (nodeId.endsWith(':root')) return null
663
+ if (nodeId.endsWith(':blank')) {
664
+ const sessionId = nodeId.slice(0, -':blank'.length)
665
+ const view = await readSessionView(ctx, sessionId)
666
+ if (view === null) return null
667
+ const { record, snapshot } = view
668
+ const turns = cachedTurns(sessionId, snapshot.events)
669
+ const lastTurn = turns.at(-1) ?? null
670
+ const last = snapshot.events.at(-1)
671
+ const prompt = firstOwnPromptFull(snapshot.events, snapshot.inheritedEventCount)
672
+ return {
673
+ nodeId,
674
+ kind: 'blank',
675
+ sessionId,
676
+ turn: 0,
677
+ startSeq: snapshot.inheritedEventCount,
678
+ endSeq: typeof last?.seq === 'number' ? last.seq : snapshot.inheritedEventCount,
679
+ prompt,
680
+ answer: '',
681
+ time: last?.time ?? record.header.createdAt,
682
+ contextTokens: lastTurn?.contextTokens ?? 0,
683
+ contextWindow: lastTurn?.contextWindow ?? 0,
684
+ truncated: prompt.length >= DETAIL_LIMIT,
685
+ }
686
+ }
687
+ const separator = nodeId.lastIndexOf(':')
688
+ if (separator <= 0) return null
689
+ const sessionId = nodeId.slice(0, separator)
690
+ const startSeq = Number(nodeId.slice(separator + 1))
691
+ if (!Number.isSafeInteger(startSeq) || startSeq < 0) return null
692
+ const view = await readSessionView(ctx, sessionId)
693
+ if (view === null) return null
694
+ const turns = cachedTurns(sessionId, view.snapshot.events)
695
+ const turn = turns.find((candidate) => candidate.startSeq === startSeq)
696
+ if (turn === undefined) return null
697
+ const prompt = turn.promptFull === '' ? turn.prompt : turn.promptFull
698
+ return {
699
+ nodeId,
700
+ kind: 'turn',
701
+ sessionId,
702
+ turn: turn.turn,
703
+ startSeq: turn.startSeq,
704
+ endSeq: turn.endSeq,
705
+ prompt,
706
+ answer: turn.answerFull,
707
+ time: turn.time,
708
+ contextTokens: turn.contextTokens ?? 0,
709
+ contextWindow: turn.contextWindow ?? 0,
710
+ truncated: (turn.promptFull !== undefined && turn.promptFull.length >= DETAIL_LIMIT)
711
+ || (turn.answerFull !== undefined && turn.answerFull.length >= DETAIL_LIMIT),
712
+ }
713
+ }
714
+
715
+ function limitText(text, limit) {
716
+ const value = String(text).trim()
717
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
718
+ }
719
+
720
+ function rawTextOf(content) {
721
+ if (!Array.isArray(content)) return ''
722
+ const parts = []
723
+ for (const block of content) {
724
+ if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
725
+ }
726
+ return parts.join('\n').trim()
727
+ }
728
+
729
+ function firstPrompt(turns) {
730
+ for (const turn of turns) if (turn.prompt !== '') return turn.prompt
731
+ return ''
732
+ }
733
+
734
+ function titleOf(events) {
735
+ for (let index = events.length - 1; index >= 0; index--) {
736
+ const event = events[index]
737
+ if (event.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.trim() !== '') {
738
+ return event.data.title.trim()
739
+ }
740
+ }
741
+ return ''
742
+ }
743
+
744
+ function textOf(content) {
745
+ if (!Array.isArray(content)) return ''
746
+ const parts = []
747
+ for (const block of content) {
748
+ if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
749
+ }
750
+ return collapse(parts.join(' '))
751
+ }
752
+
753
+ function collapse(text) {
754
+ return text.replace(/\s+/g, ' ').trim()
755
+ }
756
+
757
+ function preview(text, limit) {
758
+ const value = collapse(text)
759
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
760
+ }
761
+
762
+ function shortId(id) {
763
+ return String(id).replace(/^session-/, '').slice(0, 8)
764
+ }
765
+
766
+ function isRunning(ctx, sessionId) {
767
+ try {
768
+ const agent = ctx.get?.('agents')?.get(sessionId)
769
+ return agent?.phase?.kind === 'running'
770
+ } catch {
771
+ return false
772
+ }
773
+ }
774
+
775
+ /* ---------------------------------------------------------- snapshots -- */
776
+
777
+ const snapshotCache = new Map()
778
+ const treeCache = new Map()
779
+
780
+ async function readSnapshot(ctx, record, force = false) {
781
+ const id = record.header.id
782
+ const live = ctx.sessions.get(id)
783
+ const updatedAt = record.updatedAt ?? record.header.updatedAt
784
+ const key = live === undefined
785
+ ? `persisted:${updatedAt ?? 'unknown'}`
786
+ : `live:${live.seq}`
787
+ const ttl = live === undefined && updatedAt === undefined ? SNAPSHOT_TTL_MS : Infinity
788
+ const cached = snapshotCache.get(id)
789
+ if (force !== true && cached !== undefined && cached.key === key && Date.now() - cached.at < ttl) {
790
+ snapshotCache.delete(id)
791
+ snapshotCache.set(id, cached)
792
+ return cached.snapshot
793
+ }
794
+ const snapshot = live === undefined
795
+ ? await ctx.sessionQuery.readSession(id)
796
+ : {
797
+ session: live.header,
798
+ header: live.header,
799
+ inheritedEventCount: live.inheritedEventCount,
800
+ events: live.snapshotEvents(),
801
+ }
802
+ snapshotCache.set(id, { key, at: Date.now(), snapshot })
803
+ while (snapshotCache.size > SNAPSHOT_CACHE_MAX) {
804
+ const oldest = snapshotCache.keys().next().value
805
+ if (oldest === undefined) break
806
+ snapshotCache.delete(oldest)
807
+ }
808
+ return snapshot
809
+ }
810
+
811
+ /* -------------------------------------------------------------- state -- */
812
+
813
+ let stateQueue = Promise.resolve()
814
+
815
+ function stateFile() {
816
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
817
+ return join(home, 'storages', 'treecom', 'state.json')
818
+ }
819
+
820
+ async function readState() {
821
+ try {
822
+ const parsed = JSON.parse(await readFile(stateFile(), 'utf8'))
823
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return emptyState()
824
+ const labels = parsed.labels !== null && typeof parsed.labels === 'object' && !Array.isArray(parsed.labels)
825
+ ? parsed.labels
826
+ : {}
827
+ return { version: STATE_VERSION, labels }
828
+ } catch {
829
+ return emptyState()
830
+ }
831
+ }
832
+
833
+ function emptyState() {
834
+ return { version: STATE_VERSION, labels: {} }
835
+ }
836
+
837
+ function updateState(mutate) {
838
+ const run = stateQueue.then(async () => {
839
+ const current = await readState()
840
+ const next = mutate(current)
841
+ const file = stateFile()
842
+ await mkdir(dirname(file), { recursive: true })
843
+ await writeFile(file, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
844
+ return next
845
+ })
846
+ stateQueue = run.catch(() => {})
847
+ return run
848
+ }
849
+
850
+ function normalizeLabel(value) {
851
+ if (typeof value !== 'string') return ''
852
+ return collapse(value).slice(0, LABEL_LIMIT)
853
+ }
854
+
855
+ function messageOf(error) {
856
+ return error instanceof Error ? error.message : String(error)
857
+ }