cc-viewer 1.7.11 → 1.7.13
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 +2 -0
- package/dist/assets/{MdxEditorPanel-Br1zGDDJ.js → MdxEditorPanel-CdZjsgUe.js} +1 -1
- package/dist/assets/{Mobile-C_HxuKWt.js → Mobile-BV5o9yFd.js} +1 -1
- package/dist/assets/{ProxyStatsModal-Boxtr7ht.js → ProxyStatsModal-DGMd41pC.js} +1 -1
- package/dist/assets/index-DSTQIMmZ.js +2 -0
- package/dist/assets/seqResourceLoaders-DGyoo_5i.js +2 -0
- package/dist/assets/{seqResourceLoaders-0RXZfUKp.css → seqResourceLoaders-pBUn3c_a.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/context-rules.js +38 -1
- package/server/lib/context-watcher.js +19 -4
- package/server/lib/log-management.js +57 -0
- package/server/lib/log-watcher.js +1 -1
- package/server/lib/v2/adapter.js +9 -110
- package/server/lib/v2/convert-manager.js +7 -1
- package/server/lib/v2/migrate-prompt.js +39 -1
- package/server/lib/v2/session-list.js +276 -0
- package/server/lib/v2/session-select.js +4 -4
- package/server/routes/events.js +1 -1
- package/server/routes/logs.js +50 -8
- package/src/utils/effectiveModel.js +4 -0
- package/src/utils/helpers.js +6 -3
- package/dist/assets/App-o7gt7lIy.js +0 -2
- package/dist/assets/index-gH_W4sVT.js +0 -2
- package/dist/assets/seqResourceLoaders-ByYE7WxI.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-DSTQIMmZ.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.13",
|
|
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",
|
|
@@ -27,6 +27,29 @@ export function parseContextSizeSuffix(modelName) {
|
|
|
27
27
|
return m[2].toLowerCase() === 'm' ? num * 1000000 : num * 1000;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the model name to use for context-window classification (血条窗口判定专用).
|
|
32
|
+
*
|
|
33
|
+
* Precedence differs from getEffectiveModel (response-first) on one deliberate
|
|
34
|
+
* point: an EXPLICIT [Nk]/[Nm] suffix on the REQUEST model (`body.model`, which
|
|
35
|
+
* carries the user's hot-switch config / model selector intent) is authoritative
|
|
36
|
+
* and must NOT be overridden by the upstream response. Upstream APIs normalize
|
|
37
|
+
* the response `model` — e.g. hot-switching to `k3[1m]` makes Moonshot return
|
|
38
|
+
* `response.body.model: "k3"`, stripping the [1m] marker; a response-first read
|
|
39
|
+
* would then misclassify the window (bare k3 vs the configured 1M). So: request
|
|
40
|
+
* suffix wins; otherwise fall back to the response model, then the request name.
|
|
41
|
+
*
|
|
42
|
+
* @param {object|null|undefined} request log entry with body / response
|
|
43
|
+
* @returns {string|null}
|
|
44
|
+
*/
|
|
45
|
+
export function getCalibrationModel(request) {
|
|
46
|
+
const reqModel = request?.body?.model;
|
|
47
|
+
if (typeof reqModel === 'string' && parseContextSizeSuffix(reqModel) != null) return reqModel;
|
|
48
|
+
const respModel = request?.response?.body?.model;
|
|
49
|
+
if (typeof respModel === 'string' && respModel) return respModel;
|
|
50
|
+
return (typeof reqModel === 'string' && reqModel) ? reqModel : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
// 模型家族 → 窗口档位表(有序,首条命中)。后缀解析在表外先行(见 getModelMaxTokens)。
|
|
31
54
|
const MODEL_CONTEXT_SIZES = [
|
|
32
55
|
// haiku 全系 200K,显式置于一切 1M 默认之前(claude-haiku-4-5 等)
|
|
@@ -48,6 +71,10 @@ const MODEL_CONTEXT_SIZES = [
|
|
|
48
71
|
{ match: /gpt-4o|o1|o3|o4/i, tokens: 128000 },
|
|
49
72
|
{ match: /gpt-4/i, tokens: 128000 },
|
|
50
73
|
{ match: /gpt-3/i, tokens: 16000 },
|
|
74
|
+
// Kimi 家族精确档:k2.x/k3 等带 kimi/moonshot 前缀的 → 256K;裸 'k3'(无前缀,
|
|
75
|
+
// 代理直连时的简写 model 名)→ 256K 精确档但 classifyContextWindow 不升 1M
|
|
76
|
+
// (见该函数的家族特判,裸 k3 归 200K 桶,超量由 adaptContextWindow 纠偏)。
|
|
77
|
+
{ match: /kimi|moonshot|^k3$/i, tokens: 256000 },
|
|
51
78
|
// deepseek-v4 defaults to 1M; placed before generic /deepseek/ so the
|
|
52
79
|
// first-match-wins loop picks it up before falling through to 128K.
|
|
53
80
|
{ match: /deepseek-v4/i, tokens: 1000000 },
|
|
@@ -74,12 +101,19 @@ export function getModelMaxTokens(modelName) {
|
|
|
74
101
|
* 不变量:只返回 1000000 或 200000(resolveCalibrationTokens 依赖此不变量)。
|
|
75
102
|
* 裸 '1m' 子串(无方括号,如 deepseek-v3-1m)→ 1M 的宽松规则仅限本分类器,
|
|
76
103
|
* 刻意不进 getModelMaxTokens(后者面向精确档位)。128K/16K 档归入 200K 桶。
|
|
104
|
+
* Kimi 家族特判:kimi/moonshot 前缀型号(k2.x/k3,真实窗口 256K)归 1M 桶 ——
|
|
105
|
+
* 避免会话中段从 200K 重标定到 256K/1M 的跳变;代价是相对真实 256K 上限
|
|
106
|
+
* 长期低估(约 4 倍刻度),可接受。裸 'k3' 同样归 1M:代理热切换到
|
|
107
|
+
* 'k3[1m]' 时上游会把响应 model 归一化成裸 'k3'(剥掉 [1m] 后缀),
|
|
108
|
+
* response-first 解析读到裸 'k3' 若归 200K 桶会与请求侧 1M 判定分裂,
|
|
109
|
+
* 血条分母错成 200K;且裸 'k3' 本就是 k3[1m] 的 1M 形态被剥后缀的产物。
|
|
77
110
|
* @param {string} modelName
|
|
78
111
|
* @returns {1000000|200000}
|
|
79
112
|
*/
|
|
80
113
|
export function classifyContextWindow(modelName) {
|
|
81
114
|
if (!modelName || typeof modelName !== 'string') return 200000;
|
|
82
115
|
if (modelName.toLowerCase().includes('1m')) return 1000000;
|
|
116
|
+
if (/kimi|moonshot|^k3$/i.test(modelName)) return 1000000;
|
|
83
117
|
return getModelMaxTokens(modelName) >= 1000000 ? 1000000 : 200000;
|
|
84
118
|
}
|
|
85
119
|
|
|
@@ -88,7 +122,9 @@ export function classifyContextWindow(modelName) {
|
|
|
88
122
|
* 一个真正的 200K 模型,其输入上下文(input + cache_creation + cache_read)物理上不可能
|
|
89
123
|
* 超过 200K —— 超了 API 直接拒收。所以一旦真实输入用量越过 200K 还被判成 200K,必然是
|
|
90
124
|
* model 名识别错了(误判),此时自动升到 1M,免得血条卡死在 100%、百分比与真实进度脱节。
|
|
91
|
-
*
|
|
125
|
+
* One-way upgrades only: 200K→1M and 256K→1M (the kimi exact tier used by the
|
|
126
|
+
* server-side SSE path); every other classification (1M, 128K/16K tiers, true
|
|
127
|
+
* 200K values) is returned unchanged — 128K is deliberately never promoted.
|
|
92
128
|
* 注意:usedContextTokens 必须是"输入侧"用量(sumUsageInputTokens,不含 output_tokens),
|
|
93
129
|
* 否则大输出会误触发。
|
|
94
130
|
* @param {number} classifiedTokens classifyContextWindow / getModelMaxTokens 的结果
|
|
@@ -97,6 +133,7 @@ export function classifyContextWindow(modelName) {
|
|
|
97
133
|
*/
|
|
98
134
|
export function adaptContextWindow(classifiedTokens, usedContextTokens) {
|
|
99
135
|
if (classifiedTokens === 200000 && usedContextTokens > 200000) return 1000000;
|
|
136
|
+
if (classifiedTokens === 256000 && usedContextTokens > 256000) return 1000000;
|
|
100
137
|
return classifiedTokens;
|
|
101
138
|
}
|
|
102
139
|
|
|
@@ -2,7 +2,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { getClaudeConfigDir } from '../../findcc.js';
|
|
5
|
-
import { getModelMaxTokens, adaptContextWindow, sumUsageInputTokens, sumUsageContextTokens } from './context-rules.js';
|
|
5
|
+
import { getModelMaxTokens, adaptContextWindow, sumUsageInputTokens, sumUsageContextTokens, getCalibrationModel } from './context-rules.js';
|
|
6
6
|
|
|
7
7
|
export const CONTEXT_WINDOW_FILE = join(getClaudeConfigDir(), 'context-window.json');
|
|
8
8
|
export const CLAUDE_SETTINGS_FILE = join(getClaudeConfigDir(), 'settings.json');
|
|
@@ -43,10 +43,25 @@ export function readModelContextSize() {
|
|
|
43
43
|
/**
|
|
44
44
|
* Get context size for a given API model name (e.g. 'claude-opus-4-6-20250514').
|
|
45
45
|
* Uses startup cache to avoid re-reading the file.
|
|
46
|
-
*
|
|
46
|
+
* Accepts either a bare model-name string (legacy path) or a full log entry.
|
|
47
|
+
* Entry input resolves the model via getCalibrationModel (context-rules.js):
|
|
48
|
+
* an explicit [Nk]/[Nm] suffix on the REQUEST model wins (the user's hot-switch
|
|
49
|
+
* config intent, e.g. k3[1m]); otherwise the upstream response.body.model is
|
|
50
|
+
* authoritative. The startup cache is request-side static info, stale after a
|
|
51
|
+
* hot-switch, so entry resolution skips it and goes straight to the family
|
|
52
|
+
* rules table. String input keeps legacy cache-first behavior unchanged.
|
|
53
|
+
* @param {string|object} modelOrEntry - model name, or log entry with body/response
|
|
47
54
|
* @returns {number} context window size in tokens
|
|
48
55
|
*/
|
|
49
|
-
export function getContextSizeForModel(
|
|
56
|
+
export function getContextSizeForModel(modelOrEntry) {
|
|
57
|
+
const isEntry = modelOrEntry !== null && typeof modelOrEntry === 'object';
|
|
58
|
+
// Entry input: calibration-aware resolution (request [Nk]/[Nm] suffix wins,
|
|
59
|
+
// else response model). Authoritative over the stale startup cache.
|
|
60
|
+
if (isEntry) {
|
|
61
|
+
const model = getCalibrationModel(modelOrEntry);
|
|
62
|
+
return model ? getModelMaxTokens(model) : (_startupContextSize || 200000);
|
|
63
|
+
}
|
|
64
|
+
const apiModelName = modelOrEntry;
|
|
50
65
|
if (!apiModelName) return _startupContextSize || 200000;
|
|
51
66
|
const lower = apiModelName.toLowerCase();
|
|
52
67
|
// Extract base: 'claude-opus-4-6-20250514' → 'opus-4-6'
|
|
@@ -56,7 +71,7 @@ export function getContextSizeForModel(apiModelName) {
|
|
|
56
71
|
return _startupContextSize;
|
|
57
72
|
}
|
|
58
73
|
// 完整档位表见 server/lib/context-rules.js(与前端同源;含 haiku/旧 opus/3-opus 200K、
|
|
59
|
-
// deepseek-v4 1M、gpt/deepseek 等三方档位,默认 200K)
|
|
74
|
+
// deepseek-v4 1M、kimi/moonshot 256K、gpt/deepseek 等三方档位,默认 200K)
|
|
60
75
|
return getModelMaxTokens(apiModelName);
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -4,6 +4,8 @@ import { join, sep, dirname, basename } from 'node:path';
|
|
|
4
4
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
5
5
|
import { sanitizePathComponent } from './v2/layout.js';
|
|
6
6
|
import { listV2Sessions } from './v2/adapter.js';
|
|
7
|
+
import { summarizeSessionPage } from './v2/session-list.js';
|
|
8
|
+
import { listSessionIds } from './v2/replay.js';
|
|
7
9
|
|
|
8
10
|
// wire-v2 S5 addressing (spec §12): 'v2:<project>/<session_id>' in every
|
|
9
11
|
// existing ?file= parameter slot. Components must survive the same whitelist
|
|
@@ -122,6 +124,61 @@ export function listV2Logs(logDir, currentProjectName) {
|
|
|
122
124
|
return { ...grouped, _currentProject: currentProjectName || '' };
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Server-side paginated v2 log list for ONE project (2026-07-31). The modal
|
|
129
|
+
* only ever renders the current project's sessions, so instead of summarizing
|
|
130
|
+
* every session up front we page: enumerate session dirs (readdir — cheap),
|
|
131
|
+
* read each meta.json only for the startTs ordering + leader filter, sort
|
|
132
|
+
* newest-first, then run the EXPENSIVE summarize (journal fold + dir walk +
|
|
133
|
+
* prompts head) on just the `pageSize` sessions of the requested page — those
|
|
134
|
+
* go through the same row cache as listV2Sessions, so revisiting a page is
|
|
135
|
+
* ~1-3ms. Trade-off documented inline: `size==0` and `discard` verdicts live
|
|
136
|
+
* behind that summarize, so `total` is the pre-filter session count (the empty
|
|
137
|
+
* / quota-probe sessions are excluded as their pages are computed). For
|
|
138
|
+
* realistic data (empties + probes are a small minority) this keeps cold open
|
|
139
|
+
* at ~1 page of work instead of N.
|
|
140
|
+
*
|
|
141
|
+
* @returns {{items: Array, total: number, page: number, pageSize: number}}
|
|
142
|
+
* items rows keep the exact listV2Logs shape {file, kind, timestamp, size, turns, preview}.
|
|
143
|
+
*/
|
|
144
|
+
export function listV2LogsPage(logDir, project, { page = 1, pageSize = 50 } = {}) {
|
|
145
|
+
const out = { items: [], total: 0, page, pageSize, _currentProject: project || '' };
|
|
146
|
+
if (!project) return out;
|
|
147
|
+
const projectDir = join(logDir, project);
|
|
148
|
+
if (!existsSync(projectDir)) return out;
|
|
149
|
+
|
|
150
|
+
// Cheap pass: order candidates by startTs without paying per-session folds.
|
|
151
|
+
const candidates = [];
|
|
152
|
+
for (const dirName of listSessionIds(projectDir)) {
|
|
153
|
+
let meta = null;
|
|
154
|
+
try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
|
|
155
|
+
if (meta && meta.leader) continue; // teammate — folded into its leader's row
|
|
156
|
+
candidates.push({ dirName, startTs: (meta && meta.startTs) || '' });
|
|
157
|
+
}
|
|
158
|
+
// Newest first; dirName tiebreak matches listV2Logs' file tiebreak for stability.
|
|
159
|
+
candidates.sort((a, b) => b.startTs.localeCompare(a.startTs) || b.dirName.localeCompare(a.dirName));
|
|
160
|
+
out.total = candidates.length;
|
|
161
|
+
|
|
162
|
+
const start = (page - 1) * pageSize;
|
|
163
|
+
for (const c of candidates.slice(start, start + pageSize)) {
|
|
164
|
+
let s = null;
|
|
165
|
+
try { s = summarizeSessionPage(projectDir, c.dirName); } catch { continue; }
|
|
166
|
+
if (!s) continue;
|
|
167
|
+
if (s.leader) continue;
|
|
168
|
+
if (s.size === 0) continue;
|
|
169
|
+
if (s.discard) continue; // quota-probe orphans: never listed
|
|
170
|
+
out.items.push({
|
|
171
|
+
file: `v2:${project}/${s.sid}`,
|
|
172
|
+
kind: 'v2',
|
|
173
|
+
timestamp: compactLocalTs(s.startTs),
|
|
174
|
+
size: s.size,
|
|
175
|
+
turns: s.turns,
|
|
176
|
+
preview: s.preview || [],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
125
182
|
/**
|
|
126
183
|
* 1.7.0 v1 view: list legacy v1 `.jsonl` files, grouped per project — same row
|
|
127
184
|
* shape as listV2Logs so LogTable renders both views unchanged. Every
|
|
@@ -156,7 +156,7 @@ export function processWatchedEntry(parsed, ctx) {
|
|
|
156
156
|
if (cached) sendEventToClients(clients, 'kv_cache_content', cached);
|
|
157
157
|
const usage = parsed.response?.body?.usage;
|
|
158
158
|
if (usage) {
|
|
159
|
-
const contextSize = getContextSizeForModel(parsed
|
|
159
|
+
const contextSize = getContextSizeForModel(parsed);
|
|
160
160
|
const cwData = buildContextWindowEvent(usage, contextSize);
|
|
161
161
|
if (cwData) sendEventToClients(clients, 'context_window', cwData);
|
|
162
162
|
}
|
package/server/lib/v2/adapter.js
CHANGED
|
@@ -27,16 +27,16 @@
|
|
|
27
27
|
// (sessionId, seq) tie-break — field-equivalent to v1's "teammate writes the
|
|
28
28
|
// leader's file".
|
|
29
29
|
|
|
30
|
-
import { existsSync, readdirSync, readFileSync, statSync
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
31
31
|
import { join, dirname, basename } from 'node:path';
|
|
32
32
|
import { reportSwallowed } from '../error-report.js';
|
|
33
33
|
import { isMainAgentRequest } from '../interceptor-core.js';
|
|
34
|
-
import {
|
|
35
|
-
import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
|
|
34
|
+
import { readSession } from './replay.js';
|
|
36
35
|
import { iterateJsonlLines } from './jsonl-read.js';
|
|
37
36
|
import { isDiscardableSession } from './session-select.js';
|
|
38
|
-
import { blobPath, isSupportedWireFormat
|
|
37
|
+
import { blobPath, isSupportedWireFormat } from './layout.js';
|
|
39
38
|
import { SingleFlight } from './singleflight.js';
|
|
39
|
+
import { listV2Sessions } from './session-list.js';
|
|
40
40
|
|
|
41
41
|
// Same stamping rules as the v1 interceptor (KEEP IN SYNC: server/interceptor.js
|
|
42
42
|
// requestEntry construction) — recomputed from the journal's url, not from kind,
|
|
@@ -1056,109 +1056,8 @@ export async function streamV2WindowedEntries(sessionDir, opts, onEntry) {
|
|
|
1056
1056
|
|
|
1057
1057
|
// ─── session listing (spec §12, list entry pulled forward from S6a) ─────────
|
|
1058
1058
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
try {
|
|
1065
|
-
fd = openSync(path, 'r');
|
|
1066
|
-
const buf = Buffer.alloc(budget);
|
|
1067
|
-
const n = readSync(fd, buf, 0, budget, 0);
|
|
1068
|
-
const head = buf.toString('utf-8', 0, n);
|
|
1069
|
-
const nl = head.indexOf('\n');
|
|
1070
|
-
if (nl <= 0) return null; // no complete first line inside the budget
|
|
1071
|
-
return JSON.parse(head.slice(0, nl));
|
|
1072
|
-
} catch {
|
|
1073
|
-
return null;
|
|
1074
|
-
} finally {
|
|
1075
|
-
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* Summarize every session under LOG_DIR/<project>/ for the log list (spec §12).
|
|
1081
|
-
* Deliberately cheap: journal lines only (small) + a bounded head read of the
|
|
1082
|
-
* main conversation's first epoch for the preview — conversation bodies are
|
|
1083
|
-
* never loaded. Teammate linkage is surfaced via `leader` so the caller can
|
|
1084
|
-
* fold those sessions into their leader's view instead of double-listing.
|
|
1085
|
-
* @returns {Array<{sid, dir, startTs, leader, turns, size, preview}>}
|
|
1086
|
-
*/
|
|
1087
|
-
export function listV2Sessions(projectDir) {
|
|
1088
|
-
const out = [];
|
|
1089
|
-
for (const sid of listSessionIds(projectDir)) {
|
|
1090
|
-
try {
|
|
1091
|
-
const dir = join(projectDir, 'sessions', sid);
|
|
1092
|
-
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
|
|
1093
|
-
let meta = null;
|
|
1094
|
-
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
|
|
1095
|
-
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
|
|
1096
|
-
// Reader version gate (spec §14): don't list a session this build
|
|
1097
|
-
// can't read — a garbage preview/turn-count is worse than absence.
|
|
1098
|
-
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`));
|
|
1099
|
-
continue;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// turns = main requests that completed (journal two-phase fold). The
|
|
1103
|
-
// journal sentinel is checked in the same pass: per §14 the per-file
|
|
1104
|
-
// sentinel WINS over meta.json, and readSession/adapter refuse such a
|
|
1105
|
-
// session — listing it would show a phantom row that opens empty.
|
|
1106
|
-
const reqKind = new Map();
|
|
1107
|
-
let turns = 0;
|
|
1108
|
-
let sentinelVersion = null;
|
|
1109
|
-
let hasMainOrTeammate = false;
|
|
1110
|
-
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
1111
|
-
if (line.ph === 'req') {
|
|
1112
|
-
reqKind.set(line.seq, line.kind);
|
|
1113
|
-
if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
|
|
1114
|
-
}
|
|
1115
|
-
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
|
|
1116
|
-
turns++;
|
|
1117
|
-
reqKind.delete(line.seq); // fold duplicate done lines (§14)
|
|
1118
|
-
} else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
|
|
1119
|
-
sentinelVersion = line.wireFormat;
|
|
1120
|
-
break;
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
if (sentinelVersion != null) {
|
|
1124
|
-
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`));
|
|
1125
|
-
continue;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// preview = ALL user prompts of the session, from the prompts.jsonl
|
|
1129
|
-
// display cache (written by V2Writer / the converter; bounded head read
|
|
1130
|
-
// so the list stays O(budget) per session). Sessions predating the
|
|
1131
|
-
// cache fall back to the first epoch's first line — routed through the
|
|
1132
|
-
// shared extractor so command/caveat chrome never leaks into the row.
|
|
1133
|
-
let preview = readPromptsHead(join(dir, 'prompts.jsonl'));
|
|
1134
|
-
if (preview.length === 0) {
|
|
1135
|
-
const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl'));
|
|
1136
|
-
if (first && Array.isArray(first.msgs)) {
|
|
1137
|
-
preview = collectPromptsFromEvents([first]);
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
out.push({
|
|
1142
|
-
sid,
|
|
1143
|
-
dir,
|
|
1144
|
-
startTs: (meta && meta.startTs) || '',
|
|
1145
|
-
leader: (meta && meta.leader) || null,
|
|
1146
|
-
turns,
|
|
1147
|
-
size: dirSizeSync(dir),
|
|
1148
|
-
preview,
|
|
1149
|
-
// Discardable-session verdict. KEEP IN SYNC: session-select.js
|
|
1150
|
-
// isDiscardableSession is the canonical rule; this fold pre-computes
|
|
1151
|
-
// it for free over the FULL journal (the canonical scan is 8MB-
|
|
1152
|
-
// budgeted — intentional asymmetry, a first main sits at the head).
|
|
1153
|
-
// When the fold says discard, the canonical predicate CONFIRMS it:
|
|
1154
|
-
// readJsonlTolerant swallows an I/O error (Windows EBUSY/EPERM lock)
|
|
1155
|
-
// into zero lines, which must KEEP the session, not hide it — the
|
|
1156
|
-
// canonical path carries that error→keep direction (ioErrorResult).
|
|
1157
|
-
// Main-bearing sessions never pay the extra read; probe journals are
|
|
1158
|
-
// ~3 lines.
|
|
1159
|
-
discard: !(meta && meta.leader) && !hasMainOrTeammate && isDiscardableSession(dir, meta),
|
|
1160
|
-
});
|
|
1161
|
-
} catch { /* one unreadable session must not break the list */ }
|
|
1162
|
-
}
|
|
1163
|
-
return out;
|
|
1164
|
-
}
|
|
1059
|
+
// listV2Sessions is re-exported from session-list.js (P0-A row cache, 2026-07-31).
|
|
1060
|
+
// The full per-session summarization logic (incl. the readFirstJsonLine preview
|
|
1061
|
+
// fallback) moved there; this re-export keeps the public API unchanged for all
|
|
1062
|
+
// callers (log-management.js, routes/im.js, tests).
|
|
1063
|
+
export { listV2Sessions };
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Worker } from 'node:worker_threads';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { readConvertState } from './convert.js';
|
|
13
|
+
import { _invalidate as _invalidateMigrationStatus } from './migrate-prompt.js';
|
|
13
14
|
|
|
14
15
|
let _running = null; // { project, logDir, worker, startedAt, progress }
|
|
15
16
|
let _lastError = null; // last worker-level failure (state file may lag on hard crashes)
|
|
@@ -36,7 +37,12 @@ export function startConvert(logDir, project) {
|
|
|
36
37
|
worker.on('message', (msg) => {
|
|
37
38
|
if (!msg || !_running || _running.worker !== worker) return;
|
|
38
39
|
if (msg.type === 'progress') _running.progress = msg.progress;
|
|
39
|
-
else if (msg.type === 'final'
|
|
40
|
+
else if (msg.type === 'final') {
|
|
41
|
+
if (msg.error) _lastError = msg.error;
|
|
42
|
+
// A finished conversion immediately changes what migrationStatus returns
|
|
43
|
+
// for this project — drop the memo so the next call re-scans.
|
|
44
|
+
_invalidateMigrationStatus(_running.logDir, msg.project);
|
|
45
|
+
}
|
|
40
46
|
});
|
|
41
47
|
worker.on('error', (err) => {
|
|
42
48
|
_lastError = String(err && err.message || err);
|
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
// pending unless the convert state marks it done AT ITS CURRENT SIZE (the
|
|
6
6
|
// converter's trust rule, convert.js), because the converter never deletes
|
|
7
7
|
// v1 sources ("files exist" alone is not "migration needed").
|
|
8
|
+
//
|
|
9
|
+
// P0-B (2026-07-31): 10s TTL memo per (logDir, project). migrationStatus is
|
|
10
|
+
// called on every SSE connect (events.js), every list refresh (logs.js), and
|
|
11
|
+
// every workspace boot — each call stat-scans ALL v1 files of ALL projects.
|
|
12
|
+
// The memo collapses repeat calls to a Map lookup. `now` is injectable for
|
|
13
|
+
// tests (same house style as singleflight.js). Invalidation hook: the convert
|
|
14
|
+
// manager calls _invalidate() when its worker posts {type:'final'} (bypasses
|
|
15
|
+
// the 1s progress throttle), so a finished conversion is reflected immediately.
|
|
8
16
|
import { statSync } from 'node:fs';
|
|
9
17
|
import { join } from 'node:path';
|
|
10
18
|
import { listV1Files, listConvertibleProjects, readConvertState } from './convert.js';
|
|
11
19
|
|
|
20
|
+
const TTL_MS = 10_000;
|
|
21
|
+
/** @type {Map<string, {value: object, expiresAt: number}>} */
|
|
22
|
+
const _memo = new Map();
|
|
23
|
+
let _now = Date.now;
|
|
24
|
+
|
|
12
25
|
/** Pending v1 files + bytes of ONE project dir. */
|
|
13
26
|
function pendingOf(projectDir) {
|
|
14
27
|
const state = readConvertState(projectDir);
|
|
@@ -36,6 +49,8 @@ function pendingOf(projectDir) {
|
|
|
36
49
|
/**
|
|
37
50
|
* Migration status of one project (plus how many OTHER projects also have
|
|
38
51
|
* pending v1 logs — the prompt mentions `ccv convert --all` for those).
|
|
52
|
+
* Memoized for TTL_MS per (logDir, project); see module header for the
|
|
53
|
+
* invalidation contract.
|
|
39
54
|
* @param {string} logDir - LOG_DIR root
|
|
40
55
|
* @param {string} project - project directory name ('' → not pending)
|
|
41
56
|
* @returns {{pending: boolean, files: number, totalBytes: number, otherProjects: number}}
|
|
@@ -43,6 +58,9 @@ function pendingOf(projectDir) {
|
|
|
43
58
|
export function migrationStatus(logDir, project) {
|
|
44
59
|
const empty = { pending: false, files: 0, totalBytes: 0, otherProjects: 0 };
|
|
45
60
|
if (!logDir || !project) return empty;
|
|
61
|
+
const cacheKey = `${logDir}\0${project}`;
|
|
62
|
+
const cached = _memo.get(cacheKey);
|
|
63
|
+
if (cached && _now() < cached.expiresAt) return cached.value;
|
|
46
64
|
try {
|
|
47
65
|
const { files, totalBytes } = pendingOf(join(logDir, project));
|
|
48
66
|
let otherProjects = 0;
|
|
@@ -50,8 +68,28 @@ export function migrationStatus(logDir, project) {
|
|
|
50
68
|
if (p === project) continue;
|
|
51
69
|
if (pendingOf(join(logDir, p)).files > 0) otherProjects++;
|
|
52
70
|
}
|
|
53
|
-
|
|
71
|
+
const value = { pending: files > 0, files, totalBytes, otherProjects };
|
|
72
|
+
_memo.set(cacheKey, { value, expiresAt: _now() + TTL_MS });
|
|
73
|
+
return value;
|
|
54
74
|
} catch {
|
|
55
75
|
return empty;
|
|
56
76
|
}
|
|
57
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Drop the memo for one (or all) projects — called by the convert manager
|
|
80
|
+
* when a conversion worker finishes, and by tests. */
|
|
81
|
+
export function _invalidate(logDir, project) {
|
|
82
|
+
if (logDir === undefined) { _memo.clear(); return; }
|
|
83
|
+
_memo.delete(`${logDir}\0${project}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Test hook: replace the clock (pass `() => t`); call without args to restore. */
|
|
87
|
+
export function _setNowForTest(fn) {
|
|
88
|
+
_now = fn || Date.now;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Test hook: drop all memoized entries. */
|
|
92
|
+
export function _resetForTest() {
|
|
93
|
+
_memo.clear();
|
|
94
|
+
_now = Date.now;
|
|
95
|
+
}
|