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/assets/{App-CtPaGTFc.js → App-AKaEDUQK.js} +2 -2
- package/dist/assets/{MdxEditorPanel-CdZjsgUe.js → MdxEditorPanel-blRIkN3n.js} +1 -1
- package/dist/assets/{Mobile-BV5o9yFd.js → Mobile-C5Gl5Nls.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DGMd41pC.js → ProxyStatsModal-hmslmc8e.js} +1 -1
- package/dist/assets/{index-DSTQIMmZ.js → index-BXWDyOHS.js} +2 -2
- package/dist/assets/seqResourceLoaders-BwnpYLaI.js +2 -0
- package/dist/assets/{seqResourceLoaders-pBUn3c_a.css → seqResourceLoaders-eNDfclvk.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/delta-reconstructor.js +3 -0
- package/server/lib/v2-transcript-normalizer.js +519 -0
- package/server/system-prompt-templates/presets/kimi-k2.7-code.md +4 -0
- package/server/system-prompt-templates/presets/kimi-k3.md +5 -0
- package/src/utils/sessionManager.js +15 -4
- package/src/utils/toolResultBuilder.js +19 -1
- package/src/utils/toolResultCore.js +27 -0
- package/dist/assets/seqResourceLoaders-DGyoo_5i.js +0 -2
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-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|