cc-viewer 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/assets/App-gUSmU6Ny.js +2 -0
  2. package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  3. package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-qfRsRth0.js} +1 -1
  4. package/dist/assets/index-B51Ci0GL.js +2 -0
  5. package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  6. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/package.json +2 -1
  9. package/server/lib/log-watcher.js +29 -3
  10. package/server/lib/v2/adapter.js +71 -6
  11. package/server/lib/v2/live-feed.js +110 -3
  12. package/server/lib/v2/meta-rows.js +360 -0
  13. package/server/lib/v2/v2-writer.js +15 -0
  14. package/server/lib/wire-compress.js +167 -0
  15. package/server/routes/events.js +96 -26
  16. package/server/routes/logs.js +15 -12
  17. package/server/routes/v2.js +60 -0
  18. package/server/routes/workspaces.js +7 -6
  19. package/server/server.js +23 -1
  20. package/src/utils/apiUrl.js +26 -0
  21. package/src/utils/askFallback.js +21 -0
  22. package/src/utils/askOptionDesc.js +55 -0
  23. package/src/utils/askPortalMatcher.js +54 -0
  24. package/src/utils/autoApproveOptions.js +27 -0
  25. package/src/utils/avatarAnimationPostPass.js +49 -0
  26. package/src/utils/builtinPresets.js +18 -0
  27. package/src/utils/clearCheckpoint.js +7 -0
  28. package/src/utils/commandValidator.js +45 -0
  29. package/src/utils/contentFilter.js +620 -0
  30. package/src/utils/contextRaw.js +18 -0
  31. package/src/utils/contextSidebarNavigation.js +16 -0
  32. package/src/utils/contextTurns.js +117 -0
  33. package/src/utils/displayScaleHelper.js +47 -0
  34. package/src/utils/effectiveModel.js +13 -0
  35. package/src/utils/entry-slim.js +563 -0
  36. package/src/utils/entryCache.js +144 -0
  37. package/src/utils/errorReport.js +6 -0
  38. package/src/utils/fileExpandedPathsStorage.js +70 -0
  39. package/src/utils/fileIcons.jsx +36 -0
  40. package/src/utils/fileOpen.js +37 -0
  41. package/src/utils/formatters.js +48 -0
  42. package/src/utils/gitApi.js +50 -0
  43. package/src/utils/gitTreeBuilder.js +19 -0
  44. package/src/utils/helpers.js +846 -0
  45. package/src/utils/identityHeal.js +99 -0
  46. package/src/utils/imConnState.js +59 -0
  47. package/src/utils/imOrigin.js +24 -0
  48. package/src/utils/imTr.js +7 -0
  49. package/src/utils/imageCompress.js +83 -0
  50. package/src/utils/imageDownscale.js +28 -0
  51. package/src/utils/imageResize.js +112 -0
  52. package/src/utils/ingestPipeline.js +47 -0
  53. package/src/utils/lazyWithReload.js +53 -0
  54. package/src/utils/markdown.js +30 -0
  55. package/src/utils/markdownIncremental.js +39 -0
  56. package/src/utils/markdownProfiler.js +113 -0
  57. package/src/utils/mdExtensionDetect.js +64 -0
  58. package/src/utils/memoryLinkParser.js +44 -0
  59. package/src/utils/modalMask.js +15 -0
  60. package/src/utils/monotime.js +6 -0
  61. package/src/utils/pinnedMenu.js +55 -0
  62. package/src/utils/presetShortcuts.js +14 -0
  63. package/src/utils/projectAlias.js +135 -0
  64. package/src/utils/promptClassifier.js +112 -0
  65. package/src/utils/promptDetect.js +196 -0
  66. package/src/utils/promptNav.js +60 -0
  67. package/src/utils/ptyChunkBuilder.js +251 -0
  68. package/src/utils/quickMenuHoverIntent.js +35 -0
  69. package/src/utils/rateLimitParser.js +119 -0
  70. package/src/utils/readResultPool.js +106 -0
  71. package/src/utils/refreshCachedItemProp.js +30 -0
  72. package/src/utils/requestType.js +208 -0
  73. package/src/utils/resizeCalc.js +21 -0
  74. package/src/utils/resolveLocalized.js +17 -0
  75. package/src/utils/searchApi.js +47 -0
  76. package/src/utils/searchReplace.js +51 -0
  77. package/src/utils/seqResourceLoaders.js +79 -0
  78. package/src/utils/sessionManager.js +471 -0
  79. package/src/utils/sessionMerge.js +180 -0
  80. package/src/utils/skillModalController.js +111 -0
  81. package/src/utils/skillsParser.js +116 -0
  82. package/src/utils/slashCommandLabels.js +80 -0
  83. package/src/utils/splitDragCalc.js +73 -0
  84. package/src/utils/stickyBottomController.js +643 -0
  85. package/src/utils/svgSanitize.js +48 -0
  86. package/src/utils/systemTags.js +47 -0
  87. package/src/utils/tClaude.js +20 -0
  88. package/src/utils/teamModalBuilder.js +351 -0
  89. package/src/utils/teamSessionParser.js +190 -0
  90. package/src/utils/teammateAvatars.js +194 -0
  91. package/src/utils/teammateDetector.js +121 -0
  92. package/src/utils/termDiag.js +114 -0
  93. package/src/utils/terminalClipboard.js +117 -0
  94. package/src/utils/terminalWriteQueue.js +398 -0
  95. package/src/utils/thinkingMerge.js +49 -0
  96. package/src/utils/toolCatalog.js +29 -0
  97. package/src/utils/toolResultBuilder.js +393 -0
  98. package/src/utils/toolResultClassifier.js +16 -0
  99. package/src/utils/toolResultCore.js +167 -0
  100. package/src/utils/toolsDiff.js +44 -0
  101. package/src/utils/toolsXmlFormatter.js +3 -0
  102. package/src/utils/ultraplanController.js +109 -0
  103. package/src/utils/ultraplanExperts.js +86 -0
  104. package/src/utils/ultraplanTemplates.js +154 -0
  105. package/src/utils/userImageRefs.js +49 -0
  106. package/src/utils/v3Assembler.js +112 -0
  107. package/src/utils/v3Rows.js +48 -0
  108. package/src/utils/voicePackPlayer.js +233 -0
  109. package/src/utils/webSearchGrouping.js +111 -0
  110. package/src/utils/workflowFormat.js +52 -0
  111. package/src/utils/workflowRuns.js +118 -0
  112. package/src/utils/workflowStore.js +94 -0
  113. package/dist/assets/App-DTkacfRW.js +0 -2
  114. package/dist/assets/index-Ac8vnV3v.js +0 -2
  115. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
@@ -0,0 +1,471 @@
1
+ // Wire format 协议详见 docs/WIRE_FORMAT.md(applyInPlaceLastMsgReplace 信号驱动短路是其客户端唯一消费方)
2
+ import { isSessionBoundary, isPostClearCheckpoint } from './clearCheckpoint.js';
3
+ import { getEffectiveModel } from './effectiveModel.js';
4
+
5
+ /**
6
+ * 给一组 messages 赋 `_timestamp` 和 `_generatedTs`。
7
+ *
8
+ * 背景:cc-viewer 通过下一次 API 请求的 body.messages 才能感知到上一次的 assistant 响应。
9
+ * 旧逻辑给所有新增 message 统一赋 `entry.timestamp`,导致 assistant msg 的 _timestamp 是
10
+ * "下一次 request 的 ts",bubble 显示时间晚一拍。helpers.js:resolveProducerModelInfo 用
11
+ * `idx-1` hack 修了 model icon,但 bubble 时间标签没修。
12
+ *
13
+ * 修法:保留 `_timestamp` 语义不变(仍然是 "carrier entry's ts",所有现有消费者依赖此),
14
+ * 给 assistant 角色的新增 message 额外赋 `_generatedTs = prevMainAgentTs`(生成时 entry 的 ts),
15
+ * ChatMessage 显示 bubble 时优先用 `_generatedTs ?? _timestamp`。
16
+ *
17
+ * @param {Array} messages 当前 entry 的 messages 数组(in-place mutate)
18
+ * @param {Array} prevMessages 上一次 mainAgentSessions 的 last session.messages
19
+ * @param {boolean} isNewSession 是否触发新 session(postClearCheckpoint / 用户切换 / 长度骤降)
20
+ * @param {number} prevCount prevMessages.length(缓存)
21
+ * @param {string} currentTs 当前 entry.timestamp
22
+ * @param {string|null} prevMainAgentTs 上一次 mainAgent entry 的 timestamp,无则 null
23
+ * @returns {Array} messages(原数组引用)
24
+ */
25
+ export function assignMessageTimestamps(messages, prevMessages, isNewSession, prevCount, currentTs, prevMainAgentTs) {
26
+ if (!Array.isArray(messages)) return messages;
27
+ for (let i = 0; i < messages.length; i++) {
28
+ const m = messages[i];
29
+ if (!m) continue;
30
+ if (!isNewSession && i < prevCount && prevMessages[i] && prevMessages[i]._timestamp) {
31
+ // 历史 message:继承 prev 的 _timestamp 和 _generatedTs(如有)
32
+ m._timestamp = prevMessages[i]._timestamp;
33
+ if (prevMessages[i]._generatedTs) {
34
+ m._generatedTs = prevMessages[i]._generatedTs;
35
+ }
36
+ } else if (!m._timestamp) {
37
+ // 新增 message:赋 currentTs;assistant 角色额外赋 _generatedTs
38
+ m._timestamp = currentTs;
39
+ if (m.role === 'assistant' && prevMainAgentTs) {
40
+ m._generatedTs = prevMainAgentTs;
41
+ }
42
+ } else if (m.role === 'assistant' && !m._generatedTs && prevMainAgentTs) {
43
+ // 已有 _timestamp 但缺 _generatedTs(混合输入:部分 entry 来自旧版本):补 _generatedTs
44
+ m._generatedTs = prevMainAgentTs;
45
+ }
46
+ }
47
+ return messages;
48
+ }
49
+
50
+ /**
51
+ * 解析 bubble 对应的"生产请求 ts" —— 双向映射 msg ↔ request 的 lookup key。
52
+ *
53
+ * 语义对齐:
54
+ * - assistant msg:体现"哪次 API 调用 *生成* 此 response" → `_generatedTs` (= 上一次 mainAgent ts,
55
+ * 即真正产出该 content 的 request 的 ts)。fallback 到 `_timestamp` 兼容旧 cache / 首条 entry。
56
+ * - user / 其他 role:体现"哪次 API 调用 *承载* 此 input" → `_timestamp` (carrier,本就 = 该请求自身 ts)。
57
+ *
58
+ * 用途:
59
+ * - ChatView reqIdx = tsToIndex[resolveBubbleProducerTs(msg)] —— "查看请求"按钮跳到 producer
60
+ * - ChatView 网络报文→对话反向跳转 highlight 的 tsItemMap key 走 m.props.displayTs || m.props.timestamp
61
+ * (assistant bubble 已收 displayTs={msg._generatedTs},等价 producer ts)
62
+ *
63
+ * 不影响:`_timestamp` 作 carrier 语义(resolveModelInfo / 时间排序 / dedup key 等消费者保持不变)。
64
+ *
65
+ * @param {object} msg lastSession.messages[i]
66
+ * @returns {string|null}
67
+ */
68
+ export function resolveBubbleProducerTs(msg) {
69
+ if (!msg) return null;
70
+ if (msg.role === 'assistant') return msg._generatedTs || msg._timestamp || null;
71
+ return msg._timestamp || null;
72
+ }
73
+
74
+ /**
75
+ * 取会话的「稳定 id」—— 一致的会话身份。
76
+ *
77
+ * 首条消息的 `_timestamp`(= 会话起点 ts,首次赋值后不再变),它同时等于
78
+ * `entry._sessionId` / `AppBase._currentSessionId`(该会话为当前会话时)。
79
+ *
80
+ * 注意:**不要用 `session.entryTimestamp`** —— 它在每次 mergeMainAgentSessions 时被改写为
81
+ * 最新 entry 的 ts(sessionMerge.js),会漂移,不能作身份。仅作 messages 缺失时的兜底。
82
+ *
83
+ * @param {object} session
84
+ * @returns {string|null}
85
+ */
86
+ export function getSessionStableId(session) {
87
+ if (!session) return null;
88
+ const first = session.messages && session.messages[0];
89
+ return (first && first._timestamp) || session.sessionId || null;
90
+ }
91
+
92
+ /**
93
+ * Latest-ACTIVITY timestamp of a session — the counterpart of getSessionStableId.
94
+ * Stable id answers "which session is this?"; activity ts answers "when did this
95
+ * session last receive an entry?". `entryTimestamp` is rewritten to the newest
96
+ * merged entry's ts on every mergeMainAgentSessions call — that drift makes it
97
+ * unusable as identity but exactly right for recency.
98
+ *
99
+ * ISO-8601 strings from toISOString() compare correctly with `<`/`>` (fixed-width
100
+ * UTC).
101
+ *
102
+ * @param {object} session
103
+ * @returns {string|null}
104
+ */
105
+ export function getSessionActivityTs(session) {
106
+ if (!session) return null;
107
+ if (session.entryTimestamp) return session.entryTimestamp;
108
+ const msgs = session.messages;
109
+ const last = msgs && msgs.length ? msgs[msgs.length - 1] : null;
110
+ return (last && last._timestamp) || getSessionStableId(session);
111
+ }
112
+
113
+ /**
114
+ * Pick the session with the newest activity.
115
+ *
116
+ * Used by the "only show current session" pin to decide what "current" means.
117
+ * mainAgentSessions is ordered by insertion, never by time — with interleaved
118
+ * multi-terminal sessions or a truncated reconnect replay, the LAST list element
119
+ * is frequently an old session, so "last element == current" does not hold.
120
+ *
121
+ * A null activity ts never wins over a real timestamp (a session missing every
122
+ * timestamp source must not hijack the pick from a genuinely newer session).
123
+ * Ties resolve to the LATER list position. When NO session has a usable
124
+ * timestamp, degrade to the last element.
125
+ *
126
+ * @param {Array} sessions - mainAgentSessions
127
+ * @returns {object|null}
128
+ */
129
+ export function getLatestSessionByActivity(sessions) {
130
+ if (!Array.isArray(sessions) || sessions.length === 0) return null;
131
+ let best = null;
132
+ let bestTs = null;
133
+ for (const s of sessions) {
134
+ if (!s) continue;
135
+ const ts = getSessionActivityTs(s);
136
+ if (ts === null) continue;
137
+ if (bestTs === null || ts >= bestTs) {
138
+ best = s;
139
+ bestTs = ts;
140
+ }
141
+ }
142
+ if (best) return best;
143
+ return sessions[sessions.length - 1] || null;
144
+ }
145
+
146
+ /**
147
+ * Pure decision for adopting a server-side pin during hydrate (page load, project
148
+ * switch, SSE reconnect) or a `session_pin` broadcast.
149
+ *
150
+ * The server pin is just a persisted pointer — after idle/restart it can point at
151
+ * an older logical session (or have been poisoned by a client that derived a wrong
152
+ * latest). When "only show current session" is ON and we can already derive the
153
+ * latest session locally, the derived value is authoritative and a mismatching
154
+ * remote value is rejected. When the toggle is off, or sessions haven't loaded yet
155
+ * (no derived id), the remote value is adopted as-is.
156
+ *
157
+ * @param {*} remoteId - raw pinnedSessionId from the server (string|null|undefined)
158
+ * @param {string|null} derivedLatestId - getSessionStableId(getLatestSessionByActivity(...))
159
+ * @param {boolean} onlyCurrentSession - effective toggle value
160
+ * @returns {{adopt: boolean, value: string|null}} adopt=false means "keep/derive
161
+ * locally"; value is then the derived latest for reference.
162
+ */
163
+ export function resolveHydratedPin(remoteId, derivedLatestId, onlyCurrentSession) {
164
+ const remote = (typeof remoteId === 'string' && remoteId) ? remoteId : null;
165
+ if (!onlyCurrentSession) return { adopt: true, value: remote };
166
+ if (!derivedLatestId) return { adopt: true, value: remote };
167
+ return remote === derivedLatestId
168
+ ? { adopt: true, value: remote }
169
+ : { adopt: false, value: derivedLatestId };
170
+ }
171
+
172
+ /**
173
+ * Async orchestration of one pin-hydrate round, extracted from AppBase._hydratePin
174
+ * so the race-sensitive ordering is unit-testable without React:
175
+ *
176
+ * 1. Await the server pin; if this round was superseded (isCurrent() false —
177
+ * a newer hydrate started, e.g. project switch or another reconnect), bail
178
+ * out WITHOUT touching the gate: the newer round owns it.
179
+ * 2. Decide via resolveHydratedPin. Adopt → adopt(value) only on change.
180
+ * Reject with the local pin ALREADY at the derived latest → persistLocal():
181
+ * the follow-latest self-heal below won't change state in that case, so it
182
+ * would never POST, and a poisoned server-side pin file would survive.
183
+ * 3. finally (still current): clearGate() BEFORE selfHeal() — follow-latest
184
+ * early-outs while the gate is up, so the inverted order is a silent no-op
185
+ * and a stale pin would stick until the next stream activity.
186
+ *
187
+ * @param {object} deps
188
+ * @param {() => Promise<*>} deps.fetchPin - resolves to raw remote pinnedSessionId
189
+ * @param {() => boolean} deps.isCurrent - false when a newer hydrate superseded this one
190
+ * @param {() => string|null} deps.getDerived - derived latest stable id (at resolve time)
191
+ * @param {() => boolean} deps.effOnly - effective "only show current session" toggle
192
+ * @param {() => string|null} deps.getLocalPin - current local pin state
193
+ * @param {(value: string|null) => void} deps.adopt - apply a remote pin value locally
194
+ * @param {() => void} deps.persistLocal - POST the current local pin to the server
195
+ * @param {() => void} deps.clearGate - clear the "hydrate in flight" gate
196
+ * @param {() => void} deps.selfHeal - re-run follow-latest (AppBase._maintainPinState)
197
+ * @returns {Promise<void>}
198
+ */
199
+ export async function runPinHydration({ fetchPin, isCurrent, getDerived, effOnly, getLocalPin, adopt, persistLocal, clearGate, selfHeal }) {
200
+ try {
201
+ const remote = await fetchPin();
202
+ if (!isCurrent()) return;
203
+ const r = resolveHydratedPin(remote, getDerived(), effOnly());
204
+ if (r.adopt) {
205
+ if (r.value !== getLocalPin()) adopt(r.value);
206
+ } else if (getLocalPin() === r.value) {
207
+ persistLocal();
208
+ }
209
+ } catch {
210
+ // Network/JSON errors: nothing to adopt; fall through so the gate is cleared.
211
+ } finally {
212
+ if (isCurrent()) {
213
+ clearGate();
214
+ selfHeal();
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Batch-path boundary + timestamp state machine, extracted from
221
+ * AppBase._processOneEntry so the session-segmentation logic is unit-testable
222
+ * and shares isSessionBoundary with the live SSE path (_flushPendingEntries).
223
+ * Two deliberate deltas vs the old inlined block: the shared predicate adds the
224
+ * /compact exclusion the batch path lacked, and a truncation branch (below)
225
+ * realigns the positional accumulators after a compact rewrite.
226
+ *
227
+ * Mutates `st` in place (same contract as before extraction):
228
+ * st.timestamps / st.generatedTimestamps - positional ts accumulators
229
+ * st.currentSessionId - stable id of the session being accumulated
230
+ * st.prevUserId / st.prevMainAgentTs - carry-over between entries
231
+ * and stamps `_timestamp` / `_generatedTs` onto entry.body.messages.
232
+ *
233
+ * @param {object} st - batch accumulator state
234
+ * @param {object} entry - mainAgent entry with body.messages array
235
+ * @returns {void}
236
+ */
237
+ export function applyBatchEntryTimestamps(st, entry) {
238
+ const messages = entry.body.messages;
239
+ const count = entry._messageCount || messages.length;
240
+ const userId = entry.body.metadata?.user_id || null;
241
+ const timestamp = entry.timestamp || new Date().toISOString();
242
+
243
+ const prevCount = st.timestamps.length;
244
+ // Post-/clear checkpoints must always start a new session (bypass the transient
245
+ // filter below), otherwise the first count=1 entry after a delta rebuild would
246
+ // be swallowed and its _timestamp stolen by the next count>4 entry.
247
+ const postClearCheckpoint = isPostClearCheckpoint(entry, prevCount);
248
+ const epoch = entry._seqEpoch || null;
249
+ const epochChanged = !!(epoch && st.prevEpoch && epoch !== st.prevEpoch);
250
+ const isNewSession = isSessionBoundary(entry, { prevCount, count, prevUserId: st.prevUserId, userId, prevEpoch: st.prevEpoch, epoch });
251
+ // Transient protection: very short entries (<=4 msgs) after a long conversation
252
+ // are usually in-flight requests (request body only, no response yet) and must
253
+ // not reset the accumulated timestamps. Real /clear starts AND epoch changes
254
+ // (task B: a definitive new session, even when short) are exempt.
255
+ const isTransient = isNewSession && !postClearCheckpoint && !epochChanged && count <= 4 && prevCount > 4 && count < prevCount * 0.5;
256
+ if (isNewSession && !isTransient) {
257
+ st.currentSessionId = timestamp;
258
+ st.timestamps = [];
259
+ st.generatedTimestamps = [];
260
+ st.prevMainAgentTs = null; // new session start: reset to avoid cross-session bleed
261
+ } else if (st.currentSessionId === null) {
262
+ st.currentSessionId = timestamp;
263
+ } else if (entry._compactContinuation === true && count < st.timestamps.length) {
264
+ // /compact continuation (same session, NOT a boundary): the conversation was
265
+ // rewritten to a shorter message list. Truncate the positional accumulators so
266
+ // messages appended after the compact get fresh timestamps — mirroring the live
267
+ // path, where merge rebuilds lastSession.messages to the compact entry's list.
268
+ // Without this, positions beyond `count` keep inheriting hours-old ts until the
269
+ // conversation regrows past the pre-compact length.
270
+ st.timestamps.length = count;
271
+ st.generatedTimestamps.length = count;
272
+ }
273
+ // Extend the two parallel arrays; new positions take this entry's ts and record
274
+ // the previous mainAgent ts as their "generated at" ts.
275
+ // Note: role-gating happens in the inner loop (not at push time) — in the offline
276
+ // batch path this entry may be _slimmed (body.messages=[]) with only _messageCount,
277
+ // so messages[j] can be undefined here.
278
+ for (let j = st.timestamps.length; j < count; j++) {
279
+ st.timestamps.push(timestamp);
280
+ st.generatedTimestamps.push(st.prevMainAgentTs || null);
281
+ }
282
+ if (messages.length > 0) {
283
+ for (let j = 0; j < messages.length; j++) {
284
+ const m = messages[j];
285
+ if (!m) continue;
286
+ m._timestamp = st.timestamps[j];
287
+ if (m.role === 'assistant' && st.generatedTimestamps[j]) {
288
+ m._generatedTs = st.generatedTimestamps[j];
289
+ }
290
+ }
291
+ }
292
+ st.prevUserId = userId;
293
+ st.prevEpoch = epoch; // carry v2 session epoch for the next entry's boundary check
294
+ // Remember this entry's ts as the next entry's prevMainAgentTs.
295
+ st.prevMainAgentTs = timestamp;
296
+ }
297
+
298
+ /**
299
+ * 解析「仅展示当前会话」锁定下,实际传给 ChatView 的会话切片。
300
+ *
301
+ * 策略:把 mainAgentSessions 切到「以 pin 会话结尾」(`slice(0, idx+1)`),让 pin 会话从
302
+ * ChatView 视角就是最后一个会话 —— 所有 `si === length-1` 的既有逻辑(Last Response 卡片、
303
+ * Ask/ExitPlanMode 审批 modal、分隔线)原样可用。常见情形(pin == 最新 / 未命中 / 未开)
304
+ * 直接原样返回,行为与今天逐字节一致。
305
+ *
306
+ * @param {Array} mainAgentSessions
307
+ * @param {string|null} pinnedTs - 记住的会话稳定 id;null = 未锁定
308
+ * @param {boolean} onlyCurrentSession - 生效的「仅展示当前会话」开关
309
+ * @returns {{ sessions: Array, upperBoundTs: (string|null) }}
310
+ * upperBoundTs:pin 在中段时为下一个会话的起点 ts(供 ChatView 截断更晚会话的 sub-agent /
311
+ * 抑制 streaming 浮层);其余情形为 null。
312
+ * Exception: a mid-list pinned session that is itself the latest-by-ACTIVITY is
313
+ * the CURRENT session (the pin follows recency, and list order is insertion
314
+ * order, not time order) — there is no strictly-newer session to bound against,
315
+ * so upperBoundTs is null. A non-null bound here would invert ChatView's
316
+ * "pin is on an older session" interpretation: it would suppress the live
317
+ * streaming overlay and truncate trailing sub-agents on the very session the
318
+ * user is watching.
319
+ */
320
+ export function resolveDisplaySessions(mainAgentSessions, pinnedTs, onlyCurrentSession) {
321
+ const sessions = Array.isArray(mainAgentSessions) ? mainAgentSessions : [];
322
+ if (!onlyCurrentSession || pinnedTs == null || sessions.length === 0) {
323
+ return { sessions, upperBoundTs: null };
324
+ }
325
+ const idx = sessions.findIndex(s => getSessionStableId(s) === pinnedTs);
326
+ // 未命中(pin 会话已不在)/ 命中末尾 → 原样(回退到展示最新)
327
+ if (idx === -1 || idx === sessions.length - 1) {
328
+ return { sessions, upperBoundTs: null };
329
+ }
330
+ // 命中中段 → 切到「以 pin 会话结尾」;上界 = 下一个会话起点 ts,
331
+ // 除非 pin 会话本身就是活动最新(= 当前会话,无更新会话可作上界)。
332
+ const pinnedIsCurrent = getLatestSessionByActivity(sessions) === sessions[idx];
333
+ return {
334
+ sessions: sessions.slice(0, idx + 1),
335
+ upperBoundTs: pinnedIsCurrent ? null : getSessionStableId(sessions[idx + 1]),
336
+ };
337
+ }
338
+
339
+ /**
340
+ * 信号驱动的 in-place last-msg replace 短路。
341
+ *
342
+ * 协议合同详见 `docs/WIRE_FORMAT.md` §3.3 SUGGESTION MODE 末位替换 + §2 关键字段词典
343
+ * (`_inPlaceReplaceDetected` / `_isCheckpoint` 双信号必须齐发)。该文件是单一真理源,
344
+ * 字段重命名 / 语义变更必须同时更新文档 + interceptor.js 写入点 + 本 helper + 双端回归测试
345
+ * (`test/session-manager.test.js` + `test/session-manager.test.js`)。
346
+ *
347
+ * 双端 fp 函数互相独立、用途不同,**不要试图共用**:
348
+ * - 服务端 `server/lib/interceptor-core.js::fingerprintMsg` 用于 Plan C 检测末位 tail fp 异
349
+ * - 客户端 `src/utils/sessionMerge.js::messageFingerprint` 已升级为 `length + first32 + last32`
350
+ * 三元组(旧格式 `slice(0,64)` 单条 fp 已淘汰);用于反向锚点对齐时的多块 fp 等价校验。
351
+ *
352
+ * 客户端反向锚点算法(`src/utils/sessionMerge.js::findReverseAnchor`)在 newLen===currentLen
353
+ * 且末位 fp 异时若 anchor 未命中会走整段 append(Plan Mode 全替换语义),这是合理的;本 helper
354
+ * 是为了在服务端**已知是 in-place replace** 时跳过启发式合并直接替末位、保留前 N-1 条引用稳定,
355
+ * 让 ChatView 的 WeakMap 缓存继续命中。
356
+ *
357
+ * 修法:直接消费服务端明确信号,不走 sessionMerge 启发式算法。命中时构造新 lastSession,前
358
+ * N-1 条 message 元素引用复用(保留 _timestamp / _generatedTs 等所有 metadata),末位用
359
+ * entry.body.messages[N-1]。返回 `{ applied: true, sessions }` 表示已短路;未命中返回
360
+ * `{ applied: false }`,调用方走原 mergeMainAgentSessions 路径。
361
+ *
362
+ * 防回归(避开 1.6.249 拆 Layer 2 的两个坑):
363
+ * - 不靠客户端 fp 启发式判断(直接看服务端明确信号 _inPlaceReplaceDetected:true)
364
+ * - 不覆盖整个 lastSession.messages(只替末位,保留前 N-1 引用)
365
+ *
366
+ * 守卫顺序(异常入参逐级 fallback 给 mergeMainAgentSessions 处理):
367
+ * 1. 信号未命中(_inPlaceReplaceDetected/_isCheckpoint 非 true)
368
+ * 2. isNewSession(新 session 起点不应原地替换旧 session 末位)
369
+ * 3. prevSessions / lastSession.messages 异常
370
+ * 4. entry.body.messages 缺失
371
+ * 5. N < 2(只有 1 条消息时 "前 N-1 条" 退化为空,等价完全替换 → 让原路径处理更安全)
372
+ * 6. messages.length 与 lastSession.messages.length 不等
373
+ * 7. entry.response 缺失(inProgress 状态不应被信号触发;服务端 Plan C 仅在 completed 写信号,
374
+ * 此守卫是 belt+suspenders 防协议变化导致丢失 response 字段污染下游 ChatView Last Response)
375
+ *
376
+ * @param {Array} prevSessions
377
+ * @param {object} entry — mainAgent entry,需含 _inPlaceReplaceDetected / _isCheckpoint / body.messages
378
+ * @param {string} timestamp — entry 的 timestamp(用于赋新末位 message 的 _timestamp)
379
+ * @param {boolean} isNewSession — 是否新 session 起点(true 时不短路,让原路径处理)
380
+ * @returns {{ applied: boolean, sessions?: Array }}
381
+ */
382
+ export function applyInPlaceLastMsgReplace(prevSessions, entry, timestamp, isNewSession) {
383
+ // 诊断挂钩:守卫拒绝路径计数 + verbose trace(gated by globalThis.__CCV_SESSIONMERGE_TRACE__)。
384
+ // 用户报告"复制翻车"再现时打开 trace 即可定位是信号缺失 / 哪条守卫拦了。计数挂在函数对象上,
385
+ // 控制台直接读 applyInPlaceLastMsgReplace.fallbackCount / .appliedCount 拿快照;
386
+ // 也可通过 globalThis.__CCV_DIAGNOSTICS__.sessionMerge 拿到统一 namespace 视图(与未来其他诊断挂钩共存)。
387
+ const _trace = (typeof globalThis !== 'undefined' && globalThis.__CCV_SESSIONMERGE_TRACE__ === true);
388
+ // 整体 try-catch 包裹:诊断挂钩永不应抛(fallbackCount 若被外部 Object.freeze、
389
+ // entry getter 抛错、console 在 SSR 不可用 等异常都不应污染主路径)。
390
+ const _bumpFb = (reason) => {
391
+ try {
392
+ const _fb = applyInPlaceLastMsgReplace.fallbackCount;
393
+ // 加 cap 防 attacker 故意触发 fallback 让计数器无界膨胀(见 perf-security review P1)。
394
+ // 9999 已足够区分"罕见 vs 频发";超过 cap 的精确计数对诊断价值有限。
395
+ const cur = _fb[reason] || 0;
396
+ if (cur < 9999) _fb[reason] = cur + 1;
397
+ if (_trace) {
398
+ // eslint-disable-next-line no-console
399
+ console.warn(`[sessionMerge.trace] applyInPlaceLastMsgReplace fallback: ${reason}`, {
400
+ ts: entry && entry.timestamp,
401
+ hasSignal: entry && entry._inPlaceReplaceDetected === true,
402
+ isCheckpoint: entry && entry._isCheckpoint === true,
403
+ msgLen: entry && entry.body && Array.isArray(entry.body.messages) ? entry.body.messages.length : null,
404
+ lastSessionLen: Array.isArray(prevSessions) && prevSessions.length > 0 && prevSessions[prevSessions.length - 1] && Array.isArray(prevSessions[prevSessions.length - 1].messages) ? prevSessions[prevSessions.length - 1].messages.length : null,
405
+ });
406
+ }
407
+ } catch { /* never throw from diagnostic path */ }
408
+ };
409
+
410
+ // 老 jsonl 兼容策略:1.6.250 之前 / 1.6.250 ship 后 interceptor 漏检 race 期间写入的
411
+ // entry 不会带 `_inPlaceReplaceDetected` 字段,本 helper 直接 fallback。客户端不做"事后
412
+ // 回填"——已污染的 mainAgentSessions 内存翻倍只能靠用户刷新浏览器重读 jsonl 全量重建
413
+ // 来清理(重建路径用 sessionMerge prefix-overlap,此时旧 entry 都已乱序到位,不会再翻倍)。
414
+ // 注意:这条早返回(无信号)是绝大多数 entry 的正常路径,不计入 fallbackCount,
415
+ // 否则计数器会被 SSE 高频流量淹没,掩盖真正"信号到达但守卫拒绝"的异常路径。
416
+ if (!entry || entry._inPlaceReplaceDetected !== true || entry._isCheckpoint !== true) {
417
+ return { applied: false };
418
+ }
419
+ if (isNewSession) { _bumpFb('new-session'); return { applied: false }; }
420
+ if (!Array.isArray(prevSessions) || prevSessions.length === 0) { _bumpFb('no-prev-sessions'); return { applied: false }; }
421
+ const lastSession = prevSessions[prevSessions.length - 1];
422
+ if (!lastSession || !Array.isArray(lastSession.messages)) { _bumpFb('no-last-session-messages'); return { applied: false }; }
423
+ const messages = entry.body && Array.isArray(entry.body.messages) ? entry.body.messages : null;
424
+ if (!messages || messages.length < 2) { _bumpFb('messages-too-short'); return { applied: false }; }
425
+ if (messages.length !== lastSession.messages.length) { _bumpFb('length-mismatch'); return { applied: false }; }
426
+ // entry.response 缺失(inProgress 等异常)→ fallback,避免 newLastSession.response=undefined
427
+ // 污染下游 ChatView Last Response 渲染。服务端 Plan C 仅在 completed 写信号,正常情况这条守卫
428
+ // 不会命中;保留作为协议变更时的防御层。
429
+ if (!entry.response) { _bumpFb('response-missing'); return { applied: false }; }
430
+
431
+ const N = messages.length;
432
+ const stitched = lastSession.messages.slice(0, N - 1);
433
+ const newLastMsg = messages[N - 1];
434
+ if (newLastMsg && !newLastMsg._timestamp) newLastMsg._timestamp = timestamp;
435
+ stitched.push(newLastMsg);
436
+ const newLastSession = {
437
+ userId: lastSession.userId,
438
+ messages: stitched,
439
+ response: entry.response,
440
+ entryTimestamp: timestamp,
441
+ // Carry the session-level model stamp (see mergeMainAgentSessions): this path bypasses the
442
+ // merge and rebuilds the session object — dropping the stamp would re-bake the whole session
443
+ // with the "MainAgent" fallback. entry.response is guaranteed by the guard above, so
444
+ // getEffectiveModel is authoritative here; fall back to the previous stamp.
445
+ model: getEffectiveModel(entry) || lastSession.model || null,
446
+ // Preserve the v2 session epoch (task B) — this path clones the session, and
447
+ // dropping it would make the next entry's epoch-boundary check read undefined.
448
+ _seqEpoch: lastSession._seqEpoch,
449
+ };
450
+ // 末位替换:返回新 sessions 数组保持原顺序、prev 长度不变。
451
+ // 下游 ChatView _sessionItemCache[last] 按 index 索引该 session,依赖 index 恒定不能错位。
452
+ // appliedCount 加 cap 防长期累积溢出(实际 9999 远超调试需要)。
453
+ if (applyInPlaceLastMsgReplace.appliedCount < 9999) applyInPlaceLastMsgReplace.appliedCount++;
454
+ return { applied: true, sessions: [...prevSessions.slice(0, -1), newLastSession] };
455
+ }
456
+
457
+ // 诊断计数:信号到达但守卫拒绝时分类累加;apply 成功时单独累加。
458
+ // 调试时直接在 console 读:applyInPlaceLastMsgReplace.fallbackCount / .appliedCount。
459
+ // 各计数器加 cap=9999 防 attacker 故意触发 fallback 让计数器无界膨胀。
460
+ applyInPlaceLastMsgReplace.fallbackCount = Object.create(null);
461
+ applyInPlaceLastMsgReplace.appliedCount = 0;
462
+
463
+ // 统一 namespace(perf-security review P2):未来其他诊断挂钩可挂到 __CCV_DIAGNOSTICS__
464
+ // 而不是直接污染 globalThis 命名空间。当前仅暴露 sessionMerge 路径,避免与其他库冲突。
465
+ if (typeof globalThis !== 'undefined') {
466
+ if (!globalThis.__CCV_DIAGNOSTICS__) globalThis.__CCV_DIAGNOSTICS__ = Object.create(null);
467
+ globalThis.__CCV_DIAGNOSTICS__.sessionMerge = {
468
+ get fallbackCount() { return applyInPlaceLastMsgReplace.fallbackCount; },
469
+ get appliedCount() { return applyInPlaceLastMsgReplace.appliedCount; },
470
+ };
471
+ }
@@ -0,0 +1,180 @@
1
+ // Wire format 协议详见 docs/WIRE_FORMAT.md(服务端 entry 形态 / 关键字段 / 已知特殊窗口)
2
+ // messageFingerprint / findReverseAnchor moved VERBATIM to the shared client-safe
3
+ // module server/lib/session-boundary.js (wire-v2 S1) — re-exported below so the
4
+ // existing test/consumer API of this module is unchanged.
5
+ import { isPostClearCheckpoint, messageFingerprint, findReverseAnchor } from '../../server/lib/session-boundary.js';
6
+ import { getEffectiveModel } from './effectiveModel.js';
7
+
8
+ export { messageFingerprint };
9
+
10
+ /**
11
+ * merge 入口守卫(KEEP IN SYNC: server/lib/delta-reconstructor.js 标记写入点):
12
+ * 重建层标记的脏条目不得进入 mainAgentSessions 合并——
13
+ * - `_staleReorder`:完成序倒置的乱序条目(内容已被更新条目取代);
14
+ * - `_reconstructBroken`:重建结果与 _totalMessageCount 不符且无法修复(拼接会翻倍/错位);
15
+ * - 批量路径额外跳过 `inProgress`:孤立占位条目的 body.messages 是裸 delta 切片
16
+ * (批量 reconstructEntries 不为 inProgress 重建全量),merge 会触发 rebuild 截断。
17
+ * SSE 实时路径不拦 inProgress——watcher 增量重建器已为其拼出全量 messages,
18
+ * 无 live-port 配置下"提问气泡请求时即显示"依赖这一行为。
19
+ * AppBase 的 SSE 与批量两个 merge 入口、以及单测共用此谓词,防三处逻辑漂移。
20
+ *
21
+ * @param {object} entry
22
+ * @param {object} [options]
23
+ * @param {boolean} [options.batch=false] - 批量(强刷/历史加载)路径
24
+ * @returns {boolean} true = 该条目不应参与 session 合并
25
+ */
26
+ export function isMergeBlockedEntry(entry, options = {}) {
27
+ if (!entry) return true;
28
+ if (entry._staleReorder || entry._reconstructBroken) return true;
29
+ if (options.batch && entry.inProgress) return true;
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * 增量合并 mainAgent sessions。
35
+ *
36
+ * 核心算法:反向锚点对齐。以 `newMessages[0]` 为锚点,从 `lastSession.messages` 末尾
37
+ * 反向扫;命中后多块连续 fp 等价校验。三种结果:
38
+ * - 命中且 overlapLen === newLen:流式 no-op / suffix subset,messages 引用稳定(保 WeakMap 缓存)。
39
+ * - 命中且 overlapLen < newLen:push `newMessages[overlapLen..]`,引用稳定。
40
+ * - 未命中:newLen<curLen → rebuild(/compact summary);newLen===curLen → 整段 append(Plan Mode 全新片段);
41
+ * newLen>curLen → 严格前缀扩展语义(push tail),fp 加固后真正存在重叠的窗口必被 anchor 命中。
42
+ *
43
+ * 顶部守卫(isPostClearCheckpoint / userId / transient filter)维持 1.6.245 行为不变。
44
+ *
45
+ * @param {Array} prevSessions
46
+ * @param {object} entry
47
+ * @param {object} [options]
48
+ * @param {boolean} [options.skipTransientFilter=false] - SSE 实时追加路径设为 true。
49
+ * @returns {Array}
50
+ */
51
+ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
52
+ const newMessages = entry.body.messages;
53
+ const newResponse = entry.response;
54
+ const userId = entry.body.metadata?.user_id || null;
55
+ // Task B — v2 session identity. `_seqEpoch` = "v2:<sid>" (adapter, main
56
+ // entries only). When it changes, this is unambiguously a DIFFERENT session,
57
+ // regardless of user_id or message count — a definitive boundary that the
58
+ // heuristics below (same-user merge, transient filter) would otherwise miss
59
+ // when a short prior session precedes the current one (the cold-load
60
+ // fallback → live supersede case). null-safe: v1 leftovers have no epoch.
61
+ const seqEpoch = entry._seqEpoch || null;
62
+
63
+ const entryTimestamp = entry.timestamp || null;
64
+ // Session-level model identity: ChatView falls back to it when per-message producer resolution
65
+ // is null (e.g. the session's carrier entry is still in-flight and filtered out) — prevents the
66
+ // "MainAgent" + generic-avatar flash on carried-over history. Latest wins: an inProgress carrier
67
+ // stamps body.model, completion re-stamps the authoritative response.body.model.
68
+ const entryModel = getEffectiveModel(entry);
69
+
70
+ if (prevSessions.length === 0) {
71
+ return [{ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
72
+ }
73
+
74
+ const lastSession = prevSessions[prevSessions.length - 1];
75
+
76
+ const prevMsgCount = lastSession.messages ? lastSession.messages.length : 0;
77
+ const isNewConversation = prevMsgCount > 0 && newMessages.length < prevMsgCount * 0.5 && (prevMsgCount - newMessages.length) > 4;
78
+ const sameUser = userId !== null && userId === lastSession.userId;
79
+ // Three-part guard (NEVER two-part): only split when BOTH sides carry an
80
+ // epoch and they differ — `undefined !== "v2:x"` would wrongly split a v1
81
+ // leftover or a main-looking sub that lacks _seqEpoch.
82
+ const epochChanged = !!(seqEpoch && lastSession._seqEpoch && seqEpoch !== lastSession._seqEpoch);
83
+
84
+ // /clear 后的首个 checkpoint:始终是新会话起点。
85
+ // 同 device 下 sameUser 永远 true,否则会被下面的 same-session 分支吞掉;
86
+ // 也不能被 transient 过滤掉(即便 newMessages.length === 1)。
87
+ // epoch 变化语义等同 post-clear(确定性会话边界),走同一 append 分支且绕过
88
+ // 下方 transient 过滤(短的新会话也不能被丢弃,否则与 batch 腿分歧)。
89
+ if (isPostClearCheckpoint(entry, prevMsgCount) || epochChanged) {
90
+ for (let i = 0; i < newMessages.length; i++) {
91
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
92
+ }
93
+ return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
94
+ }
95
+
96
+ if (!options.skipTransientFilter && isNewConversation && newMessages.length <= 4 && prevMsgCount > 4) {
97
+ return prevSessions;
98
+ }
99
+
100
+ if (sameUser || (userId === lastSession.userId && !isNewConversation)) {
101
+ const curLen = prevMsgCount;
102
+ const newLen = newMessages.length;
103
+ if (!lastSession.messages) lastSession.messages = [];
104
+
105
+ // fp 缓存:预算 newMessages 的 fp 数组一次,传给 findReverseAnchor 避免多块连续校验
106
+ // 时反复调用 messageFingerprint(流式 5000+ 次/秒 SSE 路径节省 25-100ms 累计延迟)。
107
+ // curFpsCache 与 findReverseAnchor 共享:anchor miss 时等长分支的对位比较直接复用
108
+ // 反向扫已经算过的 curMsgs fp,零重复计算。
109
+ const newFps = newLen > 0 ? newMessages.map(messageFingerprint) : null;
110
+ const curFpsCache = new Array(curLen);
111
+ const anchor = findReverseAnchor(newMessages, lastSession.messages, newFps, curFpsCache);
112
+
113
+ if (anchor) {
114
+ const tailStart = anchor.overlapLen;
115
+ // tailStart === newLen:流式 no-op / suffix subset,messages 引用不动。
116
+ if (tailStart < newLen) {
117
+ for (let i = tailStart; i < newLen; i++) {
118
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
119
+ lastSession.messages.push(newMessages[i]);
120
+ }
121
+ }
122
+ } else if (newLen < curLen) {
123
+ // /compact summary 等真重建:替换 messages 引用。
124
+ for (let i = 0; i < newLen; i++) {
125
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
126
+ }
127
+ lastSession.messages = newMessages;
128
+ } else if (newLen === curLen) {
129
+ // 等长且 anchor 未命中:两种形态必须区分(docs/WIRE_FORMAT.md §3.1 / §3.7)——
130
+ // (a) Plan Mode 全新短窗口:内容与累积历史无关,整段 append 保留历史;
131
+ // (b) 同会话近似拷贝:末位原地替换 / 中段编辑,但服务端信号缺失(完成序倒置让无信号
132
+ // 的 stale checkpoint 后落盘、或旧日志无信号)。此形态整段 append 会让对话翻倍
133
+ // (mainAgent 整段重复 bug 的翻倍终点),必须替换。
134
+ let aligned = 0;
135
+ // 严格多数(非简单 plurality):对位 fp 相等数 ≥ floor(N/2)+1 才判近似拷贝→替换。
136
+ // 近似拷贝逐位几乎全等;Plan Mode 新窗口对位相等 ≈ 0。取严格多数是替换误判的
137
+ // 安全边界——N=2→需 2 条全等、N=3→2、N=4→3;宁可对"一半相同"的模糊形态保守
138
+ // append(残余形态:末位短暂陈旧),也不冒错杀新窗口历史的风险。调阈值前先想清
139
+ // 反例:近拷贝带 1 条中段编辑(N=2 时 1/2 不足多数 → append → 翻倍回归)。
140
+ const STRICT_MAJORITY = Math.floor(newLen / 2) + 1;
141
+ for (let i = 0; i < newLen && aligned < STRICT_MAJORITY; i++) {
142
+ let cfp = curFpsCache[i];
143
+ if (cfp === undefined) {
144
+ cfp = messageFingerprint(lastSession.messages[i]);
145
+ curFpsCache[i] = cfp;
146
+ }
147
+ if (newFps[i] === cfp) aligned++;
148
+ }
149
+ if (aligned >= STRICT_MAJORITY) {
150
+ // 近似拷贝 → 整段替换(等价于无信号版 in-place replace)。
151
+ // 引用更换会使 ChatView WeakMap 渲染缓存失效一次性重渲染,预期内。
152
+ for (let i = 0; i < newLen; i++) {
153
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
154
+ }
155
+ lastSession.messages = newMessages;
156
+ } else {
157
+ // Plan Mode 2-msg 全替换窗口,整段 append。
158
+ for (let i = 0; i < newLen; i++) {
159
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
160
+ lastSession.messages.push(newMessages[i]);
161
+ }
162
+ }
163
+ } else {
164
+ // newLen > curLen 且 anchor (单点 newMsgs[0]) 未命中:保守回退至"严格前缀扩展"语义——
165
+ // 只 push newMessages[curLen..]。fp 加固后真正存在重叠的窗口必被 anchor 命中走上面分支;
166
+ // 这里 anchor 未命中意味着确实无重叠(罕见,仅艺测/再快照场景),保留旧推 tail 行为防回归。
167
+ for (let i = curLen; i < newLen; i++) {
168
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
169
+ lastSession.messages.push(newMessages[i]);
170
+ }
171
+ }
172
+
173
+ lastSession.response = newResponse;
174
+ lastSession.entryTimestamp = entryTimestamp;
175
+ if (entryModel) lastSession.model = entryModel; // latest wins; model-less entries keep the stamp
176
+ return [...prevSessions];
177
+ } else {
178
+ return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
179
+ }
180
+ }