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/LICENSE +21 -0
- package/README.md +131 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +14 -0
- package/index.js +857 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +55 -4
- package/plugin.client.js +4171 -0
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
|
+
}
|