dsh-plugin-message-edit 1.0.1 → 1.1.0
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 +24 -24
- package/README.en.md +9 -3
- package/README.md +9 -3
- package/cordis.patch.yml +7 -7
- package/docs/ARCHITECTURE.md +13 -9
- package/docs/DEVELOPMENT.md +16 -4
- package/docs/TREE_DATA_MODEL.md +96 -96
- package/lib/index.js +30 -20
- package/lib/session-record.js +17 -2
- package/lib/tree-logic.js +343 -343
- package/package.json +70 -66
package/lib/tree-logic.js
CHANGED
|
@@ -1,343 +1,343 @@
|
|
|
1
|
-
// Pure helpers shared by the host (where to hang a new branch) and tests.
|
|
2
|
-
// The client copies `ringFor` and `buildTurnTree` — keep the two in sync.
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Walk up through versions of the same turn and stop at the first session
|
|
6
|
-
* that is NOT itself an edit of that turn. New siblings hang off that node
|
|
7
|
-
* so repeated edits of one message fan out instead of chaining.
|
|
8
|
-
*/
|
|
9
|
-
export function attachParentId(nodesById, sourceId, targetTurn) {
|
|
10
|
-
let id = sourceId;
|
|
11
|
-
const seen = new Set();
|
|
12
|
-
while (id && !seen.has(id)) {
|
|
13
|
-
seen.add(id);
|
|
14
|
-
const node = nodesById.get(id);
|
|
15
|
-
if (!node) return id;
|
|
16
|
-
if (node.targetTurn !== targetTurn) return id;
|
|
17
|
-
if (!node.parentSessionId) return id;
|
|
18
|
-
id = node.parentSessionId;
|
|
19
|
-
}
|
|
20
|
-
return sourceId;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The ‹ › ring for the message at `turn` while viewing `sessionId`.
|
|
25
|
-
*
|
|
26
|
-
* Collects the original message's session plus every version of that same
|
|
27
|
-
* turn, even when older builds chained them (A→B→C) instead of fanning
|
|
28
|
-
* out as siblings.
|
|
29
|
-
*/
|
|
30
|
-
export function ringFor(versions, sessionId, turn) {
|
|
31
|
-
if (!versions) return null;
|
|
32
|
-
const byId = new Map(versions.map((v) => [v.sessionId, v]));
|
|
33
|
-
let cursor = byId.get(sessionId);
|
|
34
|
-
if (!cursor) return null;
|
|
35
|
-
while (cursor.parentSessionId && typeof cursor.targetTurn === 'number' && cursor.targetTurn > turn) {
|
|
36
|
-
const parent = byId.get(cursor.parentSessionId);
|
|
37
|
-
if (!parent) break;
|
|
38
|
-
cursor = parent;
|
|
39
|
-
}
|
|
40
|
-
let fork = cursor;
|
|
41
|
-
while (fork.parentSessionId && typeof fork.targetTurn === 'number' && fork.targetTurn === turn) {
|
|
42
|
-
const parent = byId.get(fork.parentSessionId);
|
|
43
|
-
if (!parent) break;
|
|
44
|
-
fork = parent;
|
|
45
|
-
}
|
|
46
|
-
const walksToFork = (start) => {
|
|
47
|
-
let x = start;
|
|
48
|
-
const seen = new Set();
|
|
49
|
-
while (x && !seen.has(x.sessionId)) {
|
|
50
|
-
seen.add(x.sessionId);
|
|
51
|
-
if (x.sessionId === fork.sessionId) return true;
|
|
52
|
-
if (typeof x.targetTurn !== 'number' || x.targetTurn !== turn) return false;
|
|
53
|
-
x = x.parentSessionId ? byId.get(x.parentSessionId) : null;
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
56
|
-
};
|
|
57
|
-
// A deleted (ghost) version still anchors the fork and still bridges the
|
|
58
|
-
// parent walks above, but it cannot be opened, so it never appears among
|
|
59
|
-
// the alternatives: the ring renumbers over the survivors.
|
|
60
|
-
const alternatives = versions
|
|
61
|
-
.filter((v) => !v.deleted && (v.sessionId === fork.sessionId || (v.targetTurn === turn && walksToFork(v))))
|
|
62
|
-
.sort((a, b) => a.createdAt - b.createdAt || String(a.sessionId).localeCompare(String(b.sessionId)));
|
|
63
|
-
if (alternatives.length < 2) return null;
|
|
64
|
-
let index = alternatives.findIndex((v) => v.sessionId === cursor.sessionId);
|
|
65
|
-
if (index === -1) index = alternatives.findIndex((v) => v.sessionId === sessionId);
|
|
66
|
-
if (index === -1) index = 0;
|
|
67
|
-
return { alternatives, index };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The family root for `sessionId`: walk parentSessionId links until one has
|
|
72
|
-
* none. Used to key the remembered active path, so every branch of a
|
|
73
|
-
* conversation shares one entry.
|
|
74
|
-
*
|
|
75
|
-
* Mirrors rootOf() in plugin.client.js; kept here so it is testable.
|
|
76
|
-
*/
|
|
77
|
-
export function rootOf(versions, sessionId) {
|
|
78
|
-
if (!versions || sessionId === undefined) return undefined;
|
|
79
|
-
const byId = new Map(versions.map((v) => [v.sessionId, v]));
|
|
80
|
-
let cursor = byId.get(sessionId);
|
|
81
|
-
if (!cursor) return undefined;
|
|
82
|
-
const seen = new Set();
|
|
83
|
-
while (cursor.parentSessionId && !seen.has(cursor.sessionId)) {
|
|
84
|
-
seen.add(cursor.sessionId);
|
|
85
|
-
const parent = byId.get(cursor.parentSessionId);
|
|
86
|
-
if (!parent) break;
|
|
87
|
-
cursor = parent;
|
|
88
|
-
}
|
|
89
|
-
return cursor.sessionId;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Reconstruct a session's ancestor chain from its own event log, bridging
|
|
94
|
-
* ancestors whose sessions were deleted.
|
|
95
|
-
*
|
|
96
|
-
* A version's seed inherits its parent's events, which include the parent's
|
|
97
|
-
* own `message-tree/version` marker — and, transitively, the markers of every
|
|
98
|
-
* ancestor back to the root (the root has none). So `markers`, the
|
|
99
|
-
* message-tree/version events of ONE session's full log in seq order, lays
|
|
100
|
-
* out the ancestry: the last marker is the session's own, the one before it
|
|
101
|
-
* belongs to the parent, and so on; each marker's `inverse.sessionId` names
|
|
102
|
-
* its owner's parent. The information survives deletion because it lives in
|
|
103
|
-
* the descendant's log, not the ancestor's.
|
|
104
|
-
*
|
|
105
|
-
* @param parentId - the session's `header.parentSession`.
|
|
106
|
-
* @param markers - message-tree/version events of the session's log, seq order.
|
|
107
|
-
* @returns ancestors nearest-first, each `{ sessionId, marker? }`; the last
|
|
108
|
-
* entry is the family root (no marker). Empty for a root session.
|
|
109
|
-
*/
|
|
110
|
-
export function ancestorChainFromLog(parentId, markers) {
|
|
111
|
-
const chain = [];
|
|
112
|
-
const seen = new Set();
|
|
113
|
-
let id = parentId;
|
|
114
|
-
let at = markers.length - 2; // markers.at(-1) is the session's own
|
|
115
|
-
while (id !== undefined && id !== null && !seen.has(id)) {
|
|
116
|
-
seen.add(id);
|
|
117
|
-
const marker = at >= 0 ? markers[at] : undefined;
|
|
118
|
-
chain.push(marker === undefined ? { sessionId: id } : { sessionId: id, marker });
|
|
119
|
-
id = marker === undefined ? undefined
|
|
120
|
-
: marker.data && marker.data.inverse ? marker.data.inverse.sessionId : undefined;
|
|
121
|
-
at -= 1;
|
|
122
|
-
}
|
|
123
|
-
return chain;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Flatten one conversation family from `rootId` downward, bridging deleted
|
|
128
|
-
* sessions.
|
|
129
|
-
*
|
|
130
|
-
* `entries` is every node known to exist — surviving session headers plus
|
|
131
|
-
* ghost decorations recovered from descendants' logs — as
|
|
132
|
-
* `{ id, parentId?, createdAt, ghost? }`. Edges come from `parentId` and are
|
|
133
|
-
* keyed by the raw id, so a surviving child still hangs under its deleted
|
|
134
|
-
* parent. A `parentId` with no entry of its own gets a bare ghost entry, so
|
|
135
|
-
* a family fragmented by deletion stays one tree with tombstones at the
|
|
136
|
-
* holes.
|
|
137
|
-
*
|
|
138
|
-
* @returns `[{ entry, depth }]` in depth-first order, siblings by createdAt.
|
|
139
|
-
*/
|
|
140
|
-
export function collectFamily(rootId, entries) {
|
|
141
|
-
const byId = new Map();
|
|
142
|
-
for (const entry of entries) byId.set(entry.id, entry);
|
|
143
|
-
for (const entry of entries) {
|
|
144
|
-
if (entry.parentId !== undefined && !byId.has(entry.parentId)) {
|
|
145
|
-
byId.set(entry.parentId, { id: entry.parentId, ghost: true, createdAt: 0 });
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (!byId.has(rootId)) byId.set(rootId, { id: rootId, ghost: true, createdAt: 0 });
|
|
149
|
-
const childrenByParent = new Map();
|
|
150
|
-
for (const entry of byId.values()) {
|
|
151
|
-
if (entry.parentId === undefined) continue;
|
|
152
|
-
const list = childrenByParent.get(entry.parentId) ?? [];
|
|
153
|
-
list.push(entry);
|
|
154
|
-
childrenByParent.set(entry.parentId, list);
|
|
155
|
-
}
|
|
156
|
-
for (const list of childrenByParent.values()) {
|
|
157
|
-
list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.id).localeCompare(String(b.id)));
|
|
158
|
-
}
|
|
159
|
-
const out = [];
|
|
160
|
-
const seen = new Set();
|
|
161
|
-
const visit = (id, depth) => {
|
|
162
|
-
if (seen.has(id)) return;
|
|
163
|
-
seen.add(id);
|
|
164
|
-
const entry = byId.get(id);
|
|
165
|
-
if (entry === undefined) return;
|
|
166
|
-
out.push({ entry, depth });
|
|
167
|
-
for (const child of childrenByParent.get(id) ?? []) visit(child.id, depth + 1);
|
|
168
|
-
};
|
|
169
|
-
visit(rootId, 0);
|
|
170
|
-
return out;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Project conversation family versions into a turn-level branching tree.
|
|
175
|
-
*
|
|
176
|
-
* Each turn in a session becomes a distinct node in the graph:
|
|
177
|
-
* - Root session's Turn 1 hangs off the Root Conversation Node.
|
|
178
|
-
* - Sibling edits of Turn K hang off Turn K-1 of their parent (or Root if K === 1).
|
|
179
|
-
* - Subsequent turns (Turn K+1, K+2...) in the same session hang off their predecessor in that session.
|
|
180
|
-
*
|
|
181
|
-
* @param versions - The versions array from the host /message-tree payload.
|
|
182
|
-
* @param currentSessionId - The session currently being viewed.
|
|
183
|
-
* @returns Array of turn nodes with `id`, `sessionId`, `turn`, `parentId`, `title`, `text`, `time`, `onCurrentPath`, etc.
|
|
184
|
-
*/
|
|
185
|
-
export function buildTurnTree(versions, currentSessionId) {
|
|
186
|
-
if (!versions || versions.length === 0) return [];
|
|
187
|
-
const byId = new Map(versions.map((v) => [v.sessionId, v]));
|
|
188
|
-
|
|
189
|
-
// Find family root session
|
|
190
|
-
let rootVersion = versions.find((v) => !v.parentSessionId);
|
|
191
|
-
if (!rootVersion) {
|
|
192
|
-
const rootId = rootOf(versions, currentSessionId) ?? versions[0].sessionId;
|
|
193
|
-
rootVersion = byId.get(rootId) ?? versions[0];
|
|
194
|
-
}
|
|
195
|
-
const rootSessionId = rootVersion.sessionId;
|
|
196
|
-
|
|
197
|
-
// Active path of sessions from currentSessionId up to root
|
|
198
|
-
const activeSessionPath = new Set();
|
|
199
|
-
let cursor = byId.get(currentSessionId);
|
|
200
|
-
const seenSessions = new Set();
|
|
201
|
-
while (cursor && !seenSessions.has(cursor.sessionId)) {
|
|
202
|
-
seenSessions.add(cursor.sessionId);
|
|
203
|
-
activeSessionPath.add(cursor.sessionId);
|
|
204
|
-
cursor = cursor.parentSessionId ? byId.get(cursor.parentSessionId) : null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const nodes = [];
|
|
208
|
-
const rootNodeId = `${rootSessionId}#root`;
|
|
209
|
-
const nodeMap = new Map();
|
|
210
|
-
|
|
211
|
-
// 1. Root Conversation Anchor Node
|
|
212
|
-
const rootNode = {
|
|
213
|
-
id: rootNodeId,
|
|
214
|
-
sessionId: rootSessionId,
|
|
215
|
-
turn: 0,
|
|
216
|
-
isRoot: true,
|
|
217
|
-
time: rootVersion.createdAt ?? 0,
|
|
218
|
-
current: currentSessionId === rootSessionId && (!rootVersion.turns || rootVersion.turns.length === 0),
|
|
219
|
-
onCurrentPath: true,
|
|
220
|
-
deleted: !!rootVersion.deleted,
|
|
221
|
-
archived: !!rootVersion.archived,
|
|
222
|
-
};
|
|
223
|
-
nodes.push(rootNode);
|
|
224
|
-
nodeMap.set(rootNodeId, rootNode);
|
|
225
|
-
|
|
226
|
-
function findParentTurnNodeId(v, turn) {
|
|
227
|
-
if (!v.parentSessionId) {
|
|
228
|
-
if (turn === 1) return rootNodeId;
|
|
229
|
-
return `${v.sessionId}#t${turn - 1}`;
|
|
230
|
-
}
|
|
231
|
-
if (turn === v.targetTurn) {
|
|
232
|
-
if (v.targetTurn === 1) return rootNodeId;
|
|
233
|
-
return `${v.parentSessionId}#t${v.targetTurn - 1}`;
|
|
234
|
-
}
|
|
235
|
-
return `${v.sessionId}#t${turn - 1}`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
for (const v of versions) {
|
|
239
|
-
const isCurrentSession = v.sessionId === currentSessionId;
|
|
240
|
-
const turns = Array.isArray(v.turns) && v.turns.length > 0 ? v.turns : [];
|
|
241
|
-
|
|
242
|
-
if (!v.parentSessionId) {
|
|
243
|
-
for (const t of turns) {
|
|
244
|
-
const turnNum = t.turn;
|
|
245
|
-
const turnNodeId = `${v.sessionId}#t${turnNum}`;
|
|
246
|
-
const parentId = findParentTurnNodeId(v, turnNum);
|
|
247
|
-
const node = {
|
|
248
|
-
id: turnNodeId,
|
|
249
|
-
sessionId: v.sessionId,
|
|
250
|
-
turn: turnNum,
|
|
251
|
-
parentId,
|
|
252
|
-
time: t.time ?? v.createdAt,
|
|
253
|
-
text: t.text ?? '',
|
|
254
|
-
current: isCurrentSession,
|
|
255
|
-
onCurrentPath: false,
|
|
256
|
-
deleted: !!v.deleted,
|
|
257
|
-
archived: !!v.archived,
|
|
258
|
-
};
|
|
259
|
-
nodes.push(node);
|
|
260
|
-
nodeMap.set(turnNodeId, node);
|
|
261
|
-
}
|
|
262
|
-
} else {
|
|
263
|
-
const targetTurn = typeof v.targetTurn === 'number' ? v.targetTurn : 1;
|
|
264
|
-
const ownTurns = turns.filter((t) => t.turn >= targetTurn);
|
|
265
|
-
|
|
266
|
-
if (ownTurns.length === 0) {
|
|
267
|
-
const turnNodeId = `${v.sessionId}#t${targetTurn}`;
|
|
268
|
-
const parentId = findParentTurnNodeId(v, targetTurn);
|
|
269
|
-
const node = {
|
|
270
|
-
id: turnNodeId,
|
|
271
|
-
sessionId: v.sessionId,
|
|
272
|
-
turn: targetTurn,
|
|
273
|
-
parentId,
|
|
274
|
-
operation: v.operation ?? 'edit',
|
|
275
|
-
text: v.after ?? v.before ?? '',
|
|
276
|
-
time: v.createdAt ?? 0,
|
|
277
|
-
current: isCurrentSession,
|
|
278
|
-
onCurrentPath: false,
|
|
279
|
-
deleted: !!v.deleted,
|
|
280
|
-
archived: !!v.archived,
|
|
281
|
-
};
|
|
282
|
-
nodes.push(node);
|
|
283
|
-
nodeMap.set(turnNodeId, node);
|
|
284
|
-
} else {
|
|
285
|
-
for (const t of ownTurns) {
|
|
286
|
-
const turnNum = t.turn;
|
|
287
|
-
const turnNodeId = `${v.sessionId}#t${turnNum}`;
|
|
288
|
-
const parentId = findParentTurnNodeId(v, turnNum);
|
|
289
|
-
const isForkTurn = turnNum === targetTurn;
|
|
290
|
-
const node = {
|
|
291
|
-
id: turnNodeId,
|
|
292
|
-
sessionId: v.sessionId,
|
|
293
|
-
turn: turnNum,
|
|
294
|
-
parentId,
|
|
295
|
-
...isForkTurn && v.operation ? { operation: v.operation } : {},
|
|
296
|
-
text: t.text ?? (isForkTurn ? (v.after ?? v.before ?? '') : ''),
|
|
297
|
-
time: t.time ?? v.createdAt,
|
|
298
|
-
current: isCurrentSession,
|
|
299
|
-
onCurrentPath: false,
|
|
300
|
-
deleted: !!v.deleted,
|
|
301
|
-
archived: !!v.archived,
|
|
302
|
-
};
|
|
303
|
-
nodes.push(node);
|
|
304
|
-
nodeMap.set(turnNodeId, node);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Ensure any dangling parentId points to rootNodeId
|
|
311
|
-
const allIds = new Set(nodes.map((n) => n.id));
|
|
312
|
-
for (const n of nodes) {
|
|
313
|
-
if (n.parentId && !allIds.has(n.parentId)) {
|
|
314
|
-
n.parentId = rootNodeId;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Compute active path by walking parentId links from the latest turn of currentSessionId
|
|
319
|
-
const activePathIds = new Set();
|
|
320
|
-
let latestNode = null;
|
|
321
|
-
for (const n of nodes) {
|
|
322
|
-
if (n.sessionId === currentSessionId) {
|
|
323
|
-
if (!latestNode || (n.turn ?? 0) >= (latestNode.turn ?? 0)) {
|
|
324
|
-
latestNode = n;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
let pathCursor = latestNode || nodes[0];
|
|
329
|
-
const seenPath = new Set();
|
|
330
|
-
while (pathCursor && !seenPath.has(pathCursor.id)) {
|
|
331
|
-
seenPath.add(pathCursor.id);
|
|
332
|
-
activePathIds.add(pathCursor.id);
|
|
333
|
-
pathCursor = pathCursor.parentId ? nodeMap.get(pathCursor.parentId) : null;
|
|
334
|
-
}
|
|
335
|
-
activePathIds.add(rootNodeId);
|
|
336
|
-
|
|
337
|
-
for (const n of nodes) {
|
|
338
|
-
n.onCurrentPath = activePathIds.has(n.id);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return nodes;
|
|
342
|
-
}
|
|
343
|
-
|
|
1
|
+
// Pure helpers shared by the host (where to hang a new branch) and tests.
|
|
2
|
+
// The client copies `ringFor` and `buildTurnTree` — keep the two in sync.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walk up through versions of the same turn and stop at the first session
|
|
6
|
+
* that is NOT itself an edit of that turn. New siblings hang off that node
|
|
7
|
+
* so repeated edits of one message fan out instead of chaining.
|
|
8
|
+
*/
|
|
9
|
+
export function attachParentId(nodesById, sourceId, targetTurn) {
|
|
10
|
+
let id = sourceId;
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
while (id && !seen.has(id)) {
|
|
13
|
+
seen.add(id);
|
|
14
|
+
const node = nodesById.get(id);
|
|
15
|
+
if (!node) return id;
|
|
16
|
+
if (node.targetTurn !== targetTurn) return id;
|
|
17
|
+
if (!node.parentSessionId) return id;
|
|
18
|
+
id = node.parentSessionId;
|
|
19
|
+
}
|
|
20
|
+
return sourceId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ‹ › ring for the message at `turn` while viewing `sessionId`.
|
|
25
|
+
*
|
|
26
|
+
* Collects the original message's session plus every version of that same
|
|
27
|
+
* turn, even when older builds chained them (A→B→C) instead of fanning
|
|
28
|
+
* out as siblings.
|
|
29
|
+
*/
|
|
30
|
+
export function ringFor(versions, sessionId, turn) {
|
|
31
|
+
if (!versions) return null;
|
|
32
|
+
const byId = new Map(versions.map((v) => [v.sessionId, v]));
|
|
33
|
+
let cursor = byId.get(sessionId);
|
|
34
|
+
if (!cursor) return null;
|
|
35
|
+
while (cursor.parentSessionId && typeof cursor.targetTurn === 'number' && cursor.targetTurn > turn) {
|
|
36
|
+
const parent = byId.get(cursor.parentSessionId);
|
|
37
|
+
if (!parent) break;
|
|
38
|
+
cursor = parent;
|
|
39
|
+
}
|
|
40
|
+
let fork = cursor;
|
|
41
|
+
while (fork.parentSessionId && typeof fork.targetTurn === 'number' && fork.targetTurn === turn) {
|
|
42
|
+
const parent = byId.get(fork.parentSessionId);
|
|
43
|
+
if (!parent) break;
|
|
44
|
+
fork = parent;
|
|
45
|
+
}
|
|
46
|
+
const walksToFork = (start) => {
|
|
47
|
+
let x = start;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
while (x && !seen.has(x.sessionId)) {
|
|
50
|
+
seen.add(x.sessionId);
|
|
51
|
+
if (x.sessionId === fork.sessionId) return true;
|
|
52
|
+
if (typeof x.targetTurn !== 'number' || x.targetTurn !== turn) return false;
|
|
53
|
+
x = x.parentSessionId ? byId.get(x.parentSessionId) : null;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
};
|
|
57
|
+
// A deleted (ghost) version still anchors the fork and still bridges the
|
|
58
|
+
// parent walks above, but it cannot be opened, so it never appears among
|
|
59
|
+
// the alternatives: the ring renumbers over the survivors.
|
|
60
|
+
const alternatives = versions
|
|
61
|
+
.filter((v) => !v.deleted && (v.sessionId === fork.sessionId || (v.targetTurn === turn && walksToFork(v))))
|
|
62
|
+
.sort((a, b) => a.createdAt - b.createdAt || String(a.sessionId).localeCompare(String(b.sessionId)));
|
|
63
|
+
if (alternatives.length < 2) return null;
|
|
64
|
+
let index = alternatives.findIndex((v) => v.sessionId === cursor.sessionId);
|
|
65
|
+
if (index === -1) index = alternatives.findIndex((v) => v.sessionId === sessionId);
|
|
66
|
+
if (index === -1) index = 0;
|
|
67
|
+
return { alternatives, index };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The family root for `sessionId`: walk parentSessionId links until one has
|
|
72
|
+
* none. Used to key the remembered active path, so every branch of a
|
|
73
|
+
* conversation shares one entry.
|
|
74
|
+
*
|
|
75
|
+
* Mirrors rootOf() in plugin.client.js; kept here so it is testable.
|
|
76
|
+
*/
|
|
77
|
+
export function rootOf(versions, sessionId) {
|
|
78
|
+
if (!versions || sessionId === undefined) return undefined;
|
|
79
|
+
const byId = new Map(versions.map((v) => [v.sessionId, v]));
|
|
80
|
+
let cursor = byId.get(sessionId);
|
|
81
|
+
if (!cursor) return undefined;
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
while (cursor.parentSessionId && !seen.has(cursor.sessionId)) {
|
|
84
|
+
seen.add(cursor.sessionId);
|
|
85
|
+
const parent = byId.get(cursor.parentSessionId);
|
|
86
|
+
if (!parent) break;
|
|
87
|
+
cursor = parent;
|
|
88
|
+
}
|
|
89
|
+
return cursor.sessionId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reconstruct a session's ancestor chain from its own event log, bridging
|
|
94
|
+
* ancestors whose sessions were deleted.
|
|
95
|
+
*
|
|
96
|
+
* A version's seed inherits its parent's events, which include the parent's
|
|
97
|
+
* own `message-tree/version` marker — and, transitively, the markers of every
|
|
98
|
+
* ancestor back to the root (the root has none). So `markers`, the
|
|
99
|
+
* message-tree/version events of ONE session's full log in seq order, lays
|
|
100
|
+
* out the ancestry: the last marker is the session's own, the one before it
|
|
101
|
+
* belongs to the parent, and so on; each marker's `inverse.sessionId` names
|
|
102
|
+
* its owner's parent. The information survives deletion because it lives in
|
|
103
|
+
* the descendant's log, not the ancestor's.
|
|
104
|
+
*
|
|
105
|
+
* @param parentId - the session's `header.parentSession`.
|
|
106
|
+
* @param markers - message-tree/version events of the session's log, seq order.
|
|
107
|
+
* @returns ancestors nearest-first, each `{ sessionId, marker? }`; the last
|
|
108
|
+
* entry is the family root (no marker). Empty for a root session.
|
|
109
|
+
*/
|
|
110
|
+
export function ancestorChainFromLog(parentId, markers) {
|
|
111
|
+
const chain = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
let id = parentId;
|
|
114
|
+
let at = markers.length - 2; // markers.at(-1) is the session's own
|
|
115
|
+
while (id !== undefined && id !== null && !seen.has(id)) {
|
|
116
|
+
seen.add(id);
|
|
117
|
+
const marker = at >= 0 ? markers[at] : undefined;
|
|
118
|
+
chain.push(marker === undefined ? { sessionId: id } : { sessionId: id, marker });
|
|
119
|
+
id = marker === undefined ? undefined
|
|
120
|
+
: marker.data && marker.data.inverse ? marker.data.inverse.sessionId : undefined;
|
|
121
|
+
at -= 1;
|
|
122
|
+
}
|
|
123
|
+
return chain;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Flatten one conversation family from `rootId` downward, bridging deleted
|
|
128
|
+
* sessions.
|
|
129
|
+
*
|
|
130
|
+
* `entries` is every node known to exist — surviving session headers plus
|
|
131
|
+
* ghost decorations recovered from descendants' logs — as
|
|
132
|
+
* `{ id, parentId?, createdAt, ghost? }`. Edges come from `parentId` and are
|
|
133
|
+
* keyed by the raw id, so a surviving child still hangs under its deleted
|
|
134
|
+
* parent. A `parentId` with no entry of its own gets a bare ghost entry, so
|
|
135
|
+
* a family fragmented by deletion stays one tree with tombstones at the
|
|
136
|
+
* holes.
|
|
137
|
+
*
|
|
138
|
+
* @returns `[{ entry, depth }]` in depth-first order, siblings by createdAt.
|
|
139
|
+
*/
|
|
140
|
+
export function collectFamily(rootId, entries) {
|
|
141
|
+
const byId = new Map();
|
|
142
|
+
for (const entry of entries) byId.set(entry.id, entry);
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (entry.parentId !== undefined && !byId.has(entry.parentId)) {
|
|
145
|
+
byId.set(entry.parentId, { id: entry.parentId, ghost: true, createdAt: 0 });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!byId.has(rootId)) byId.set(rootId, { id: rootId, ghost: true, createdAt: 0 });
|
|
149
|
+
const childrenByParent = new Map();
|
|
150
|
+
for (const entry of byId.values()) {
|
|
151
|
+
if (entry.parentId === undefined) continue;
|
|
152
|
+
const list = childrenByParent.get(entry.parentId) ?? [];
|
|
153
|
+
list.push(entry);
|
|
154
|
+
childrenByParent.set(entry.parentId, list);
|
|
155
|
+
}
|
|
156
|
+
for (const list of childrenByParent.values()) {
|
|
157
|
+
list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.id).localeCompare(String(b.id)));
|
|
158
|
+
}
|
|
159
|
+
const out = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const visit = (id, depth) => {
|
|
162
|
+
if (seen.has(id)) return;
|
|
163
|
+
seen.add(id);
|
|
164
|
+
const entry = byId.get(id);
|
|
165
|
+
if (entry === undefined) return;
|
|
166
|
+
out.push({ entry, depth });
|
|
167
|
+
for (const child of childrenByParent.get(id) ?? []) visit(child.id, depth + 1);
|
|
168
|
+
};
|
|
169
|
+
visit(rootId, 0);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Project conversation family versions into a turn-level branching tree.
|
|
175
|
+
*
|
|
176
|
+
* Each turn in a session becomes a distinct node in the graph:
|
|
177
|
+
* - Root session's Turn 1 hangs off the Root Conversation Node.
|
|
178
|
+
* - Sibling edits of Turn K hang off Turn K-1 of their parent (or Root if K === 1).
|
|
179
|
+
* - Subsequent turns (Turn K+1, K+2...) in the same session hang off their predecessor in that session.
|
|
180
|
+
*
|
|
181
|
+
* @param versions - The versions array from the host /message-tree payload.
|
|
182
|
+
* @param currentSessionId - The session currently being viewed.
|
|
183
|
+
* @returns Array of turn nodes with `id`, `sessionId`, `turn`, `parentId`, `title`, `text`, `time`, `onCurrentPath`, etc.
|
|
184
|
+
*/
|
|
185
|
+
export function buildTurnTree(versions, currentSessionId) {
|
|
186
|
+
if (!versions || versions.length === 0) return [];
|
|
187
|
+
const byId = new Map(versions.map((v) => [v.sessionId, v]));
|
|
188
|
+
|
|
189
|
+
// Find family root session
|
|
190
|
+
let rootVersion = versions.find((v) => !v.parentSessionId);
|
|
191
|
+
if (!rootVersion) {
|
|
192
|
+
const rootId = rootOf(versions, currentSessionId) ?? versions[0].sessionId;
|
|
193
|
+
rootVersion = byId.get(rootId) ?? versions[0];
|
|
194
|
+
}
|
|
195
|
+
const rootSessionId = rootVersion.sessionId;
|
|
196
|
+
|
|
197
|
+
// Active path of sessions from currentSessionId up to root
|
|
198
|
+
const activeSessionPath = new Set();
|
|
199
|
+
let cursor = byId.get(currentSessionId);
|
|
200
|
+
const seenSessions = new Set();
|
|
201
|
+
while (cursor && !seenSessions.has(cursor.sessionId)) {
|
|
202
|
+
seenSessions.add(cursor.sessionId);
|
|
203
|
+
activeSessionPath.add(cursor.sessionId);
|
|
204
|
+
cursor = cursor.parentSessionId ? byId.get(cursor.parentSessionId) : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const nodes = [];
|
|
208
|
+
const rootNodeId = `${rootSessionId}#root`;
|
|
209
|
+
const nodeMap = new Map();
|
|
210
|
+
|
|
211
|
+
// 1. Root Conversation Anchor Node
|
|
212
|
+
const rootNode = {
|
|
213
|
+
id: rootNodeId,
|
|
214
|
+
sessionId: rootSessionId,
|
|
215
|
+
turn: 0,
|
|
216
|
+
isRoot: true,
|
|
217
|
+
time: rootVersion.createdAt ?? 0,
|
|
218
|
+
current: currentSessionId === rootSessionId && (!rootVersion.turns || rootVersion.turns.length === 0),
|
|
219
|
+
onCurrentPath: true,
|
|
220
|
+
deleted: !!rootVersion.deleted,
|
|
221
|
+
archived: !!rootVersion.archived,
|
|
222
|
+
};
|
|
223
|
+
nodes.push(rootNode);
|
|
224
|
+
nodeMap.set(rootNodeId, rootNode);
|
|
225
|
+
|
|
226
|
+
function findParentTurnNodeId(v, turn) {
|
|
227
|
+
if (!v.parentSessionId) {
|
|
228
|
+
if (turn === 1) return rootNodeId;
|
|
229
|
+
return `${v.sessionId}#t${turn - 1}`;
|
|
230
|
+
}
|
|
231
|
+
if (turn === v.targetTurn) {
|
|
232
|
+
if (v.targetTurn === 1) return rootNodeId;
|
|
233
|
+
return `${v.parentSessionId}#t${v.targetTurn - 1}`;
|
|
234
|
+
}
|
|
235
|
+
return `${v.sessionId}#t${turn - 1}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const v of versions) {
|
|
239
|
+
const isCurrentSession = v.sessionId === currentSessionId;
|
|
240
|
+
const turns = Array.isArray(v.turns) && v.turns.length > 0 ? v.turns : [];
|
|
241
|
+
|
|
242
|
+
if (!v.parentSessionId) {
|
|
243
|
+
for (const t of turns) {
|
|
244
|
+
const turnNum = t.turn;
|
|
245
|
+
const turnNodeId = `${v.sessionId}#t${turnNum}`;
|
|
246
|
+
const parentId = findParentTurnNodeId(v, turnNum);
|
|
247
|
+
const node = {
|
|
248
|
+
id: turnNodeId,
|
|
249
|
+
sessionId: v.sessionId,
|
|
250
|
+
turn: turnNum,
|
|
251
|
+
parentId,
|
|
252
|
+
time: t.time ?? v.createdAt,
|
|
253
|
+
text: t.text ?? '',
|
|
254
|
+
current: isCurrentSession,
|
|
255
|
+
onCurrentPath: false,
|
|
256
|
+
deleted: !!v.deleted,
|
|
257
|
+
archived: !!v.archived,
|
|
258
|
+
};
|
|
259
|
+
nodes.push(node);
|
|
260
|
+
nodeMap.set(turnNodeId, node);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
const targetTurn = typeof v.targetTurn === 'number' ? v.targetTurn : 1;
|
|
264
|
+
const ownTurns = turns.filter((t) => t.turn >= targetTurn);
|
|
265
|
+
|
|
266
|
+
if (ownTurns.length === 0) {
|
|
267
|
+
const turnNodeId = `${v.sessionId}#t${targetTurn}`;
|
|
268
|
+
const parentId = findParentTurnNodeId(v, targetTurn);
|
|
269
|
+
const node = {
|
|
270
|
+
id: turnNodeId,
|
|
271
|
+
sessionId: v.sessionId,
|
|
272
|
+
turn: targetTurn,
|
|
273
|
+
parentId,
|
|
274
|
+
operation: v.operation ?? 'edit',
|
|
275
|
+
text: v.after ?? v.before ?? '',
|
|
276
|
+
time: v.createdAt ?? 0,
|
|
277
|
+
current: isCurrentSession,
|
|
278
|
+
onCurrentPath: false,
|
|
279
|
+
deleted: !!v.deleted,
|
|
280
|
+
archived: !!v.archived,
|
|
281
|
+
};
|
|
282
|
+
nodes.push(node);
|
|
283
|
+
nodeMap.set(turnNodeId, node);
|
|
284
|
+
} else {
|
|
285
|
+
for (const t of ownTurns) {
|
|
286
|
+
const turnNum = t.turn;
|
|
287
|
+
const turnNodeId = `${v.sessionId}#t${turnNum}`;
|
|
288
|
+
const parentId = findParentTurnNodeId(v, turnNum);
|
|
289
|
+
const isForkTurn = turnNum === targetTurn;
|
|
290
|
+
const node = {
|
|
291
|
+
id: turnNodeId,
|
|
292
|
+
sessionId: v.sessionId,
|
|
293
|
+
turn: turnNum,
|
|
294
|
+
parentId,
|
|
295
|
+
...isForkTurn && v.operation ? { operation: v.operation } : {},
|
|
296
|
+
text: t.text ?? (isForkTurn ? (v.after ?? v.before ?? '') : ''),
|
|
297
|
+
time: t.time ?? v.createdAt,
|
|
298
|
+
current: isCurrentSession,
|
|
299
|
+
onCurrentPath: false,
|
|
300
|
+
deleted: !!v.deleted,
|
|
301
|
+
archived: !!v.archived,
|
|
302
|
+
};
|
|
303
|
+
nodes.push(node);
|
|
304
|
+
nodeMap.set(turnNodeId, node);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Ensure any dangling parentId points to rootNodeId
|
|
311
|
+
const allIds = new Set(nodes.map((n) => n.id));
|
|
312
|
+
for (const n of nodes) {
|
|
313
|
+
if (n.parentId && !allIds.has(n.parentId)) {
|
|
314
|
+
n.parentId = rootNodeId;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Compute active path by walking parentId links from the latest turn of currentSessionId
|
|
319
|
+
const activePathIds = new Set();
|
|
320
|
+
let latestNode = null;
|
|
321
|
+
for (const n of nodes) {
|
|
322
|
+
if (n.sessionId === currentSessionId) {
|
|
323
|
+
if (!latestNode || (n.turn ?? 0) >= (latestNode.turn ?? 0)) {
|
|
324
|
+
latestNode = n;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let pathCursor = latestNode || nodes[0];
|
|
329
|
+
const seenPath = new Set();
|
|
330
|
+
while (pathCursor && !seenPath.has(pathCursor.id)) {
|
|
331
|
+
seenPath.add(pathCursor.id);
|
|
332
|
+
activePathIds.add(pathCursor.id);
|
|
333
|
+
pathCursor = pathCursor.parentId ? nodeMap.get(pathCursor.parentId) : null;
|
|
334
|
+
}
|
|
335
|
+
activePathIds.add(rootNodeId);
|
|
336
|
+
|
|
337
|
+
for (const n of nodes) {
|
|
338
|
+
n.onCurrentPath = activePathIds.has(n.id);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return nodes;
|
|
342
|
+
}
|
|
343
|
+
|