dsh-plugin-message-edit 1.0.0 → 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/lib/index.js CHANGED
@@ -1,830 +1,829 @@
1
- // dsh-plugin-message-tree — host half.
2
- //
3
- // Owns the /message-tree HTTP route. POST builds a version branch: a new
4
- // session seeded with every event BEFORE the edited turn (true rewind, unlike
5
- // the client-side session fork which cuts after a turn), a durable
6
- // `message-tree/version` marker naming what changed, and the edited prompt
7
- // queued for a fresh answer. GET projects the whole version tree for the
8
- // client's ‹ › rings and tree view.
9
- //
10
- // The branch-transaction shape is derived from dsh-message-edit
11
- // (MIT © Moeblack) — simplified to ChatGPT semantics: user-message edits and
12
- // turn retries only, always truncating downstream.
13
-
14
- import { attachParentId, ancestorChainFromLog, collectFamily } from './tree-logic.js';
15
-
16
- // The package is named dsh-plugin-message-edit, but the route, the cordis id
17
- // and the durable event type below deliberately keep the `message-tree`
18
- // spelling. Moeblack's dsh-message-edit already owns `/message-edit`, the
19
- // cordis id `message-edit` and the event type `message-edit/version`; reusing
20
- // those would collide whenever both plugins are installed. Keeping ours
21
- // distinct also preserves the version links already written into existing
22
- // session logs, which name this type verbatim.
23
- /** Same-origin endpoint owned by this plugin's host half. */
24
- const MESSAGE_TREE_PATH = '/message-tree';
25
- /** Durable version-marker schema. */
26
- const MESSAGE_TREE_SCHEMA = 1;
27
-
28
- const name = 'message-tree';
29
- const inject = [
30
- 'sessions',
31
- 'agents',
32
- 'sessionPersistence',
33
- 'sessionQuery',
34
- 'webServer',
35
- // Used three ways: to flag archived versions in the tree payload (the app
36
- // cannot navigate to an archived session, so the client unarchives before
37
- // opening), to unarchive on demand, and to attach a new version to the same
38
- // sidebar workspace group as its parent. Plain string: cordis uses array
39
- // inject entries as service names directly.
40
- 'workspaceRegistry',
41
- ];
42
-
43
- /* ------------------------------------------------------------ log reading -- */
44
-
45
- /** Fold complete turn brackets; an open tail is deliberately absent. */
46
- function closedTurns(events) {
47
- const result = [];
48
- let current;
49
- for (const event of events) {
50
- if (event.type === 'turn/start') {
51
- current = { turn: event.data.turn, startSeq: event.seq };
52
- continue;
53
- }
54
- if (current === undefined) continue;
55
- if (event.type === 'user/message' && current.user === undefined && event.data.source.kind === 'user') {
56
- current.user = event;
57
- continue;
58
- }
59
- if (event.type === 'turn/end' && event.data.turn === current.turn) {
60
- result.push({ ...current, endSeq: event.seq });
61
- current = undefined;
62
- }
63
- }
64
- return result;
65
- }
66
-
67
- function userText(message) {
68
- return message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n');
69
- }
70
-
71
- function cloneUser(message, content = structuredClone(message.content)) {
72
- return Object.freeze({
73
- id: crypto.randomUUID(),
74
- role: 'user',
75
- content: Object.freeze(content),
76
- source: Object.freeze({ kind: 'user' }),
77
- });
78
- }
79
-
80
- function replaceTextBlock(content, blockIndex, text) {
81
- const block = content[blockIndex];
82
- if (block?.type !== 'text') throw new Error('所选内容块不是可编辑文本。');
83
- return content.map((candidate, index) => index === blockIndex ? { ...candidate, text } : structuredClone(candidate));
84
- }
85
-
86
- /* ---------------------------------------------------------------- planning -- */
87
-
88
- function pairVersionEffect(sourceSessionId, effect) {
89
- return {
90
- schemaVersion: MESSAGE_TREE_SCHEMA,
91
- effect: { ...effect, id: crypto.randomUUID() },
92
- inverse: { kind: 'restore-version', sessionId: sourceSessionId },
93
- };
94
- }
95
-
96
- /** Edit a user message: rewind to before its turn, queue the edited prompt. */
97
- function editPlan(operation, turns) {
98
- const turn = turns.find((candidate) => operation.eventSeq > candidate.startSeq && operation.eventSeq < candidate.endSeq);
99
- if (turn === undefined) throw new Error('所选消息不属于已落定回合。');
100
- if (turn.user === undefined || turn.user.seq !== operation.eventSeq) throw new Error('所选消息不是用户消息。');
101
- const before = turn.user.data.content[operation.blockIndex];
102
- if (before?.type !== 'text') throw new Error('所选用户消息块不是文本。');
103
- const edited = cloneUser(turn.user.data, replaceTextBlock(turn.user.data.content, operation.blockIndex, operation.text));
104
- return {
105
- boundary: turn.startSeq - 1,
106
- version: pairVersionEffect(operation.sessionId, {
107
- operation: 'edit',
108
- targetTurn: turn.turn,
109
- targetEventSeq: turn.user.seq,
110
- before: before.text,
111
- after: operation.text,
112
- }),
113
- queuedUsers: [edited],
114
- };
115
- }
116
-
117
- /** Regenerate a turn: rewind to before it, queue the original prompt again. */
118
- function retryPlan(sessionId, turnNumber, turns) {
119
- const turn = turns.find((candidate) => candidate.turn === turnNumber);
120
- if (turn?.user === undefined) throw new Error('所选回合没有可重放的用户输入。');
121
- return {
122
- boundary: turn.startSeq - 1,
123
- version: pairVersionEffect(sessionId, {
124
- operation: 'retry',
125
- targetTurn: turn.turn,
126
- targetEventSeq: turn.user.seq,
127
- before: userText(turn.user.data),
128
- }),
129
- queuedUsers: [cloneUser(turn.user.data)],
130
- };
131
- }
132
-
133
- function planOperation(operation, events) {
134
- const turns = closedTurns(events);
135
- switch (operation.action) {
136
- case 'edit': return editPlan(operation, turns);
137
- case 'retry': return retryPlan(operation.sessionId, operation.turn, turns);
138
- }
139
- }
140
-
141
- /* -------------------------------------------------------- branch creation -- */
142
-
143
- function agentOptions(events, fallback) {
144
- const config = events.findLast((event) => event.type === 'request/header')?.data.header.config;
145
- const provider = config?.provider ?? fallback?.provider;
146
- const model = config?.model ?? fallback?.model;
147
- if (provider === undefined || provider.length === 0 || model === undefined || model.length === 0) {
148
- throw new Error('无法从会话历史解析模型路由。');
149
- }
150
- const maxTokens = config?.maxTokens ?? fallback?.maxTokens;
151
- return { provider, model, ...maxTokens === undefined ? {} : { maxTokens } };
152
- }
153
-
154
- async function withSourceAgent(ctx, sessionId, operation) {
155
- let handle;
156
- let agent = ctx.agents.get(sessionId);
157
- if (agent === undefined) {
158
- const snapshot = await ctx.sessionQuery.readSession(sessionId);
159
- handle = await ctx.agents.resume({
160
- resumeSessionId: sessionId,
161
- agentOptions: agentOptions(snapshot.events),
162
- });
163
- agent = handle.agent;
164
- }
165
- try {
166
- return await agent.runMaintenance(async () => operation(agent));
167
- } finally {
168
- await handle?.dispose();
169
- }
170
- }
171
-
172
- function inheritedSeed(source, boundary) {
173
- if (boundary === -1) return [];
174
- const boundaryEvent = source.events[boundary];
175
- if (boundary < 0 || boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
176
- throw new Error('分支边界不是连续会话事件。');
177
- }
178
- return source.events.slice(0, boundary + 1);
179
- }
180
-
181
- function versionSeed(source, plan) {
182
- const events = inheritedSeed(source, plan.boundary);
183
- const inheritedLength = events.length;
184
- events.push({
185
- type: 'message-tree/version',
186
- seq: events.length,
187
- time: Date.now(),
188
- data: plan.version,
189
- // Plugin event types live outside the harness vocabulary; without this
190
- // marker the read path refuses to interpret the whole session log.
191
- ignorable: true,
192
- });
193
- return { events, inheritedLength };
194
- }
195
-
196
- function sessionPreset(session) {
197
- for (let index = session.events.length - 1; index >= 0; index -= 1) {
198
- const event = session.events[index];
199
- if (event?.type === 'agent-preset/selected') return event.data.agentPreset;
200
- }
201
- return session.header.agentPreset;
202
- }
203
-
204
- async function loadSessionRecord(ctx, sessionId) {
205
- const live = ctx.sessions.get(sessionId);
206
- if (live !== undefined) return live;
207
- return ctx.sessionQuery.readSession(sessionId);
208
- }
209
-
210
- function sessionRecordId(session) {
211
- return session.header?.id ?? session.id;
212
- }
213
-
214
- function sessionTargetTurn(session) {
215
- try {
216
- return ownVersionEvent(session.header, session.events)?.effect.targetTurn;
217
- } catch (error) {
218
- return undefined;
219
- }
220
- }
221
-
222
- /**
223
- * Repeated edits of the same message must hang off the original, not off
224
- * the previous edit. Walk the parent chain and stop at the first session
225
- * that is not itself a version of `targetTurn`.
226
- */
227
- async function resolveAttachSession(ctx, sourceSession, targetTurn) {
228
- const nodes = new Map();
229
- let session = sourceSession;
230
- const seen = new Set();
231
- while (session) {
232
- const id = sessionRecordId(session);
233
- if (id === undefined || seen.has(id)) break;
234
- seen.add(id);
235
- nodes.set(id, {
236
- targetTurn: sessionTargetTurn(session),
237
- parentSessionId: session.header.parentSession,
238
- session,
239
- });
240
- const parentId = session.header.parentSession;
241
- if (parentId === undefined || nodes.has(parentId)) break;
242
- session = await loadSessionRecord(ctx, parentId);
243
- }
244
- const byId = new Map([...nodes].map(([id, node]) => [id, {
245
- targetTurn: node.targetTurn,
246
- parentSessionId: node.parentSessionId,
247
- }]));
248
- const attachId = attachParentId(byId, sessionRecordId(sourceSession), targetTurn);
249
- return nodes.get(attachId)?.session ?? sourceSession;
250
- }
251
-
252
- async function createVersionAgent(ctx, source, childId, plan, options) {
253
- const seed = versionSeed(source, plan);
254
- const presets = ctx.get('agentPresets');
255
- const presetId = sessionPreset(source);
256
- let agentPreset;
257
- let setup;
258
- if (presets !== undefined && presetId !== undefined) {
259
- const resolved = (await presets.resolve(presetId)).id;
260
- agentPreset = resolved;
261
- setup = async (agentCtx) => { await presets.mount(agentCtx, resolved); };
262
- }
263
- const child = await ctx.agents.create({
264
- sessionId: childId,
265
- seed: seed.events,
266
- meta: {
267
- ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
268
- parentSession: source.id ?? source.header.id,
269
- seedLength: seed.inheritedLength,
270
- // NOTE: deliberately NOT `origin: 'subagent'`, and deliberately not
271
- // hidden from the sidebar at all.
272
- //
273
- // Two hiding approaches are known-bad:
274
- //
275
- // - `origin: 'subagent'` keeps versions out of the sidebar (the
276
- // workspace list filters on `origin !== 'subagent'`), but the API
277
- // proxy fences the same field: `hasApiRemoteSubagentOwner` treats
278
- // such a session as owned by subagent routing and refuses both
279
- // `session.cancel` and model selection with
280
- // agent-busy: session "..." is owned by subagent routing
281
- // so every edited or retried message became impossible to stop.
282
- // The schema accepts no third `origin` value to hide behind.
283
- //
284
- // - Archiving the child (`workspaceRegistry.archiveSession`) hides it
285
- // without fencing it, but the app cannot NAVIGATE to an archived
286
- // session: opening one bounces to the workspace picker, so edit,
287
- // retry and version switching all dead-ended on the home screen.
288
- //
289
- // Versions therefore appear in the sidebar as ordinary sessions. That
290
- // is cosmetic; stopping, model switching and navigation all work.
291
- ...agentPreset === undefined ? {} : { agentPreset },
292
- },
293
- agentOptions: options,
294
- ...setup === undefined ? {} : { setup },
295
- });
296
- try {
297
- await ctx.sessions.flush(child.agent.session);
298
- return child;
299
- } catch (error) {
300
- await child.dispose();
301
- throw error;
302
- }
303
- }
304
-
305
- async function recoverOperation(inverses) {
306
- const failures = [];
307
- for (const inverse of inverses.reverse()) {
308
- try {
309
- await inverse();
310
- } catch (error) {
311
- failures.push(error);
312
- }
313
- }
314
- if (failures.length > 0) throw new AggregateError(failures, '版本操作恢复失败。');
315
- }
316
-
317
- /**
318
- * Stop a still-running turn on `sessionId` so an edit can fork away from it.
319
- *
320
- * Two reasons this is needed. Editing forks from the source session but does
321
- * not stop it, so the superseded turn keeps streaming and keeps spending
322
- * tokens. And `runMaintenance` throws outright unless the agent is idle, which
323
- * is why editing was blocked while a turn was live at all.
324
- *
325
- * Cancelling the parent is also what stops its subagents: a subagent session is
326
- * fenced from the ordinary cancel path ("owned by subagent routing"), but the
327
- * subtree is owned by the parent's fiber and unwinds with it.
328
- *
329
- * `cancel` only signals the abort; `whenIdle` waits for the phase to actually
330
- * settle, without which the `runMaintenance` that follows can still throw.
331
- */
332
- async function stopRunningTurn(ctx, sessionId) {
333
- const agent = ctx.agents.get(sessionId);
334
- if (agent === undefined) return false;
335
- try {
336
- agent.cancel({ kind: 'user' });
337
- await agent.whenIdle();
338
- return true;
339
- } catch (error) {
340
- // An agent that was already finishing is not an error for our purposes:
341
- // the goal is only that it is no longer running.
342
- return false;
343
- }
344
- }
345
-
346
- /** Archived session ids, as a Set; empty when the registry cannot say. */
347
- function archivedSessionIdSet(ctx) {
348
- try {
349
- const ids = ctx.get('workspaceRegistry')?.archivedSessionIds;
350
- return new Set(Array.isArray(ids) ? ids : []);
351
- } catch (error) {
352
- return new Set();
353
- }
354
- }
355
-
356
- /**
357
- * Unarchive one session so the app can navigate to it.
358
- *
359
- * The app cannot open an archived session it bounces to the workspace
360
- * picker and archiving versions to declutter the sidebar is a natural thing
361
- * to do, so paging the ring onto one must unarchive it first. This dsh build
362
- * has no unarchive API anywhere (the registry's archiveSession only adds), so
363
- * this mirrors archiveSession's own state discipline: same operation queue,
364
- * same durable state write. The API proxy watches this state and broadcasts
365
- * host/archived-sessions-changed, so the sidebar updates live.
366
- */
367
- async function activateVersion(ctx, sessionId) {
368
- const registry = ctx.get('workspaceRegistry');
369
- if (registry === undefined) throw new Error('workspaceRegistry 不可用,无法取消归档。');
370
- if (!registry.archivedSessionIds.includes(sessionId)) return { unarchived: false };
371
- if (typeof registry.enqueueOperation !== 'function'
372
- || typeof registry.requireState !== 'function'
373
- || typeof registry.setState !== 'function') {
374
- throw new Error('此 dsh 版本未提供取消归档的途径。');
375
- }
376
- await registry.enqueueOperation(async () => {
377
- const state = registry.requireState();
378
- if (!state.archivedSessionIds.includes(sessionId)) return;
379
- await registry.setState({
380
- ...state,
381
- archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
382
- });
383
- });
384
- return { unarchived: true };
385
- }
386
-
387
- /**
388
- * Keep a version in the same sidebar workspace group as its tree parent.
389
- * Versions inherit the parent's cwd but were never attached to its workspace,
390
- * so they showed up as stray ungrouped rows. Failure is cosmetic — the
391
- * version works either way — so it never fails the edit.
392
- */
393
- async function attachToParentWorkspace(ctx, parentId, childId) {
394
- try {
395
- const registry = ctx.get('workspaceRegistry');
396
- const workspace = registry?.list().find((w) => w.sessionIds.includes(parentId));
397
- if (workspace !== undefined) await workspace.attachSession(childId);
398
- } catch (error) {}
399
- }
400
-
401
- /** All message-tree/version events of one session's full log, in seq order. */
402
- async function versionMarkers(ctx, sessionId) {
403
- const { events } = await ctx.sessionQuery.readSession(sessionId);
404
- return events.filter((event) => event.type === 'message-tree/version');
405
- }
406
-
407
- /**
408
- * Assemble one conversation family, bridging sessions the user has deleted.
409
- *
410
- * dsh's traceSession stops at the first missing parent, so deleting one
411
- * version used to fragment the family: siblings of a deleted original lost
412
- * their ‹k/N› counters entirely, and everything below a deleted chain link
413
- * vanished from the Versions tree. But every version's seed inherits its
414
- * ancestors' `message-tree/version` markers, so a deleted ancestor's identity,
415
- * parent and target turn all survive in its descendants' logs. This walks
416
- * surviving headers where possible and recovers the rest from those markers,
417
- * emitting ghost entries for the deleted sessions so the tree stays whole.
418
- *
419
- * Families never span working directories (a version inherits its source's
420
- * cwd), so orphan logs outside the target's cwd are never read.
421
- *
422
- * @returns { rootId, flat: [{ entry, depth }], recordsById } where each entry
423
- * is { id, parentId?, createdAt, ghost, marker? }.
424
- */
425
- async function assembleFamily(ctx, sessionId) {
426
- const records = await ctx.sessionQuery.listSessions();
427
- const recordsById = new Map(records.map((record) => [record.header.id, record]));
428
- const target = recordsById.get(sessionId);
429
- if (target === undefined) throw new Error(`session "${sessionId}" not found`);
430
-
431
- const ghostInfo = new Map();
432
- const absorbChain = (chain) => {
433
- for (let i = 0; i < chain.length; i++) {
434
- const link = chain[i];
435
- if (recordsById.has(link.sessionId)) continue;
436
- const info = ghostInfo.get(link.sessionId) ?? {};
437
- if (link.marker !== undefined) info.marker = link.marker;
438
- if (i + 1 < chain.length) info.parentId = chain[i + 1].sessionId;
439
- ghostInfo.set(link.sessionId, info);
440
- }
441
- };
442
-
443
- // The target's root: surviving headers first, the log bridge at a hole.
444
- let rootId;
445
- {
446
- const seen = new Set();
447
- let cursor = target.header;
448
- while (cursor.parentSession !== undefined && !seen.has(cursor.id)) {
449
- seen.add(cursor.id);
450
- const parent = recordsById.get(cursor.parentSession);
451
- if (parent === undefined) break;
452
- cursor = parent.header;
453
- }
454
- rootId = cursor.id;
455
- if (cursor.parentSession !== undefined) {
456
- const chain = ancestorChainFromLog(cursor.parentSession, await versionMarkers(ctx, cursor.id));
457
- absorbChain(chain);
458
- if (chain.length > 0) rootId = chain[chain.length - 1].sessionId;
459
- }
460
- }
461
-
462
- // Other orphans in the same cwd may belong to this family through their own
463
- // holes; each orphan's log names its full ancestry, connecting it or ruling
464
- // it out. Orphans are rare (they only exist where something was deleted),
465
- // so the full-log reads here are few.
466
- for (const record of records) {
467
- const parentId = record.header.parentSession;
468
- if (parentId === undefined || recordsById.has(parentId)) continue;
469
- if (record.header.cwd !== target.header.cwd) continue;
470
- if (record.header.id === sessionId) continue;
471
- try {
472
- absorbChain(ancestorChainFromLog(parentId, await versionMarkers(ctx, record.header.id)));
473
- } catch (error) {
474
- // An unreadable orphan stays an island; the family is still assembled.
475
- }
476
- }
477
-
478
- const entries = [];
479
- for (const record of records) {
480
- if (record.header.cwd !== target.header.cwd && record.header.id !== sessionId) continue;
481
- entries.push({
482
- id: record.header.id,
483
- ...record.header.parentSession === undefined ? {} : { parentId: record.header.parentSession },
484
- createdAt: record.header.createdAt,
485
- ghost: false,
486
- });
487
- }
488
- for (const [id, info] of ghostInfo) {
489
- entries.push({
490
- id,
491
- ...info.parentId === undefined ? {} : { parentId: info.parentId },
492
- createdAt: info.marker?.time ?? 0,
493
- ghost: true,
494
- ...info.marker === undefined ? {} : { marker: info.marker },
495
- });
496
- }
497
- return { rootId, flat: collectFamily(rootId, entries), recordsById };
498
- }
499
-
500
- /**
501
- * Every surviving session in this conversation's version family — the root
502
- * and all descendants, bridged across deleted members, ghosts excluded.
503
- */
504
- async function familySessionIds(ctx, sessionId) {
505
- const { flat } = await assembleFamily(ctx, sessionId);
506
- const ids = new Set([sessionId]);
507
- for (const { entry } of flat) {
508
- if (!entry.ghost) ids.add(entry.id);
509
- }
510
- return [...ids];
511
- }
512
-
513
- /**
514
- * Stop every still-running turn in this conversation's family before branching.
515
- *
516
- * Editing must stop the reply it supersedes that is what every chat UI does,
517
- * and leaving it running silently spends tokens on an answer nobody will read.
518
- * It is not enough to cancel only the session being edited: versions are
519
- * separate sessions, so a sibling branch started earlier can still be
520
- * streaming while you edit a different one. Those are exactly the runs that are
521
- * hard to notice and hard to stop by hand.
522
- *
523
- * Falls back to the source session alone if the family cannot be traced, so a
524
- * lookup failure still stops the obvious one rather than nothing.
525
- */
526
- async function stopFamilyTurns(ctx, sessionId) {
527
- let ids;
528
- try {
529
- ids = await familySessionIds(ctx, sessionId);
530
- } catch (error) {
531
- ids = [sessionId];
532
- }
533
- let stopped = 0;
534
- for (const id of ids) {
535
- if (await stopRunningTurn(ctx, id)) stopped += 1;
536
- }
537
- return stopped;
538
- }
539
-
540
- async function runOperation(ctx, operation) {
541
- const sourceId = sessionIdOf(operation.sessionId);
542
- if (operation.stopPrevious === true) await stopFamilyTurns(ctx, sourceId);
543
- return withSourceAgent(ctx, sourceId, async (source) => {
544
- const childId = sessionIdOf(`session-${crypto.randomUUID()}`);
545
- const inverses = [];
546
- try {
547
- const events = source.session.events;
548
- const plan = planOperation(operation, events);
549
- const options = agentOptions(events, source.options);
550
- const attach = await resolveAttachSession(ctx, source.session, plan.version.effect.targetTurn);
551
- if (sessionRecordId(attach) !== sessionRecordId(source.session)) {
552
- const turn = closedTurns(attach.events).find((candidate) => candidate.turn === plan.version.effect.targetTurn);
553
- if (turn === undefined) throw new Error('无法在父会话上定位同一回合。');
554
- plan.boundary = turn.startSeq - 1;
555
- plan.version.inverse.sessionId = sessionRecordId(attach);
556
- }
557
- const child = await createVersionAgent(ctx, attach, childId, plan, options);
558
- inverses.push(() => child.dispose());
559
- for (const message of plan.queuedUsers) child.agent.followup(message);
560
- inverses.length = 0;
561
- await attachToParentWorkspace(ctx, sessionRecordId(attach), childId);
562
- return { sessionId: childId, queuedTurns: plan.queuedUsers.length };
563
- } catch (error) {
564
- try {
565
- await recoverOperation(inverses);
566
- } catch (recoveryError) {
567
- throw new AggregateError([error, recoveryError], '版本操作及其恢复均失败。');
568
- }
569
- throw error;
570
- }
571
- });
572
- }
573
-
574
- /* ------------------------------------------------------- tree projection -- */
575
-
576
- function ownVersionEvent(header, events) {
577
- const inherited = header.seedLength ?? 0;
578
- const ownEvents = events.filter((event) => event.type === 'message-tree/version' && event.seq >= inherited);
579
- if (ownEvents.length === 0) return undefined;
580
- const event = ownEvents[0];
581
- const version = event.data;
582
- const parent = header.parentSession;
583
- if (version.schemaVersion !== MESSAGE_TREE_SCHEMA) throw new Error(`会话 ${header.id} 使用不支持的版本效果结构。`);
584
- if (version.inverse.kind !== 'restore-version' || parent === undefined || version.inverse.sessionId !== parent) {
585
- throw new Error(`会话 ${header.id} 的版本效果与逆不匹配。`);
586
- }
587
- return { effect: version.effect, time: event.time };
588
- }
589
-
590
- const TREE_READ_CONCURRENCY = 4;
591
- async function mapConcurrent(items, worker) {
592
- const results = new Array(items.length);
593
- let cursor = 0;
594
- const run = async () => {
595
- for (;;) {
596
- const index = cursor;
597
- cursor += 1;
598
- if (index >= items.length) return;
599
- results[index] = await worker(items[index]);
600
- }
601
- };
602
- const workers = Math.min(TREE_READ_CONCURRENCY, items.length);
603
- await Promise.all(Array.from({ length: workers }, () => run()));
604
- return results;
605
- }
606
-
607
- /** Extract all user turns from a session's event stream. */
608
- function extractTurns(events) {
609
- if (!Array.isArray(events)) return [];
610
- const result = [];
611
- let current;
612
- for (const event of events) {
613
- if (event.type === 'turn/start') {
614
- current = { turn: event.data.turn, startSeq: event.seq, time: event.time };
615
- continue;
616
- }
617
- if (current === undefined) continue;
618
- if (event.type === 'user/message' && current.user === undefined && event.data?.source?.kind === 'user') {
619
- current.user = event;
620
- current.text = userText(event.data);
621
- current.time = event.time ?? current.time;
622
- continue;
623
- }
624
- if (event.type === 'turn/end' && event.data.turn === current.turn) {
625
- result.push({ turn: current.turn, text: current.text ?? '', time: current.time ?? event.time });
626
- current = undefined;
627
- }
628
- }
629
- if (current && current.user) {
630
- result.push({ turn: current.turn, text: current.text ?? '', time: current.time ?? Date.now() });
631
- }
632
- return result;
633
- }
634
-
635
- const SESSION_CACHE_MAX = 500;
636
- const sessionParsedCache = new Map();
637
-
638
- function getSessionCacheKey(ctx, header) {
639
- const live = ctx.sessions.get(header.id);
640
- if (live !== undefined) return `live:${live.events.length}`;
641
- return `disk:${header.updatedAt ?? header.createdAt ?? 0}`;
642
- }
643
-
644
- async function sessionParsedData(ctx, record) {
645
- if (!record || !record.header) return { turns: [], effect: undefined, time: undefined };
646
- const id = record.header.id;
647
- const key = getSessionCacheKey(ctx, record.header);
648
- const cached = sessionParsedCache.get(id);
649
- if (cached !== undefined && cached.key === key) {
650
- return cached;
651
- }
652
-
653
- const live = ctx.sessions.get(id);
654
- const events = live !== undefined ? live.events : (await ctx.sessionQuery.readSession(id)).events;
655
- const turns = extractTurns(events);
656
- let effect;
657
- let time;
658
- try {
659
- const version = ownVersionEvent(record.header, events);
660
- effect = version?.effect;
661
- time = version?.time;
662
- } catch (error) {
663
- effect = undefined;
664
- }
665
-
666
- const entry = { key, turns, effect, time };
667
- if (sessionParsedCache.size >= SESSION_CACHE_MAX) {
668
- const firstKey = sessionParsedCache.keys().next().value;
669
- sessionParsedCache.delete(firstKey);
670
- }
671
- sessionParsedCache.set(id, entry);
672
- return entry;
673
- }
674
-
675
- async function tree(ctx, sessionId) {
676
- const { flat, recordsById } = await assembleFamily(ctx, sessionId);
677
- const archived = archivedSessionIdSet(ctx);
678
- const parsedLogs = await mapConcurrent(flat, async ({ entry }) => {
679
- if (entry.ghost) return null;
680
- const record = recordsById.get(entry.id);
681
- return record === undefined ? null : sessionParsedData(ctx, record);
682
- });
683
- const parentOf = new Map(flat.map(({ entry }) => [entry.id, entry.parentId]));
684
- const currentPath = new Set();
685
- let pathId = sessionId;
686
- while (pathId !== undefined && !currentPath.has(pathId)) {
687
- currentPath.add(pathId);
688
- pathId = parentOf.get(pathId);
689
- }
690
- const versions = flat.map(({ entry, depth }, index) => {
691
- let effect;
692
- let time;
693
- let turns = [];
694
- if (entry.ghost) {
695
- effect = entry.marker?.data?.effect;
696
- time = entry.marker?.time;
697
- } else {
698
- const parsed = parsedLogs[index];
699
- turns = parsed?.turns ?? [];
700
- effect = parsed?.effect;
701
- time = parsed?.time;
702
- }
703
- const record = entry.ghost ? undefined : recordsById.get(entry.id);
704
- return {
705
- sessionId: entry.id,
706
- ...entry.parentId === undefined ? {} : { parentSessionId: entry.parentId },
707
- createdAt: time ?? record?.header.createdAt ?? entry.createdAt,
708
- depth,
709
- current: entry.id === sessionId,
710
- onCurrentPath: currentPath.has(entry.id),
711
- ...entry.ghost ? { deleted: true } : {},
712
- ...archived.has(entry.id) ? { archived: true } : {},
713
- ...effect === undefined ? {} : {
714
- operation: effect.operation,
715
- targetTurn: effect.targetTurn,
716
- targetEventSeq: effect.targetEventSeq,
717
- ...effect.before === undefined ? {} : { before: effect.before },
718
- ...effect.after === undefined ? {} : { after: effect.after },
719
- },
720
- turns,
721
- };
722
- });
723
- return { sessionId, versions };
724
- }
725
-
726
- /* --------------------------------------------------------- HTTP plumbing -- */
727
-
728
- function objectValue(value) {
729
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError('请求体必须是 JSON 对象。');
730
- return value;
731
- }
732
-
733
- function sessionIdOf(value) {
734
- if (typeof value !== 'string' || value.length === 0) throw new TypeError('sessionId 必须是非空字符串。');
735
- return value;
736
- }
737
-
738
- function integerOf(value, label) {
739
- if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${label} 必须是非负安全整数。`);
740
- return value;
741
- }
742
-
743
- function decodeOperation(value) {
744
- const record = objectValue(value);
745
- const sessionId = sessionIdOf(record['sessionId']);
746
- switch (record['action']) {
747
- case 'edit':
748
- if (typeof record['text'] !== 'string' || record['text'].trim().length === 0) throw new TypeError('text 必须是非空字符串。');
749
- return {
750
- action: 'edit',
751
- sessionId,
752
- eventSeq: integerOf(record['eventSeq'], 'eventSeq'),
753
- blockIndex: integerOf(record['blockIndex'], 'blockIndex'),
754
- text: record['text'],
755
- stopPrevious: record['stopPrevious'] === true,
756
- };
757
- case 'retry':
758
- return {
759
- action: 'retry',
760
- sessionId,
761
- turn: integerOf(record['turn'], 'turn'),
762
- stopPrevious: record['stopPrevious'] === true,
763
- };
764
- default:
765
- throw new TypeError('action 必须是 edit 或 retry。');
766
- }
767
- }
768
-
769
- function requestJson(request) {
770
- return new Promise((resolve, reject) => {
771
- const decoder = new TextDecoder();
772
- let text = '';
773
- request.on('data', (chunk) => {
774
- text += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
775
- });
776
- request.on('end', () => {
777
- try {
778
- text += decoder.decode();
779
- resolve(JSON.parse(text));
780
- } catch (error) {
781
- reject(error);
782
- }
783
- });
784
- request.on('error', reject);
785
- });
786
- }
787
-
788
- function respondJson(response, status, value) {
789
- response.writeHead(status, {
790
- 'content-type': 'application/json; charset=utf-8',
791
- 'cache-control': 'no-store',
792
- });
793
- response.end(JSON.stringify(value));
794
- }
795
-
796
- async function handleRoute(ctx, request, response) {
797
- try {
798
- if (request.method === 'GET') {
799
- const url = new URL(request.url ?? MESSAGE_TREE_PATH, 'http://message-tree.local');
800
- respondJson(response, 200, await tree(ctx, sessionIdOf(url.searchParams.get('sessionId'))));
801
- return;
802
- }
803
- if (request.method === 'POST') {
804
- const body = await requestJson(request);
805
- // activate = make an archived version navigable again. Kept apart from
806
- // decodeOperation: it creates nothing, it only clears the archive flag.
807
- if (body !== null && typeof body === 'object' && body.action === 'activate') {
808
- respondJson(response, 200, await activateVersion(ctx, sessionIdOf(body.sessionId)));
809
- return;
810
- }
811
- respondJson(response, 200, await runOperation(ctx, decodeOperation(body)));
812
- return;
813
- }
814
- response.writeHead(405);
815
- response.end();
816
- } catch (error) {
817
- const message = error instanceof Error ? error.message : String(error);
818
- respondJson(response, error instanceof TypeError ? 400 : 409, { error: message });
819
- }
820
- }
821
-
822
- function apply(ctx) {
823
- ctx.effect(() => ctx.webServer.register({
824
- kind: 'exact',
825
- path: MESSAGE_TREE_PATH,
826
- handler: (request, response) => handleRoute(ctx, request, response),
827
- }), 'message-tree: HTTP route');
828
- }
829
-
830
- export { MESSAGE_TREE_PATH, MESSAGE_TREE_SCHEMA, apply, inject, name };
1
+ // dsh-plugin-message-tree — host half.
2
+ //
3
+ // Owns the /message-tree HTTP route. POST builds a version branch: a new
4
+ // session seeded with every event BEFORE the edited turn (true rewind, unlike
5
+ // the client-side session fork which cuts after a turn), a durable
6
+ // `message-tree/version` marker naming what changed, and the edited prompt
7
+ // queued for a fresh answer. GET projects the whole version tree for the
8
+ // client's ‹ › rings and tree view.
9
+ //
10
+ // The branch-transaction shape is derived from dsh-message-edit
11
+ // (MIT © Moeblack) — simplified to ChatGPT semantics: user-message edits and
12
+ // turn retries only, always truncating downstream.
13
+
14
+ import { attachParentId, ancestorChainFromLog, collectFamily } from './tree-logic.js';
15
+ import { sessionEventCount, sessionRecord, branchSeedOptions } from './session-record.js';
16
+
17
+ // The package is named dsh-plugin-message-edit, but the route, the cordis id
18
+ // and the durable event type below deliberately keep the `message-tree`
19
+ // spelling. Moeblack's dsh-message-edit already owns `/message-edit`, the
20
+ // cordis id `message-edit` and the event type `message-edit/version`; reusing
21
+ // those would collide whenever both plugins are installed. Keeping ours
22
+ // distinct also preserves the version links already written into existing
23
+ // session logs, which name this type verbatim.
24
+ /** Same-origin endpoint owned by this plugin's host half. */
25
+ const MESSAGE_TREE_PATH = '/message-tree';
26
+ /** Durable version-marker schema. */
27
+ const MESSAGE_TREE_SCHEMA = 1;
28
+
29
+ const name = 'message-tree';
30
+ const inject = [
31
+ 'sessions',
32
+ 'agents',
33
+ 'sessionPersistence',
34
+ 'sessionQuery',
35
+ 'webServer',
36
+ // Used three ways: to flag archived versions in the tree payload (the app
37
+ // cannot navigate to an archived session, so the client unarchives before
38
+ // opening), to unarchive on demand, and to attach a new version to the same
39
+ // sidebar workspace group as its parent. Plain string: cordis uses array
40
+ // inject entries as service names directly.
41
+ 'workspaceRegistry',
42
+ ];
43
+
44
+ /* ------------------------------------------------------------ log reading -- */
45
+
46
+ /** Fold complete turn brackets; an open tail is deliberately absent. */
47
+ function closedTurns(events) {
48
+ const result = [];
49
+ let current;
50
+ for (const event of events) {
51
+ if (event.type === 'turn/start') {
52
+ current = { turn: event.data.turn, startSeq: event.seq };
53
+ continue;
54
+ }
55
+ if (current === undefined) continue;
56
+ if (event.type === 'user/message' && current.user === undefined && event.data.source.kind === 'user') {
57
+ current.user = event;
58
+ continue;
59
+ }
60
+ if (event.type === 'turn/end' && event.data.turn === current.turn) {
61
+ result.push({ ...current, endSeq: event.seq });
62
+ current = undefined;
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function userText(message) {
69
+ return message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n');
70
+ }
71
+
72
+ function cloneUser(message, content = structuredClone(message.content)) {
73
+ return Object.freeze({
74
+ id: crypto.randomUUID(),
75
+ role: 'user',
76
+ content: Object.freeze(content),
77
+ source: Object.freeze({ kind: 'user' }),
78
+ });
79
+ }
80
+
81
+ function replaceTextBlock(content, blockIndex, text) {
82
+ const block = content[blockIndex];
83
+ if (block?.type !== 'text') throw new Error('所选内容块不是可编辑文本。');
84
+ return content.map((candidate, index) => index === blockIndex ? { ...candidate, text } : structuredClone(candidate));
85
+ }
86
+
87
+ /* ---------------------------------------------------------------- planning -- */
88
+
89
+ function pairVersionEffect(sourceSessionId, effect) {
90
+ return {
91
+ schemaVersion: MESSAGE_TREE_SCHEMA,
92
+ effect: { ...effect, id: crypto.randomUUID() },
93
+ inverse: { kind: 'restore-version', sessionId: sourceSessionId },
94
+ };
95
+ }
96
+
97
+ /** Edit a user message: rewind to before its turn, queue the edited prompt. */
98
+ function editPlan(operation, turns) {
99
+ const turn = turns.find((candidate) => operation.eventSeq > candidate.startSeq && operation.eventSeq < candidate.endSeq);
100
+ if (turn === undefined) throw new Error('所选消息不属于已落定回合。');
101
+ if (turn.user === undefined || turn.user.seq !== operation.eventSeq) throw new Error('所选消息不是用户消息。');
102
+ const before = turn.user.data.content[operation.blockIndex];
103
+ if (before?.type !== 'text') throw new Error('所选用户消息块不是文本。');
104
+ const edited = cloneUser(turn.user.data, replaceTextBlock(turn.user.data.content, operation.blockIndex, operation.text));
105
+ return {
106
+ boundary: turn.startSeq - 1,
107
+ version: pairVersionEffect(operation.sessionId, {
108
+ operation: 'edit',
109
+ targetTurn: turn.turn,
110
+ targetEventSeq: turn.user.seq,
111
+ before: before.text,
112
+ after: operation.text,
113
+ }),
114
+ queuedUsers: [edited],
115
+ };
116
+ }
117
+
118
+ /** Regenerate a turn: rewind to before it, queue the original prompt again. */
119
+ function retryPlan(sessionId, turnNumber, turns) {
120
+ const turn = turns.find((candidate) => candidate.turn === turnNumber);
121
+ if (turn?.user === undefined) throw new Error('所选回合没有可重放的用户输入。');
122
+ return {
123
+ boundary: turn.startSeq - 1,
124
+ version: pairVersionEffect(sessionId, {
125
+ operation: 'retry',
126
+ targetTurn: turn.turn,
127
+ targetEventSeq: turn.user.seq,
128
+ before: userText(turn.user.data),
129
+ }),
130
+ queuedUsers: [cloneUser(turn.user.data)],
131
+ };
132
+ }
133
+
134
+ function planOperation(operation, events) {
135
+ const turns = closedTurns(events);
136
+ switch (operation.action) {
137
+ case 'edit': return editPlan(operation, turns);
138
+ case 'retry': return retryPlan(operation.sessionId, operation.turn, turns);
139
+ }
140
+ }
141
+
142
+ /* -------------------------------------------------------- branch creation -- */
143
+
144
+ function agentOptions(events, fallback) {
145
+ const config = events.findLast((event) => event.type === 'request/header')?.data.header.config;
146
+ const provider = config?.provider ?? fallback?.provider;
147
+ const model = config?.model ?? fallback?.model;
148
+ if (provider === undefined || provider.length === 0 || model === undefined || model.length === 0) {
149
+ throw new Error('无法从会话历史解析模型路由。');
150
+ }
151
+ const maxTokens = config?.maxTokens ?? fallback?.maxTokens;
152
+ return { provider, model, ...maxTokens === undefined ? {} : { maxTokens } };
153
+ }
154
+
155
+ async function withSourceAgent(ctx, sessionId, operation) {
156
+ let handle;
157
+ let agent = ctx.agents.get(sessionId);
158
+ if (agent === undefined) {
159
+ const snapshot = sessionRecord(await ctx.sessionQuery.readSession(sessionId));
160
+ handle = await ctx.agents.resume({
161
+ resumeSessionId: sessionId,
162
+ agentOptions: agentOptions(snapshot.events),
163
+ });
164
+ agent = handle.agent;
165
+ }
166
+ try {
167
+ return await agent.runMaintenance(async () => operation(agent));
168
+ } finally {
169
+ await handle?.dispose();
170
+ }
171
+ }
172
+
173
+ function inheritedSeed(source, boundary) {
174
+ if (boundary === -1) return [];
175
+ const boundaryEvent = source.events[boundary];
176
+ if (boundary < 0 || boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
177
+ throw new Error('分支边界不是连续会话事件。');
178
+ }
179
+ return source.events.slice(0, boundary + 1);
180
+ }
181
+
182
+ function versionSeed(source, plan) {
183
+ const events = inheritedSeed(source, plan.boundary);
184
+ const inheritedLength = events.length;
185
+ events.push({
186
+ type: 'message-tree/version',
187
+ seq: events.length,
188
+ time: Date.now(),
189
+ data: plan.version,
190
+ // Plugin event types live outside the harness vocabulary; without this
191
+ // marker the read path refuses to interpret the whole session log.
192
+ ignorable: true,
193
+ });
194
+ return { events, inheritedLength };
195
+ }
196
+
197
+ function sessionPreset(session) {
198
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
199
+ const event = session.events[index];
200
+ if (event?.type === 'agent-preset/selected') return event.data.agentPreset;
201
+ }
202
+ return session.header.agentPreset;
203
+ }
204
+
205
+ async function loadSessionRecord(ctx, sessionId) {
206
+ const live = ctx.sessions.get(sessionId);
207
+ return sessionRecord(live ?? await ctx.sessionQuery.readSession(sessionId));
208
+ }
209
+
210
+ function sessionRecordId(session) {
211
+ return session.header?.id ?? session.id;
212
+ }
213
+
214
+ function sessionTargetTurn(session) {
215
+ return ownVersionEvent(session)?.effect.targetTurn;
216
+ }
217
+
218
+ /**
219
+ * Repeated edits of the same message must hang off the original, not off
220
+ * the previous edit. Walk the parent chain and stop at the first session
221
+ * that is not itself a version of `targetTurn`.
222
+ */
223
+ async function resolveAttachSession(ctx, sourceSession, targetTurn) {
224
+ const nodes = new Map();
225
+ let session = sourceSession;
226
+ const seen = new Set();
227
+ while (session) {
228
+ const id = sessionRecordId(session);
229
+ if (id === undefined || seen.has(id)) break;
230
+ seen.add(id);
231
+ nodes.set(id, {
232
+ targetTurn: sessionTargetTurn(session),
233
+ parentSessionId: session.header.parentSession,
234
+ session,
235
+ });
236
+ const parentId = session.header.parentSession;
237
+ if (parentId === undefined || nodes.has(parentId)) break;
238
+ session = await loadSessionRecord(ctx, parentId);
239
+ }
240
+ const byId = new Map([...nodes].map(([id, node]) => [id, {
241
+ targetTurn: node.targetTurn,
242
+ parentSessionId: node.parentSessionId,
243
+ }]));
244
+ const attachId = attachParentId(byId, sessionRecordId(sourceSession), targetTurn);
245
+ return nodes.get(attachId)?.session ?? sourceSession;
246
+ }
247
+
248
+ async function createVersionAgent(ctx, source, childId, plan, options) {
249
+ const seed = versionSeed(source, plan);
250
+ const presets = ctx.get('agentPresets');
251
+ const presetId = sessionPreset(source);
252
+ let agentPreset;
253
+ let setup;
254
+ if (presets !== undefined && presetId !== undefined) {
255
+ const resolved = (await presets.resolve(presetId)).id;
256
+ agentPreset = resolved;
257
+ setup = async (agentCtx) => { await presets.mount(agentCtx, resolved); };
258
+ }
259
+ const seedOptions = branchSeedOptions(source, seed.inheritedLength);
260
+ const child = await ctx.agents.create({
261
+ sessionId: childId,
262
+ seed: seed.events,
263
+ ...seedOptions,
264
+ meta: {
265
+ ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
266
+ parentSession: source.id ?? source.header.id,
267
+ ...seedOptions.meta,
268
+ // NOTE: deliberately NOT `origin: 'subagent'`, and deliberately not
269
+ // hidden from the sidebar at all.
270
+ //
271
+ // Two hiding approaches are known-bad:
272
+ //
273
+ // - `origin: 'subagent'` keeps versions out of the sidebar (the
274
+ // workspace list filters on `origin !== 'subagent'`), but the API
275
+ // proxy fences the same field: `hasApiRemoteSubagentOwner` treats
276
+ // such a session as owned by subagent routing and refuses both
277
+ // `session.cancel` and model selection with
278
+ // agent-busy: session "..." is owned by subagent routing
279
+ // so every edited or retried message became impossible to stop.
280
+ // The schema accepts no third `origin` value to hide behind.
281
+ //
282
+ // - Archiving the child (`workspaceRegistry.archiveSession`) hides it
283
+ // without fencing it, but the app cannot NAVIGATE to an archived
284
+ // session: opening one bounces to the workspace picker, so edit,
285
+ // retry and version switching all dead-ended on the home screen.
286
+ //
287
+ // Versions therefore appear in the sidebar as ordinary sessions. That
288
+ // is cosmetic; stopping, model switching and navigation all work.
289
+ ...agentPreset === undefined ? {} : { agentPreset },
290
+ },
291
+ agentOptions: options,
292
+ ...setup === undefined ? {} : { setup },
293
+ });
294
+ try {
295
+ await ctx.sessions.flush(child.agent.session);
296
+ return child;
297
+ } catch (error) {
298
+ await child.dispose();
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ async function recoverOperation(inverses) {
304
+ const failures = [];
305
+ for (const inverse of inverses.reverse()) {
306
+ try {
307
+ await inverse();
308
+ } catch (error) {
309
+ failures.push(error);
310
+ }
311
+ }
312
+ if (failures.length > 0) throw new AggregateError(failures, '版本操作恢复失败。');
313
+ }
314
+
315
+ /**
316
+ * Stop a still-running turn on `sessionId` so an edit can fork away from it.
317
+ *
318
+ * Two reasons this is needed. Editing forks from the source session but does
319
+ * not stop it, so the superseded turn keeps streaming and keeps spending
320
+ * tokens. And `runMaintenance` throws outright unless the agent is idle, which
321
+ * is why editing was blocked while a turn was live at all.
322
+ *
323
+ * Cancelling the parent is also what stops its subagents: a subagent session is
324
+ * fenced from the ordinary cancel path ("owned by subagent routing"), but the
325
+ * subtree is owned by the parent's fiber and unwinds with it.
326
+ *
327
+ * `cancel` only signals the abort; `whenIdle` waits for the phase to actually
328
+ * settle, without which the `runMaintenance` that follows can still throw.
329
+ */
330
+ async function stopRunningTurn(ctx, sessionId) {
331
+ const agent = ctx.agents.get(sessionId);
332
+ if (agent === undefined) return false;
333
+ try {
334
+ agent.cancel({ kind: 'user' });
335
+ await agent.whenIdle();
336
+ return true;
337
+ } catch (error) {
338
+ // An agent that was already finishing is not an error for our purposes:
339
+ // the goal is only that it is no longer running.
340
+ return false;
341
+ }
342
+ }
343
+
344
+ /** Archived session ids, as a Set; empty when the registry cannot say. */
345
+ function archivedSessionIdSet(ctx) {
346
+ try {
347
+ const ids = ctx.get('workspaceRegistry')?.archivedSessionIds;
348
+ return new Set(Array.isArray(ids) ? ids : []);
349
+ } catch (error) {
350
+ return new Set();
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Unarchive one session so the app can navigate to it.
356
+ *
357
+ * The app cannot open an archived session it bounces to the workspace
358
+ * picker — and archiving versions to declutter the sidebar is a natural thing
359
+ * to do, so paging the ring onto one must unarchive it first. This dsh build
360
+ * has no unarchive API anywhere (the registry's archiveSession only adds), so
361
+ * this mirrors archiveSession's own state discipline: same operation queue,
362
+ * same durable state write. The API proxy watches this state and broadcasts
363
+ * host/archived-sessions-changed, so the sidebar updates live.
364
+ */
365
+ async function activateVersion(ctx, sessionId) {
366
+ const registry = ctx.get('workspaceRegistry');
367
+ if (registry === undefined) throw new Error('workspaceRegistry 不可用,无法取消归档。');
368
+ if (!registry.archivedSessionIds.includes(sessionId)) return { unarchived: false };
369
+ if (typeof registry.enqueueOperation !== 'function'
370
+ || typeof registry.requireState !== 'function'
371
+ || typeof registry.setState !== 'function') {
372
+ throw new Error('此 dsh 版本未提供取消归档的途径。');
373
+ }
374
+ await registry.enqueueOperation(async () => {
375
+ const state = registry.requireState();
376
+ if (!state.archivedSessionIds.includes(sessionId)) return;
377
+ await registry.setState({
378
+ ...state,
379
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
380
+ });
381
+ });
382
+ return { unarchived: true };
383
+ }
384
+
385
+ /**
386
+ * Keep a version in the same sidebar workspace group as its tree parent.
387
+ * Versions inherit the parent's cwd but were never attached to its workspace,
388
+ * so they showed up as stray ungrouped rows. Failure is cosmetic the
389
+ * version works either way so it never fails the edit.
390
+ */
391
+ async function attachToParentWorkspace(ctx, parentId, childId) {
392
+ try {
393
+ const registry = ctx.get('workspaceRegistry');
394
+ const workspace = registry?.list().find((w) => w.sessionIds.includes(parentId));
395
+ if (workspace !== undefined) await workspace.attachSession(childId);
396
+ } catch (error) {}
397
+ }
398
+
399
+ /** All message-tree/version events of one session's full log, in seq order. */
400
+ async function versionMarkers(ctx, sessionId) {
401
+ const { events } = await loadSessionRecord(ctx, sessionId);
402
+ return events.filter((event) => event.type === 'message-tree/version');
403
+ }
404
+
405
+ /**
406
+ * Assemble one conversation family, bridging sessions the user has deleted.
407
+ *
408
+ * dsh's traceSession stops at the first missing parent, so deleting one
409
+ * version used to fragment the family: siblings of a deleted original lost
410
+ * their ‹k/N› counters entirely, and everything below a deleted chain link
411
+ * vanished from the Versions tree. But every version's seed inherits its
412
+ * ancestors' `message-tree/version` markers, so a deleted ancestor's identity,
413
+ * parent and target turn all survive in its descendants' logs. This walks
414
+ * surviving headers where possible and recovers the rest from those markers,
415
+ * emitting ghost entries for the deleted sessions so the tree stays whole.
416
+ *
417
+ * Families never span working directories (a version inherits its source's
418
+ * cwd), so orphan logs outside the target's cwd are never read.
419
+ *
420
+ * @returns { rootId, flat: [{ entry, depth }], recordsById } where each entry
421
+ * is { id, parentId?, createdAt, ghost, marker? }.
422
+ */
423
+ async function assembleFamily(ctx, sessionId) {
424
+ const records = await ctx.sessionQuery.listSessions();
425
+ const recordsById = new Map(records.map((record) => [record.header.id, record]));
426
+ const target = recordsById.get(sessionId);
427
+ if (target === undefined) throw new Error(`session "${sessionId}" not found`);
428
+
429
+ const ghostInfo = new Map();
430
+ const absorbChain = (chain) => {
431
+ for (let i = 0; i < chain.length; i++) {
432
+ const link = chain[i];
433
+ if (recordsById.has(link.sessionId)) continue;
434
+ const info = ghostInfo.get(link.sessionId) ?? {};
435
+ if (link.marker !== undefined) info.marker = link.marker;
436
+ if (i + 1 < chain.length) info.parentId = chain[i + 1].sessionId;
437
+ ghostInfo.set(link.sessionId, info);
438
+ }
439
+ };
440
+
441
+ // The target's root: surviving headers first, the log bridge at a hole.
442
+ let rootId;
443
+ {
444
+ const seen = new Set();
445
+ let cursor = target.header;
446
+ while (cursor.parentSession !== undefined && !seen.has(cursor.id)) {
447
+ seen.add(cursor.id);
448
+ const parent = recordsById.get(cursor.parentSession);
449
+ if (parent === undefined) break;
450
+ cursor = parent.header;
451
+ }
452
+ rootId = cursor.id;
453
+ if (cursor.parentSession !== undefined) {
454
+ const chain = ancestorChainFromLog(cursor.parentSession, await versionMarkers(ctx, cursor.id));
455
+ absorbChain(chain);
456
+ if (chain.length > 0) rootId = chain[chain.length - 1].sessionId;
457
+ }
458
+ }
459
+
460
+ // Other orphans in the same cwd may belong to this family through their own
461
+ // holes; each orphan's log names its full ancestry, connecting it or ruling
462
+ // it out. Orphans are rare (they only exist where something was deleted),
463
+ // so the full-log reads here are few.
464
+ for (const record of records) {
465
+ const parentId = record.header.parentSession;
466
+ if (parentId === undefined || recordsById.has(parentId)) continue;
467
+ if (record.header.cwd !== target.header.cwd) continue;
468
+ if (record.header.id === sessionId) continue;
469
+ try {
470
+ absorbChain(ancestorChainFromLog(parentId, await versionMarkers(ctx, record.header.id)));
471
+ } catch (error) {
472
+ // An unreadable orphan stays an island; the family is still assembled.
473
+ }
474
+ }
475
+
476
+ const entries = [];
477
+ for (const record of records) {
478
+ if (record.header.cwd !== target.header.cwd && record.header.id !== sessionId) continue;
479
+ entries.push({
480
+ id: record.header.id,
481
+ ...record.header.parentSession === undefined ? {} : { parentId: record.header.parentSession },
482
+ createdAt: record.header.createdAt,
483
+ ghost: false,
484
+ });
485
+ }
486
+ for (const [id, info] of ghostInfo) {
487
+ entries.push({
488
+ id,
489
+ ...info.parentId === undefined ? {} : { parentId: info.parentId },
490
+ createdAt: info.marker?.time ?? 0,
491
+ ghost: true,
492
+ ...info.marker === undefined ? {} : { marker: info.marker },
493
+ });
494
+ }
495
+ return { rootId, flat: collectFamily(rootId, entries), recordsById };
496
+ }
497
+
498
+ /**
499
+ * Every surviving session in this conversation's version family — the root
500
+ * and all descendants, bridged across deleted members, ghosts excluded.
501
+ */
502
+ async function familySessionIds(ctx, sessionId) {
503
+ const { flat } = await assembleFamily(ctx, sessionId);
504
+ const ids = new Set([sessionId]);
505
+ for (const { entry } of flat) {
506
+ if (!entry.ghost) ids.add(entry.id);
507
+ }
508
+ return [...ids];
509
+ }
510
+
511
+ /**
512
+ * Stop every still-running turn in this conversation's family before branching.
513
+ *
514
+ * Editing must stop the reply it supersedes that is what every chat UI does,
515
+ * and leaving it running silently spends tokens on an answer nobody will read.
516
+ * It is not enough to cancel only the session being edited: versions are
517
+ * separate sessions, so a sibling branch started earlier can still be
518
+ * streaming while you edit a different one. Those are exactly the runs that are
519
+ * hard to notice and hard to stop by hand.
520
+ *
521
+ * Falls back to the source session alone if the family cannot be traced, so a
522
+ * lookup failure still stops the obvious one rather than nothing.
523
+ */
524
+ async function stopFamilyTurns(ctx, sessionId) {
525
+ let ids;
526
+ try {
527
+ ids = await familySessionIds(ctx, sessionId);
528
+ } catch (error) {
529
+ ids = [sessionId];
530
+ }
531
+ let stopped = 0;
532
+ for (const id of ids) {
533
+ if (await stopRunningTurn(ctx, id)) stopped += 1;
534
+ }
535
+ return stopped;
536
+ }
537
+
538
+ async function runOperation(ctx, operation) {
539
+ const sourceId = sessionIdOf(operation.sessionId);
540
+ if (operation.stopPrevious === true) await stopFamilyTurns(ctx, sourceId);
541
+ return withSourceAgent(ctx, sourceId, async (source) => {
542
+ const childId = sessionIdOf(`session-${crypto.randomUUID()}`);
543
+ const inverses = [];
544
+ try {
545
+ const record = sessionRecord(source.session);
546
+ const events = record.events;
547
+ const plan = planOperation(operation, events);
548
+ const options = agentOptions(events, source.options);
549
+ const attach = await resolveAttachSession(ctx, record, plan.version.effect.targetTurn);
550
+ if (sessionRecordId(attach) !== record.id) {
551
+ const turn = closedTurns(attach.events).find((candidate) => candidate.turn === plan.version.effect.targetTurn);
552
+ if (turn === undefined) throw new Error('无法在父会话上定位同一回合。');
553
+ plan.boundary = turn.startSeq - 1;
554
+ plan.version.inverse.sessionId = sessionRecordId(attach);
555
+ }
556
+ const child = await createVersionAgent(ctx, attach, childId, plan, options);
557
+ inverses.push(() => child.dispose());
558
+ for (const message of plan.queuedUsers) child.agent.followup(message);
559
+ inverses.length = 0;
560
+ await attachToParentWorkspace(ctx, sessionRecordId(attach), childId);
561
+ return { sessionId: childId, queuedTurns: plan.queuedUsers.length };
562
+ } catch (error) {
563
+ try {
564
+ await recoverOperation(inverses);
565
+ } catch (recoveryError) {
566
+ throw new AggregateError([error, recoveryError], '版本操作及其恢复均失败。');
567
+ }
568
+ throw error;
569
+ }
570
+ });
571
+ }
572
+
573
+ /* ------------------------------------------------------- tree projection -- */
574
+
575
+ function ownVersionEvent({ header, events, inheritedEventCount: inherited }) {
576
+ const ownEvents = events.filter((event) => event.type === 'message-tree/version' && event.seq >= inherited);
577
+ if (ownEvents.length === 0) return undefined;
578
+ const event = ownEvents[0];
579
+ const version = event.data;
580
+ const parent = header.parentSession;
581
+ if (version.schemaVersion !== MESSAGE_TREE_SCHEMA) throw new Error(`会话 ${header.id} 使用不支持的版本效果结构。`);
582
+ if (version.inverse.kind !== 'restore-version' || parent === undefined || version.inverse.sessionId !== parent) {
583
+ throw new Error(`会话 ${header.id} 的版本效果与逆不匹配。`);
584
+ }
585
+ return { effect: version.effect, time: event.time };
586
+ }
587
+
588
+ const TREE_READ_CONCURRENCY = 4;
589
+ async function mapConcurrent(items, worker) {
590
+ const results = new Array(items.length);
591
+ let cursor = 0;
592
+ const run = async () => {
593
+ for (;;) {
594
+ const index = cursor;
595
+ cursor += 1;
596
+ if (index >= items.length) return;
597
+ results[index] = await worker(items[index]);
598
+ }
599
+ };
600
+ const workers = Math.min(TREE_READ_CONCURRENCY, items.length);
601
+ await Promise.all(Array.from({ length: workers }, () => run()));
602
+ return results;
603
+ }
604
+
605
+ /** Extract all user turns from a session's event stream. */
606
+ function extractTurns(events) {
607
+ if (!Array.isArray(events)) return [];
608
+ const result = [];
609
+ let current;
610
+ for (const event of events) {
611
+ if (event.type === 'turn/start') {
612
+ current = { turn: event.data.turn, startSeq: event.seq, time: event.time };
613
+ continue;
614
+ }
615
+ if (current === undefined) continue;
616
+ if (event.type === 'user/message' && current.user === undefined && event.data?.source?.kind === 'user') {
617
+ current.user = event;
618
+ current.text = userText(event.data);
619
+ current.time = event.time ?? current.time;
620
+ continue;
621
+ }
622
+ if (event.type === 'turn/end' && event.data.turn === current.turn) {
623
+ result.push({ turn: current.turn, text: current.text ?? '', time: current.time ?? event.time });
624
+ current = undefined;
625
+ }
626
+ }
627
+ if (current && current.user) {
628
+ result.push({ turn: current.turn, text: current.text ?? '', time: current.time ?? Date.now() });
629
+ }
630
+ return result;
631
+ }
632
+
633
+ const SESSION_CACHE_MAX = 500;
634
+ const sessionParsedCaches = new WeakMap();
635
+
636
+ async function sessionParsedData(ctx, record) {
637
+ if (!record || !record.header) return { turns: [], effect: undefined, time: undefined };
638
+ const id = record.header.id;
639
+ let sessionParsedCache = sessionParsedCaches.get(ctx);
640
+ if (!sessionParsedCache) sessionParsedCaches.set(ctx, sessionParsedCache = new Map());
641
+ const live = ctx.sessions.get(id);
642
+ // Header creation timestamps never change when a cold log grows. Read a
643
+ // cold snapshot before caching by length; live logs expose a cheap count.
644
+ const snapshot = live === undefined ? await loadSessionRecord(ctx, id) : undefined;
645
+ const key = live === undefined ? snapshot.events.length : sessionEventCount(live);
646
+ const cached = sessionParsedCache.get(id);
647
+ if (cached !== undefined && cached.key === key && cached.live?.deref() === live) {
648
+ return cached;
649
+ }
650
+
651
+ const source = snapshot ?? sessionRecord(live);
652
+ const events = source.events;
653
+ const turns = extractTurns(events);
654
+ let effect;
655
+ let time;
656
+ try {
657
+ const version = ownVersionEvent(source);
658
+ effect = version?.effect;
659
+ time = version?.time;
660
+ } catch (error) {
661
+ effect = undefined;
662
+ }
663
+
664
+ // Cache identity without keeping a disposed session's entire log alive.
665
+ const entry = { key, live: live === undefined ? undefined : new WeakRef(live), turns, effect, time };
666
+ if (sessionParsedCache.size >= SESSION_CACHE_MAX) {
667
+ const firstKey = sessionParsedCache.keys().next().value;
668
+ sessionParsedCache.delete(firstKey);
669
+ }
670
+ sessionParsedCache.set(id, entry);
671
+ return entry;
672
+ }
673
+
674
+ async function tree(ctx, sessionId) {
675
+ const { flat, recordsById } = await assembleFamily(ctx, sessionId);
676
+ const archived = archivedSessionIdSet(ctx);
677
+ const parsedLogs = await mapConcurrent(flat, async ({ entry }) => {
678
+ if (entry.ghost) return null;
679
+ const record = recordsById.get(entry.id);
680
+ return record === undefined ? null : sessionParsedData(ctx, record);
681
+ });
682
+ const parentOf = new Map(flat.map(({ entry }) => [entry.id, entry.parentId]));
683
+ const currentPath = new Set();
684
+ let pathId = sessionId;
685
+ while (pathId !== undefined && !currentPath.has(pathId)) {
686
+ currentPath.add(pathId);
687
+ pathId = parentOf.get(pathId);
688
+ }
689
+ const versions = flat.map(({ entry, depth }, index) => {
690
+ let effect;
691
+ let time;
692
+ let turns = [];
693
+ if (entry.ghost) {
694
+ effect = entry.marker?.data?.effect;
695
+ time = entry.marker?.time;
696
+ } else {
697
+ const parsed = parsedLogs[index];
698
+ turns = parsed?.turns ?? [];
699
+ effect = parsed?.effect;
700
+ time = parsed?.time;
701
+ }
702
+ const record = entry.ghost ? undefined : recordsById.get(entry.id);
703
+ return {
704
+ sessionId: entry.id,
705
+ ...entry.parentId === undefined ? {} : { parentSessionId: entry.parentId },
706
+ createdAt: time ?? record?.header.createdAt ?? entry.createdAt,
707
+ depth,
708
+ current: entry.id === sessionId,
709
+ onCurrentPath: currentPath.has(entry.id),
710
+ ...entry.ghost ? { deleted: true } : {},
711
+ ...archived.has(entry.id) ? { archived: true } : {},
712
+ ...effect === undefined ? {} : {
713
+ operation: effect.operation,
714
+ targetTurn: effect.targetTurn,
715
+ targetEventSeq: effect.targetEventSeq,
716
+ ...effect.before === undefined ? {} : { before: effect.before },
717
+ ...effect.after === undefined ? {} : { after: effect.after },
718
+ },
719
+ turns,
720
+ };
721
+ });
722
+ return { sessionId, versions };
723
+ }
724
+
725
+ /* --------------------------------------------------------- HTTP plumbing -- */
726
+
727
+ function objectValue(value) {
728
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError('请求体必须是 JSON 对象。');
729
+ return value;
730
+ }
731
+
732
+ function sessionIdOf(value) {
733
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError('sessionId 必须是非空字符串。');
734
+ return value;
735
+ }
736
+
737
+ function integerOf(value, label) {
738
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${label} 必须是非负安全整数。`);
739
+ return value;
740
+ }
741
+
742
+ function decodeOperation(value) {
743
+ const record = objectValue(value);
744
+ const sessionId = sessionIdOf(record['sessionId']);
745
+ switch (record['action']) {
746
+ case 'edit':
747
+ if (typeof record['text'] !== 'string' || record['text'].trim().length === 0) throw new TypeError('text 必须是非空字符串。');
748
+ return {
749
+ action: 'edit',
750
+ sessionId,
751
+ eventSeq: integerOf(record['eventSeq'], 'eventSeq'),
752
+ blockIndex: integerOf(record['blockIndex'], 'blockIndex'),
753
+ text: record['text'],
754
+ stopPrevious: record['stopPrevious'] === true,
755
+ };
756
+ case 'retry':
757
+ return {
758
+ action: 'retry',
759
+ sessionId,
760
+ turn: integerOf(record['turn'], 'turn'),
761
+ stopPrevious: record['stopPrevious'] === true,
762
+ };
763
+ default:
764
+ throw new TypeError('action 必须是 edit 或 retry。');
765
+ }
766
+ }
767
+
768
+ function requestJson(request) {
769
+ return new Promise((resolve, reject) => {
770
+ const decoder = new TextDecoder();
771
+ let text = '';
772
+ request.on('data', (chunk) => {
773
+ text += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
774
+ });
775
+ request.on('end', () => {
776
+ try {
777
+ text += decoder.decode();
778
+ resolve(JSON.parse(text));
779
+ } catch (error) {
780
+ reject(error);
781
+ }
782
+ });
783
+ request.on('error', reject);
784
+ });
785
+ }
786
+
787
+ function respondJson(response, status, value) {
788
+ response.writeHead(status, {
789
+ 'content-type': 'application/json; charset=utf-8',
790
+ 'cache-control': 'no-store',
791
+ });
792
+ response.end(JSON.stringify(value));
793
+ }
794
+
795
+ async function handleRoute(ctx, request, response) {
796
+ try {
797
+ if (request.method === 'GET') {
798
+ const url = new URL(request.url ?? MESSAGE_TREE_PATH, 'http://message-tree.local');
799
+ respondJson(response, 200, await tree(ctx, sessionIdOf(url.searchParams.get('sessionId'))));
800
+ return;
801
+ }
802
+ if (request.method === 'POST') {
803
+ const body = await requestJson(request);
804
+ // activate = make an archived version navigable again. Kept apart from
805
+ // decodeOperation: it creates nothing, it only clears the archive flag.
806
+ if (body !== null && typeof body === 'object' && body.action === 'activate') {
807
+ respondJson(response, 200, await activateVersion(ctx, sessionIdOf(body.sessionId)));
808
+ return;
809
+ }
810
+ respondJson(response, 200, await runOperation(ctx, decodeOperation(body)));
811
+ return;
812
+ }
813
+ response.writeHead(405);
814
+ response.end();
815
+ } catch (error) {
816
+ const message = error instanceof Error ? error.message : String(error);
817
+ respondJson(response, error instanceof TypeError ? 400 : 409, { error: message });
818
+ }
819
+ }
820
+
821
+ function apply(ctx) {
822
+ ctx.effect(() => ctx.webServer.register({
823
+ kind: 'exact',
824
+ path: MESSAGE_TREE_PATH,
825
+ handler: (request, response) => handleRoute(ctx, request, response),
826
+ }), 'message-tree: HTTP route');
827
+ }
828
+
829
+ export { MESSAGE_TREE_PATH, MESSAGE_TREE_SCHEMA, apply, inject, name };