@morlay/ui-conversation-message-actions 0.0.10 → 0.0.12

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/src/index.ts ADDED
@@ -0,0 +1,936 @@
1
+ import { Service, type Context } from "@deepseek-ai/cordis";
2
+ import type { AssistantMessage, ContentBlock, UserMessage } from "@deepseek-ai/dsh-llm";
3
+ import type {
4
+ Session,
5
+ SessionEvent,
6
+ SessionId,
7
+ SurfaceEventType,
8
+ SurfaceIntent,
9
+ } from "@deepseek-ai/dsh-session";
10
+ import { randomUUID } from "node:crypto";
11
+ import {
12
+ SESSION_BRANCH_VERSION_SCHEMA,
13
+ SessionBranchError,
14
+ balanceRewindPrefix,
15
+ type BranchBoundary,
16
+ type BranchTimeline,
17
+ type EditableBlockKind,
18
+ type SessionBranchEffect,
19
+ type SessionBranchVersionEvent,
20
+ } from "@morlay/session-branch";
21
+ import type {
22
+ EditOperation,
23
+ EditableMessageBlock,
24
+ RerollOperation,
25
+ RetryOperation,
26
+ RetryableTurn,
27
+ SessionEditorResult,
28
+ } from "./types.ts";
29
+ import {
30
+ SESSION_EDITOR_PATH,
31
+ type SessionEditorOperation,
32
+ type SessionEditorOperationResult,
33
+ type SessionEditorTimeline,
34
+ toTimelinePayload,
35
+ } from "./shared.ts";
36
+
37
+ export type { CascadePolicy, EditableBlockKind } from "@morlay/session-branch";
38
+ export type {
39
+ EditableMessageBlock,
40
+ EditOperation,
41
+ RerollOperation,
42
+ RetryOperation,
43
+ RetryableTurn,
44
+ RewindOperation,
45
+ SessionEditorOperation,
46
+ SessionEditorResult,
47
+ } from "./types.ts";
48
+
49
+ export { closedTurns, editableMessages, retryableTurns };
50
+ export { SESSION_BRANCH_VERSION_SCHEMA } from "@morlay/session-branch";
51
+
52
+ export type { BranchTimeline, SessionBranchVersionEvent } from "@morlay/session-branch";
53
+
54
+ declare module "@deepseek-ai/cordis" {
55
+ interface Context {
56
+ sessionEditor: SessionEditor;
57
+ }
58
+ }
59
+
60
+ export interface EditorAgent {
61
+ readonly session: Session;
62
+ followup(message: UserMessage): void;
63
+ }
64
+
65
+ export interface EditorAgentHandle {
66
+ readonly agent: EditorAgent;
67
+ dispose(): Promise<void>;
68
+ }
69
+
70
+ export interface EditorAgentRegistry {
71
+ get(sessionId: SessionId): EditorAgent | undefined;
72
+ create(options: {
73
+ sessionId?: SessionId;
74
+ seed?: readonly SessionEvent[];
75
+ meta?: {
76
+ cwd?: string;
77
+ parentSession?: SessionId;
78
+ seedLength?: number;
79
+ agentPreset?: string;
80
+ };
81
+ agentOptions?: { provider: string; model: string; maxTokens?: number };
82
+ }): Promise<EditorAgentHandle>;
83
+
84
+ resume(options: {
85
+ resumeSessionId: SessionId;
86
+ agentOptions?: { provider: string; model: string; maxTokens?: number };
87
+ }): Promise<EditorAgentHandle>;
88
+ }
89
+
90
+ interface ClosedTurn {
91
+ turn: number;
92
+ startSeq: number;
93
+
94
+ endSeq?: number;
95
+
96
+ closed: boolean;
97
+ user?: SessionEvent<"user/message">;
98
+ assistants: SessionEvent<"assistant/message">[];
99
+ }
100
+
101
+ function closedTurns(events: readonly SessionEvent[]): ClosedTurn[] {
102
+ const result: ClosedTurn[] = [];
103
+ let current: Omit<ClosedTurn, "endSeq" | "closed"> | undefined;
104
+ for (const event of events) {
105
+ if (event.type === "turn/start") {
106
+ current = { turn: event.data.turn, startSeq: event.seq, assistants: [] };
107
+ continue;
108
+ }
109
+ if (current === undefined) continue;
110
+ if (
111
+ event.type === "user/message" &&
112
+ current.user === undefined &&
113
+ event.data.source.kind === "user"
114
+ ) {
115
+ current.user = event;
116
+ continue;
117
+ }
118
+ if (event.type === "assistant/message" && event.data.turn === current.turn) {
119
+ current.assistants.push(event);
120
+ continue;
121
+ }
122
+ if (event.type === "turn/end" && event.data.turn === current.turn) {
123
+ result.push({ ...current, endSeq: event.seq, closed: true });
124
+ current = undefined;
125
+ }
126
+ }
127
+ // 未闭合轮次(无 turn/end)也保留:user 消息已落定,编辑时 rewind 到该
128
+ // 消息(exclusive drop)重放即可。
129
+ if (current !== undefined) result.push({ ...current, closed: false });
130
+ return result;
131
+ }
132
+
133
+ function isTextualBlock(
134
+ block: ContentBlock | undefined,
135
+ ): block is Extract<ContentBlock, { type: "text" | "reasoning" }> {
136
+ return block?.type === "text" || block?.type === "reasoning";
137
+ }
138
+
139
+ function userText(message: UserMessage): string {
140
+ return message.content
141
+ .filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
142
+ .map((block) => block.text)
143
+ .join("\n");
144
+ }
145
+
146
+ function cloneUser(
147
+ message: UserMessage,
148
+ content: ContentBlock[] = structuredClone(message.content),
149
+ ): UserMessage {
150
+ return Object.freeze({
151
+ id: randomUUID(),
152
+ role: "user",
153
+ content: Object.freeze(content),
154
+ source: Object.freeze({ kind: "user" }),
155
+ }) as UserMessage;
156
+ }
157
+
158
+ function replaceTextBlock(
159
+ content: readonly ContentBlock[],
160
+ blockIndex: number,
161
+ text: string,
162
+ ): ContentBlock[] {
163
+ const block = content[blockIndex];
164
+ if (!isTextualBlock(block))
165
+ throw new SessionBranchError("所选内容块不是可编辑文本。", "INVALID_BOUNDARY");
166
+ return content.map((candidate, index) =>
167
+ index === blockIndex ? ({ ...candidate, text } as ContentBlock) : structuredClone(candidate),
168
+ );
169
+ }
170
+
171
+ function editableMessages(turns: readonly ClosedTurn[]): EditableMessageBlock[] {
172
+ const result: EditableMessageBlock[] = [];
173
+ for (const turn of turns) {
174
+ if (turn.user !== undefined) {
175
+ for (const [blockIndex, block] of turn.user.data.content.entries()) {
176
+ if (block.type !== "text") continue;
177
+ result.push({
178
+ key: `${String(turn.user.seq)}:${String(blockIndex)}`,
179
+ turn: turn.turn,
180
+ eventSeq: turn.user.seq,
181
+ blockIndex,
182
+ kind: "user",
183
+ text: block.text,
184
+ time: turn.user.time,
185
+ });
186
+ }
187
+ }
188
+ // 未闭合轮次的助手消息是流式 partial,不可编辑。
189
+ if (!turn.closed) continue;
190
+ for (const event of turn.assistants) {
191
+ for (const [blockIndex, block] of event.data.message.content.entries()) {
192
+ if (!isTextualBlock(block)) continue;
193
+ result.push({
194
+ key: `${String(event.seq)}:${String(blockIndex)}`,
195
+ turn: turn.turn,
196
+ eventSeq: event.seq,
197
+ blockIndex,
198
+ kind: block.type === "reasoning" ? "assistant.reasoning" : "assistant.response",
199
+ text: block.text,
200
+ time: event.time,
201
+ });
202
+ }
203
+ }
204
+ }
205
+ return result;
206
+ }
207
+
208
+ function retryableTurns(turns: readonly ClosedTurn[]): RetryableTurn[] {
209
+ return turns.flatMap((turn): RetryableTurn[] =>
210
+ // 未闭合轮次无已落定回复可重生成,不可重试。
211
+ turn.user === undefined || !turn.closed
212
+ ? []
213
+ : [
214
+ {
215
+ turn: turn.turn,
216
+ userEventSeq: turn.user.seq,
217
+ preview: userText(turn.user.data),
218
+ time: turn.user.time,
219
+ },
220
+ ],
221
+ );
222
+ }
223
+
224
+ function downstreamUsers(turns: readonly ClosedTurn[], start: number): UserMessage[] {
225
+ return turns
226
+ .slice(start)
227
+ .flatMap((turn): UserMessage[] => (turn.user === undefined ? [] : [cloneUser(turn.user.data)]));
228
+ }
229
+
230
+ function assistantReplacement(
231
+ event: SessionEvent<"assistant/message">,
232
+ blockIndex: number,
233
+ text: string,
234
+ ): AssistantMessage {
235
+ const replaced = replaceTextBlock(event.data.message.content, blockIndex, text).filter(
236
+ (block) => block.type === "text" || block.type === "reasoning",
237
+ );
238
+ return Object.freeze({
239
+ id: randomUUID(),
240
+ role: "assistant",
241
+ content: Object.freeze(replaced),
242
+ source: Object.freeze({
243
+ kind: "model",
244
+ provider: event.data.message.source.provider,
245
+ model: event.data.message.source.model,
246
+ }),
247
+ }) as AssistantMessage;
248
+ }
249
+
250
+ interface OperationPlan {
251
+ anchorSeq: number;
252
+
253
+ rewindBoundary?: number;
254
+ version: SessionBranchVersionEvent;
255
+
256
+ manualTurn?: { turn: number; user: UserMessage; assistant: AssistantMessage };
257
+
258
+ queuedUsers: UserMessage[];
259
+ }
260
+
261
+ function pairVersionEffect(
262
+ sourceSessionId: SessionId,
263
+ effect: Omit<SessionBranchEffect, "id">,
264
+ ): SessionBranchVersionEvent {
265
+ return {
266
+ schemaVersion: SESSION_BRANCH_VERSION_SCHEMA,
267
+ effect: { ...effect, id: randomUUID() },
268
+ inverse: { kind: "restore-version", sessionId: sourceSessionId },
269
+ };
270
+ }
271
+
272
+ function editPlan(operation: EditOperation, turns: readonly ClosedTurn[]): OperationPlan {
273
+ const turnIndex = turns.findIndex(
274
+ (turn) =>
275
+ operation.eventSeq > turn.startSeq &&
276
+ (turn.endSeq === undefined || operation.eventSeq < turn.endSeq),
277
+ );
278
+ const turn = turns[turnIndex];
279
+ if (turn === undefined)
280
+ throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
281
+ const event =
282
+ turn.user?.seq === operation.eventSeq
283
+ ? turn.user
284
+ : turn.assistants.find((candidate) => candidate.seq === operation.eventSeq);
285
+ if (event === undefined)
286
+ throw new SessionBranchError("所选消息不存在或不可编辑。", "INVALID_BOUNDARY");
287
+
288
+ if (event.type === "user/message") {
289
+ const before = event.data.content[operation.blockIndex];
290
+ if (before?.type !== "text")
291
+ throw new SessionBranchError("所选用户消息块不是文本。", "INVALID_BOUNDARY");
292
+ const edited = cloneUser(
293
+ event.data,
294
+ replaceTextBlock(event.data.content, operation.blockIndex, operation.text),
295
+ );
296
+ const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
297
+ return {
298
+ anchorSeq: turn.startSeq,
299
+ // 未闭合轮次:rewind 到该 user 消息(exclusive drop),由编辑版替换;
300
+ // 闭合轮次走前一轮 turn/end 的 inclusive 语义。
301
+ ...(turn.closed ? {} : { rewindBoundary: event.seq }),
302
+ version: pairVersionEffect(operation.sessionId, {
303
+ operation: "edit",
304
+ cascade: operation.cascade,
305
+ targetTurn: turn.turn,
306
+ targetEventSeq: event.seq,
307
+ targetBlockIndex: operation.blockIndex,
308
+ blockKind: "user",
309
+ before: before.text,
310
+ after: operation.text,
311
+ }),
312
+ queuedUsers: [edited, ...later],
313
+ };
314
+ }
315
+
316
+ // 未闭合轮次的助手消息是流式 partial,无最终内容可编辑。
317
+ if (!turn.closed)
318
+ throw new SessionBranchError("未闭合轮次的助手消息不可编辑。", "INVALID_BOUNDARY");
319
+ const before = event.data.message.content[operation.blockIndex];
320
+ if (!isTextualBlock(before))
321
+ throw new SessionBranchError("所选助手消息块不是文本或思考。", "INVALID_BOUNDARY");
322
+ const blockKind: EditableBlockKind =
323
+ before.type === "reasoning" ? "assistant.reasoning" : "assistant.response";
324
+ if (turn.user === undefined)
325
+ throw new SessionBranchError("所选助手消息没有可重建的用户输入。", "INVALID_BOUNDARY");
326
+ return {
327
+ anchorSeq: turn.startSeq,
328
+ version: pairVersionEffect(operation.sessionId, {
329
+ operation: "edit",
330
+ cascade: operation.cascade,
331
+ targetTurn: turn.turn,
332
+ targetEventSeq: event.seq,
333
+ targetBlockIndex: operation.blockIndex,
334
+ blockKind,
335
+ before: before.text,
336
+ after: operation.text,
337
+ }),
338
+ manualTurn: {
339
+ turn: turn.turn,
340
+ user: cloneUser(turn.user.data),
341
+ assistant: assistantReplacement(event, operation.blockIndex, operation.text),
342
+ },
343
+ queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [],
344
+ };
345
+ }
346
+
347
+ function retryPlan(operation: RetryOperation, turns: readonly ClosedTurn[]): OperationPlan {
348
+ const turnIndex = turns.findIndex((turn) => turn.turn === operation.turn);
349
+ const turn = turns[turnIndex];
350
+ if (turn?.user === undefined)
351
+ throw new SessionBranchError("所选回合没有可重放的用户输入。", "INVALID_BOUNDARY");
352
+ return {
353
+ anchorSeq: turn.startSeq,
354
+ version: pairVersionEffect(operation.sessionId, {
355
+ operation: "retry",
356
+ cascade: operation.cascade,
357
+ targetTurn: turn.turn,
358
+ targetEventSeq: turn.user.seq,
359
+ }),
360
+ queuedUsers:
361
+ operation.cascade === "preserve"
362
+ ? downstreamUsers(turns, turnIndex)
363
+ : [cloneUser(turn.user.data)],
364
+ };
365
+ }
366
+
367
+ function rerollPlan(operation: RerollOperation, turns: readonly ClosedTurn[]): OperationPlan {
368
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
369
+ const turn = turns[index];
370
+ // 未闭合轮次无已落定的助手回复可重生成。
371
+ if (turn?.user === undefined || !turn.closed) continue;
372
+ const target = turn.assistants.findLast((event) =>
373
+ event.data.message.content.some(isTextualBlock),
374
+ );
375
+ if (target === undefined) continue;
376
+ return {
377
+ anchorSeq: turn.startSeq,
378
+ version: pairVersionEffect(operation.sessionId, {
379
+ operation: "reroll",
380
+ cascade: "truncate",
381
+ targetTurn: turn.turn,
382
+ targetEventSeq: target.seq,
383
+ }),
384
+ queuedUsers: [cloneUser(turn.user.data)],
385
+ };
386
+ }
387
+ throw new SessionBranchError("当前会话没有可重生成的已落定助手回复。", "INVALID_BOUNDARY");
388
+ }
389
+
390
+ function appendLogSeedEvent(
391
+ events: SessionEvent[],
392
+ type: string,
393
+ data: unknown,
394
+ ignorable = false,
395
+ ): void {
396
+ events.push({
397
+ type: type as SessionEvent["type"],
398
+ seq: events.length,
399
+ time: Date.now(),
400
+ data: data as SessionEvent["data"],
401
+ ...(ignorable ? { ignorable: true as const } : {}),
402
+ } as SessionEvent);
403
+ }
404
+
405
+ function appendSurfaceSeedEvent<T extends SurfaceEventType>(
406
+ events: SessionEvent[],
407
+ type: T,
408
+ data: import("@deepseek-ai/dsh-session").SessionEvent<T>["data"],
409
+ intent: SurfaceIntent,
410
+ ): void {
411
+ events.push({
412
+ type,
413
+ seq: events.length,
414
+ time: Date.now(),
415
+ data,
416
+ surfaceOp: intent.surfaceOp,
417
+ ...(intent.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: intent.sourceEventSeqs }),
418
+ } as SessionEvent<T>);
419
+ }
420
+
421
+ function appendManualTurn(
422
+ events: SessionEvent[],
423
+ manual: { turn: number; user: UserMessage; assistant: AssistantMessage },
424
+ ): void {
425
+ const { turn, user, assistant } = manual;
426
+ appendLogSeedEvent(events, "turn/start", { turn });
427
+ appendSurfaceSeedEvent(events, "user/message", user, { surfaceOp: "append" });
428
+ appendLogSeedEvent(events, "step/start", { turn, step: 1 });
429
+ appendSurfaceSeedEvent(
430
+ events,
431
+ "assistant/message",
432
+ { turn, step: 1, message: assistant },
433
+ {
434
+ surfaceOp: "append",
435
+ sourceEventSeqs: [],
436
+ },
437
+ );
438
+ appendLogSeedEvent(events, "step/end", { turn, step: 1 });
439
+ appendLogSeedEvent(events, "turn/end", {
440
+ turn,
441
+ reason: { kind: "completed" },
442
+ });
443
+ }
444
+
445
+ function appendSeedSuffixLive(session: Session, seedSuffix: readonly SessionEvent[]): void {
446
+ for (const event of seedSuffix) {
447
+ // 版本效果事件携带 ignorable 标记(上游类型无此字段,duck-type 读取)。
448
+ const ignorable = (event as { ignorable?: boolean }).ignorable === true;
449
+ if (ignorable) {
450
+ const s = session as unknown as {
451
+ log: SessionEvent[];
452
+ eventsSnapshot?: unknown;
453
+ };
454
+ // ignorable 语义:live log 保留、不落 canonical log。session.append 不
455
+ // 保留 ignorable 标记,因此直接 push 内存 log(不发布);seq 按 log
456
+ // 续接重编号(seedSuffix 内部编号从 0 起)。
457
+ s.log.push({ ...event, seq: s.log.length } as SessionEvent);
458
+ s.eventsSnapshot = undefined;
459
+ continue;
460
+ }
461
+ const s = session as unknown as {
462
+ append(
463
+ type: string,
464
+ data: unknown,
465
+ opts?: { surfaceOp?: unknown; sourceEventSeqs?: readonly number[] },
466
+ ): SessionEvent;
467
+ };
468
+ const raw = event as SessionEvent & {
469
+ surfaceOp?: unknown;
470
+ sourceEventSeqs?: readonly number[];
471
+ };
472
+ if (raw.surfaceOp !== undefined) {
473
+ s.append(event.type, event.data, {
474
+ surfaceOp: raw.surfaceOp,
475
+ ...(raw.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: raw.sourceEventSeqs }),
476
+ });
477
+ } else {
478
+ s.append(event.type, event.data);
479
+ }
480
+ }
481
+ }
482
+
483
+ export class SessionEditor extends Service {
484
+ static inject = ["sessionBranch", "sessionPersistence", "sessions"];
485
+
486
+ constructor(ctx: Context) {
487
+ super(ctx, "sessionEditor");
488
+ // HTTP 路由随类构造注册(dsh 用 default 类插件,apply 不被调用);
489
+ // bundles 顺序保证 webserver 先于本类实例化。
490
+ registerHttpRoutes(ctx);
491
+ }
492
+
493
+ readBranchPrefix(
494
+ id: SessionId,
495
+ atSeq?: number,
496
+ mode?: "after" | "before",
497
+ signal?: AbortSignal,
498
+ ): Promise<BranchBoundary> {
499
+ return this.ctx.sessionBranch.readBranchPrefix(id, atSeq, mode, signal);
500
+ }
501
+
502
+ fork(
503
+ sourceId: SessionId,
504
+ atSeq?: number,
505
+ childSessionId?: SessionId,
506
+ meta?: { cwd?: string; agentPreset?: string },
507
+ signal?: AbortSignal,
508
+ ): Promise<SessionId> {
509
+ return this.ctx.sessionBranch.forkFrom(
510
+ sourceId,
511
+ {
512
+ ...(atSeq === undefined ? {} : { atSeq }),
513
+ ...(childSessionId === undefined ? {} : { childSessionId }),
514
+ ...(meta === undefined ? {} : { meta }),
515
+ },
516
+ signal,
517
+ );
518
+ }
519
+
520
+ rewind(id: SessionId, toBoundary: number, signal?: AbortSignal) {
521
+ return this.ctx.sessionBranch.rewind(id, toBoundary, signal);
522
+ }
523
+
524
+ timeline(sessionId: SessionId, signal?: AbortSignal): Promise<BranchTimeline> {
525
+ return this.ctx.sessionBranch.timeline(sessionId, signal);
526
+ }
527
+
528
+ edit(operation: EditOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
529
+ return this.branchOperation(operation, signal);
530
+ }
531
+
532
+ reroll(operation: RerollOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
533
+ return this.branchOperation(operation, signal);
534
+ }
535
+
536
+ retry(operation: RetryOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
537
+ return this.branchOperation(operation, signal);
538
+ }
539
+
540
+ async editableMessages(
541
+ sessionId: SessionId,
542
+ signal?: AbortSignal,
543
+ ): Promise<EditableMessageBlock[]> {
544
+ const events = await this.readEvents(sessionId, signal);
545
+ return editableMessages(closedTurns(events));
546
+ }
547
+
548
+ async retryableTurns(sessionId: SessionId, signal?: AbortSignal): Promise<RetryableTurn[]> {
549
+ const events = await this.readEvents(sessionId, signal);
550
+ return retryableTurns(closedTurns(events));
551
+ }
552
+
553
+ private async branchOperation(
554
+ operation: EditOperation | RerollOperation | RetryOperation,
555
+ signal?: AbortSignal,
556
+ ): Promise<SessionEditorResult> {
557
+ signal?.throwIfAborted();
558
+ const events = await this.readEvents(operation.sessionId, signal);
559
+ const turns = closedTurns(events);
560
+ const plan =
561
+ operation.action === "edit"
562
+ ? editPlan(operation, turns)
563
+ : operation.action === "retry"
564
+ ? retryPlan(operation, turns)
565
+ : rerollPlan(operation, turns);
566
+ // rewind 前解析模型配置:就地编辑可能截断最后的 request/header
567
+ // (编辑第一轮 boundary = -1 清空全部),重放 agent 需要 provider/model。
568
+ const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header
569
+ .config;
570
+
571
+ // 派生 seed 后缀:版本效果 + 可选手工回合。版本事件对核心是 ignorable,
572
+ // 保证非 branch 读者可安全跳过。
573
+ const seedSuffix: SessionEvent[] = [];
574
+ appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
575
+ if (plan.manualTurn !== undefined) appendManualTurn(seedSuffix, plan.manualTurn);
576
+
577
+ // 就地编辑:不创建新会话、不改变 id。先 rewind 截断到目标轮之前的闭合
578
+ // 边界,再 append 版本效果与重放输入,最后(可选)驱动 agent 重放。
579
+ const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
580
+ const boundary =
581
+ plan.rewindBoundary !== undefined
582
+ ? plan.rewindBoundary
583
+ : turnIndex <= 0
584
+ ? -1
585
+ : turns[turnIndex - 1]!.endSeq!;
586
+ const live = this.ctx.sessions.get(operation.sessionId);
587
+ await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
588
+ if (seedSuffix.length > 0) {
589
+ if (live !== undefined) {
590
+ // live:版本效果 push 内存 log(ignorable 保留)、manualTurn 走 append,
591
+ // 同步 cursor 后显式 flush(push 不发布、不进缓冲,cursor 会落后)。
592
+ appendSeedSuffixLive(live, seedSuffix);
593
+ this.ctx.sessionBranch.syncLiveCursor(operation.sessionId);
594
+ await this.ctx.sessions.flush(live);
595
+ } else {
596
+ // cold:续写 seq 从平衡后的保留前缀接续(exclusive 截断可能残留
597
+ // 孤儿 step/start,落盘 log 对 token meter 重放非法)。
598
+ const rawKeepLength = boundary + (plan.rewindBoundary === undefined ? 1 : 0);
599
+ const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
600
+ const renumbered = seedSuffix.map(
601
+ (event, index) =>
602
+ ({
603
+ ...event,
604
+ seq: keepLength + index,
605
+ }) as SessionEvent,
606
+ );
607
+ await this.ctx.sessionPersistence.append(operation.sessionId, renumbered);
608
+ }
609
+ }
610
+
611
+ // 可选增强:agent 驱动(重放排队用户输入)。缺失 agents 服务时退化为
612
+ // 已 durable 的就地版本。
613
+ const queuedTurns = await this.driveAgent(
614
+ operation.sessionId,
615
+ plan.queuedUsers,
616
+ signal,
617
+ headerConfig,
618
+ );
619
+ return {
620
+ sessionId: operation.sessionId,
621
+ queuedTurns,
622
+ // 操作后是否仍有 live owner(driveAgent 可能 resume 出 agent)。
623
+ live: this.ctx.sessions.get(operation.sessionId) !== undefined,
624
+ };
625
+ }
626
+
627
+ private async readEvents(
628
+ sessionId: SessionId,
629
+ signal?: AbortSignal,
630
+ ): Promise<readonly SessionEvent[]> {
631
+ const live = this.ctx.sessions.get(sessionId);
632
+ if (live !== undefined) return live.snapshotEvents();
633
+ // cold:读原始事件(loadStored 不补 closers)——inspect 会把未闭合 log
634
+ // 补成闭合,编辑未闭合轮次的 user 消息会走错边界。
635
+ const branch = this.ctx.sessionBranch as unknown as {
636
+ readRawEvents(
637
+ id: SessionId,
638
+ signal?: AbortSignal,
639
+ ): Promise<{ meta: unknown; events: readonly SessionEvent[] }>;
640
+ };
641
+ return (await branch.readRawEvents(sessionId, signal)).events;
642
+ }
643
+
644
+ private async driveAgent(
645
+ sessionId: SessionId,
646
+ queuedUsers: readonly UserMessage[],
647
+ signal?: AbortSignal,
648
+ headerConfig?: { provider?: string; model?: string; maxTokens?: number },
649
+ ): Promise<number> {
650
+ if (queuedUsers.length === 0) return 0;
651
+ signal?.throwIfAborted();
652
+ const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
653
+ if (agents === undefined) return 0;
654
+ const provider = headerConfig?.provider ?? "";
655
+ const model = headerConfig?.model ?? "";
656
+ if (provider.length === 0 || model.length === 0) {
657
+ // 兜底:从当前会话 events 解析(headerConfig 未提供时)。
658
+ const events = await this.readEvents(sessionId, signal);
659
+ const config = events.findLast((event) => event.type === "request/header")?.data.header
660
+ .config;
661
+ const fallbackProvider = config?.provider ?? "";
662
+ const fallbackModel = config?.model ?? "";
663
+ if (fallbackProvider.length === 0 || fallbackModel.length === 0) return 0;
664
+ return this.queueThroughAgent(
665
+ sessionId,
666
+ queuedUsers,
667
+ signal,
668
+ fallbackProvider,
669
+ fallbackModel,
670
+ );
671
+ }
672
+ return this.queueThroughAgent(sessionId, queuedUsers, signal, provider, model);
673
+ }
674
+
675
+ private async queueThroughAgent(
676
+ sessionId: SessionId,
677
+ queuedUsers: readonly UserMessage[],
678
+ signal: AbortSignal | undefined,
679
+ provider: string,
680
+ model: string,
681
+ ): Promise<number> {
682
+ const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
683
+ if (agents === undefined) return 0;
684
+ // 现有 live agent:直接排队输入(其 session 内存已被 rewind 截断,
685
+ // followup 基于截断后历史重放,不改 id、不重建)。
686
+ const existing = agents.get(sessionId);
687
+ if (existing !== undefined) {
688
+ for (const message of queuedUsers) existing.followup(message);
689
+ await this.ctx.sessions.flush(existing.session);
690
+ return queuedUsers.length;
691
+ }
692
+ // cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
693
+ // resume 后 agent 驻留,不 dispose(dispose 会破坏客户端打开的窗口)。
694
+ const handle = await agents
695
+ .resume({ resumeSessionId: sessionId, agentOptions: { provider, model } })
696
+ .catch((error: unknown) => {
697
+ // agent 组合失败不应使已 durable 的版本失效:退化为持久化版本。
698
+ this.ctx.logger.warn(
699
+ "session-editor: agent resume failed (%s); version remains durable",
700
+ String(error),
701
+ );
702
+ return undefined;
703
+ });
704
+ if (handle === undefined) return 0;
705
+ for (const message of queuedUsers) handle.agent.followup(message);
706
+ await this.ctx.sessions.flush(handle.agent.session);
707
+ return queuedUsers.length;
708
+ }
709
+ }
710
+
711
+ export default SessionEditor;
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // HTTP 面(host):GET /session-editor(timeline 投影)/ POST /session-editor
715
+ // (edit | reroll | retry | rewind | fork)。
716
+ // ---------------------------------------------------------------------------
717
+
718
+ interface HttpRequestLike {
719
+ method?: string;
720
+ url?: string;
721
+ on(event: "data", listener: (chunk: Uint8Array | string) => void): this;
722
+ on(event: "end", listener: () => void): this;
723
+ on(event: "error", listener: (error: unknown) => void): this;
724
+ }
725
+
726
+ interface HttpResponseLike {
727
+ writeHead(status: number, headers?: Record<string, string>): unknown;
728
+ end(body?: string): void;
729
+ }
730
+
731
+ interface HttpServerLike {
732
+ register(route: {
733
+ kind: "exact";
734
+ path: string;
735
+ handler: (request: HttpRequestLike, response: HttpResponseLike) => void | Promise<void>;
736
+ }): () => void;
737
+ }
738
+
739
+ declare module "@deepseek-ai/cordis" {
740
+ interface Context {
741
+ webServer: HttpServerLike;
742
+ }
743
+ }
744
+
745
+ function objectValue(value: unknown): Record<string, unknown> {
746
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
747
+ throw new TypeError("请求体必须是 JSON 对象。");
748
+ }
749
+ return value as Record<string, unknown>;
750
+ }
751
+
752
+ function sessionIdOf(value: unknown): SessionId {
753
+ if (typeof value !== "string" || value.length === 0)
754
+ throw new TypeError("sessionId 必须是非空字符串。");
755
+ return value as SessionId;
756
+ }
757
+
758
+ function integerOf(value: unknown, name: string): number {
759
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
760
+ throw new TypeError(`${name} 必须是非负安全整数。`);
761
+ }
762
+ return value as number;
763
+ }
764
+
765
+ function cascadeOf(value: unknown): import("@morlay/session-branch").CascadePolicy {
766
+ if (value !== "truncate" && value !== "preserve")
767
+ throw new TypeError("cascade 必须是 truncate 或 preserve。");
768
+ return value;
769
+ }
770
+
771
+ function decodeOperation(value: unknown): SessionEditorOperation {
772
+ const record = objectValue(value);
773
+ const sessionId = sessionIdOf(record["sessionId"]);
774
+ switch (record["action"]) {
775
+ case "edit":
776
+ if (typeof record["text"] !== "string") throw new TypeError("text 必须是字符串。");
777
+ return {
778
+ action: "edit",
779
+ sessionId,
780
+ eventSeq: integerOf(record["eventSeq"], "eventSeq"),
781
+ blockIndex: integerOf(record["blockIndex"], "blockIndex"),
782
+ text: record["text"],
783
+ cascade: cascadeOf(record["cascade"]),
784
+ };
785
+ case "reroll":
786
+ return { action: "reroll", sessionId };
787
+ case "retry":
788
+ return {
789
+ action: "retry",
790
+ sessionId,
791
+ turn: integerOf(record["turn"], "turn"),
792
+ cascade: cascadeOf(record["cascade"]),
793
+ };
794
+ case "rewind":
795
+ return {
796
+ action: "rewind",
797
+ sessionId,
798
+ toBoundary: integerOf(record["toBoundary"], "toBoundary"),
799
+ };
800
+ case "fork":
801
+ return {
802
+ action: "fork",
803
+ sessionId,
804
+ ...(record["atSeq"] === undefined ? {} : { atSeq: integerOf(record["atSeq"], "atSeq") }),
805
+ ...(record["childSessionId"] === undefined
806
+ ? {}
807
+ : { childSessionId: sessionIdOf(record["childSessionId"]) }),
808
+ };
809
+ default:
810
+ throw new TypeError("action 必须是 edit、reroll、retry、rewind 或 fork。");
811
+ }
812
+ }
813
+
814
+ function requestJson(request: HttpRequestLike): Promise<unknown> {
815
+ return new Promise((resolve, reject) => {
816
+ const decoder = new TextDecoder();
817
+ let text = "";
818
+ request.on("data", (chunk) => {
819
+ text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
820
+ });
821
+ request.on("end", () => {
822
+ try {
823
+ text += decoder.decode();
824
+ resolve(JSON.parse(text) as unknown);
825
+ } catch (error) {
826
+ reject(error);
827
+ }
828
+ });
829
+ request.on("error", reject);
830
+ });
831
+ }
832
+
833
+ function respondJson(response: HttpResponseLike, status: number, value: unknown): void {
834
+ response.writeHead(status, {
835
+ "content-type": "application/json; charset=utf-8",
836
+ "cache-control": "no-store",
837
+ });
838
+ response.end(JSON.stringify(value));
839
+ }
840
+
841
+ async function readTimeline(
842
+ editor: SessionEditor,
843
+ sessionId: SessionId,
844
+ ): Promise<SessionEditorTimeline> {
845
+ const timeline = await editor.timeline(sessionId);
846
+ const messages = await editor.editableMessages(sessionId);
847
+ const retryable = await editor.retryableTurns(sessionId);
848
+ return toTimelinePayload(sessionId, timeline, messages, retryable);
849
+ }
850
+
851
+ async function runOperation(
852
+ editor: SessionEditor,
853
+ operation: SessionEditorOperation,
854
+ ): Promise<SessionEditorOperationResult> {
855
+ switch (operation.action) {
856
+ case "edit": {
857
+ const result = await editor.edit(operation);
858
+ return {
859
+ sessionId: result.sessionId,
860
+ queuedTurns: result.queuedTurns,
861
+ ...(result.live === undefined ? {} : { live: result.live }),
862
+ };
863
+ }
864
+ case "reroll": {
865
+ const result = await editor.reroll(operation);
866
+ return {
867
+ sessionId: result.sessionId,
868
+ queuedTurns: result.queuedTurns,
869
+ ...(result.live === undefined ? {} : { live: result.live }),
870
+ };
871
+ }
872
+ case "retry": {
873
+ const result = await editor.retry(operation);
874
+ return {
875
+ sessionId: result.sessionId,
876
+ queuedTurns: result.queuedTurns,
877
+ ...(result.live === undefined ? {} : { live: result.live }),
878
+ };
879
+ }
880
+ case "rewind":
881
+ await editor.rewind(operation.sessionId, operation.toBoundary);
882
+ return { sessionId: operation.sessionId, queuedTurns: 0 };
883
+ case "fork":
884
+ return {
885
+ sessionId: await editor.fork(
886
+ operation.sessionId,
887
+ operation.atSeq,
888
+ operation.childSessionId,
889
+ ),
890
+ queuedTurns: 0,
891
+ };
892
+ }
893
+ }
894
+
895
+ async function handleRoute(
896
+ editor: SessionEditor,
897
+ request: HttpRequestLike,
898
+ response: HttpResponseLike,
899
+ ): Promise<void> {
900
+ try {
901
+ if (request.method === "GET") {
902
+ const url = new URL(request.url ?? SESSION_EDITOR_PATH, "http://session-editor.local");
903
+ const sessionId = sessionIdOf(url.searchParams.get("sessionId"));
904
+ respondJson(response, 200, await readTimeline(editor, sessionId));
905
+ return;
906
+ }
907
+ if (request.method === "POST") {
908
+ respondJson(
909
+ response,
910
+ 200,
911
+ await runOperation(editor, decodeOperation(await requestJson(request))),
912
+ );
913
+ return;
914
+ }
915
+ response.writeHead(405);
916
+ response.end();
917
+ } catch (error: unknown) {
918
+ const message = error instanceof Error ? error.message : String(error);
919
+ respondJson(response, error instanceof TypeError ? 400 : 409, {
920
+ error: message,
921
+ });
922
+ }
923
+ }
924
+
925
+ function registerHttpRoutes(ctx: Context): void {
926
+ const webServer = ctx.get("webServer") as HttpServerLike | undefined;
927
+ if (webServer === undefined) return;
928
+ ctx.effect(() => {
929
+ const editor = ctx.sessionEditor;
930
+ return webServer.register({
931
+ kind: "exact",
932
+ path: SESSION_EDITOR_PATH,
933
+ handler: (request, response) => handleRoute(editor, request, response),
934
+ });
935
+ }, "session-editor: HTTP route");
936
+ }