cc-viewer 1.6.26 → 1.6.28

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
@@ -6,8 +6,8 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-D3V7nrsz.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-Bp_VsiSj.css">
9
+ <script type="module" crossorigin src="/assets/index-Drek604n.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-DbVOM09a.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/interceptor.js CHANGED
@@ -11,7 +11,7 @@ import { homedir } from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { dirname, join, basename } from 'node:path';
13
13
  import { LOG_DIR } from './findcc.js';
14
- import { assembleStreamMessage, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, migrateConversationContext } from './lib/interceptor-core.js';
14
+ import { assembleStreamMessage, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile } from './lib/interceptor-core.js';
15
15
 
16
16
 
17
17
 
@@ -85,6 +85,20 @@ function resolveResumeChoice(choice) {
85
85
  return result;
86
86
  }
87
87
 
88
+ // Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
89
+ // 注意:delta 计算依赖 mainAgent 请求串行(Claude CLI 保证),不做并发互斥
90
+ const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
91
+ let _lastMessagesCount = 0; // 上一次 mainAgent 写入的完整 messages 数量
92
+ let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
93
+ const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
94
+
95
+ /** Delta storage: completed 写入成功后更新状态 */
96
+ function _commitDeltaState(originalLength) {
97
+ if (_deltaStorageEnabled && originalLength > 0) {
98
+ _lastMessagesCount = originalLength;
99
+ }
100
+ }
101
+
88
102
  // Teammate 子进程检测:--parent-session-id(旧模式)或 --agent-name(原生 team 模式)
89
103
  const _isTeammate = process.argv.includes('--parent-session-id') || process.argv.includes('--agent-name');
90
104
  // 提取 teammate 元数据(--agent-name worker-1 --team-name fix-ts-errors)
@@ -184,21 +198,24 @@ export function resetWorkspace() {
184
198
  LOG_FILE = '';
185
199
  }
186
200
 
187
- const MAX_LOG_SIZE = 150 * 1024 * 1024; // 150MB
201
+ const MAX_LOG_SIZE = 250 * 1024 * 1024; // 250MB
188
202
 
189
203
  function checkAndRotateLogFile() {
190
204
  // Teammate 不做日志轮转,由 leader 负责
191
205
  if (_isTeammate) return;
192
206
  try {
193
- if (!existsSync(LOG_FILE)) return;
194
- const size = statSync(LOG_FILE).size;
195
- if (size >= MAX_LOG_SIZE) {
196
- const oldFile = LOG_FILE;
197
- const { filePath } = generateNewLogFilePath();
198
- LOG_FILE = filePath;
199
- migrateConversationContext(oldFile, filePath);
207
+ if (!existsSync(LOG_FILE) || statSync(LOG_FILE).size < MAX_LOG_SIZE) return;
208
+ } catch { return; }
209
+ const { filePath } = generateNewLogFilePath();
210
+ const result = rotateLogFile(LOG_FILE, filePath, MAX_LOG_SIZE);
211
+ if (result.rotated) {
212
+ LOG_FILE = result.newFile;
213
+ // 重置 delta 状态,强制下一条 mainAgent 请求写完整 checkpoint
214
+ if (_deltaStorageEnabled) {
215
+ _lastMessagesCount = 0;
216
+ _mainAgentDeltaCount = 0;
200
217
  }
201
- } catch { }
218
+ }
202
219
  }
203
220
 
204
221
  // 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
@@ -356,7 +373,7 @@ export function setupInterceptor() {
356
373
  }
357
374
  } catch { }
358
375
 
359
- // 用户新指令边界:检查日志文件大小,超过 200MB 则切换新文件
376
+ // 用户新指令边界:检查日志文件大小,超过 250MB 则切换新文件
360
377
  if (requestEntry?.mainAgent) {
361
378
  checkAndRotateLogFile();
362
379
  // 仅 mainAgent 请求时缓存模型名,避免 SubAgent 覆盖
@@ -369,6 +386,36 @@ export function setupInterceptor() {
369
386
  }
370
387
  }
371
388
 
389
+ // Delta storage:仅 mainAgent 且开关启用时,将 body.messages 转为增量格式
390
+ let _deltaOriginalMessagesLength = 0; // 缓存本次请求的原始 messages 长度,用于 completed 后更新状态
391
+ if (_deltaStorageEnabled && requestEntry?.mainAgent && Array.isArray(requestEntry.body?.messages)) {
392
+ const messages = requestEntry.body.messages;
393
+ _deltaOriginalMessagesLength = messages.length;
394
+ _mainAgentDeltaCount++;
395
+
396
+ // 判断是否需要写 checkpoint
397
+ const needsCheckpoint =
398
+ _lastMessagesCount === 0 || // 进程重启 / 首次请求
399
+ messages.length < _lastMessagesCount || // messages 缩短(/clear、context 压缩)
400
+ (_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0); // 定期 checkpoint
401
+
402
+ if (needsCheckpoint) {
403
+ // checkpoint:保持完整 messages,标记 _isCheckpoint
404
+ requestEntry._deltaFormat = 1;
405
+ requestEntry._totalMessageCount = messages.length;
406
+ requestEntry._conversationId = 'mainAgent';
407
+ requestEntry._isCheckpoint = true;
408
+ } else {
409
+ // delta:只保留新增的 messages
410
+ const delta = messages.slice(_lastMessagesCount);
411
+ requestEntry._deltaFormat = 1;
412
+ requestEntry._totalMessageCount = messages.length;
413
+ requestEntry._conversationId = 'mainAgent';
414
+ requestEntry._isCheckpoint = false;
415
+ requestEntry.body.messages = delta;
416
+ }
417
+ }
418
+
372
419
  // 生成唯一请求 ID,用于关联在途请求和完成请求
373
420
  const requestId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
374
421
  if (requestEntry) {
@@ -447,6 +494,7 @@ export function setupInterceptor() {
447
494
  delete requestEntry.inProgress;
448
495
  delete requestEntry.requestId;
449
496
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
497
+ _commitDeltaState(_deltaOriginalMessagesLength);
450
498
  // Release memory: clear large objects after disk write
451
499
  streamedContent = '';
452
500
  requestEntry.response = null;
@@ -455,6 +503,7 @@ export function setupInterceptor() {
455
503
  delete requestEntry.inProgress;
456
504
  delete requestEntry.requestId;
457
505
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
506
+ _commitDeltaState(_deltaOriginalMessagesLength);
458
507
  streamedContent = '';
459
508
  requestEntry.response = null;
460
509
  }
@@ -487,6 +536,7 @@ export function setupInterceptor() {
487
536
  delete requestEntry.inProgress;
488
537
  delete requestEntry.requestId;
489
538
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
539
+ _commitDeltaState(_deltaOriginalMessagesLength);
490
540
  }
491
541
  } else {
492
542
  // 对于非流式响应,可以安全读取body
@@ -513,10 +563,12 @@ export function setupInterceptor() {
513
563
  delete requestEntry.requestId;
514
564
 
515
565
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
566
+ _commitDeltaState(_deltaOriginalMessagesLength);
516
567
  } catch (err) {
517
568
  delete requestEntry.inProgress;
518
569
  delete requestEntry.requestId;
519
570
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
571
+ _commitDeltaState(_deltaOriginalMessagesLength);
520
572
  }
521
573
  }
522
574
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Delta Reconstructor — 增量日志重建模块
3
+ *
4
+ * 将 delta 格式的日志条目重建为完整的 messages 数组。
5
+ * 仅处理 mainAgent 条目,teammate/旧格式条目直接跳过。
6
+ *
7
+ * 提供两种 API:
8
+ * - reconstructEntries(entries): 批量重建,用于 readLogFile() 和 readLocalLog()
9
+ * - createIncrementalReconstructor(): 有状态的增量重建器,用于 watcher 逐条重建
10
+ */
11
+
12
+ /**
13
+ * 判断一个条目是否为 checkpoint(应重置累积状态)。
14
+ * 三种情况视为 checkpoint:
15
+ * 1. 无 _deltaFormat 字段 → 旧格式全量条目
16
+ * 2. _isCheckpoint === true → 显式 checkpoint
17
+ * 3. _totalMessageCount === body.messages.length → 隐式 checkpoint(delta 长度 === 总长度)
18
+ */
19
+ function isCheckpointEntry(entry) {
20
+ // 无 _deltaFormat:旧格式全量条目
21
+ if (!entry._deltaFormat) return true;
22
+ // 显式 checkpoint
23
+ if (entry._isCheckpoint) return true;
24
+ // 隐式 checkpoint:delta 长度等于总长度
25
+ const msgs = entry.body?.messages;
26
+ if (Array.isArray(msgs) && entry._totalMessageCount === msgs.length) return true;
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * 判断一个条目是否为需要重建的 delta 条目(mainAgent + _deltaFormat)。
32
+ */
33
+ function isDeltaEntry(entry) {
34
+ return entry._deltaFormat && entry.mainAgent;
35
+ }
36
+
37
+ /**
38
+ * 批量重建 — 用于 readLogFile() 和 readLocalLog()。
39
+ * 输入已去重的条目数组,输出重建后的条目数组(原地修改 body.messages)。
40
+ * 非 mainAgent delta 条目不受影响。
41
+ *
42
+ * @param {Array} entries - 已去重、按时间顺序排列的条目数组
43
+ * @returns {Array} 重建后的条目数组(同一引用,原地修改)
44
+ */
45
+ export function reconstructEntries(entries) {
46
+ // 第一遍:正向重建
47
+ let accumulated = []; // mainAgent 累积 messages
48
+ const broken = []; // 记录重建失败的条目索引(用于第二遍补偿)
49
+
50
+ for (let i = 0; i < entries.length; i++) {
51
+ const entry = entries[i];
52
+ if (!isDeltaEntry(entry)) {
53
+ // 非 delta 条目(旧格式 / teammate):如果是 mainAgent 旧格式,重置累积状态
54
+ if (entry.mainAgent && Array.isArray(entry.body?.messages)) {
55
+ accumulated = [...entry.body.messages];
56
+ }
57
+ continue;
58
+ }
59
+
60
+ // delta 条目处理
61
+ const msgs = entry.body?.messages;
62
+ if (!Array.isArray(msgs)) continue;
63
+
64
+ if (isCheckpointEntry(entry)) {
65
+ // checkpoint:用当前 messages 重置累积状态
66
+ accumulated = [...msgs];
67
+ } else {
68
+ // delta:拼接到累积数组
69
+ accumulated = [...accumulated, ...msgs];
70
+ // 挂载重建后的完整 messages(checkpoint/旧格式条目保持不变)
71
+ entry.body.messages = accumulated;
72
+ if (entry._totalMessageCount && accumulated.length !== entry._totalMessageCount) {
73
+ broken.push(i);
74
+ }
75
+ }
76
+ }
77
+
78
+ // 第二遍:补偿修复 — 用后续最近的 checkpoint 回填断裂的条目
79
+ if (broken.length > 0) {
80
+ _compensateBrokenEntries(entries, broken);
81
+ }
82
+
83
+ return entries;
84
+ }
85
+
86
+ /**
87
+ * 补偿修复:对断裂的 delta 条目,从后续最近的 checkpoint 中提取完整 messages 回填。
88
+ * checkpoint 包含截至该点的完整历史,可以据此反推之前条目的 messages。
89
+ */
90
+ function _compensateBrokenEntries(entries, brokenIndices) {
91
+ for (const brokenIdx of brokenIndices) {
92
+ const brokenEntry = entries[brokenIdx];
93
+ const expectedCount = brokenEntry._totalMessageCount;
94
+ if (!expectedCount) continue;
95
+
96
+ // 向后查找最近的 checkpoint 或旧格式全量条目
97
+ for (let j = brokenIdx + 1; j < entries.length; j++) {
98
+ const candidate = entries[j];
99
+ if (!candidate.mainAgent || !Array.isArray(candidate.body?.messages)) continue;
100
+
101
+ const candidateMsgs = candidate.body.messages;
102
+ const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
103
+
104
+ // 候选条目必须是 checkpoint/旧格式且包含足够的 messages
105
+ const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
106
+ if (isFullEntry && candidateTotal >= expectedCount) {
107
+ // 从完整 messages 中截取前 expectedCount 条作为补偿
108
+ brokenEntry.body.messages = candidateMsgs.slice(0, expectedCount);
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 创建有状态的增量重建器 — 用于 watcher 逐条重建。
117
+ * 每次调用 reconstruct(entry) 处理一条新条目。
118
+ *
119
+ * @returns {{ reconstruct: (entry: object) => object }}
120
+ */
121
+ export function createIncrementalReconstructor() {
122
+ let accumulated = []; // mainAgent 累积 messages
123
+
124
+ return {
125
+ /**
126
+ * 重建单条条目。
127
+ * - 非 delta 条目:如果是 mainAgent 旧格式,更新累积状态,原样返回
128
+ * - checkpoint:重置累积状态,原样返回
129
+ * - delta:拼接重建,修改 body.messages 后返回
130
+ *
131
+ * @param {object} entry - 单条日志条目
132
+ * @returns {object} 重建后的条目(同一引用)
133
+ */
134
+ reconstruct(entry) {
135
+ // 跳过 inProgress 条目:它会被同一请求的 completed 条目覆盖,
136
+ // 如果两者都处理会导致 accumulated 重复拼接相同的 delta
137
+ if (entry.inProgress) return entry;
138
+
139
+ if (!isDeltaEntry(entry)) {
140
+ // 非 delta 条目:如果是 mainAgent 旧格式,更新累积状态
141
+ if (entry.mainAgent && Array.isArray(entry.body?.messages)) {
142
+ accumulated = [...entry.body.messages];
143
+ }
144
+ return entry;
145
+ }
146
+
147
+ const msgs = entry.body?.messages;
148
+ if (!Array.isArray(msgs)) return entry;
149
+
150
+ if (isCheckpointEntry(entry)) {
151
+ // checkpoint:重置累积状态
152
+ accumulated = [...msgs];
153
+ } else {
154
+ // delta:拼接
155
+ accumulated = [...accumulated, ...msgs];
156
+ entry.body.messages = accumulated;
157
+ }
158
+
159
+ return entry;
160
+ },
161
+
162
+ /**
163
+ * 重置累积状态(用于 full_reload 等场景)。
164
+ */
165
+ reset() {
166
+ accumulated = [];
167
+ }
168
+ };
169
+ }
@@ -201,7 +201,9 @@ export function migrateConversationContext(oldFile, newFile) {
201
201
  const entry = JSON.parse(parts[i]);
202
202
  if (entry.mainAgent) {
203
203
  const msgs = entry.body?.messages;
204
- if (Array.isArray(msgs) && msgs.length === 1) {
204
+ // Delta storage: 使用 _totalMessageCount(delta 条目)或 msgs.length(旧格式)
205
+ const msgCount = entry._totalMessageCount || (Array.isArray(msgs) ? msgs.length : 0);
206
+ if (msgCount === 1) {
205
207
  originIndex = i;
206
208
  break;
207
209
  }
@@ -236,3 +238,27 @@ export function migrateConversationContext(oldFile, newFile) {
236
238
  }
237
239
  } catch { }
238
240
  }
241
+
242
+ /**
243
+ * Rotate log file when it exceeds maxSize.
244
+ * Creates an empty new file (no content migration) and appends '\n' to old file
245
+ * to trigger fs.watchFile callback for watcher migration.
246
+ *
247
+ * @param {string} currentFile - current log file path
248
+ * @param {string} newFile - new log file path to rotate to
249
+ * @param {number} maxSize - max file size in bytes
250
+ * @returns {{ rotated: boolean, oldFile?: string, newFile?: string }}
251
+ */
252
+ export function rotateLogFile(currentFile, newFile, maxSize) {
253
+ try {
254
+ if (!existsSync(currentFile)) return { rotated: false };
255
+ const size = statSync(currentFile).size;
256
+ if (size < maxSize) return { rotated: false };
257
+ // 不迁移旧内容,创建空新文件(立即创建,避免 watcher 时序窗口)
258
+ try { writeFileSync(newFile, ''); } catch { }
259
+ // 触发旧文件 watcher 回调,使其检测到文件变更并切换到新文件
260
+ try { appendFileSync(currentFile, '\n'); } catch { }
261
+ return { rotated: true, oldFile: currentFile, newFile };
262
+ } catch { }
263
+ return { rotated: false };
264
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { reconstructEntries } from './delta-reconstructor.js';
3
4
 
4
5
  /**
5
6
  * Validate that a resolved file path is contained within logDir.
@@ -77,10 +78,16 @@ export function readLocalLog(logDir, file) {
77
78
  validateLogPath(logDir, file);
78
79
  const filePath = join(logDir, file);
79
80
  const content = readFileSync(filePath, 'utf-8');
80
- const entries = content.split('\n---\n').filter(line => line.trim()).map(entry => {
81
+ const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
81
82
  try { return JSON.parse(entry); } catch { return null; }
82
83
  }).filter(Boolean);
83
- return entries;
84
+ // Delta storage: 先去重(timestamp|url),再重建 delta 条目
85
+ const map = new Map();
86
+ for (const entry of parsed) {
87
+ const key = `${entry.timestamp}|${entry.url}`;
88
+ map.set(key, entry);
89
+ }
90
+ return reconstructEntries(Array.from(map.values()));
84
91
  }
85
92
 
86
93
  /**
@@ -160,11 +167,33 @@ export function mergeLogFiles(logDir, files) {
160
167
  err.code = 'INVALID_INPUT';
161
168
  throw err;
162
169
  }
163
- // 合并内容写入第一个文件
170
+ // Delta storage: 合并时先重建为全量格式再拼接(输出全部为旧格式全量条目,无 _deltaFormat)
164
171
  const targetFile = files[0];
165
172
  const targetPath = join(logDir, targetFile);
166
- const contents = files.map(f => readFileSync(join(logDir, f), 'utf-8').trimEnd());
167
- writeFileSync(targetPath, contents.join('\n---\n') + '\n');
173
+ const allEntries = [];
174
+ for (const f of files) {
175
+ const content = readFileSync(join(logDir, f), 'utf-8');
176
+ const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
177
+ try { return JSON.parse(entry); } catch { return null; }
178
+ }).filter(Boolean);
179
+ // 去重
180
+ const map = new Map();
181
+ for (const entry of parsed) {
182
+ const key = `${entry.timestamp}|${entry.url}`;
183
+ map.set(key, entry);
184
+ }
185
+ // 重建 delta 为全量
186
+ const reconstructed = reconstructEntries(Array.from(map.values()));
187
+ // 清除 delta 元字段,输出为旧格式全量条目
188
+ for (const entry of reconstructed) {
189
+ delete entry._deltaFormat;
190
+ delete entry._totalMessageCount;
191
+ delete entry._conversationId;
192
+ delete entry._isCheckpoint;
193
+ }
194
+ allEntries.push(...reconstructed);
195
+ }
196
+ writeFileSync(targetPath, allEntries.map(e => JSON.stringify(e)).join('\n---\n') + '\n---\n');
168
197
  // 删除其余文件
169
198
  for (let i = 1; i < files.length; i++) {
170
199
  unlinkSync(join(logDir, files[i]));
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync, watchFile, unwatchFile, openSync, readSync, closeSync, statSync } from 'node:fs';
2
2
  import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
3
3
  import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
4
+ import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
4
5
 
5
6
  // 跟踪所有被 watch 的日志文件
6
7
  const watchedFiles = new Map();
@@ -31,7 +32,7 @@ export function readLogFile(logFile) {
31
32
  const key = `${entry.timestamp}|${entry.url}`;
32
33
  map.set(key, entry);
33
34
  }
34
- return Array.from(map.values());
35
+ return reconstructEntries(Array.from(map.values()));
35
36
  } catch (err) {
36
37
  console.error('Error reading log file:', err);
37
38
  return [];
@@ -84,6 +85,8 @@ export function watchLogFile(opts) {
84
85
  // Track byte offset instead of string length — avoids full-file re-read on every poll
85
86
  let lastByteOffset = 0;
86
87
  let pendingTail = ''; // incomplete entry carried across polls
88
+ // Delta storage: 增量重建器,用于逐条重建 mainAgent delta 条目
89
+ const _reconstructor = createIncrementalReconstructor();
87
90
  try {
88
91
  if (existsSync(logFile)) {
89
92
  lastByteOffset = statSync(logFile).size;
@@ -99,6 +102,7 @@ export function watchLogFile(opts) {
99
102
  if (currentSize < lastByteOffset) {
100
103
  lastByteOffset = 0;
101
104
  pendingTail = '';
105
+ _reconstructor.reset();
102
106
 
103
107
  // 文件被清空可能是轮转信号,立即检查是否已切换到新文件
104
108
  const currentLogFile = getLogFile();
@@ -157,6 +161,8 @@ export function watchLogFile(opts) {
157
161
  if (!parsed.pid) {
158
162
  parsed.pid = getClaudePid();
159
163
  }
164
+ // Delta storage: reconstruct before push — 确保前端收到完整 messages
165
+ _reconstructor.reconstruct(parsed);
160
166
  sendToClients(clients, parsed);
161
167
  runParallelHook('onNewEntry', parsed).catch(() => {});
162
168
  if (isMainAgentEntry(parsed)) {
@@ -108,7 +108,8 @@ function parseJsonlFile(filePath) {
108
108
  // 会话轮次统计(仅 MainAgent,排除 SUGGESTION MODE)
109
109
  if (entry.mainAgent && Array.isArray(entry.body?.messages)) {
110
110
  const msgs = entry.body.messages;
111
- const len = msgs.length;
111
+ // Delta storage: 使用 _totalMessageCount(delta 条目)或 msgs.length(旧格式)
112
+ const len = entry._totalMessageCount || msgs.length;
112
113
  if (!isSuggestionMode(msgs)) {
113
114
  // messages 数量大幅缩减:新会话(/clear 等),重置追踪
114
115
  if (len < maxMsgLen * 0.5 && (maxMsgLen - len) > 4) {
@@ -129,12 +130,14 @@ function parseJsonlFile(filePath) {
129
130
  maxMsgLen = len;
130
131
  }
131
132
  const texts = extractUserTexts(msgs);
133
+ // Delta storage: delta 条目的 msgs 只有新增部分,prevTextCount 不适用,从 0 开始收集
134
+ const textStart = entry._deltaFormat ? 0 : prevTextCount;
132
135
  // 生成本次 prompt 签名,用于跳过同轮重复请求
133
136
  const sig = texts.join('\x00');
134
137
  if (sig === lastCollectedSig && !isNewTurn) {
135
138
  // 同一轮的重复请求(内容完全相同),跳过
136
139
  } else {
137
- for (let ti = prevTextCount; ti < texts.length; ti++) {
140
+ for (let ti = textStart; ti < texts.length; ti++) {
138
141
  const flat = texts[ti].replace(/[\r\n]+/g, ' ').trim();
139
142
  if (flat) preview.push(flat.slice(0, 100));
140
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.26",
3
+ "version": "1.6.28",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -1313,14 +1313,37 @@ async function handleRequest(req, res) {
1313
1313
  return;
1314
1314
  }
1315
1315
  const fileName = file.split('/').pop();
1316
- const stat = statSync(realPath);
1317
- res.writeHead(200, {
1318
- 'Content-Type': 'application/octet-stream',
1319
- 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
1320
- 'Content-Length': stat.size,
1321
- });
1322
- const stream = createReadStream(realPath);
1323
- stream.pipe(res);
1316
+ const format = parsedUrl.searchParams.get('format');
1317
+ // Delta storage: format=raw 下载原始文件;默认下载重建后的全量格式
1318
+ if (format === 'raw') {
1319
+ const stat = statSync(realPath);
1320
+ res.writeHead(200, {
1321
+ 'Content-Type': 'application/octet-stream',
1322
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
1323
+ 'Content-Length': stat.size,
1324
+ });
1325
+ const stream = createReadStream(realPath);
1326
+ stream.pipe(res);
1327
+ } else {
1328
+ // 重建为全量格式下载
1329
+ const { readLocalLog } = await import('./lib/log-management.js');
1330
+ const entries = readLocalLog(LOG_DIR, file);
1331
+ // 清除 delta 元字段
1332
+ for (const entry of entries) {
1333
+ delete entry._deltaFormat;
1334
+ delete entry._totalMessageCount;
1335
+ delete entry._conversationId;
1336
+ delete entry._isCheckpoint;
1337
+ }
1338
+ const content = entries.map(e => JSON.stringify(e)).join('\n---\n') + '\n---\n';
1339
+ const buf = Buffer.from(content, 'utf-8');
1340
+ res.writeHead(200, {
1341
+ 'Content-Type': 'application/octet-stream',
1342
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
1343
+ 'Content-Length': buf.length,
1344
+ });
1345
+ res.end(buf);
1346
+ }
1324
1347
  } catch (err) {
1325
1348
  res.writeHead(500, { 'Content-Type': 'application/json' });
1326
1349
  res.end(JSON.stringify({ error: err.message }));