@yeaft/webchat-agent 1.0.640 → 1.0.642

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Session 历史复制/合并的纯变换。持久 ID 只在所属 stream 内有意义;
3
+ * origin 跨 fork/merge 保留,绝不把相同提问文本当作共同历史。
4
+ */
5
+ import { isVisibleConversationRow } from './internal-control.js';
6
+
7
+ /** Create a history-boundary error with a stable machine-readable code. */
8
+ export function historyError(code, message) {
9
+ return Object.assign(new Error(message), { code });
10
+ }
11
+
12
+ /** Whether a durable row is a human request rather than protocol/control input. */
13
+ export function isHumanRequest(row) {
14
+ return row?.role === 'user' && isVisibleConversationRow(row);
15
+ }
16
+
17
+ /** Preserve fork lineage, or establish an origin in the source stream's lifetime. */
18
+ export function messageOrigin(row, sessionId, streamId) {
19
+ const origin = row?.origin;
20
+ if (origin && typeof origin.streamId === 'string' && origin.streamId
21
+ && typeof origin.messageId === 'string' && origin.messageId) return { ...origin };
22
+ return { sessionId, streamId, messageId: row.id, ...(row.turnId ? { turnId: row.turnId } : {}) };
23
+ }
24
+
25
+ /**
26
+ * Cross-Session rows are historical projections, never native continuations.
27
+ * Signatures/encrypted reasoning are bound to the original owner and exact
28
+ * tool IDs. Keep the source transcript (linked by origin) as provenance, not
29
+ * an opaque payload in the new Session. Reconstruct only canonical text/tools.
30
+ * This deliberately does not relax replayProviderState's owner/digest fences.
31
+ */
32
+ export function historicalReplayRow(row) {
33
+ const { providerState, thinkingBlocks, ...history } = row;
34
+ return history;
35
+ }
36
+
37
+ function originKey(origin) {
38
+ return JSON.stringify([origin.streamId, origin.messageId]);
39
+ }
40
+
41
+ function stableJson(value) {
42
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
43
+ if (value && typeof value === 'object') {
44
+ return `{${Object.keys(value).sort().filter(key => value[key] !== undefined)
45
+ .map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
46
+ }
47
+ return JSON.stringify(value);
48
+ }
49
+
50
+ function sentAt(row) {
51
+ return row.time ?? row.timestamp ?? row.ts ?? '';
52
+ }
53
+
54
+ function toolScope(row, callId) {
55
+ return JSON.stringify([row.turnId || '', row.speakerVpId || row.vpId || '', row.threadId || 'main', callId]);
56
+ }
57
+
58
+ function ambiguous(message) {
59
+ throw historyError('ambiguous_merge_history', message);
60
+ }
61
+
62
+ // 原始 transcript 不被 sanitize(sanitize 会丢数据)。返回每个结果对应的
63
+ // assistant 调用位置,允许不同 VP/turn 重用同一个 provider tool-call ID。
64
+ function toolLinks(rows) {
65
+ const calls = new Map();
66
+ const results = new Map();
67
+ const paired = new Set();
68
+ for (const [index, row] of rows.entries()) {
69
+ for (const [callIndex, call] of (row.toolCalls || []).entries()) {
70
+ const key = toolScope(row, call.id);
71
+ if (!call.id || calls.has(key)) ambiguous('Duplicate tool call identity within a request');
72
+ calls.set(key, { index, callIndex });
73
+ }
74
+ if (row.role !== 'tool') continue;
75
+ const key = toolScope(row, row.toolCallId);
76
+ let call = calls.get(key);
77
+ // Some legacy tool rows lack speaker/turn metadata. Only accept an
78
+ // unambiguous preceding call, never a globally colliding bare tool id.
79
+ if (!call) {
80
+ const candidates = [...calls.values()].filter(candidate => {
81
+ const owner = rows[candidate.index];
82
+ return owner.toolCalls[candidate.callIndex].id === row.toolCallId
83
+ && (!row.turnId || owner.turnId === row.turnId)
84
+ && (!(row.speakerVpId || row.vpId) || (owner.speakerVpId || owner.vpId) === (row.speakerVpId || row.vpId))
85
+ && (!row.threadId || (owner.threadId || 'main') === row.threadId);
86
+ });
87
+ if (candidates.length === 1) [call] = candidates;
88
+ }
89
+ if (!call || paired.has(call)) ambiguous('Unpaired or ambiguous tool result within a request');
90
+ results.set(index, call);
91
+ paired.add(call);
92
+ }
93
+ if ([...calls.values()].some(call => !paired.has(call))) ambiguous('Incomplete tool history within a request');
94
+ return { calls, results };
95
+ }
96
+
97
+ function requestTurns(input) {
98
+ const { rows, sessionId, streamId } = input;
99
+ if (new Set(rows.map(row => row.id)).size !== rows.length) ambiguous('Duplicate persisted identity within one Session');
100
+ const turns = [];
101
+ const byRow = new Map();
102
+ const byClient = new Map();
103
+ for (const [index, row] of rows.entries()) {
104
+ if (!isHumanRequest(row)) continue;
105
+ const candidates = row.clientMessageId ? (byClient.get(row.clientMessageId) || []) : [];
106
+ // Historical multi-VP prompt variants share a client identity and source
107
+ // stream. Distinct origins in a merged Session must remain distinct even
108
+ // if two clients happened to reuse the same clientMessageId.
109
+ const sameStream = candidate => !candidate.user.origin || !row.origin
110
+ || candidate.user.origin.streamId === row.origin.streamId;
111
+ if (candidates.some(candidate => sameStream(candidate) && sentAt(candidate.user) !== sentAt(row))) {
112
+ ambiguous('Reused clientMessageId within one Session');
113
+ }
114
+ let turn = candidates.find(candidate => sameStream(candidate) && sentAt(candidate.user) === sentAt(row));
115
+ if (!turn) {
116
+ turn = { user: row, rows: [], origins: new Set(), explicitOrigins: false, index, input };
117
+ turns.push(turn);
118
+ if (row.clientMessageId) byClient.set(row.clientMessageId, [...candidates, turn]);
119
+ }
120
+ byRow.set(row.id, turn);
121
+ turn.origins.add(originKey(messageOrigin(row, sessionId, streamId)));
122
+ turn.explicitOrigins ||= !!row.origin;
123
+ }
124
+ if (!turns.length) {
125
+ if (rows.length) ambiguous('History without a durable human request cannot be merged');
126
+ return [];
127
+ }
128
+ const records = new Map(rows.map(row => [row.id, row]));
129
+ function causalTurn(row, seen = new Set()) {
130
+ if (!row?.causalRootId || seen.has(row.id)) return null;
131
+ seen.add(row.id);
132
+ const direct = byRow.get(row.causalRootId);
133
+ if (direct) return direct;
134
+ const candidates = byClient.get(row.causalRootId) || [];
135
+ if (candidates.length > 1) ambiguous('Ambiguous client causal reference');
136
+ return candidates[0] || causalTurn(records.get(row.causalRootId), seen);
137
+ }
138
+ const byVpTurn = new Map();
139
+ const vpTurnKey = row => row.turnId ? JSON.stringify([row.speakerVpId || row.vpId || '', row.turnId]) : null;
140
+ // Learn explicit causal ownership before assigning interleaved legacy tails.
141
+ for (const row of rows) {
142
+ const turn = causalTurn(row);
143
+ const key = vpTurnKey(row);
144
+ if (!turn || !key) continue;
145
+ if (byVpTurn.has(key) && byVpTurn.get(key) !== turn) ambiguous('A VP turn belongs to multiple human requests');
146
+ byVpTurn.set(key, turn);
147
+ }
148
+ let current = null;
149
+ for (const row of rows) {
150
+ if (isHumanRequest(row)) current = byRow.get(row.id);
151
+ const key = vpTurnKey(row);
152
+ const turn = byRow.get(row.id) || causalTurn(row) || byVpTurn.get(key) || current;
153
+ if (!turn) {
154
+ if (isVisibleConversationRow(row) && row.role !== 'system') ambiguous('History begins inside a human request');
155
+ turns[0].rows.push(row);
156
+ byRow.set(row.id, turns[0]);
157
+ continue;
158
+ }
159
+ if (key) {
160
+ if (byVpTurn.has(key) && byVpTurn.get(key) !== turn) ambiguous('Ambiguous interleaved VP history');
161
+ byVpTurn.set(key, turn);
162
+ }
163
+ turn.rows.push(row);
164
+ byRow.set(row.id, turn);
165
+ }
166
+ for (const turn of turns) {
167
+ turn.links = toolLinks(turn.rows);
168
+ turn.vpTurns = new Set(turn.rows.filter(row => row.role === 'assistant' && row.turnId)
169
+ .map(row => JSON.stringify([row.speakerVpId || row.vpId || '', row.origin?.turnId || row.turnId])));
170
+ // Legacy forks preserved these identities, even though persisted row IDs
171
+ // were remapped. Require sent-time evidence too; no text-only matching.
172
+ turn.legacyKey = turn.user.clientMessageId && sentAt(turn.user)
173
+ ? stableJson(['client', turn.user.clientMessageId, sentAt(turn.user)])
174
+ : (turn.vpTurns.size && sentAt(turn.user)
175
+ ? stableJson(['turns', [...turn.vpTurns].sort(), sentAt(turn.user)]) : null);
176
+ }
177
+ return turns;
178
+ }
179
+
180
+ function commonRequest(left, right) {
181
+ if ([...left.origins].some(key => right.origins.has(key))) return true;
182
+ if (left.user.clientMessageId && right.user.clientMessageId
183
+ && left.user.clientMessageId !== right.user.clientMessageId) return false;
184
+ // Explicit, different origins prove these are distinct user requests.
185
+ if (left.explicitOrigins && right.explicitOrigins) return false;
186
+ if (left.legacyKey && left.legacyKey === right.legacyKey) return true;
187
+ // A fork made at an assistant boundary may contain only one VP's answer.
188
+ // Recognize overlap, then reject unequal request bodies below instead of
189
+ // treating that partial shared request as two independent user requests.
190
+ return !!sentAt(left.user) && sentAt(left.user) === sentAt(right.user)
191
+ && [...left.vpTurns].some(key => right.vpTurns.has(key));
192
+ }
193
+
194
+ function equivalentRows(left, right, aliases) {
195
+ if (left.rows.length !== right.rows.length) return false;
196
+ function normalized(turn) {
197
+ const rowRefs = new Map();
198
+ for (const candidate of turn.input.turns) {
199
+ const canonical = aliases.get(candidate) || candidate;
200
+ const requestKey = originKey(messageOrigin(canonical.user, canonical.input.sessionId, canonical.input.streamId));
201
+ candidate.rows.forEach((row, index) => rowRefs.set(row.id, `${requestKey}:${index}`));
202
+ }
203
+ const clientRefs = new Map();
204
+ for (const candidate of turn.input.turns) {
205
+ if (!candidate.user.clientMessageId) continue;
206
+ const clientId = candidate.user.clientMessageId;
207
+ const canonical = rowRefs.get(candidate.user.id);
208
+ clientRefs.set(clientId, clientRefs.has(clientId) && clientRefs.get(clientId) !== canonical ? null : canonical);
209
+ }
210
+ const remap = value => rowRefs.get(value) || value;
211
+ const callRefs = new Map([...turn.links.calls].map(([key, call]) => [key, `call:${call.index}:${call.callIndex}`]));
212
+ return turn.rows.map((row, index) => {
213
+ const copy = historicalReplayRow(row);
214
+ for (const key of ['id', 'seq', 'sessionId', 'origin', 'cold']) delete copy[key];
215
+ if (row.turnId) copy.turnId = row.origin?.turnId || row.turnId;
216
+ if (row.causalRootId) copy.causalRootId = rowRefs.get(row.causalRootId) || clientRefs.get(row.causalRootId) || row.causalRootId;
217
+ for (const key of ['foldedMessageIds', 'sourceMessageIds']) {
218
+ if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
219
+ }
220
+ if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
221
+ if (Array.isArray(row.attachments)) copy.attachments = row.attachments.map(att => {
222
+ const { path, ...metadata } = att;
223
+ return metadata;
224
+ });
225
+ if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: callRefs.get(toolScope(row, call.id)) }));
226
+ const call = turn.links.results.get(index);
227
+ if (call) copy.toolCallId = `call:${call.index}:${call.callIndex}`;
228
+ if (copy.presentationCallId && callRefs.has(toolScope(row, copy.presentationCallId))) {
229
+ copy.presentationCallId = callRefs.get(toolScope(row, copy.presentationCallId));
230
+ }
231
+ return copy;
232
+ });
233
+ }
234
+ return stableJson(normalized(left)) === stableJson(normalized(right));
235
+ }
236
+
237
+ /**
238
+ * 规划两份 idle Session transcript 的完整请求合并;不写磁盘。
239
+ * @param {Array<{sessionId:string, streamId:string, rows:object[]}>} inputs
240
+ * @returns {{turns:object[], aliases:Map<object,object>, sharedTurnCount:number}}
241
+ */
242
+ export function planSessionMerge(inputs) {
243
+ for (const input of inputs) input.turns = requestTurns(input);
244
+ const [left, right] = inputs.map(input => input.turns);
245
+ const aliases = new Map();
246
+ for (const other of right) {
247
+ const matches = left.filter(turn => commonRequest(turn, other));
248
+ if (matches.length > 1) ambiguous('Shared request identity is not unique');
249
+ if (matches.length) {
250
+ if ([...aliases.values()].includes(matches[0])) ambiguous('Shared request occurs more than once');
251
+ aliases.set(other, matches[0]);
252
+ }
253
+ }
254
+ for (const [other, source] of aliases) {
255
+ if (!equivalentRows(source, other, aliases)) ambiguous('Shared human request has divergent or partial branch history');
256
+ }
257
+ // Only an aligned shared prefix precedes the chronological merge. A shared
258
+ // request can also occur in the middle after earlier merges (AB + BC); moving
259
+ // every shared request to the front would incorrectly produce B, A, C.
260
+ let prefixLength = 0;
261
+ while (prefixLength < left.length && aliases.get(right[prefixLength]) === left[prefixLength]) prefixLength++;
262
+ const common = left.slice(0, prefixLength);
263
+ const tails = [...left.slice(prefixLength), ...right.slice(prefixLength).filter(turn => !aliases.has(turn))];
264
+ const timestamp = turn => {
265
+ const value = sentAt(turn.user);
266
+ const parsed = typeof value === 'number' ? value : Date.parse(value);
267
+ return Number.isFinite(parsed) ? parsed : 0;
268
+ };
269
+ // Stable tie break: source argument order, then original request order.
270
+ tails.sort((a, b) => timestamp(a) - timestamp(b)
271
+ || inputs.indexOf(a.input) - inputs.indexOf(b.input) || a.index - b.index);
272
+ return { turns: [...common, ...tails], aliases, sharedTurnCount: aliases.size };
273
+ }
274
+
275
+ /**
276
+ * 为计划分配新 persisted IDs 并重写内部引用;每个源有独立命名空间。
277
+ * 附件由调用者按实际源所有权复制;provider 私有状态转为 canonical 历史。
278
+ * @param {object} plan planSessionMerge 的返回值
279
+ * @param {string} targetSessionId 新 Session
280
+ * @param {number} firstSeq 已保留的起始序号
281
+ * @returns {{rows:object[], idMaps:Map<string,Map<string,string>>}}
282
+ */
283
+ export function materializeSessionMerge(plan, targetSessionId, firstSeq) {
284
+ const idMaps = new Map();
285
+ const mapping = input => {
286
+ if (!idMaps.has(input.sessionId)) idMaps.set(input.sessionId, new Map());
287
+ return idMaps.get(input.sessionId);
288
+ };
289
+ let seq = firstSeq;
290
+ for (const turn of plan.turns) {
291
+ for (const row of turn.rows) mapping(turn.input).set(row.id, `m${String(seq++).padStart(4, '0')}`);
292
+ }
293
+ for (const [other, source] of plan.aliases) {
294
+ other.rows.forEach((row, index) => mapping(other.input).set(row.id, mapping(source.input).get(source.rows[index].id)));
295
+ }
296
+ const clientMaps = new Map();
297
+ for (const input of new Set([...plan.turns, ...plan.aliases.keys()].map(turn => turn.input))) {
298
+ const clients = new Map();
299
+ for (const turn of input.turns) {
300
+ if (!turn.user.clientMessageId) continue;
301
+ const clientId = turn.user.clientMessageId;
302
+ const targetId = mapping(input).get(turn.user.id);
303
+ clients.set(clientId, clients.has(clientId) && clients.get(clientId) !== targetId ? null : targetId);
304
+ }
305
+ clientMaps.set(input, clients);
306
+ }
307
+ const rows = [];
308
+ for (const turn of plan.turns) {
309
+ const ids = mapping(turn.input);
310
+ const vpTurns = new Map();
311
+ const remap = id => ids.get(id) || id;
312
+ const callId = call => `merge_${ids.get(turn.rows[call.index].id)}_${call.callIndex}`;
313
+ const calls = new Map([...turn.links.calls].map(([key, call]) => [key, callId(call)]));
314
+ for (const [index, row] of turn.rows.entries()) {
315
+ const copy = { ...historicalReplayRow(row), id: ids.get(row.id), sessionId: targetSessionId,
316
+ origin: messageOrigin(row, turn.input.sessionId, turn.input.streamId) };
317
+ delete copy.seq;
318
+ if (row.turnId) {
319
+ const key = JSON.stringify([row.speakerVpId || row.vpId || '', row.turnId]);
320
+ if (!vpTurns.has(key)) vpTurns.set(key, `merge_${copy.id}_turn`);
321
+ copy.turnId = vpTurns.get(key);
322
+ }
323
+ if (row.causalRootId) copy.causalRootId = ids.get(row.causalRootId)
324
+ || clientMaps.get(turn.input)?.get(row.causalRootId) || row.causalRootId;
325
+ for (const key of ['foldedMessageIds', 'sourceMessageIds']) {
326
+ if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
327
+ }
328
+ if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
329
+ if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: calls.get(toolScope(row, call.id)) }));
330
+ if (turn.links.results.has(index)) copy.toolCallId = callId(turn.links.results.get(index));
331
+ if (calls.has(toolScope(row, row.presentationCallId))) copy.presentationCallId = calls.get(toolScope(row, row.presentationCallId));
332
+ rows.push(copy);
333
+ }
334
+ }
335
+ return { rows, idMaps };
336
+ }