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/assets/{index-Bp_VsiSj.css → index-DbVOM09a.css} +2 -2
- package/dist/assets/{index-D3V7nrsz.js → index-Drek604n.js} +125 -125
- package/dist/index.html +2 -2
- package/interceptor.js +63 -11
- package/lib/delta-reconstructor.js +169 -0
- package/lib/interceptor-core.js +27 -1
- package/lib/log-management.js +34 -5
- package/lib/log-watcher.js +7 -1
- package/lib/stats-worker.js +5 -2
- package/package.json +1 -1
- package/server.js +31 -8
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
}
|
|
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
|
-
// 用户新指令边界:检查日志文件大小,超过
|
|
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
|
+
}
|
package/lib/interceptor-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/log-management.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
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]));
|
package/lib/log-watcher.js
CHANGED
|
@@ -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)) {
|
package/lib/stats-worker.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
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
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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 }));
|