cc-viewer 1.7.13 → 1.7.15

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/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-DSTQIMmZ.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-BXWDyOHS.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DADYo_zg.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-tF6HNoR6.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CFAmRN3Y.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.7.13",
3
+ "version": "1.7.15",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -244,6 +244,9 @@ export function reconstructEntries(entries) {
244
244
  */
245
245
  function _tryRepairFromCandidate(brokenEntry, expectedCount, candidate) {
246
246
  if (!candidate.mainAgent || candidate.teammate || !Array.isArray(candidate.body?.messages)) return false;
247
+ // V2 transcript synthetic entries are never a valid repair source for legacy
248
+ // delta rows — their messages are a different conversation's content.
249
+ if (candidate._syntheticV2) return false;
247
250
  const candidateMsgs = candidate.body.messages;
248
251
  const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
249
252
  const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
@@ -0,0 +1,519 @@
1
+ // CLIENT-SAFE: no node deps. Imported by src/ — do not add fs/process/node: imports.
2
+ /**
3
+ * V2 Transcript Normalizer — Claude Code 2.x JSONL → legacy-compatible entry
4
+ *
5
+ * Claude Code 2.x writes session transcripts in a new line format: each JSONL
6
+ * line carries `{ type, message: { role, content }, parentUuid, sessionId,
7
+ * timestamp, ... }` and has NO `body` / `mainAgent` / `_deltaFormat` / `url`.
8
+ * The legacy cc-viewer pipeline (isMainAgent + delta-reconstructor +
9
+ * applyBatchEntryTimestamps + mergeMainAgentSessions) only understands
10
+ * `{ mainAgent: true, body: { messages } }` requests, so v2 lines currently
11
+ * never reach the Chat view — they surface in the raw request list as
12
+ * text-only rows.
13
+ *
14
+ * Key insight: v2 `message.content` blocks are byte-identical to the API wire
15
+ * format (tool_use / tool_result / text / thinking / image). Reassembling them
16
+ * into a legacy-shaped entry therefore unlocks the entire existing render
17
+ * chain (toolResultMap pairing → extractToolResultImages → ToolResultView)
18
+ * with zero changes there.
19
+ *
20
+ * Design decisions (validated against real 2.x transcripts):
21
+ * - One synthetic entry per (sessionId, /clear segment). Segments preserve the
22
+ * v1 /clear session-boundary semantics; entry.timestamp = messages[0]._timestamp
23
+ * so the `entry.timestamp === messages[0]._timestamp` invariant held by
24
+ * sessionManager (stable session id / pin) is satisfied.
25
+ * - Synthetic entries are APPENDED to the end of the entries array (never
26
+ * interleaved with legacy rows) so a synthetic baseline cannot poison
27
+ * subsequent legacy delta reconstruction.
28
+ * - Assistant rows sharing a `message.id` (a single assistant message split
29
+ * across thinking/text/tool_use lines) are merged into one message, mirroring
30
+ * the legacy message shape.
31
+ * - Line-level dedup key is `uuid` — message.id is shared across a message's
32
+ * split rows, promptId is shared across a turn, and timestamps collide
33
+ * (8 same-ms groups observed in real data).
34
+ * - The redundant top-level `toolUseResult` (a second, binary encoding of the
35
+ * same image) is ignored — message.content already carries the standard
36
+ * base64 data; honoring both would double memory.
37
+ * - Metadata rows (mode / ai-title / last-prompt / file-history-snapshot /
38
+ * queue-operation) carry no message and are dropped — isRelevantRequest
39
+ * would otherwise surface them as garbage request rows.
40
+ */
41
+
42
+ const V2_LINE_TYPES = new Set(['user', 'assistant']);
43
+ const V2_LINE_ROLES = new Set(['user', 'assistant']);
44
+
45
+ // /clear marker in a string user content — a segment boundary. Matches ONLY
46
+ // the explicit command tag (v1 /clear rows carry `<command-name>/clear</command-name>`);
47
+ // a bare "clear" user message must never split the session. /compact is
48
+ // deliberately NOT a boundary: legacy semantics treat it as a same-session
49
+ // continuation (session-boundary.js), so v2 rows must match that.
50
+ const CLEAR_CMD_RE = /<command-name>\/?(?:clear)<\/command-name>/i;
51
+
52
+ const DEDUP_MAX = 1024;
53
+
54
+ /**
55
+ * True when `entry` is a v2 transcript line worth rendering: a user/assistant
56
+ * message row on the main agent stream (isSidechain marks teammate rows, which
57
+ * must never enter the main-agent session — same rationale as the
58
+ * delta-reconstructor teammate exclusion).
59
+ */
60
+ export function isV2TranscriptLine(entry) {
61
+ if (!entry || typeof entry !== 'object') return false;
62
+ if (!V2_LINE_TYPES.has(entry.type)) return false;
63
+ if (entry.isSidechain === true) return false;
64
+ const msg = entry.message;
65
+ if (!msg || typeof msg !== 'object') return false;
66
+ return V2_LINE_ROLES.has(msg.role);
67
+ }
68
+
69
+ /**
70
+ * True for a legacy (or already-normalized) renderable request row.
71
+ * Anything that is neither a v2 line nor a legacy request is a metadata row
72
+ * (mode / ai-title / ...) and gets dropped by normalizeV2Entries.
73
+ */
74
+ function isLegacyRequestLine(entry) {
75
+ if (!entry || typeof entry !== 'object') return false;
76
+ if (entry.mainAgent === true) return true;
77
+ if (entry.body && Array.isArray(entry.body.messages)) return true;
78
+ return typeof entry.url === 'string' && entry.url.length > 0;
79
+ }
80
+
81
+ /**
82
+ * True for a metadata row (mode / ai-title / last-prompt / file-history-snapshot
83
+ * / queue-operation / system / attachment frames): neither a v2 transcript
84
+ * line nor a legacy request. isRelevantRequest would otherwise surface these
85
+ * as garbage rows, so the live SSE path skips them.
86
+ */
87
+ export function isMetadataRow(entry) {
88
+ if (!entry || typeof entry !== 'object') return false;
89
+ return !isV2TranscriptLine(entry) && !isLegacyRequestLine(entry);
90
+ }
91
+
92
+ function parseTs(ts) {
93
+ if (typeof ts !== 'string') return 0;
94
+ const n = Date.parse(ts);
95
+ return Number.isFinite(n) ? n : 0;
96
+ }
97
+
98
+ function isClearRow(entry) {
99
+ const c = entry?.message?.content;
100
+ return typeof c === 'string' && CLEAR_CMD_RE.test(c);
101
+ }
102
+
103
+ /** Sort v2 lines by timestamp; equal timestamps keep input (file) order
104
+ * (Array.prototype.sort is stable per ES2019). */
105
+ function sortLines(lines) {
106
+ return [...lines].sort((a, b) => parseTs(a.timestamp) - parseTs(b.timestamp));
107
+ }
108
+
109
+ /**
110
+ * Merge the lines of one /clear segment into an ordered message array.
111
+ * Assistant rows sharing a message.id are folded into a single message with
112
+ * concatenated content (thinking → text → tool_use order by timestamp).
113
+ *
114
+ * @returns {Array} messages, each pre-stamped with _timestamp / _generatedTs
115
+ * (assistant) / _entryTs (filled below in buildSyntheticEntry).
116
+ */
117
+ function buildSegmentMessages(lines) {
118
+ const messages = [];
119
+ const assistantById = new Map(); // message.id → message object
120
+ for (const line of lines) {
121
+ const msg = line.message;
122
+ if (msg.role === 'user') {
123
+ messages.push({
124
+ role: 'user',
125
+ content: msg.content,
126
+ _timestamp: line.timestamp,
127
+ _generatedTs: undefined,
128
+ });
129
+ continue;
130
+ }
131
+ // assistant
132
+ const mid = msg.id;
133
+ if (mid && assistantById.has(mid)) {
134
+ const existing = assistantById.get(mid);
135
+ if (Array.isArray(msg.content) && Array.isArray(existing.content)) {
136
+ existing.content = existing.content.concat(msg.content);
137
+ } else if (Array.isArray(msg.content) && typeof existing.content === 'string') {
138
+ // legacy string content: replace with the array form
139
+ existing.content = msg.content;
140
+ } else if (typeof msg.content === 'string' && typeof existing.content === 'string') {
141
+ existing.content = existing.content + '\n' + msg.content;
142
+ }
143
+ if (line.timestamp) existing._generatedTs = line.timestamp;
144
+ if (msg.model) existing.model = msg.model;
145
+ if (msg.usage) existing.usage = msg.usage;
146
+ continue;
147
+ }
148
+ const m = {
149
+ role: 'assistant',
150
+ content: msg.content,
151
+ _timestamp: line.timestamp,
152
+ _generatedTs: line.timestamp,
153
+ };
154
+ if (msg.model) m.model = msg.model;
155
+ if (msg.usage) m.usage = msg.usage;
156
+ if (mid) m._mid = mid; // merge-key for the incremental normalizer
157
+ messages.push(m);
158
+ if (mid) assistantById.set(mid, m);
159
+ }
160
+ return messages;
161
+ }
162
+
163
+ /**
164
+ * Build one synthetic legacy entry from the sorted v2 lines of a single
165
+ * sessionId segment. `messages` are pre-stamped with per-line _timestamp /
166
+ * _generatedTs; _entryTs is filled here to satisfy the entry.timestamp ===
167
+ * messages[0]._timestamp invariant.
168
+ */
169
+ export function buildSyntheticEntry(lines, sessionId, segIdx) {
170
+ const messages = buildSegmentMessages(lines);
171
+ if (messages.length === 0) return null;
172
+ const entryTs = messages[0]._timestamp;
173
+ for (const m of messages) m._entryTs = entryTs;
174
+
175
+ // Last assistant row carries model/usage — expose it as a synthetic
176
+ // response so model display / token stats / KV-cache branches work.
177
+ let lastModel = null;
178
+ let lastUsage = null;
179
+ for (const m of messages) {
180
+ if (m.role === 'assistant') {
181
+ if (m.model) lastModel = m.model;
182
+ if (m.usage) lastUsage = m.usage;
183
+ }
184
+ }
185
+
186
+ const entry = {
187
+ mainAgent: true,
188
+ body: { messages },
189
+ timestamp: entryTs,
190
+ url: `claude-code://session/${sessionId}:${segIdx}`,
191
+ sessionId,
192
+ _seqEpoch: `v2:${sessionId}:${segIdx}`,
193
+ _syntheticV2: true,
194
+ _messageCount: messages.length,
195
+ };
196
+ if (lastModel || lastUsage) {
197
+ entry.response = { body: {} };
198
+ if (lastModel) entry.response.body.model = lastModel;
199
+ if (lastUsage) entry.response.body.usage = lastUsage;
200
+ }
201
+ return entry;
202
+ }
203
+
204
+ /**
205
+ * Split sorted v2 lines of one sessionId into /clear-delimited segments.
206
+ * A /clear row starts a new segment (it carries the command itself), so the
207
+ * caveat/descendant rows land in the new segment — mirroring v1's
208
+ * "new session after /clear" behavior.
209
+ */
210
+ function splitSegments(sortedLines) {
211
+ const segments = [];
212
+ let current = [];
213
+ for (const line of sortedLines) {
214
+ if (isClearRow(line)) {
215
+ // /clear closes the current segment and starts a new one; the command
216
+ // row itself is not conversation content, so it is dropped.
217
+ if (current.length > 0) segments.push(current);
218
+ current = [];
219
+ continue;
220
+ }
221
+ current.push(line);
222
+ }
223
+ if (current.length > 0) segments.push(current);
224
+ return segments;
225
+ }
226
+
227
+ /**
228
+ * Batch normalizer: convert v2 transcript lines in `rawEntries` into
229
+ * legacy-shaped synthetic entries.
230
+ *
231
+ * - No v2 lines → returns the SAME array reference (zero behavior change).
232
+ * - Metadata rows (neither v2 nor legacy) are dropped.
233
+ * - Legacy rows keep their relative order; synthetic entries are appended at
234
+ * the end (per sessionId, ts-sorted, /clear-segmented) so a synthetic
235
+ * baseline can never precede legacy delta rows.
236
+ */
237
+ export function normalizeV2Entries(rawEntries) {
238
+ if (!Array.isArray(rawEntries) || rawEntries.length === 0) return rawEntries;
239
+
240
+ let v2Count = 0;
241
+ const kept = [];
242
+ for (const e of rawEntries) {
243
+ if (isV2TranscriptLine(e)) {
244
+ v2Count++;
245
+ } else if (isLegacyRequestLine(e)) {
246
+ kept.push(e);
247
+ }
248
+ // metadata rows: dropped
249
+ }
250
+ if (v2Count === 0) return rawEntries;
251
+
252
+ const bySession = new Map(); // sessionId → v2 lines
253
+ for (const e of rawEntries) {
254
+ if (!isV2TranscriptLine(e)) continue;
255
+ const sid = e.sessionId || 'unknown';
256
+ let arr = bySession.get(sid);
257
+ if (!arr) bySession.set(sid, (arr = []));
258
+ arr.push(e);
259
+ }
260
+
261
+ const synthetics = [];
262
+ for (const [sid, lines] of bySession) {
263
+ const sorted = sortLines(lines);
264
+ const segments = splitSegments(sorted);
265
+ for (let i = 0; i < segments.length; i++) {
266
+ const entry = buildSyntheticEntry(segments[i], sid, i);
267
+ if (entry) synthetics.push(entry);
268
+ }
269
+ }
270
+ return [...kept, ...synthetics];
271
+ }
272
+
273
+ /**
274
+ * Incremental normalizer for live SSE: rebuilds the accumulated message array
275
+ * line by line and returns the current full synthetic entry per line.
276
+ *
277
+ * Ordering: live trusts append order (single-writer append-only file; replay
278
+ * is deduped by uuid). Timestamp reordering only matters for historical files,
279
+ * which the batch path handles.
280
+ *
281
+ * `prime(syntheticEntry)` seeds accumulated from a cold-loaded synthetic entry
282
+ * so the first live flush extends the existing session instead of truncating
283
+ * it (merge's REBUILD branch would replace a 282-message session with a
284
+ * 1-message snapshot on an empty baseline).
285
+ */
286
+ export function createV2IncrementalReconstructor() {
287
+ const state = {
288
+ sessionId: null,
289
+ entryTs: null,
290
+ epoch: null, // _seqEpoch carried by snapshots (must match the
291
+ // primed cold entry's segment epoch, or the merge
292
+ // boundary check would split the session)
293
+ accumulated: null, // array of messages (cold snapshot or live-built)
294
+ shared: false, // accumulated entries came from prime (copy-on-write)
295
+ assistantById: new Map(),
296
+ // tool_use blocks merged into an already-scanned message by a later split
297
+ // row (thinking/text/tool_use share a message.id). ChatView's incremental
298
+ // toolResultMap scans by message index only, so these would never pair —
299
+ // they are surfaced here for a follow-up scan.
300
+ mergedToolUses: [],
301
+ seenUuids: new Set(),
302
+ uuidOrder: [],
303
+ lastAssistant: null,
304
+ };
305
+
306
+ /**
307
+ * Merge one live assistant line into accumulated.
308
+ * A row sharing a cold message's _mid appends to that message — with
309
+ * copy-on-write: primed messages are shallow clones shared with the rendered
310
+ * cold entry, so the target is re-cloned (message + content array) before
311
+ * mutating, keeping the rendered snapshot pristine.
312
+ */
313
+ function pushAssistant(msg) {
314
+ const mid = msg._mid;
315
+ if (mid && state.assistantById.has(mid)) {
316
+ let existing = state.assistantById.get(mid);
317
+ if (state.shared && state.accumulated.includes(existing)) {
318
+ const clone = {
319
+ ...existing,
320
+ content: Array.isArray(existing.content) ? [...existing.content] : existing.content,
321
+ };
322
+ const idx = state.accumulated.indexOf(existing);
323
+ state.accumulated[idx] = clone;
324
+ state.assistantById.set(mid, clone);
325
+ if (state.lastAssistant === existing) state.lastAssistant = clone;
326
+ existing = clone;
327
+ }
328
+ if (Array.isArray(msg.content) && Array.isArray(existing.content)) {
329
+ existing.content = existing.content.concat(msg.content);
330
+ }
331
+ if (msg._generatedTs) existing._generatedTs = msg._generatedTs;
332
+ if (msg.model) existing.model = msg.model;
333
+ if (msg.usage) existing.usage = msg.usage;
334
+ // Surface newly merged tool_use blocks on the message itself AND the
335
+ // entry-level _toolUses — the message may have already been scanned by
336
+ // the incremental toolResultMap, so the blocks must be re-registered.
337
+ const merged = [];
338
+ if (Array.isArray(msg.content)) {
339
+ for (const b of msg.content) {
340
+ if (b && b.type === 'tool_use' && b.id) {
341
+ merged.push(b);
342
+ state.mergedToolUses.push(b);
343
+ }
344
+ }
345
+ }
346
+ if (merged.length > 0) {
347
+ existing._toolUses = [...(existing._toolUses || []), ...merged];
348
+ }
349
+ state.lastAssistant = existing;
350
+ return existing;
351
+ }
352
+ const m = {
353
+ role: 'assistant',
354
+ content: msg.content,
355
+ _timestamp: msg._timestamp,
356
+ _generatedTs: msg._generatedTs,
357
+ };
358
+ if (msg.model) m.model = msg.model;
359
+ if (msg.usage) m.usage = msg.usage;
360
+ if (mid) m._mid = mid;
361
+ state.accumulated.push(m);
362
+ state.assistantById.set(mid, m);
363
+ state.lastAssistant = m;
364
+ return m;
365
+ }
366
+
367
+ function snapshotEntry() {
368
+ const messages = [...state.accumulated];
369
+ // entry.timestamp must equal messages[0]._timestamp (the invariant
370
+ // sessionManager's stable session id / pin depends on). After a live
371
+ // /clear the segment starts fresh, so fall back to the first message's ts
372
+ // instead of the stale clear-row ts (same as the batch path).
373
+ const entryTs = messages.length > 0 ? (messages[0]._timestamp || state.entryTs) : state.entryTs;
374
+ const epoch = state.epoch || `v2:${state.sessionId}:0`;
375
+ const entry = {
376
+ mainAgent: true,
377
+ body: { messages },
378
+ timestamp: entryTs,
379
+ url: `claude-code://session/${state.sessionId}:${epoch.slice(epoch.lastIndexOf(':') + 1)}`,
380
+ sessionId: state.sessionId,
381
+ _seqEpoch: epoch,
382
+ _syntheticV2: true,
383
+ _messageCount: messages.length,
384
+ };
385
+ if (state.lastAssistant) {
386
+ const body = {};
387
+ if (state.lastAssistant.model) body.model = state.lastAssistant.model;
388
+ if (state.lastAssistant.usage) body.usage = state.lastAssistant.usage;
389
+ if (body.model || body.usage) entry.response = { body };
390
+ }
391
+ // Surface merged tool_use blocks for the incremental toolResultMap scan
392
+ // (drained per snapshot — each entry carries only the not-yet-paired ones).
393
+ if (state.mergedToolUses.length > 0) {
394
+ entry._toolUses = state.mergedToolUses.splice(0);
395
+ }
396
+ return entry;
397
+ }
398
+
399
+ return {
400
+ /** @returns {boolean} true when no baseline exists yet. */
401
+ empty() {
402
+ return state.accumulated === null;
403
+ },
404
+
405
+ /**
406
+ * Seed accumulated from a cold-loaded synthetic entry (same sessionId).
407
+ * Messages are shallow-copied so later live appends never mutate the
408
+ * rendered cold snapshot.
409
+ */
410
+ prime(syntheticEntry) {
411
+ if (!syntheticEntry || syntheticEntry._syntheticV2 !== true) return;
412
+ if (state.accumulated !== null) return;
413
+ const messages = (syntheticEntry.body && syntheticEntry.body.messages) || [];
414
+ if (messages.length === 0) return;
415
+ state.sessionId = syntheticEntry.sessionId || 'unknown';
416
+ state.entryTs = syntheticEntry.timestamp;
417
+ state.epoch = syntheticEntry._seqEpoch || null;
418
+ state.accumulated = messages.map((m) => ({
419
+ ...m,
420
+ content: Array.isArray(m.content) ? [...m.content] : m.content,
421
+ }));
422
+ state.shared = true;
423
+ state.assistantById.clear();
424
+ for (const m of state.accumulated) {
425
+ if (m.role === 'assistant') {
426
+ if (m._mid) state.assistantById.set(m._mid, m);
427
+ state.lastAssistant = m;
428
+ }
429
+ }
430
+ },
431
+
432
+ /**
433
+ * Process one v2 line, returning the current full synthetic entry, or null
434
+ * for a skip (sidechain row, replay of a seen uuid).
435
+ * Non-v2 lines are passed through unchanged (defensive; callers branch on
436
+ * isV2TranscriptLine first).
437
+ */
438
+ reconstruct(line) {
439
+ if (!isV2TranscriptLine(line)) return null;
440
+ // Session switch (workspace switch / another project's file on the same
441
+ // SSE): the old session is already rendered — drop its baseline and
442
+ // start a fresh one so rows never cross sessions.
443
+ if (state.sessionId !== null && line.sessionId && line.sessionId !== state.sessionId) {
444
+ state.sessionId = line.sessionId;
445
+ state.entryTs = line.timestamp;
446
+ state.epoch = null;
447
+ state.accumulated = [];
448
+ state.shared = false;
449
+ state.assistantById.clear();
450
+ state.lastAssistant = null;
451
+ state.mergedToolUses = [];
452
+ }
453
+ const uuid = line.uuid;
454
+ if (uuid && state.seenUuids.has(uuid)) return null;
455
+ if (uuid) {
456
+ state.seenUuids.add(uuid);
457
+ state.uuidOrder.push(uuid);
458
+ if (state.uuidOrder.length > DEDUP_MAX) {
459
+ state.seenUuids.delete(state.uuidOrder.shift());
460
+ }
461
+ }
462
+ if (state.accumulated === null) {
463
+ // No cold baseline (tail-load / file-mid start): begin fresh.
464
+ state.sessionId = line.sessionId || 'unknown';
465
+ state.entryTs = line.timestamp;
466
+ state.epoch = null;
467
+ state.accumulated = [];
468
+ state.shared = false;
469
+ }
470
+ const msg = line.message;
471
+ if (msg.role === 'user' && isClearRow(line)) {
472
+ // Live /clear: start a new segment (new epoch) so the batch and live
473
+ // paths split sessions identically. The command row itself is dropped.
474
+ // seenUuids is intentionally kept — a replay of a pre-clear row must
475
+ // still dedup; a replay of the clear row itself returns null above.
476
+ // Returns null (skip): an empty snapshot would sink into requests as a
477
+ // ghost MainAgent entry / empty session (regression P2).
478
+ state.epoch = `v2:${state.sessionId}:${(state.epoch?.split(':').pop() ?? 0) + 1}`;
479
+ state.entryTs = null; // first message of the new segment owns the ts
480
+ state.accumulated = [];
481
+ state.shared = false;
482
+ state.assistantById.clear();
483
+ state.lastAssistant = null;
484
+ state.mergedToolUses = [];
485
+ return null;
486
+ }
487
+ if (msg.role === 'user') {
488
+ const m = {
489
+ role: 'user',
490
+ content: msg.content,
491
+ _timestamp: line.timestamp,
492
+ };
493
+ state.accumulated.push(m);
494
+ } else {
495
+ pushAssistant({
496
+ _mid: msg.id,
497
+ content: msg.content,
498
+ _timestamp: line.timestamp,
499
+ _generatedTs: line.timestamp,
500
+ model: msg.model,
501
+ usage: msg.usage,
502
+ });
503
+ }
504
+ return snapshotEntry();
505
+ },
506
+
507
+ reset() {
508
+ state.sessionId = null;
509
+ state.entryTs = null;
510
+ state.epoch = null;
511
+ state.accumulated = null;
512
+ state.shared = false;
513
+ state.assistantById.clear();
514
+ state.seenUuids.clear();
515
+ state.uuidOrder = [];
516
+ state.lastAssistant = null;
517
+ },
518
+ };
519
+ }
@@ -14,6 +14,9 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
14
14
 
15
15
  # Doing tasks
16
16
  - Read the relevant files before acting or answering; ground every claim and change in code you have actually looked at.
17
+ - Read once with enough context instead of nibbling: prefer one wide Read (or a Grep for the exact line range) over many small Reads.
18
+ - Self-check for repetition: if you have already read the same file twice, do not read it a third time — stop and answer from what you have. Never re-read the same lines with only the offset or limit changed.
19
+ - Convergence budget: once you have enough evidence to answer, stop searching and answer; a partial report beats endless exploration.
17
20
  - When a request could be read either as a question or as a change to make, treat it as a task and carry it out. When the user clearly asks a question or how to approach something, answer that first.
18
21
  - Deliver exactly what was asked and nothing more: no unrequested CLI wrappers, configuration options, logging, progress output, or abstractions. This is very important to your performance.
19
22
  - Never assume a library or framework is available — check the project's manifest or neighboring files before using it.
@@ -26,6 +29,7 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
26
29
  - Do not narrate tool calls; the calls themselves show the user what you are doing.
27
30
  - Send independent tool calls together in one response instead of one at a time.
28
31
  - Track multi-step work explicitly and mark each step done as you finish it.
32
+ - Every ten tool calls, write one line saying what you have confirmed and what is still missing; if you cannot, stop calling tools and report what you have.
29
33
  - Tool results and user messages may include <system-reminder> tags. They carry information from the system, not from the user.
30
34
 
31
35
  # Executing actions with care
@@ -13,6 +13,10 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
13
13
 
14
14
  # Doing tasks
15
15
  - Read the relevant files before acting or answering; ground every claim and change in code you have actually looked at.
16
+ - Before your first tool call, output a one-sentence action plan; then act.
17
+ - Read once with enough context instead of nibbling: prefer one wide Read (or a Grep for the exact line range) over many small Reads.
18
+ - Self-check for repetition: if you have already read the same file twice, do not read it a third time — stop and answer from what you have. Never re-read the same lines with only the offset or limit changed.
19
+ - Convergence budget: once you have enough evidence to answer, stop searching and answer; a partial report beats endless exploration.
16
20
  - When a request could be read either as a question or as a change to make, treat it as a task and carry it out. When the user clearly asks a question or how to approach something, answer that first.
17
21
  - Deliver exactly what was asked and nothing more: no unrequested CLI wrappers, configuration options, logging, progress output, or abstractions. This is very important to your performance.
18
22
  - Never assume a library or framework is available — check the project's manifest or neighboring files before using it.
@@ -25,6 +29,7 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
25
29
  - Do not narrate tool calls; the calls themselves show the user what you are doing.
26
30
  - Send independent tool calls together in one response instead of one at a time.
27
31
  - Track multi-step work explicitly and mark each step done as you finish it.
32
+ - Every ten tool calls, write one line saying what you have confirmed and what is still missing; if you cannot, stop calling tools and report what you have.
28
33
  - Tool results and user messages may include <system-reminder> tags. They carry information from the system, not from the user.
29
34
 
30
35
  # Executing actions with care
@@ -41,6 +41,8 @@ export function assignMessageTimestamps(messages, prevMessages, isNewSession, pr
41
41
  }
42
42
  } else if (m.role === 'assistant' && !m._generatedTs && prevMainAgentTs) {
43
43
  // 已有 _timestamp 但缺 _generatedTs(混合输入:部分 entry 来自旧版本):补 _generatedTs
44
+ // Synthetic v2 messages carry their own _generatedTs (stamped by the
45
+ // normalizer), so they never reach this branch.
44
46
  m._generatedTs = prevMainAgentTs;
45
47
  }
46
48
  }
@@ -245,14 +247,23 @@ export function applyBatchEntryTimestamps(st, entry) {
245
247
  // filter below), otherwise the first count=1 entry after a delta rebuild would
246
248
  // be swallowed and its _timestamp stolen by the next count>4 entry.
247
249
  const postClearCheckpoint = isPostClearCheckpoint(entry, prevCount);
250
+ // V2 transcript synthetic entries pre-stamp per-message _timestamp/_generatedTs
251
+ // with the real row timestamps; the positional overwrites below would flatten
252
+ // every message to entry.timestamp, so they become protective for synthetics.
253
+ const isSyntheticV2 = entry._syntheticV2 === true;
248
254
  const epoch = entry._seqEpoch || null;
249
255
  const epochChanged = !!(epoch && st.prevEpoch && epoch !== st.prevEpoch);
250
- const isNewSession = isSessionBoundary(entry, { prevCount, count, prevUserId: st.prevUserId, userId, prevEpoch: st.prevEpoch, epoch });
256
+ // A synthetic v2 entry is a complete /clear-segmented session always a new
257
+ // session (its _seqEpoch is a definitive boundary marker), even when the
258
+ // count-based heuristics would call it a continuation.
259
+ const isNewSession = isSyntheticV2 || isSessionBoundary(entry, { prevCount, count, prevUserId: st.prevUserId, userId, prevEpoch: st.prevEpoch, epoch });
251
260
  // Transient protection: very short entries (<=4 msgs) after a long conversation
252
261
  // are usually in-flight requests (request body only, no response yet) and must
253
262
  // not reset the accumulated timestamps. Real /clear starts AND epoch changes
254
263
  // (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;
264
+ // A synthetic v2 entry is never transient it is a complete /clear-segmented
265
+ // session, so it must start a session even right after a long legacy one.
266
+ const isTransient = !isSyntheticV2 && isNewSession && !postClearCheckpoint && !epochChanged && count <= 4 && prevCount > 4 && count < prevCount * 0.5;
256
267
  if (isNewSession && !isTransient) {
257
268
  st.currentSessionId = timestamp;
258
269
  st.timestamps = [];
@@ -283,8 +294,8 @@ export function applyBatchEntryTimestamps(st, entry) {
283
294
  for (let j = 0; j < messages.length; j++) {
284
295
  const m = messages[j];
285
296
  if (!m) continue;
286
- m._timestamp = st.timestamps[j];
287
- if (m.role === 'assistant' && st.generatedTimestamps[j]) {
297
+ if (!isSyntheticV2 || m._timestamp == null) m._timestamp = st.timestamps[j];
298
+ if (m.role === 'assistant' && st.generatedTimestamps[j] && (!isSyntheticV2 || m._generatedTs == null)) {
288
299
  m._generatedTs = st.generatedTimestamps[j];
289
300
  }
290
301
  }
@@ -188,8 +188,26 @@ export function buildSubAgentResultMap(req, globalIndex) {
188
188
  return { ...localState.toolResultMap, ...filled };
189
189
  }
190
190
 
191
- export function appendToolResultMap(state, messages, startIndex) {
191
+ export function appendToolResultMap(state, messages, startIndex, extraToolUses) {
192
192
  const { toolUseMap, toolResultMap, readContentMap, editSnapshotMap, askAnswerMap, planApprovalMap, _fileState } = state;
193
+ // V2 live: tool_use blocks merged into an already-scanned message by a later
194
+ // split row are surfaced on the synthetic entry's _toolUses. Register them so
195
+ // their tool_result pairs with a real matchedTool (label / input / Read/Edit
196
+ // state). Side-effect tracking (Write/Edit/Ask/ExitPlanMode) stays in the
197
+ // message loop below — these blocks were already seen there when first merged.
198
+ if (Array.isArray(extraToolUses)) {
199
+ for (const block of extraToolUses) {
200
+ if (!block || !block.id || block.id in toolUseMap) continue;
201
+ let parsed = block;
202
+ if (typeof block.input === 'string') {
203
+ try {
204
+ const cleaned = block.input.replace(/^\[object Object\]/, '');
205
+ parsed = { ...block, input: JSON.parse(cleaned) };
206
+ } catch {}
207
+ }
208
+ toolUseMap[parsed.id] = parsed;
209
+ }
210
+ }
193
211
  for (let i = startIndex; i < messages.length; i++) {
194
212
  const msg = messages[i];
195
213
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {