@morlay/session-branch 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/cordis.patch.yml +3 -0
- package/dist/index.d.mts +121 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +110 -0
- package/dist/index.mjs.map +1 -0
- package/dist/invariant.d.mts +8 -0
- package/dist/invariant.d.mts.map +1 -0
- package/dist/invariant.mjs +10 -0
- package/dist/invariant.mjs.map +1 -0
- package/package.json +45 -0
- package/src/balance.ts +23 -0
- package/src/branch.ts +35 -0
- package/src/index.ts +20 -0
- package/src/invariant.ts +13 -0
- package/src/provider.ts +28 -0
- package/src/timeline.ts +122 -0
- package/src/types.ts +125 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 morlay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @morlay/session-branch
|
|
2
|
+
|
|
3
|
+
分支式会话编辑的**契约层**:定义数据层分支原语 `SessionBranchProvider`
|
|
4
|
+
(rewind / forkFrom / readBranchPrefix)、高层服务 `SessionBranch`
|
|
5
|
+
(`ctx.sessionBranch`)与共享的版本树投影 `buildTimeline`。
|
|
6
|
+
|
|
7
|
+
## 与上游 `SessionPersistence` 的关系
|
|
8
|
+
|
|
9
|
+
| 面 | 上游 `PersistenceBackend` | 本包 `SessionBranchProvider` |
|
|
10
|
+
| ------ | --------------------------------------------- | ------------------------------------------------------ |
|
|
11
|
+
| 覆盖 | 持久读写 + 崩溃修复(append-only) | 显式回退 + 闭合边界派生(分支面) |
|
|
12
|
+
| 原语 | `loadStored` / `appendBatch` / `commitRepair` | `readBranchPrefix` / `forkFrom` / `rewind` |
|
|
13
|
+
| 实现方 | JSONL / RDB 等 | RDB 等(`@morlay/session-rdb` 同时实现两者,形成闭环) |
|
|
14
|
+
|
|
15
|
+
## Provider 抽象
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
interface SessionBranchProvider {
|
|
19
|
+
readBranchPrefix(id, atSeq?, mode?, signal?): Promise<BranchBoundary>;
|
|
20
|
+
forkFrom(sourceId, options?, signal?): Promise<SessionId>;
|
|
21
|
+
rewind(id, toBoundary, signal?): Promise<SessionPersistenceSnapshot>;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `readBranchPrefix`:定位 `atSeq` 锚定的闭合 `turn/end` 边界并返回前缀;
|
|
26
|
+
`mode: "after"`(包含目标轮,fork 语义)/ `"before"`(排除目标轮,编辑 /
|
|
27
|
+
重掷 / 重试语义)。
|
|
28
|
+
- `forkFrom`:纯 append——新会话(`parentSession` / `seedLength`),seed =
|
|
29
|
+
边界前缀 + `seedSuffix`,不触碰源会话。
|
|
30
|
+
- `rewind`:唯一改写事件 log 的操作,事务整体提交或回滚;**支持 live
|
|
31
|
+
会话**(内存 log 截断 + 派生缓存复位 + coordinator cursor 同步,见
|
|
32
|
+
`@morlay/session-rdb` 的 `SessionBranchRdbProvider`)。
|
|
33
|
+
- `syncLiveCursor(sessionId)`:编排层把 ignorable 版本效果 push 进 live log
|
|
34
|
+
后(不发布、不进 write-behind 缓冲),用其对齐 coordinator cursor,避免
|
|
35
|
+
后续 append 的 seq 校验错位(默认空实现,rdb 覆写)。
|
|
36
|
+
|
|
37
|
+
## 版本树
|
|
38
|
+
|
|
39
|
+
`buildTimeline(snapshots, readOwnEvents, sessionId)` 从会话快照(header
|
|
40
|
+
lineage)+ 每会话自有后缀(`seq >= seedLength` 的 `session-branch/version`
|
|
41
|
+
事件)投影完整版本树。
|
|
42
|
+
|
|
43
|
+
> 版本效果事件携带 `ignorable: true`(对核心可跳过、**不进 canonical log**),
|
|
44
|
+
> live 会话从内存 log 读到效果,cold 会话 timeline 只有 lineage 骨架。
|
package/cordis.patch.yml
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Context, Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import { SessionEvent, SessionEventMap, SessionId } from "@deepseek-ai/dsh-session";
|
|
3
|
+
import { SessionPersistenceSnapshot } from "@deepseek-ai/dsh-session-persistence";
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
type _BranchKeyCheck = "session-branch/version" extends keyof SessionEventMap ? true : false;
|
|
6
|
+
declare const _branchKeyVisible: _BranchKeyCheck;
|
|
7
|
+
declare const SESSION_BRANCH_VERSION_SCHEMA = 1;
|
|
8
|
+
type CascadePolicy = "truncate" | "preserve";
|
|
9
|
+
type VersionOperation = "edit" | "reroll" | "retry" | "fork" | "rewind";
|
|
10
|
+
type EditableBlockKind = "user" | "assistant.reasoning" | "assistant.response";
|
|
11
|
+
interface SessionBranchEffect {
|
|
12
|
+
id: string;
|
|
13
|
+
operation: VersionOperation;
|
|
14
|
+
cascade: CascadePolicy;
|
|
15
|
+
targetTurn: number;
|
|
16
|
+
targetEventSeq: number;
|
|
17
|
+
targetBlockIndex?: number;
|
|
18
|
+
blockKind?: EditableBlockKind;
|
|
19
|
+
before?: string;
|
|
20
|
+
after?: string;
|
|
21
|
+
}
|
|
22
|
+
interface SessionBranchInverse {
|
|
23
|
+
kind: "restore-version";
|
|
24
|
+
sessionId: SessionId;
|
|
25
|
+
}
|
|
26
|
+
interface SessionBranchVersionEvent {
|
|
27
|
+
schemaVersion: typeof SESSION_BRANCH_VERSION_SCHEMA;
|
|
28
|
+
effect: SessionBranchEffect;
|
|
29
|
+
inverse: SessionBranchInverse;
|
|
30
|
+
}
|
|
31
|
+
declare module "@deepseek-ai/dsh-session" {
|
|
32
|
+
interface SessionEventMap {
|
|
33
|
+
"session-branch/version": SessionBranchVersionEvent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
interface BranchBoundary {
|
|
37
|
+
seq: number;
|
|
38
|
+
events: readonly SessionEvent[];
|
|
39
|
+
}
|
|
40
|
+
interface BranchForkMeta {
|
|
41
|
+
cwd?: string;
|
|
42
|
+
createdAt?: number;
|
|
43
|
+
agentPreset?: string;
|
|
44
|
+
origin?: "subagent";
|
|
45
|
+
delegationDepth?: number;
|
|
46
|
+
}
|
|
47
|
+
interface ForkFromOptions {
|
|
48
|
+
atSeq?: number;
|
|
49
|
+
anchorMode?: BranchAnchorMode;
|
|
50
|
+
seedSuffix?: readonly SessionEvent[];
|
|
51
|
+
childSessionId?: SessionId;
|
|
52
|
+
meta?: BranchForkMeta;
|
|
53
|
+
}
|
|
54
|
+
interface BranchVersionNode {
|
|
55
|
+
sessionId: SessionId;
|
|
56
|
+
parentSessionId?: SessionId;
|
|
57
|
+
seedLength: number;
|
|
58
|
+
createdAt: number;
|
|
59
|
+
effect?: SessionBranchEffect;
|
|
60
|
+
inverseSessionId?: SessionId;
|
|
61
|
+
}
|
|
62
|
+
interface BranchTimeline {
|
|
63
|
+
root: BranchVersionNode;
|
|
64
|
+
nodes: BranchVersionNode[];
|
|
65
|
+
}
|
|
66
|
+
type SessionBranchErrorCode = "SESSION_NOT_FOUND" | "INVALID_BOUNDARY" | "OPEN_TURN" | "FORK_UNAVAILABLE" | "REWIND_CONFLICT";
|
|
67
|
+
declare class SessionBranchError extends Error {
|
|
68
|
+
readonly code: SessionBranchErrorCode;
|
|
69
|
+
constructor(message: string, code: SessionBranchErrorCode);
|
|
70
|
+
}
|
|
71
|
+
interface SessionBranchVersionEventEnvelope {
|
|
72
|
+
type: "session-branch/version";
|
|
73
|
+
seq: number;
|
|
74
|
+
time: number;
|
|
75
|
+
ignorable?: true;
|
|
76
|
+
data: SessionBranchVersionEvent;
|
|
77
|
+
}
|
|
78
|
+
declare function isSessionBranchVersionEvent(event: SessionEvent | {
|
|
79
|
+
type: string;
|
|
80
|
+
data: unknown;
|
|
81
|
+
}): event is SessionBranchVersionEventEnvelope;
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/provider.d.ts
|
|
84
|
+
type BranchAnchorMode = "after" | "before";
|
|
85
|
+
interface SessionBranchProvider {
|
|
86
|
+
readonly name: string;
|
|
87
|
+
readBranchPrefix(id: SessionId, atSeq?: number, mode?: BranchAnchorMode, signal?: AbortSignal): Promise<BranchBoundary>;
|
|
88
|
+
forkFrom(sourceId: SessionId, options?: ForkFromOptions, signal?: AbortSignal): Promise<SessionId>;
|
|
89
|
+
rewind(id: SessionId, toBoundary: number, signal?: AbortSignal): Promise<SessionPersistenceSnapshot>;
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/branch.d.ts
|
|
93
|
+
declare abstract class SessionBranch extends Service {
|
|
94
|
+
constructor(ctx: import("@deepseek-ai/cordis").Context);
|
|
95
|
+
abstract readBranchPrefix(id: SessionId, atSeq?: number, mode?: BranchAnchorMode, signal?: AbortSignal): Promise<BranchBoundary>;
|
|
96
|
+
abstract forkFrom(sourceId: SessionId, options?: ForkFromOptions, signal?: AbortSignal): Promise<SessionId>;
|
|
97
|
+
abstract rewind(id: SessionId, toBoundary: number, signal?: AbortSignal): Promise<SessionPersistenceSnapshot>;
|
|
98
|
+
abstract timeline(sessionId: SessionId, signal?: AbortSignal): Promise<BranchTimeline>;
|
|
99
|
+
syncLiveCursor(_sessionId: SessionId): void;
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/timeline.d.ts
|
|
103
|
+
type BranchSnapshot = SessionPersistenceSnapshot & {
|
|
104
|
+
inheritedEventCount: number;
|
|
105
|
+
};
|
|
106
|
+
type OwnEventsReader = (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<readonly import("@deepseek-ai/dsh-session").SessionEvent[]>;
|
|
107
|
+
declare function buildTimeline(snapshots: readonly BranchSnapshot[], readOwnEvents: OwnEventsReader, sessionId: SessionId, signal?: AbortSignal): Promise<BranchTimeline>;
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/balance.d.ts
|
|
110
|
+
declare function balanceRewindPrefix(events: readonly SessionEvent[]): SessionEvent[];
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/index.d.ts
|
|
113
|
+
declare module "@deepseek-ai/cordis" {
|
|
114
|
+
interface Context {
|
|
115
|
+
sessionBranch: SessionBranch;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
declare function apply(_ctx: Context): void;
|
|
119
|
+
//#endregion
|
|
120
|
+
export { type BranchAnchorMode, BranchBoundary, BranchForkMeta, BranchTimeline, BranchVersionNode, CascadePolicy, EditableBlockKind, ForkFromOptions, type OwnEventsReader, SESSION_BRANCH_VERSION_SCHEMA, SessionBranch, SessionBranchEffect, SessionBranchError, SessionBranchErrorCode, SessionBranchInverse, type SessionBranchProvider, SessionBranchVersionEvent, SessionBranchVersionEventEnvelope, VersionOperation, _branchKeyVisible, apply, balanceRewindPrefix, buildTimeline, isSessionBranchVersionEvent };
|
|
121
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/provider.ts","../src/branch.ts","../src/timeline.ts","../src/balance.ts","../src/index.ts"],"mappings":";;;;KAMK,yDAAyD;cACjD,mBAAmB;cAEnB;KAED;KAEA;KAEA;UAEK;EACf;EACA,WAAW;EACX,SAAS;EAET;EAEA;EACA;EACA,YAAY;EAEZ;EAEA;;UAGe;EACf;EACA,WAAW;;UAGI;EACf,sBAAsB;EACtB,QAAQ;EACR,SAAS;;;YAIC;IACR,0BAA0B;;;UAIb;EACf;EAEA,iBAAiB;;UAGF;EACf;EACA;EACA;EACA;EACA;;UAGe;EACf;EAEA,aAHe;EAKf,sBAAsB;EAEtB,iBAAiB;EAEjB,OAAO;;UAGQ;EACf,WAAW;EACX,kBAAkB;EAElB;EACA;EAEA,SAAS;EAET,mBAAmB;;UAGJ;EACf,MAAM;EACN,OAAO;;KAGG;cAOC,2BAA2B;WAC7B,MAAM;EACf,YAAY,iBAAiB,MAAM;;UAOpB;EACf;EACA;EACA;EACA;EACA,MAAM;;iBAGQ,4BACd,OAAO;EAAiB;EAAc;IACrC,SAAS;;;KCnHA;UAEK;WACN;EAET,iBACE,IAAI,WACJ,gBACA,OAAO,kBACP,SAAS,cACR,QAAQ;EAEX,SACE,UAAU,WACV,UAAU,iBACV,SAAS,cACR,QAAQ;EAEX,OACE,IAAI,WACJ,oBACA,SAAS,cACR,QAAQ;;;;uBCrBS,sBAAsB;EAC1C,YAAY,mCAAmC;WAItC,iBACP,IAAI,WACJ,gBACA,OAFI,kBAGJ,SAAS,cACR,QAAQ;WAEF,SACP,UAAU,WACV,UAAU,iBACV,SAAS,cACR,QAAQ;WAEF,OACP,IAAI,WACJ,oBACA,SAAS,cACR,QAAQ;WAEF,SAAS,WAAW,WAAW,SAAS,cAAc,QAAQ;EAEvE,eAAe,YAAY;;;;KCrBjB,iBAAiB;EAC3B;;KAGU,mBACV,IAAI,WACJ,iBACA,SAAS,gBACN,oDAAoD;iBAEnC,cACpB,oBAAoB,kBACpB,eAAe,iBACf,WAAW,WACX,SAAS,cACR,QAAQ;;;iBCvBK,oBAAoB,iBAAiB,iBAAiB;;;;YCS1D;IACR,eAAe;;;iBAIH,MAAM,MAAM"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
//#region src/branch.ts
|
|
3
|
+
var SessionBranch = class extends Service {
|
|
4
|
+
constructor(ctx) {
|
|
5
|
+
super(ctx, "sessionBranch");
|
|
6
|
+
}
|
|
7
|
+
syncLiveCursor(_sessionId) {}
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/types.ts
|
|
11
|
+
const _branchKeyVisible = true;
|
|
12
|
+
const SESSION_BRANCH_VERSION_SCHEMA = 1;
|
|
13
|
+
var SessionBranchError = class extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(message, code) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "SessionBranchError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function isSessionBranchVersionEvent(event) {
|
|
22
|
+
return event.type === "session-branch/version" && event.data.schemaVersion === 1;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/timeline.ts
|
|
26
|
+
async function buildTimeline(snapshots, readOwnEvents, sessionId, signal) {
|
|
27
|
+
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
28
|
+
const ancestors = [];
|
|
29
|
+
let cursor = sessionId;
|
|
30
|
+
const seen = /* @__PURE__ */ new Set();
|
|
31
|
+
while (cursor !== void 0) {
|
|
32
|
+
if (seen.has(cursor)) throw new SessionBranchError("lineage contains a cycle", "INVALID_BOUNDARY");
|
|
33
|
+
seen.add(cursor);
|
|
34
|
+
ancestors.push(cursor);
|
|
35
|
+
cursor = byId.get(cursor)?.header.parentSession;
|
|
36
|
+
}
|
|
37
|
+
const rootId = ancestors.at(-1);
|
|
38
|
+
if (rootId === void 0) throw new SessionBranchError(`session "${sessionId}" is not persisted`, "SESSION_NOT_FOUND");
|
|
39
|
+
const ordered = [];
|
|
40
|
+
const queue = [rootId];
|
|
41
|
+
while (queue.length > 0) {
|
|
42
|
+
const id = queue.shift();
|
|
43
|
+
if (id === void 0) continue;
|
|
44
|
+
ordered.push(id);
|
|
45
|
+
const children = snapshots.filter((snapshot) => snapshot.header.parentSession === id).sort((left, right) => left.header.createdAt - right.header.createdAt || String(left.header.id).localeCompare(String(right.header.id))).map((snapshot) => snapshot.header.id);
|
|
46
|
+
queue.push(...children);
|
|
47
|
+
}
|
|
48
|
+
const nodes = [];
|
|
49
|
+
const effectIds = /* @__PURE__ */ new Set();
|
|
50
|
+
for (const id of ordered) {
|
|
51
|
+
const snapshot = byId.get(id);
|
|
52
|
+
if (snapshot === void 0) continue;
|
|
53
|
+
const header = snapshot.header;
|
|
54
|
+
const node = {
|
|
55
|
+
sessionId: header.id,
|
|
56
|
+
...header.parentSession === void 0 ? {} : { parentSessionId: header.parentSession },
|
|
57
|
+
seedLength: snapshot.inheritedEventCount ?? 0,
|
|
58
|
+
createdAt: header.createdAt
|
|
59
|
+
};
|
|
60
|
+
if (header.parentSession !== void 0) {
|
|
61
|
+
const events = await readOwnEvents(header.id, node.seedLength, signal);
|
|
62
|
+
let version;
|
|
63
|
+
for (const event of events) if (isSessionBranchVersionEvent(event)) {
|
|
64
|
+
if (version !== void 0) throw new SessionBranchError(`session ${header.id} carries multiple own version effects`, "INVALID_BOUNDARY");
|
|
65
|
+
version = event;
|
|
66
|
+
}
|
|
67
|
+
if (version !== void 0) {
|
|
68
|
+
const data = version.data;
|
|
69
|
+
if (data.inverse.kind !== "restore-version" || data.inverse.sessionId !== header.parentSession) throw new SessionBranchError(`session ${header.id} version inverse does not match its parent`, "INVALID_BOUNDARY");
|
|
70
|
+
if (effectIds.has(data.effect.id)) throw new SessionBranchError(`version effect ${data.effect.id} is duplicated`, "INVALID_BOUNDARY");
|
|
71
|
+
effectIds.add(data.effect.id);
|
|
72
|
+
node.effect = data.effect;
|
|
73
|
+
node.inverseSessionId = data.inverse.sessionId;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
nodes.push(node);
|
|
77
|
+
}
|
|
78
|
+
const root = nodes.find((node) => node.parentSessionId === void 0);
|
|
79
|
+
if (root === void 0) throw new SessionBranchError(`session "${sessionId}" lineage has no root`, "SESSION_NOT_FOUND");
|
|
80
|
+
return {
|
|
81
|
+
root,
|
|
82
|
+
nodes
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/balance.ts
|
|
87
|
+
function balanceRewindPrefix(events) {
|
|
88
|
+
let keep = events.length;
|
|
89
|
+
let openStepEnds = 0;
|
|
90
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
91
|
+
const type = events[index]?.type;
|
|
92
|
+
if (type === "turn/end") break;
|
|
93
|
+
if (type === "step/end") {
|
|
94
|
+
openStepEnds += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (type === "step/start") {
|
|
98
|
+
if (openStepEnds > 0) openStepEnds -= 1;
|
|
99
|
+
else keep = index;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return events.slice(0, keep);
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/index.ts
|
|
106
|
+
function apply(_ctx) {}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { SESSION_BRANCH_VERSION_SCHEMA, SessionBranch, SessionBranchError, _branchKeyVisible, apply, balanceRewindPrefix, buildTimeline, isSessionBranchVersionEvent };
|
|
109
|
+
|
|
110
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/branch.ts","../src/types.ts","../src/timeline.ts","../src/balance.ts","../src/index.ts"],"sourcesContent":["import { Service } from \"@deepseek-ai/cordis\";\nimport type { SessionId } from \"@deepseek-ai/dsh-session\";\nimport type { SessionPersistenceSnapshot } from \"@deepseek-ai/dsh-session-persistence\";\nimport type { BranchBoundary, BranchTimeline, ForkFromOptions } from \"./types.ts\";\n\nexport abstract class SessionBranch extends Service {\n constructor(ctx: import(\"@deepseek-ai/cordis\").Context) {\n super(ctx, \"sessionBranch\");\n }\n\n abstract readBranchPrefix(\n id: SessionId,\n atSeq?: number,\n mode?: import(\"./provider.ts\").BranchAnchorMode,\n signal?: AbortSignal,\n ): Promise<BranchBoundary>;\n\n abstract forkFrom(\n sourceId: SessionId,\n options?: ForkFromOptions,\n signal?: AbortSignal,\n ): Promise<SessionId>;\n\n abstract rewind(\n id: SessionId,\n toBoundary: number,\n signal?: AbortSignal,\n ): Promise<SessionPersistenceSnapshot>;\n\n abstract timeline(sessionId: SessionId, signal?: AbortSignal): Promise<BranchTimeline>;\n\n syncLiveCursor(_sessionId: SessionId): void {\n // 默认无操作。\n }\n}\n","import type { SessionEvent, SessionId } from \"@deepseek-ai/dsh-session\";\nimport type { SessionEventMap } from \"@deepseek-ai/dsh-session\";\n\n// 编译期诊断:module augmentation 后 `keyof SessionEventMap` 可见,但\n// dsh-session 内已解析的 `SessionEventType` 别名不会重求值——因此\n// `SessionEvent<\"session-branch/version\">` 泛型不可用,守卫/消费走结构化。\ntype _BranchKeyCheck = \"session-branch/version\" extends keyof SessionEventMap ? true : false;\nexport const _branchKeyVisible: _BranchKeyCheck = true as const;\n\nexport const SESSION_BRANCH_VERSION_SCHEMA = 1;\n\nexport type CascadePolicy = \"truncate\" | \"preserve\";\n\nexport type VersionOperation = \"edit\" | \"reroll\" | \"retry\" | \"fork\" | \"rewind\";\n\nexport type EditableBlockKind = \"user\" | \"assistant.reasoning\" | \"assistant.response\";\n\nexport interface SessionBranchEffect {\n id: string;\n operation: VersionOperation;\n cascade: CascadePolicy;\n\n targetTurn: number;\n\n targetEventSeq: number;\n targetBlockIndex?: number;\n blockKind?: EditableBlockKind;\n\n before?: string;\n\n after?: string;\n}\n\nexport interface SessionBranchInverse {\n kind: \"restore-version\";\n sessionId: SessionId;\n}\n\nexport interface SessionBranchVersionEvent {\n schemaVersion: typeof SESSION_BRANCH_VERSION_SCHEMA;\n effect: SessionBranchEffect;\n inverse: SessionBranchInverse;\n}\n\ndeclare module \"@deepseek-ai/dsh-session\" {\n interface SessionEventMap {\n \"session-branch/version\": SessionBranchVersionEvent;\n }\n}\n\nexport interface BranchBoundary {\n seq: number;\n\n events: readonly SessionEvent[];\n}\n\nexport interface BranchForkMeta {\n cwd?: string;\n createdAt?: number;\n agentPreset?: string;\n origin?: \"subagent\";\n delegationDepth?: number;\n}\n\nexport interface ForkFromOptions {\n atSeq?: number;\n\n anchorMode?: import(\"./provider.ts\").BranchAnchorMode;\n\n seedSuffix?: readonly SessionEvent[];\n\n childSessionId?: SessionId;\n\n meta?: BranchForkMeta;\n}\n\nexport interface BranchVersionNode {\n sessionId: SessionId;\n parentSessionId?: SessionId;\n\n seedLength: number;\n createdAt: number;\n\n effect?: SessionBranchEffect;\n\n inverseSessionId?: SessionId;\n}\n\nexport interface BranchTimeline {\n root: BranchVersionNode;\n nodes: BranchVersionNode[];\n}\n\nexport type SessionBranchErrorCode =\n | \"SESSION_NOT_FOUND\"\n | \"INVALID_BOUNDARY\"\n | \"OPEN_TURN\"\n | \"FORK_UNAVAILABLE\"\n | \"REWIND_CONFLICT\";\n\nexport class SessionBranchError extends Error {\n readonly code: SessionBranchErrorCode;\n constructor(message: string, code: SessionBranchErrorCode) {\n super(message);\n this.name = \"SessionBranchError\";\n this.code = code;\n }\n}\n\nexport interface SessionBranchVersionEventEnvelope {\n type: \"session-branch/version\";\n seq: number;\n time: number;\n ignorable?: true;\n data: SessionBranchVersionEvent;\n}\n\nexport function isSessionBranchVersionEvent(\n event: SessionEvent | { type: string; data: unknown },\n): event is SessionBranchVersionEventEnvelope {\n return (\n event.type === \"session-branch/version\" &&\n (event.data as { schemaVersion?: unknown }).schemaVersion === SESSION_BRANCH_VERSION_SCHEMA\n );\n}\n","import type { SessionId } from \"@deepseek-ai/dsh-session\";\nimport type { SessionPersistenceSnapshot } from \"@deepseek-ai/dsh-session-persistence\";\nimport {\n SessionBranchError,\n isSessionBranchVersionEvent,\n type BranchTimeline,\n type BranchVersionNode,\n type SessionBranchVersionEventEnvelope,\n} from \"./types.ts\";\n\nexport type BranchSnapshot = SessionPersistenceSnapshot & {\n inheritedEventCount: number;\n};\n\nexport type OwnEventsReader = (\n id: SessionId,\n fromSeq: number,\n signal?: AbortSignal,\n) => Promise<readonly import(\"@deepseek-ai/dsh-session\").SessionEvent[]>;\n\nexport async function buildTimeline(\n snapshots: readonly BranchSnapshot[],\n readOwnEvents: OwnEventsReader,\n sessionId: SessionId,\n signal?: AbortSignal,\n): Promise<BranchTimeline> {\n const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot] as const));\n\n // 回溯到根(当前会话 → 祖先链)。\n const ancestors: SessionId[] = [];\n let cursor: SessionId | undefined = sessionId;\n const seen = new Set<SessionId>();\n while (cursor !== undefined) {\n if (seen.has(cursor))\n throw new SessionBranchError(\"lineage contains a cycle\", \"INVALID_BOUNDARY\");\n seen.add(cursor);\n ancestors.push(cursor);\n const snapshot = byId.get(cursor);\n cursor = snapshot?.header.parentSession;\n }\n const rootId = ancestors.at(-1);\n if (rootId === undefined) {\n throw new SessionBranchError(`session \"${sessionId}\" is not persisted`, \"SESSION_NOT_FOUND\");\n }\n\n // 根向下的完整后代(BFS;按 createdAt 稳定排序)。\n const ordered: SessionId[] = [];\n const queue: SessionId[] = [rootId];\n while (queue.length > 0) {\n const id = queue.shift();\n if (id === undefined) continue;\n ordered.push(id);\n const children = snapshots\n .filter((snapshot) => snapshot.header.parentSession === id)\n .sort(\n (left, right) =>\n left.header.createdAt - right.header.createdAt ||\n String(left.header.id).localeCompare(String(right.header.id)),\n )\n .map((snapshot) => snapshot.header.id);\n queue.push(...children);\n }\n\n const nodes: BranchVersionNode[] = [];\n const effectIds = new Set<string>();\n for (const id of ordered) {\n const snapshot = byId.get(id);\n if (snapshot === undefined) continue;\n const header = snapshot.header;\n const node: BranchVersionNode = {\n sessionId: header.id,\n ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),\n seedLength: snapshot.inheritedEventCount ?? 0,\n createdAt: header.createdAt,\n };\n // 根节点不可能带版本效果;其余节点读自有后缀。\n if (header.parentSession !== undefined) {\n const events = await readOwnEvents(header.id, node.seedLength, signal);\n // 结构化守卫 + find(版本事件类型不在固化的 SessionEventType 中)。\n let version: SessionBranchVersionEventEnvelope | undefined;\n for (const event of events) {\n if (isSessionBranchVersionEvent(event)) {\n if (version !== undefined) {\n throw new SessionBranchError(\n `session ${header.id} carries multiple own version effects`,\n \"INVALID_BOUNDARY\",\n );\n }\n version = event;\n }\n }\n if (version !== undefined) {\n const data = version.data;\n if (\n data.inverse.kind !== \"restore-version\" ||\n data.inverse.sessionId !== header.parentSession\n ) {\n throw new SessionBranchError(\n `session ${header.id} version inverse does not match its parent`,\n \"INVALID_BOUNDARY\",\n );\n }\n if (effectIds.has(data.effect.id)) {\n throw new SessionBranchError(\n `version effect ${data.effect.id} is duplicated`,\n \"INVALID_BOUNDARY\",\n );\n }\n effectIds.add(data.effect.id);\n node.effect = data.effect;\n node.inverseSessionId = data.inverse.sessionId;\n }\n }\n nodes.push(node);\n }\n\n const root = nodes.find((node) => node.parentSessionId === undefined);\n if (root === undefined) {\n throw new SessionBranchError(`session \"${sessionId}\" lineage has no root`, \"SESSION_NOT_FOUND\");\n }\n return { root, nodes };\n}\n","import type { SessionEvent } from \"@deepseek-ai/dsh-session\";\n\nexport function balanceRewindPrefix(events: readonly SessionEvent[]): SessionEvent[] {\n let keep = events.length;\n // 从尾部向前待配对的 step/end 数(每个配对它之前的 step/start)。\n let openStepEnds = 0;\n for (let index = events.length - 1; index >= 0; index -= 1) {\n const type = events[index]?.type;\n if (type === \"turn/end\") break; // 轮次闭合:之前的 step 全部配对。\n if (type === \"step/end\") {\n openStepEnds += 1;\n continue;\n }\n if (type === \"step/start\") {\n if (openStepEnds > 0) {\n openStepEnds -= 1; // 配对成功。\n } else {\n keep = index; // 孤儿 step/start:剔除它及其后。\n }\n }\n }\n return events.slice(0, keep);\n}\n","import type { Context } from \"@deepseek-ai/cordis\";\nimport { SessionBranch } from \"./branch.ts\";\n\nexport { SessionBranch } from \"./branch.ts\";\nexport type { SessionBranchProvider, BranchAnchorMode } from \"./provider.ts\";\nexport { buildTimeline } from \"./timeline.ts\";\nexport type { OwnEventsReader } from \"./timeline.ts\";\nexport { balanceRewindPrefix } from \"./balance.ts\";\nexport * from \"./types.ts\";\n\ndeclare module \"@deepseek-ai/cordis\" {\n interface Context {\n sessionBranch: SessionBranch;\n }\n}\n\nexport function apply(_ctx: Context): void {\n // 契约层不发布服务:ctx.sessionBranch 由实现 provider 的后端插件注册。\n // 保留 apply 仅为 cordis 插件装配兼容,不注入任何服务。\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
//#region src/invariant.d.ts
|
|
3
|
+
declare const name = "session-branch-invariant";
|
|
4
|
+
declare const inject: string[];
|
|
5
|
+
declare const apply: (ctx: Context) => Promise<() => void>;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { apply, inject, name };
|
|
8
|
+
//# sourceMappingURL=invariant.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invariant.d.mts","names":[],"sources":["../src/invariant.ts"],"mappings":";;cAKa;cAEA;cAIA,QAAK,KAAS,YAAU"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/invariant.ts
|
|
2
|
+
const PACKAGE_NAME = "@morlay/session-branch";
|
|
3
|
+
const name = "session-branch-invariant";
|
|
4
|
+
const inject = ["invariants"];
|
|
5
|
+
const install = () => {};
|
|
6
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
7
|
+
//#endregion
|
|
8
|
+
export { apply, inject, name };
|
|
9
|
+
|
|
10
|
+
//# sourceMappingURL=invariant.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invariant.mjs","names":[],"sources":["../src/invariant.ts"],"sourcesContent":["import type { Context } from \"@deepseek-ai/cordis\";\nimport type { InvariantInstaller } from \"@deepseek-ai/dsh-invariants\";\n\nconst PACKAGE_NAME = \"@morlay/session-branch\";\n\nexport const name = \"session-branch-invariant\";\n\nexport const inject = [\"invariants\"];\n\nconst install: InvariantInstaller = () => {};\n\nexport const apply = (ctx: Context): Promise<() => void> =>\n Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));\n"],"mappings":";AAGA,MAAM,eAAe;AAErB,MAAa,OAAO;AAEpB,MAAa,SAAS,CAAC,YAAY;AAEnC,MAAM,gBAAoC,CAAC;AAE3C,MAAa,SAAS,QACpB,QAAQ,QAAQ,IAAI,WAAW,SAAS,cAAc,OAAO,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morlay/session-branch",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Provider abstraction + high-level service for rewind / retry / fork over DeepSeek Harness event-sourced sessions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"fork",
|
|
9
|
+
"retry",
|
|
10
|
+
"rewind",
|
|
11
|
+
"session-branch"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/morlay/better-session.git"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"!**/__tests__"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./dist/index.mjs",
|
|
27
|
+
"./invariant": "./dist/invariant.mjs",
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
33
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
|
|
34
|
+
"@deepseek-ai/dsh-session": "^0.1.2-rc.1",
|
|
35
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.2-rc.1"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "pnpm exec tsdown"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/balance.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SessionEvent } from "@deepseek-ai/dsh-session";
|
|
2
|
+
|
|
3
|
+
export function balanceRewindPrefix(events: readonly SessionEvent[]): SessionEvent[] {
|
|
4
|
+
let keep = events.length;
|
|
5
|
+
// 从尾部向前待配对的 step/end 数(每个配对它之前的 step/start)。
|
|
6
|
+
let openStepEnds = 0;
|
|
7
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
8
|
+
const type = events[index]?.type;
|
|
9
|
+
if (type === "turn/end") break; // 轮次闭合:之前的 step 全部配对。
|
|
10
|
+
if (type === "step/end") {
|
|
11
|
+
openStepEnds += 1;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (type === "step/start") {
|
|
15
|
+
if (openStepEnds > 0) {
|
|
16
|
+
openStepEnds -= 1; // 配对成功。
|
|
17
|
+
} else {
|
|
18
|
+
keep = index; // 孤儿 step/start:剔除它及其后。
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return events.slice(0, keep);
|
|
23
|
+
}
|
package/src/branch.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
3
|
+
import type { SessionPersistenceSnapshot } from "@deepseek-ai/dsh-session-persistence";
|
|
4
|
+
import type { BranchBoundary, BranchTimeline, ForkFromOptions } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export abstract class SessionBranch extends Service {
|
|
7
|
+
constructor(ctx: import("@deepseek-ai/cordis").Context) {
|
|
8
|
+
super(ctx, "sessionBranch");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
abstract readBranchPrefix(
|
|
12
|
+
id: SessionId,
|
|
13
|
+
atSeq?: number,
|
|
14
|
+
mode?: import("./provider.ts").BranchAnchorMode,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<BranchBoundary>;
|
|
17
|
+
|
|
18
|
+
abstract forkFrom(
|
|
19
|
+
sourceId: SessionId,
|
|
20
|
+
options?: ForkFromOptions,
|
|
21
|
+
signal?: AbortSignal,
|
|
22
|
+
): Promise<SessionId>;
|
|
23
|
+
|
|
24
|
+
abstract rewind(
|
|
25
|
+
id: SessionId,
|
|
26
|
+
toBoundary: number,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
): Promise<SessionPersistenceSnapshot>;
|
|
29
|
+
|
|
30
|
+
abstract timeline(sessionId: SessionId, signal?: AbortSignal): Promise<BranchTimeline>;
|
|
31
|
+
|
|
32
|
+
syncLiveCursor(_sessionId: SessionId): void {
|
|
33
|
+
// 默认无操作。
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import { SessionBranch } from "./branch.ts";
|
|
3
|
+
|
|
4
|
+
export { SessionBranch } from "./branch.ts";
|
|
5
|
+
export type { SessionBranchProvider, BranchAnchorMode } from "./provider.ts";
|
|
6
|
+
export { buildTimeline } from "./timeline.ts";
|
|
7
|
+
export type { OwnEventsReader } from "./timeline.ts";
|
|
8
|
+
export { balanceRewindPrefix } from "./balance.ts";
|
|
9
|
+
export * from "./types.ts";
|
|
10
|
+
|
|
11
|
+
declare module "@deepseek-ai/cordis" {
|
|
12
|
+
interface Context {
|
|
13
|
+
sessionBranch: SessionBranch;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function apply(_ctx: Context): void {
|
|
18
|
+
// 契约层不发布服务:ctx.sessionBranch 由实现 provider 的后端插件注册。
|
|
19
|
+
// 保留 apply 仅为 cordis 插件装配兼容,不注入任何服务。
|
|
20
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { InvariantInstaller } from "@deepseek-ai/dsh-invariants";
|
|
3
|
+
|
|
4
|
+
const PACKAGE_NAME = "@morlay/session-branch";
|
|
5
|
+
|
|
6
|
+
export const name = "session-branch-invariant";
|
|
7
|
+
|
|
8
|
+
export const inject = ["invariants"];
|
|
9
|
+
|
|
10
|
+
const install: InvariantInstaller = () => {};
|
|
11
|
+
|
|
12
|
+
export const apply = (ctx: Context): Promise<() => void> =>
|
|
13
|
+
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import type { SessionPersistenceSnapshot } from "@deepseek-ai/dsh-session-persistence";
|
|
3
|
+
import type { BranchBoundary, ForkFromOptions } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type BranchAnchorMode = "after" | "before";
|
|
6
|
+
|
|
7
|
+
export interface SessionBranchProvider {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
|
|
10
|
+
readBranchPrefix(
|
|
11
|
+
id: SessionId,
|
|
12
|
+
atSeq?: number,
|
|
13
|
+
mode?: BranchAnchorMode,
|
|
14
|
+
signal?: AbortSignal,
|
|
15
|
+
): Promise<BranchBoundary>;
|
|
16
|
+
|
|
17
|
+
forkFrom(
|
|
18
|
+
sourceId: SessionId,
|
|
19
|
+
options?: ForkFromOptions,
|
|
20
|
+
signal?: AbortSignal,
|
|
21
|
+
): Promise<SessionId>;
|
|
22
|
+
|
|
23
|
+
rewind(
|
|
24
|
+
id: SessionId,
|
|
25
|
+
toBoundary: number,
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
): Promise<SessionPersistenceSnapshot>;
|
|
28
|
+
}
|
package/src/timeline.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import type { SessionPersistenceSnapshot } from "@deepseek-ai/dsh-session-persistence";
|
|
3
|
+
import {
|
|
4
|
+
SessionBranchError,
|
|
5
|
+
isSessionBranchVersionEvent,
|
|
6
|
+
type BranchTimeline,
|
|
7
|
+
type BranchVersionNode,
|
|
8
|
+
type SessionBranchVersionEventEnvelope,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export type BranchSnapshot = SessionPersistenceSnapshot & {
|
|
12
|
+
inheritedEventCount: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type OwnEventsReader = (
|
|
16
|
+
id: SessionId,
|
|
17
|
+
fromSeq: number,
|
|
18
|
+
signal?: AbortSignal,
|
|
19
|
+
) => Promise<readonly import("@deepseek-ai/dsh-session").SessionEvent[]>;
|
|
20
|
+
|
|
21
|
+
export async function buildTimeline(
|
|
22
|
+
snapshots: readonly BranchSnapshot[],
|
|
23
|
+
readOwnEvents: OwnEventsReader,
|
|
24
|
+
sessionId: SessionId,
|
|
25
|
+
signal?: AbortSignal,
|
|
26
|
+
): Promise<BranchTimeline> {
|
|
27
|
+
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot] as const));
|
|
28
|
+
|
|
29
|
+
// 回溯到根(当前会话 → 祖先链)。
|
|
30
|
+
const ancestors: SessionId[] = [];
|
|
31
|
+
let cursor: SessionId | undefined = sessionId;
|
|
32
|
+
const seen = new Set<SessionId>();
|
|
33
|
+
while (cursor !== undefined) {
|
|
34
|
+
if (seen.has(cursor))
|
|
35
|
+
throw new SessionBranchError("lineage contains a cycle", "INVALID_BOUNDARY");
|
|
36
|
+
seen.add(cursor);
|
|
37
|
+
ancestors.push(cursor);
|
|
38
|
+
const snapshot = byId.get(cursor);
|
|
39
|
+
cursor = snapshot?.header.parentSession;
|
|
40
|
+
}
|
|
41
|
+
const rootId = ancestors.at(-1);
|
|
42
|
+
if (rootId === undefined) {
|
|
43
|
+
throw new SessionBranchError(`session "${sessionId}" is not persisted`, "SESSION_NOT_FOUND");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 根向下的完整后代(BFS;按 createdAt 稳定排序)。
|
|
47
|
+
const ordered: SessionId[] = [];
|
|
48
|
+
const queue: SessionId[] = [rootId];
|
|
49
|
+
while (queue.length > 0) {
|
|
50
|
+
const id = queue.shift();
|
|
51
|
+
if (id === undefined) continue;
|
|
52
|
+
ordered.push(id);
|
|
53
|
+
const children = snapshots
|
|
54
|
+
.filter((snapshot) => snapshot.header.parentSession === id)
|
|
55
|
+
.sort(
|
|
56
|
+
(left, right) =>
|
|
57
|
+
left.header.createdAt - right.header.createdAt ||
|
|
58
|
+
String(left.header.id).localeCompare(String(right.header.id)),
|
|
59
|
+
)
|
|
60
|
+
.map((snapshot) => snapshot.header.id);
|
|
61
|
+
queue.push(...children);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const nodes: BranchVersionNode[] = [];
|
|
65
|
+
const effectIds = new Set<string>();
|
|
66
|
+
for (const id of ordered) {
|
|
67
|
+
const snapshot = byId.get(id);
|
|
68
|
+
if (snapshot === undefined) continue;
|
|
69
|
+
const header = snapshot.header;
|
|
70
|
+
const node: BranchVersionNode = {
|
|
71
|
+
sessionId: header.id,
|
|
72
|
+
...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
|
|
73
|
+
seedLength: snapshot.inheritedEventCount ?? 0,
|
|
74
|
+
createdAt: header.createdAt,
|
|
75
|
+
};
|
|
76
|
+
// 根节点不可能带版本效果;其余节点读自有后缀。
|
|
77
|
+
if (header.parentSession !== undefined) {
|
|
78
|
+
const events = await readOwnEvents(header.id, node.seedLength, signal);
|
|
79
|
+
// 结构化守卫 + find(版本事件类型不在固化的 SessionEventType 中)。
|
|
80
|
+
let version: SessionBranchVersionEventEnvelope | undefined;
|
|
81
|
+
for (const event of events) {
|
|
82
|
+
if (isSessionBranchVersionEvent(event)) {
|
|
83
|
+
if (version !== undefined) {
|
|
84
|
+
throw new SessionBranchError(
|
|
85
|
+
`session ${header.id} carries multiple own version effects`,
|
|
86
|
+
"INVALID_BOUNDARY",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
version = event;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (version !== undefined) {
|
|
93
|
+
const data = version.data;
|
|
94
|
+
if (
|
|
95
|
+
data.inverse.kind !== "restore-version" ||
|
|
96
|
+
data.inverse.sessionId !== header.parentSession
|
|
97
|
+
) {
|
|
98
|
+
throw new SessionBranchError(
|
|
99
|
+
`session ${header.id} version inverse does not match its parent`,
|
|
100
|
+
"INVALID_BOUNDARY",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (effectIds.has(data.effect.id)) {
|
|
104
|
+
throw new SessionBranchError(
|
|
105
|
+
`version effect ${data.effect.id} is duplicated`,
|
|
106
|
+
"INVALID_BOUNDARY",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
effectIds.add(data.effect.id);
|
|
110
|
+
node.effect = data.effect;
|
|
111
|
+
node.inverseSessionId = data.inverse.sessionId;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
nodes.push(node);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const root = nodes.find((node) => node.parentSessionId === undefined);
|
|
118
|
+
if (root === undefined) {
|
|
119
|
+
throw new SessionBranchError(`session "${sessionId}" lineage has no root`, "SESSION_NOT_FOUND");
|
|
120
|
+
}
|
|
121
|
+
return { root, nodes };
|
|
122
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import type { SessionEventMap } from "@deepseek-ai/dsh-session";
|
|
3
|
+
|
|
4
|
+
// 编译期诊断:module augmentation 后 `keyof SessionEventMap` 可见,但
|
|
5
|
+
// dsh-session 内已解析的 `SessionEventType` 别名不会重求值——因此
|
|
6
|
+
// `SessionEvent<"session-branch/version">` 泛型不可用,守卫/消费走结构化。
|
|
7
|
+
type _BranchKeyCheck = "session-branch/version" extends keyof SessionEventMap ? true : false;
|
|
8
|
+
export const _branchKeyVisible: _BranchKeyCheck = true as const;
|
|
9
|
+
|
|
10
|
+
export const SESSION_BRANCH_VERSION_SCHEMA = 1;
|
|
11
|
+
|
|
12
|
+
export type CascadePolicy = "truncate" | "preserve";
|
|
13
|
+
|
|
14
|
+
export type VersionOperation = "edit" | "reroll" | "retry" | "fork" | "rewind";
|
|
15
|
+
|
|
16
|
+
export type EditableBlockKind = "user" | "assistant.reasoning" | "assistant.response";
|
|
17
|
+
|
|
18
|
+
export interface SessionBranchEffect {
|
|
19
|
+
id: string;
|
|
20
|
+
operation: VersionOperation;
|
|
21
|
+
cascade: CascadePolicy;
|
|
22
|
+
|
|
23
|
+
targetTurn: number;
|
|
24
|
+
|
|
25
|
+
targetEventSeq: number;
|
|
26
|
+
targetBlockIndex?: number;
|
|
27
|
+
blockKind?: EditableBlockKind;
|
|
28
|
+
|
|
29
|
+
before?: string;
|
|
30
|
+
|
|
31
|
+
after?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionBranchInverse {
|
|
35
|
+
kind: "restore-version";
|
|
36
|
+
sessionId: SessionId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionBranchVersionEvent {
|
|
40
|
+
schemaVersion: typeof SESSION_BRANCH_VERSION_SCHEMA;
|
|
41
|
+
effect: SessionBranchEffect;
|
|
42
|
+
inverse: SessionBranchInverse;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
declare module "@deepseek-ai/dsh-session" {
|
|
46
|
+
interface SessionEventMap {
|
|
47
|
+
"session-branch/version": SessionBranchVersionEvent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BranchBoundary {
|
|
52
|
+
seq: number;
|
|
53
|
+
|
|
54
|
+
events: readonly SessionEvent[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BranchForkMeta {
|
|
58
|
+
cwd?: string;
|
|
59
|
+
createdAt?: number;
|
|
60
|
+
agentPreset?: string;
|
|
61
|
+
origin?: "subagent";
|
|
62
|
+
delegationDepth?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ForkFromOptions {
|
|
66
|
+
atSeq?: number;
|
|
67
|
+
|
|
68
|
+
anchorMode?: import("./provider.ts").BranchAnchorMode;
|
|
69
|
+
|
|
70
|
+
seedSuffix?: readonly SessionEvent[];
|
|
71
|
+
|
|
72
|
+
childSessionId?: SessionId;
|
|
73
|
+
|
|
74
|
+
meta?: BranchForkMeta;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BranchVersionNode {
|
|
78
|
+
sessionId: SessionId;
|
|
79
|
+
parentSessionId?: SessionId;
|
|
80
|
+
|
|
81
|
+
seedLength: number;
|
|
82
|
+
createdAt: number;
|
|
83
|
+
|
|
84
|
+
effect?: SessionBranchEffect;
|
|
85
|
+
|
|
86
|
+
inverseSessionId?: SessionId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BranchTimeline {
|
|
90
|
+
root: BranchVersionNode;
|
|
91
|
+
nodes: BranchVersionNode[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type SessionBranchErrorCode =
|
|
95
|
+
| "SESSION_NOT_FOUND"
|
|
96
|
+
| "INVALID_BOUNDARY"
|
|
97
|
+
| "OPEN_TURN"
|
|
98
|
+
| "FORK_UNAVAILABLE"
|
|
99
|
+
| "REWIND_CONFLICT";
|
|
100
|
+
|
|
101
|
+
export class SessionBranchError extends Error {
|
|
102
|
+
readonly code: SessionBranchErrorCode;
|
|
103
|
+
constructor(message: string, code: SessionBranchErrorCode) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = "SessionBranchError";
|
|
106
|
+
this.code = code;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface SessionBranchVersionEventEnvelope {
|
|
111
|
+
type: "session-branch/version";
|
|
112
|
+
seq: number;
|
|
113
|
+
time: number;
|
|
114
|
+
ignorable?: true;
|
|
115
|
+
data: SessionBranchVersionEvent;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isSessionBranchVersionEvent(
|
|
119
|
+
event: SessionEvent | { type: string; data: unknown },
|
|
120
|
+
): event is SessionBranchVersionEventEnvelope {
|
|
121
|
+
return (
|
|
122
|
+
event.type === "session-branch/version" &&
|
|
123
|
+
(event.data as { schemaVersion?: unknown }).schemaVersion === SESSION_BRANCH_VERSION_SCHEMA
|
|
124
|
+
);
|
|
125
|
+
}
|