@loredunk/ccoach 0.1.0
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/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/cli.js +739 -0
- package/dist/index.js +3 -0
- package/dist/src-CKrgEaD-.js +1957 -0
- package/package.json +36 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { VERSION, buildReport, claudeProjectsDir, codexHome, comma, inLocalRange, promptFlags, repoName, resolveWindow } from "./src-CKrgEaD-.js";
|
|
3
|
+
import { cac } from "cac";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
//#region src/emit/json.ts
|
|
9
|
+
function emitJson(report) {
|
|
10
|
+
return JSON.stringify(report, null, 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/emit/text.ts
|
|
15
|
+
function truncate(s, n) {
|
|
16
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
17
|
+
}
|
|
18
|
+
function pct(part, total) {
|
|
19
|
+
return total > 0 ? part / total * 100 : 0;
|
|
20
|
+
}
|
|
21
|
+
function renderUsageBreakdown(lines, rows, total) {
|
|
22
|
+
const limit = Math.min(rows.length, 8);
|
|
23
|
+
for (const row of rows.slice(0, limit)) lines.push(` ${truncate(row.name, 16).padEnd(16)} ${row.sessions} 会话 ${pct(row.tokens, total).toFixed(1).padStart(5)}% ${comma(row.tokens)} token`);
|
|
24
|
+
if (rows.length > limit) lines.push(` …另有 ${rows.length - limit} 项`);
|
|
25
|
+
}
|
|
26
|
+
function renderHours(lines, hours, total) {
|
|
27
|
+
let max = 0;
|
|
28
|
+
for (const h of hours) if (h.tokens > max) max = h.tokens;
|
|
29
|
+
for (const h of hours) {
|
|
30
|
+
const bars = max > 0 ? Math.trunc(h.tokens / max * 20) : 0;
|
|
31
|
+
lines.push(` ${String(h.hour).padStart(2, "0")}:00 ${"█".repeat(bars).padEnd(20)} ${pct(h.tokens, total).toFixed(1).padStart(5)}% ${comma(h.tokens)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const PLATFORM_LABEL = {
|
|
35
|
+
"claude-code": "Claude Code",
|
|
36
|
+
codex: "Codex",
|
|
37
|
+
all: "全部平台"
|
|
38
|
+
};
|
|
39
|
+
const BILLING_MODE_LABEL = {
|
|
40
|
+
subscription: "订阅",
|
|
41
|
+
api_or_relay: "API/中转",
|
|
42
|
+
unknown: "未知"
|
|
43
|
+
};
|
|
44
|
+
const CONFIDENCE_LABEL = {
|
|
45
|
+
high: "高",
|
|
46
|
+
medium: "中",
|
|
47
|
+
low: "低"
|
|
48
|
+
};
|
|
49
|
+
function renderExtras(lines, r) {
|
|
50
|
+
if (r.endpoints?.length) {
|
|
51
|
+
lines.push("端点 / 计费模式(账户级当前快照,读本机 config)");
|
|
52
|
+
for (const e of r.endpoints) {
|
|
53
|
+
const host = e.official_host ? `官方(${e.official_host})` : e.endpoint === "custom" ? "自定义/中转" : "未知端点";
|
|
54
|
+
const relay = e.relay_suspected ? " ⚠️疑似中转" : "";
|
|
55
|
+
const sub = e.subscription_type ? ` · 订阅档 ${e.subscription_type}` : "";
|
|
56
|
+
const mode = BILLING_MODE_LABEL[e.billing_mode] ?? e.billing_mode;
|
|
57
|
+
lines.push(` ${(PLATFORM_LABEL[e.platform] ?? e.platform).padEnd(12)} ${host} · 计费 ${mode}(置信${CONFIDENCE_LABEL[e.confidence] ?? e.confidence})${sub}${relay}`);
|
|
58
|
+
}
|
|
59
|
+
lines.push("");
|
|
60
|
+
}
|
|
61
|
+
if (r.billing) {
|
|
62
|
+
const billingTotal = Object.values(r.billing.by_plan_tier).reduce((a, c) => a + c, 0) + r.billing.unclassified;
|
|
63
|
+
lines.push("计费拆分(Codex,按订阅 plan tier)");
|
|
64
|
+
for (const [tier, tok] of Object.entries(r.billing.by_plan_tier).sort((a, b) => b[1] - a[1])) lines.push(` ${tier.padEnd(12)} ${pct(tok, billingTotal).toFixed(1).padStart(5)}% ${comma(tok)} token`);
|
|
65
|
+
if (r.billing.unclassified) lines.push(` ${"未分类".padEnd(11)} ${pct(r.billing.unclassified, billingTotal).toFixed(1).padStart(5)}% ${comma(r.billing.unclassified)} token(有token无plan_type,≠确定API)`);
|
|
66
|
+
lines.push(` ⓘ plan_type 来自后端响应、可被中转伪造(confidence: ${r.billing.confidence})`);
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
if (r.codex_specific) {
|
|
70
|
+
const cs = r.codex_specific;
|
|
71
|
+
lines.push("Codex 执行画像");
|
|
72
|
+
const dist = (label, rec) => {
|
|
73
|
+
if (rec && Object.keys(rec).length) lines.push(` ${label}: ` + Object.entries(rec).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}(${v})`).join(" "));
|
|
74
|
+
};
|
|
75
|
+
dist("推理强度", cs.effort);
|
|
76
|
+
dist("审批策略", cs.approval_policy);
|
|
77
|
+
dist("沙箱", cs.sandbox);
|
|
78
|
+
dist("协作模式", cs.collaboration_mode);
|
|
79
|
+
dist("客户端", cs.originators);
|
|
80
|
+
const misc = [];
|
|
81
|
+
if (cs.compactions) misc.push(`上下文压缩 ${cs.compactions}`);
|
|
82
|
+
if (cs.aborted_turns) misc.push(`放弃回合 ${cs.aborted_turns}`);
|
|
83
|
+
if (cs.context_window) misc.push(`上下文窗口 ${comma(cs.context_window)}`);
|
|
84
|
+
if (cs.personality && Object.keys(cs.personality).length) misc.push("人格 " + Object.keys(cs.personality).join("/"));
|
|
85
|
+
if (cs.git_repo_identity) misc.push("git 仓库身份 ✓");
|
|
86
|
+
if (misc.length) lines.push(" " + misc.join(" · "));
|
|
87
|
+
lines.push("");
|
|
88
|
+
}
|
|
89
|
+
if (r.claude_specific) {
|
|
90
|
+
const c = r.claude_specific;
|
|
91
|
+
if (c.web_search_requests || c.web_fetch_requests) {
|
|
92
|
+
lines.push(`Claude 服务端工具: web 搜索 ${comma(c.web_search_requests)} · web 抓取 ${comma(c.web_fetch_requests)}`);
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function emitText(r, byRepo) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
const label = PLATFORM_LABEL[r.platform] ?? r.platform;
|
|
100
|
+
lines.push(`AI 用量报告 · ${label} · ${r.generated_for} · ${r.timezone}`);
|
|
101
|
+
lines.push(`仅本机数据 (来源: ${r.source}) · ${r.sessions} 个会话 · 时长 ${r.duration}`);
|
|
102
|
+
lines.push("");
|
|
103
|
+
if (r.tokens.total === 0) {
|
|
104
|
+
lines.push("(该时间窗口内没有使用记录)");
|
|
105
|
+
return lines.join("\n") + "\n";
|
|
106
|
+
}
|
|
107
|
+
lines.push("Token");
|
|
108
|
+
lines.push(` input ${comma(r.tokens.input)} · cached ${comma(r.tokens.cached_input)} · output ${comma(r.tokens.output)} · reasoning ${comma(r.tokens.reasoning_output)} · cache_creation ${comma(r.tokens.cache_creation)} · total ${comma(r.tokens.total)}`);
|
|
109
|
+
lines.push(` 缓存命中率 ${(r.cache_hit_rate * 100).toFixed(1)}% · reasoning 占 output ${(r.reasoning_ratio * 100).toFixed(1)}%`);
|
|
110
|
+
const modelNote = r.models.length ? " · 模型: " + r.models.join(", ") : "";
|
|
111
|
+
lines.push(` 估算成本 $${r.estimated_cost_usd.toFixed(2)}(估算价,仅供参考${modelNote})`);
|
|
112
|
+
if (r.unpriced_models?.length) lines.push(` 注意: 以下模型无内置价格,未计入成本: ${r.unpriced_models.join(", ")}`);
|
|
113
|
+
if (r.models_timeline && r.models_timeline.length > 1) {
|
|
114
|
+
lines.push(" 模型时间线 (首次→最后, 本机时区):");
|
|
115
|
+
for (const mt of r.models_timeline) {
|
|
116
|
+
const span = mt.last_day !== mt.first_day ? `${mt.first_day}→${mt.last_day}` : mt.first_day;
|
|
117
|
+
lines.push(` ${truncate(mt.model, 16).padEnd(16)} ${span} · ${comma(mt.tokens)} token`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push(`工具调用 (共 ${r.tools.total_calls})`);
|
|
122
|
+
lines.push(` shell ${r.tools.shell_calls} · web 搜索 ${r.tools.web_searches} · 改文件 ${r.tools.file_changes}`);
|
|
123
|
+
if (r.tools.top_commands.length) lines.push(" top 命令: " + r.tools.top_commands.map((c) => `${c.command}(${c.count})`).join(" "));
|
|
124
|
+
lines.push("");
|
|
125
|
+
if (r.sources.length) {
|
|
126
|
+
lines.push("按来源");
|
|
127
|
+
renderUsageBreakdown(lines, r.sources, r.tokens.total);
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
130
|
+
if (r.languages.length) {
|
|
131
|
+
lines.push("按语言(根据本机仓库文件估算)");
|
|
132
|
+
renderUsageBreakdown(lines, r.languages, r.tokens.total);
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
const es = r.error_signals;
|
|
136
|
+
if (es.tool_calls > 0 || es.interrupted > 0 || es.api_errors > 0) {
|
|
137
|
+
lines.push("错误 / 卡顿");
|
|
138
|
+
lines.push(` 工具失败 ${es.tool_errors}/${es.tool_calls} (${(es.error_rate * 100).toFixed(1)}%) · 中断 ${es.interrupted} · API 错误 ${es.api_errors}`);
|
|
139
|
+
if (es.by_category?.length) lines.push(" 按类别: " + es.by_category.map((c) => `${c.command}(${c.count})`).join(" "));
|
|
140
|
+
if (es.by_tool?.length) lines.push(" 按工具: " + es.by_tool.map((c) => `${c.command}(${c.count})`).join(" "));
|
|
141
|
+
lines.push("");
|
|
142
|
+
}
|
|
143
|
+
const rw = r.rework_signals;
|
|
144
|
+
if (rw.edits > 0) {
|
|
145
|
+
lines.push("返工 / 改动");
|
|
146
|
+
lines.push(` 编辑 ${rw.edits} 次 · 手改率 ${(rw.user_modified_rate * 100).toFixed(1)}% · +${rw.lines_added}/-${rw.lines_removed} 行`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
}
|
|
149
|
+
if (r.skills?.length) {
|
|
150
|
+
lines.push("Skill: " + r.skills.map((c) => `${c.command}(${c.count})`).join(" "));
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
const env = r.environment;
|
|
154
|
+
if (env) {
|
|
155
|
+
const parts = [];
|
|
156
|
+
if (env.claude_versions?.length) parts.push("版本 " + env.claude_versions.join("/"));
|
|
157
|
+
if (env.permission_modes?.length) parts.push("权限 " + env.permission_modes.map((c) => `${c.command}(${c.count})`).join(" "));
|
|
158
|
+
if (env.attachments) parts.push(`附件 ${env.attachments}`);
|
|
159
|
+
if (env.subagent_messages) parts.push(`子代理消息 ${env.subagent_messages}`);
|
|
160
|
+
if (parts.length) {
|
|
161
|
+
lines.push("环境: " + parts.join(" · "));
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
renderExtras(lines, r);
|
|
166
|
+
lines.push("习惯");
|
|
167
|
+
lines.push(` Git 命令 ${r.git_habits.command_count} 次 · 分支上下文 ${r.git_habits.branch_count} 个 · 多分支仓库 ${r.git_habits.multi_branch_repos} 个`);
|
|
168
|
+
if (r.project_management.signals?.length) lines.push(` 项目管理: ${r.project_management.signals.join(";")}`);
|
|
169
|
+
lines.push("");
|
|
170
|
+
if (r.repos.length) {
|
|
171
|
+
lines.push("按仓库");
|
|
172
|
+
let limit = r.repos.length;
|
|
173
|
+
if (!byRepo && limit > 8) limit = 8;
|
|
174
|
+
for (const rr of r.repos.slice(0, limit)) {
|
|
175
|
+
const branch = byRepo && rr.branches?.length ? ` [${rr.branches.join(",")}]` : "";
|
|
176
|
+
const detail = rr.language ? ` · ${rr.language}` : "";
|
|
177
|
+
lines.push(` ${truncate(rr.repo, 24).padEnd(24)} ${rr.sessions} 会话 ${comma(rr.tokens)} token $${rr.estimated_cost_usd.toFixed(2)}${branch}${detail}`);
|
|
178
|
+
}
|
|
179
|
+
if (!byRepo && r.repos.length > limit) lines.push(` …另有 ${r.repos.length - limit} 个仓库(用 --by-repo 查看全部)`);
|
|
180
|
+
lines.push("");
|
|
181
|
+
}
|
|
182
|
+
if (r.hours.length) {
|
|
183
|
+
lines.push("按时段 (本机时间)");
|
|
184
|
+
renderHours(lines, r.hours, r.tokens.total);
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n") + "\n";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/sessions.ts
|
|
191
|
+
function num(x) {
|
|
192
|
+
return typeof x === "number" && isFinite(x) ? x : 0;
|
|
193
|
+
}
|
|
194
|
+
const r4 = (x) => Math.round(x * 1e4) / 1e4;
|
|
195
|
+
function tzName() {
|
|
196
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
197
|
+
}
|
|
198
|
+
const HOME = homedir();
|
|
199
|
+
const REDACTORS = [
|
|
200
|
+
[/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED"],
|
|
201
|
+
[/ghp_[A-Za-z0-9]{12,}/g, "ghp_REDACTED"],
|
|
202
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/g, "xox-REDACTED"],
|
|
203
|
+
[/AKIA[0-9A-Z]{12,}/g, "AKIA-REDACTED"],
|
|
204
|
+
[/(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/gi, "$1=REDACTED"],
|
|
205
|
+
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, "<email>"],
|
|
206
|
+
[/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "<ip>"]
|
|
207
|
+
];
|
|
208
|
+
function redact(text, charLimit) {
|
|
209
|
+
let t = HOME ? text.split(HOME).join("~") : text;
|
|
210
|
+
for (const [rgx, repl] of REDACTORS) t = t.replace(rgx, repl);
|
|
211
|
+
t = t.replace(/(?:\/[\w.\-]+){3,}/g, (m) => "/…/" + (m.split("/").pop() ?? ""));
|
|
212
|
+
t = t.trim();
|
|
213
|
+
const cps = [...t];
|
|
214
|
+
if (cps.length > charLimit) t = cps.slice(0, charLimit).join("").replace(/\s+$/, "") + " …[truncated]";
|
|
215
|
+
return t;
|
|
216
|
+
}
|
|
217
|
+
function walkJsonl(dir) {
|
|
218
|
+
const out = [];
|
|
219
|
+
let entries;
|
|
220
|
+
try {
|
|
221
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
222
|
+
} catch {
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
for (const e of entries) {
|
|
226
|
+
const p = join(dir, e.name);
|
|
227
|
+
if (e.isDirectory()) out.push(...walkJsonl(p));
|
|
228
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(p);
|
|
229
|
+
}
|
|
230
|
+
return out.sort();
|
|
231
|
+
}
|
|
232
|
+
function userText(message) {
|
|
233
|
+
const content = message?.content;
|
|
234
|
+
if (typeof content === "string") return content;
|
|
235
|
+
if (!Array.isArray(content)) return "";
|
|
236
|
+
const parts = [];
|
|
237
|
+
for (const c of content) if (typeof c === "string") parts.push(c);
|
|
238
|
+
else if (c && c.type === "text" && typeof c.text === "string") parts.push(c.text);
|
|
239
|
+
return parts.join("\n");
|
|
240
|
+
}
|
|
241
|
+
function claudeSummary(s) {
|
|
242
|
+
const n = s.prompts || 1;
|
|
243
|
+
const span = s.first && s.last ? Math.round((s.last.getTime() - s.first.getTime()) / 6e4 * 10) / 10 : null;
|
|
244
|
+
return {
|
|
245
|
+
session_id: s.session_id,
|
|
246
|
+
repo: s.repo ?? "(unknown)",
|
|
247
|
+
tokens: s.tokens,
|
|
248
|
+
tool_calls: s.tool_calls,
|
|
249
|
+
prompts: s.prompts,
|
|
250
|
+
models: [...s.models].sort(),
|
|
251
|
+
first: s.first ? s.first.toISOString() : null,
|
|
252
|
+
last: s.last ? s.last.toISOString() : null,
|
|
253
|
+
span_minutes: span,
|
|
254
|
+
prompt_signals: {
|
|
255
|
+
avg_len: Math.round(s.len_sum / n * 10) / 10,
|
|
256
|
+
structured_ratio: r4(s.flags.structured / n),
|
|
257
|
+
file_ref_ratio: r4(s.flags.file_ref / n),
|
|
258
|
+
constraint_ratio: r4(s.flags.constraint / n),
|
|
259
|
+
correction_rate: r4(s.flags.correction / n)
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function listClaudeSessions(dir, window, opts) {
|
|
264
|
+
const include = opts.includePrompts === true;
|
|
265
|
+
const top = opts.top ?? 20;
|
|
266
|
+
const charLimit = opts.promptCharLimit ?? 1200;
|
|
267
|
+
const wantId = include ? opts.sessionId || "*" : null;
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
const sessions = new Map();
|
|
270
|
+
for (const file of walkJsonl(dir)) {
|
|
271
|
+
let content;
|
|
272
|
+
try {
|
|
273
|
+
content = readFileSync(file, "utf8");
|
|
274
|
+
} catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
for (const line of content.split("\n")) {
|
|
278
|
+
const t = line.trim();
|
|
279
|
+
if (!t) continue;
|
|
280
|
+
let rec;
|
|
281
|
+
try {
|
|
282
|
+
rec = JSON.parse(t);
|
|
283
|
+
} catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (rec?.isSidechain === true) continue;
|
|
287
|
+
const sid = typeof rec.sessionId === "string" ? rec.sessionId : "";
|
|
288
|
+
if (!sid) continue;
|
|
289
|
+
const tsRaw = rec?.timestamp;
|
|
290
|
+
const ts = typeof tsRaw === "string" ? new Date(tsRaw) : null;
|
|
291
|
+
const tsv = ts && !Number.isNaN(ts.getTime()) ? ts : null;
|
|
292
|
+
if (tsv && !inLocalRange(tsv, window)) continue;
|
|
293
|
+
const msgId = rec?.message?.id;
|
|
294
|
+
const dedupKey = typeof msgId === "string" && msgId !== "" ? `${msgId}:${rec?.requestId ?? ""}` : typeof rec?.uuid === "string" && rec.uuid !== "" ? rec.uuid : null;
|
|
295
|
+
if (dedupKey !== null) {
|
|
296
|
+
if (seen.has(dedupKey)) continue;
|
|
297
|
+
seen.add(dedupKey);
|
|
298
|
+
}
|
|
299
|
+
let s = sessions.get(sid);
|
|
300
|
+
if (!s) {
|
|
301
|
+
s = {
|
|
302
|
+
session_id: sid,
|
|
303
|
+
repo: null,
|
|
304
|
+
tokens: 0,
|
|
305
|
+
tool_calls: 0,
|
|
306
|
+
prompts: 0,
|
|
307
|
+
first: null,
|
|
308
|
+
last: null,
|
|
309
|
+
models: new Set(),
|
|
310
|
+
flags: {
|
|
311
|
+
structured: 0,
|
|
312
|
+
file_ref: 0,
|
|
313
|
+
constraint: 0,
|
|
314
|
+
correction: 0
|
|
315
|
+
},
|
|
316
|
+
len_sum: 0,
|
|
317
|
+
texts: []
|
|
318
|
+
};
|
|
319
|
+
sessions.set(sid, s);
|
|
320
|
+
}
|
|
321
|
+
const cwd = typeof rec.cwd === "string" ? rec.cwd : "";
|
|
322
|
+
if (s.repo === null && cwd) s.repo = repoName(cwd);
|
|
323
|
+
if (tsv) {
|
|
324
|
+
if (!s.first || tsv < s.first) s.first = tsv;
|
|
325
|
+
if (!s.last || tsv > s.last) s.last = tsv;
|
|
326
|
+
}
|
|
327
|
+
if (rec.type === "user") {
|
|
328
|
+
const text = userText(rec.message).trim();
|
|
329
|
+
if (!text) continue;
|
|
330
|
+
s.prompts += 1;
|
|
331
|
+
const fl = promptFlags(text);
|
|
332
|
+
s.len_sum += fl.len;
|
|
333
|
+
if (fl.structured) s.flags.structured += 1;
|
|
334
|
+
if (fl.file_ref) s.flags.file_ref += 1;
|
|
335
|
+
if (fl.constraint) s.flags.constraint += 1;
|
|
336
|
+
if (fl.correction) s.flags.correction += 1;
|
|
337
|
+
if (wantId && (wantId === "*" || sid === wantId)) s.texts.push({
|
|
338
|
+
ts: tsv,
|
|
339
|
+
text,
|
|
340
|
+
fl
|
|
341
|
+
});
|
|
342
|
+
} else if (rec.type === "assistant") {
|
|
343
|
+
const u = rec.message?.usage ?? {};
|
|
344
|
+
s.tokens += num(u.input_tokens) + num(u.output_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens);
|
|
345
|
+
const model = rec.message?.model;
|
|
346
|
+
if (typeof model === "string" && model) s.models.add(model);
|
|
347
|
+
for (const c of Array.isArray(rec.message?.content) ? rec.message.content : []) if (c && c.type === "tool_use") s.tool_calls += 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
let rows = [...sessions.values()];
|
|
352
|
+
if (opts.sessionId) rows = rows.filter((s) => s.session_id.includes(opts.sessionId));
|
|
353
|
+
if (opts.repo) {
|
|
354
|
+
const rl = opts.repo.toLowerCase();
|
|
355
|
+
rows = rows.filter((s) => (s.repo ?? "").toLowerCase().includes(rl));
|
|
356
|
+
}
|
|
357
|
+
rows.sort((a, b) => b.tokens - a.tokens || b.tool_calls - a.tool_calls);
|
|
358
|
+
if (include) rows = rows.slice(0, 1);
|
|
359
|
+
else if (top > 0) rows = rows.slice(0, top);
|
|
360
|
+
const out = {
|
|
361
|
+
platform: "claude-code",
|
|
362
|
+
generated_for: window.desc,
|
|
363
|
+
timezone: tzName(),
|
|
364
|
+
source: "~/.claude/projects/**/*.jsonl (本地解析)",
|
|
365
|
+
includes_user_prompts: include,
|
|
366
|
+
prompt_scope: include ? "one selected session" : "none",
|
|
367
|
+
sessions: rows.map(claudeSummary)
|
|
368
|
+
};
|
|
369
|
+
if (include && rows.length) {
|
|
370
|
+
const s = rows[0];
|
|
371
|
+
const prompts = [...s.texts].sort((a, b) => (a.ts?.getTime() ?? -Infinity) - (b.ts?.getTime() ?? -Infinity)).map((p, i) => ({
|
|
372
|
+
idx: i,
|
|
373
|
+
timestamp: p.ts ? p.ts.toISOString() : null,
|
|
374
|
+
signals: {
|
|
375
|
+
len: p.fl.len,
|
|
376
|
+
structured: p.fl.structured,
|
|
377
|
+
file_ref: p.fl.file_ref,
|
|
378
|
+
constraint: p.fl.constraint,
|
|
379
|
+
correction: p.fl.correction
|
|
380
|
+
},
|
|
381
|
+
preview: redact(p.text, charLimit)
|
|
382
|
+
}));
|
|
383
|
+
out.selected_session = {
|
|
384
|
+
...claudeSummary(s),
|
|
385
|
+
prompts
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
function globRollouts(home) {
|
|
391
|
+
const root = join(home, "sessions");
|
|
392
|
+
const out = [];
|
|
393
|
+
const walk = (dir) => {
|
|
394
|
+
let entries;
|
|
395
|
+
try {
|
|
396
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
397
|
+
} catch {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
for (const e of entries) {
|
|
401
|
+
const p = join(dir, e.name);
|
|
402
|
+
if (e.isDirectory()) walk(p);
|
|
403
|
+
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(p);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
walk(root);
|
|
407
|
+
return out.sort();
|
|
408
|
+
}
|
|
409
|
+
function sourceKey(source) {
|
|
410
|
+
const s = (source ?? "").trim().toLowerCase();
|
|
411
|
+
if (s === "") return "(unknown)";
|
|
412
|
+
if (s.includes("vscode") || s.includes("ide")) return "plugin";
|
|
413
|
+
if (s.includes("codex-app") || s.includes("desktop") || s === "app") return "codex-app";
|
|
414
|
+
if (s.includes("cli") || s.includes("terminal")) return "cli";
|
|
415
|
+
return s;
|
|
416
|
+
}
|
|
417
|
+
function repoKey(origin, cwd) {
|
|
418
|
+
const o = (origin ?? "").replace(/\/+$/, "").replace(/\.git$/, "");
|
|
419
|
+
if (o) {
|
|
420
|
+
const parts = o.split(/[/ :]/);
|
|
421
|
+
return parts[parts.length - 1] || "(unknown)";
|
|
422
|
+
}
|
|
423
|
+
return cwd ? repoName(cwd) : "(unknown)";
|
|
424
|
+
}
|
|
425
|
+
function commandFromArgs(raw) {
|
|
426
|
+
let a;
|
|
427
|
+
try {
|
|
428
|
+
a = JSON.parse(raw);
|
|
429
|
+
} catch {
|
|
430
|
+
return "";
|
|
431
|
+
}
|
|
432
|
+
if (a && typeof a === "object") {
|
|
433
|
+
if (typeof a.cmd === "string") return a.cmd.split(/\s+/).filter(Boolean).join(" ");
|
|
434
|
+
if (Array.isArray(a.command)) return a.command.map((x) => String(x)).join(" ");
|
|
435
|
+
}
|
|
436
|
+
return "";
|
|
437
|
+
}
|
|
438
|
+
function collectText(value) {
|
|
439
|
+
if (typeof value === "string") return value.split(/\s+/).filter(Boolean).join(" ");
|
|
440
|
+
if (Array.isArray(value)) return value.map(collectText).filter(Boolean).join(" ");
|
|
441
|
+
if (value && typeof value === "object") {
|
|
442
|
+
const parts = [];
|
|
443
|
+
for (const key of [
|
|
444
|
+
"text",
|
|
445
|
+
"input_text",
|
|
446
|
+
"content",
|
|
447
|
+
"message"
|
|
448
|
+
]) if (key in value) parts.push(collectText(value[key]));
|
|
449
|
+
return parts.filter(Boolean).join(" ");
|
|
450
|
+
}
|
|
451
|
+
return "";
|
|
452
|
+
}
|
|
453
|
+
function extractUserPrompt(lineType, payload) {
|
|
454
|
+
let role = payload?.role;
|
|
455
|
+
if (payload && typeof payload.author === "object" && payload.author) role = role || payload.author.role;
|
|
456
|
+
if (role === "user") return collectText(payload.content ?? payload.text ?? payload.message ?? payload);
|
|
457
|
+
if (lineType === "user_message" || lineType === "user_prompt" || lineType === "input_message") return collectText(payload.content ?? payload.text ?? payload.message ?? payload);
|
|
458
|
+
if (lineType === "response_item" && payload?.type === "message" && payload?.role === "user") return collectText(payload.content ?? payload);
|
|
459
|
+
return "";
|
|
460
|
+
}
|
|
461
|
+
function isSubagentRollout(lines) {
|
|
462
|
+
for (const line of lines) {
|
|
463
|
+
const t = line.trim();
|
|
464
|
+
if (!t) continue;
|
|
465
|
+
let rec;
|
|
466
|
+
try {
|
|
467
|
+
rec = JSON.parse(t);
|
|
468
|
+
} catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (rec?.type === "session_meta") {
|
|
472
|
+
const probe = { ...rec.payload ?? {} };
|
|
473
|
+
delete probe.cwd;
|
|
474
|
+
const s = JSON.stringify(probe);
|
|
475
|
+
return s.includes("subagent") || s.includes("thread_spawn");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
function tokUsage(raw) {
|
|
481
|
+
return {
|
|
482
|
+
input: num(raw?.input_tokens),
|
|
483
|
+
cached_input: num(raw?.cached_input_tokens),
|
|
484
|
+
output: num(raw?.output_tokens),
|
|
485
|
+
reasoning_output: num(raw?.reasoning_output_tokens),
|
|
486
|
+
total: num(raw?.total_tokens)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function parseRolloutSession(file, window, include, charLimit) {
|
|
490
|
+
let content;
|
|
491
|
+
try {
|
|
492
|
+
content = readFileSync(file, "utf8");
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const lines = content.split("\n");
|
|
497
|
+
if (isSubagentRollout(lines)) return null;
|
|
498
|
+
let sessionId = "";
|
|
499
|
+
let cwd = "";
|
|
500
|
+
let source = "";
|
|
501
|
+
let branch = "";
|
|
502
|
+
let model = "";
|
|
503
|
+
let origin = "";
|
|
504
|
+
const tokens = {
|
|
505
|
+
input: 0,
|
|
506
|
+
cached_input: 0,
|
|
507
|
+
output: 0,
|
|
508
|
+
reasoning_output: 0,
|
|
509
|
+
total: 0
|
|
510
|
+
};
|
|
511
|
+
const tools = {
|
|
512
|
+
shell_calls: 0,
|
|
513
|
+
web_searches: 0,
|
|
514
|
+
file_changes: 0,
|
|
515
|
+
total_calls: 0
|
|
516
|
+
};
|
|
517
|
+
const commands = new Map();
|
|
518
|
+
const prompts = [];
|
|
519
|
+
let first = null;
|
|
520
|
+
let last = null;
|
|
521
|
+
let baseline = null;
|
|
522
|
+
const span = (ts) => {
|
|
523
|
+
if (!ts) return;
|
|
524
|
+
if (!first || ts < first) first = ts;
|
|
525
|
+
if (!last || ts > last) last = ts;
|
|
526
|
+
};
|
|
527
|
+
for (const raw of lines) {
|
|
528
|
+
const tr = raw.trim();
|
|
529
|
+
if (!tr) continue;
|
|
530
|
+
let rec;
|
|
531
|
+
try {
|
|
532
|
+
rec = JSON.parse(tr);
|
|
533
|
+
} catch {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const tsRaw = rec?.timestamp;
|
|
537
|
+
const ts = typeof tsRaw === "string" ? new Date(tsRaw) : null;
|
|
538
|
+
const tsv = ts && !Number.isNaN(ts.getTime()) ? ts : null;
|
|
539
|
+
const inWin = tsv ? inLocalRange(tsv, window) : false;
|
|
540
|
+
const payload = rec?.payload ?? {};
|
|
541
|
+
const lineType = rec?.type ?? "";
|
|
542
|
+
if (lineType === "session_meta") {
|
|
543
|
+
sessionId = sessionId || (typeof payload.id === "string" ? payload.id : "");
|
|
544
|
+
cwd = cwd || (typeof payload.cwd === "string" ? payload.cwd : "");
|
|
545
|
+
source = source || (typeof payload.source === "string" ? payload.source : "");
|
|
546
|
+
origin = origin || (typeof payload.git_origin_url === "string" ? payload.git_origin_url : "");
|
|
547
|
+
branch = branch || (typeof payload.git_branch === "string" ? payload.git_branch : "");
|
|
548
|
+
} else if (lineType === "turn_context") {
|
|
549
|
+
if (typeof payload.model === "string" && payload.model) model = payload.model;
|
|
550
|
+
}
|
|
551
|
+
if (lineType === "event_msg") {
|
|
552
|
+
const et = payload.type;
|
|
553
|
+
if (et === "token_count" && payload.info) {
|
|
554
|
+
const cur = tokUsage(payload.info.total_token_usage ?? {});
|
|
555
|
+
if (baseline === null) {
|
|
556
|
+
baseline = cur;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const delta = {
|
|
560
|
+
input: cur.input - baseline.input,
|
|
561
|
+
cached_input: cur.cached_input - baseline.cached_input,
|
|
562
|
+
output: cur.output - baseline.output,
|
|
563
|
+
reasoning_output: cur.reasoning_output - baseline.reasoning_output,
|
|
564
|
+
total: cur.total - baseline.total
|
|
565
|
+
};
|
|
566
|
+
baseline = cur;
|
|
567
|
+
if (delta.total < 0 || !inWin) continue;
|
|
568
|
+
tokens.input += delta.input;
|
|
569
|
+
tokens.cached_input += delta.cached_input;
|
|
570
|
+
tokens.output += delta.output;
|
|
571
|
+
tokens.reasoning_output += delta.reasoning_output;
|
|
572
|
+
tokens.total += delta.total;
|
|
573
|
+
span(tsv);
|
|
574
|
+
} else if (et === "patch_apply_end" && inWin) {
|
|
575
|
+
tools.file_changes += Object.keys(payload.changes ?? {}).length;
|
|
576
|
+
span(tsv);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (lineType === "response_item" && inWin) {
|
|
580
|
+
const it = payload.type;
|
|
581
|
+
if (it === "function_call" || it === "local_shell_call" || it === "custom_tool_call" || it === "web_search_call") {
|
|
582
|
+
tools.total_calls += 1;
|
|
583
|
+
span(tsv);
|
|
584
|
+
}
|
|
585
|
+
if (it === "function_call") {
|
|
586
|
+
const name = payload.name;
|
|
587
|
+
if (name === "exec_command" || name === "local_shell_call" || name === "shell") {
|
|
588
|
+
tools.shell_calls += 1;
|
|
589
|
+
const cmd = commandFromArgs(typeof payload.arguments === "string" ? payload.arguments : "");
|
|
590
|
+
if (cmd) {
|
|
591
|
+
const head = cmd.split(/\s+/)[0];
|
|
592
|
+
commands.set(head, (commands.get(head) ?? 0) + 1);
|
|
593
|
+
}
|
|
594
|
+
} else if (name === "web_search_call") tools.web_searches += 1;
|
|
595
|
+
} else if (it === "web_search_call") tools.web_searches += 1;
|
|
596
|
+
}
|
|
597
|
+
if (include && inWin) {
|
|
598
|
+
const text = extractUserPrompt(lineType, payload);
|
|
599
|
+
if (text) prompts.push({
|
|
600
|
+
timestamp: tsv ? tsv.toISOString() : null,
|
|
601
|
+
text: redact(text, charLimit)
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (tokens.total === 0 && tools.total_calls === 0 && prompts.length === 0) return null;
|
|
606
|
+
const topCommands = [...commands.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([command, count]) => ({
|
|
607
|
+
command,
|
|
608
|
+
count
|
|
609
|
+
}));
|
|
610
|
+
const f = first;
|
|
611
|
+
const l = last;
|
|
612
|
+
const result = {
|
|
613
|
+
session_id: sessionId,
|
|
614
|
+
repo: repoKey(origin, cwd),
|
|
615
|
+
source: sourceKey(source),
|
|
616
|
+
branch,
|
|
617
|
+
model,
|
|
618
|
+
rollout_path: file,
|
|
619
|
+
first_seen: f ? f.toISOString() : "",
|
|
620
|
+
last_seen: l ? l.toISOString() : "",
|
|
621
|
+
duration_seconds: f && l ? Math.floor((l.getTime() - f.getTime()) / 1e3) : 0,
|
|
622
|
+
tokens,
|
|
623
|
+
tools,
|
|
624
|
+
top_commands: topCommands,
|
|
625
|
+
prompt_count: prompts.length
|
|
626
|
+
};
|
|
627
|
+
if (include) result.user_prompts = prompts;
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
function listCodexSessions(home, window, opts) {
|
|
631
|
+
const include = opts.includePrompts === true;
|
|
632
|
+
const top = opts.top ?? 20;
|
|
633
|
+
const charLimit = opts.promptCharLimit ?? 1200;
|
|
634
|
+
const files = opts.rollout ? [opts.rollout] : globRollouts(home);
|
|
635
|
+
let sessions = [];
|
|
636
|
+
for (const file of files) {
|
|
637
|
+
const parsed = parseRolloutSession(file, window, include, charLimit);
|
|
638
|
+
if (!parsed) continue;
|
|
639
|
+
if (opts.sessionId && !String(parsed.session_id ?? "").includes(opts.sessionId)) continue;
|
|
640
|
+
if (opts.repo) {
|
|
641
|
+
const hay = `${parsed.repo ?? ""}`.toLowerCase();
|
|
642
|
+
if (!hay.includes(opts.repo.toLowerCase())) continue;
|
|
643
|
+
}
|
|
644
|
+
sessions.push(parsed);
|
|
645
|
+
}
|
|
646
|
+
sessions.sort((a, b) => {
|
|
647
|
+
const ta = a.tokens.total, tb = b.tokens.total;
|
|
648
|
+
if (tb !== ta) return tb - ta;
|
|
649
|
+
return b.tools.total_calls - a.tools.total_calls;
|
|
650
|
+
});
|
|
651
|
+
if (top > 0) sessions = sessions.slice(0, top);
|
|
652
|
+
return {
|
|
653
|
+
platform: "codex",
|
|
654
|
+
generated_for: window.desc,
|
|
655
|
+
codex_home: home,
|
|
656
|
+
privacy: {
|
|
657
|
+
includes_user_prompts: include,
|
|
658
|
+
includes_system_prompts: false,
|
|
659
|
+
prompt_scope: include ? "one selected session" : "none"
|
|
660
|
+
},
|
|
661
|
+
sessions
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/cli.ts
|
|
667
|
+
const PLATFORMS = [
|
|
668
|
+
"claude-code",
|
|
669
|
+
"codex",
|
|
670
|
+
"all"
|
|
671
|
+
];
|
|
672
|
+
const SCOPES = [
|
|
673
|
+
"global",
|
|
674
|
+
"project",
|
|
675
|
+
"session"
|
|
676
|
+
];
|
|
677
|
+
const cli = cac("ccoach");
|
|
678
|
+
cli.command("[...filter]", "本机 AI 用量教练:只读分析 Claude Code / Codex 用量与习惯").option("--date <date>", "单日窗口 (YYYY-MM-DD)").option("--since <date>", "从某日至今 (YYYY-MM-DD)").option("--days <n>", "最近 N 天(含今天)").option("--by-repo", "展开全部仓库(默认仅前 8)").option("--platform <platform>", "数据源:claude-code | codex | all", { default: "all" }).option("--scope <scope>", "分析层级:global | project | session(额外给 projects[]/sessions_detail[])", { default: "global" }).option("--json", "输出机器可读 JSON(agent 友好)").option("--no-glossary", "省略 glossary 自描述块(省 ~2KB token)").action((_filter, options) => {
|
|
679
|
+
try {
|
|
680
|
+
const platform = String(options.platform ?? "all");
|
|
681
|
+
if (!PLATFORMS.includes(platform)) throw new Error(`invalid --platform ${platform} (want claude-code|codex|all)`);
|
|
682
|
+
const scope = String(options.scope ?? "global");
|
|
683
|
+
if (!SCOPES.includes(scope)) throw new Error(`invalid --scope ${scope} (want global|project|session)`);
|
|
684
|
+
const daysRaw = options.days;
|
|
685
|
+
const days = daysRaw != null ? Number(daysRaw) : void 0;
|
|
686
|
+
if (days !== void 0 && !Number.isFinite(days)) throw new Error(`invalid --days ${String(daysRaw)}`);
|
|
687
|
+
const window = resolveWindow({
|
|
688
|
+
date: options.date,
|
|
689
|
+
since: options.since,
|
|
690
|
+
days
|
|
691
|
+
}, new Date());
|
|
692
|
+
const report = buildReport({
|
|
693
|
+
platform,
|
|
694
|
+
window,
|
|
695
|
+
scope
|
|
696
|
+
});
|
|
697
|
+
if (options.glossary === false) delete report.glossary;
|
|
698
|
+
const out = options.json ? emitJson(report) + "\n" : emitText(report, Boolean(options.byRepo));
|
|
699
|
+
process.stdout.write(out);
|
|
700
|
+
} catch (e) {
|
|
701
|
+
process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n");
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
cli.command("sessions", "会话候选清单 + opt-in 单会话 redacted prompt 预览(content-layer review)").option("--platform <platform>", "数据源:claude-code | codex", { default: "claude-code" }).option("--date <date>", "单日窗口 (YYYY-MM-DD)").option("--since <date>", "从某日至今 (YYYY-MM-DD)").option("--days <n>", "最近 N 天(含今天)").option("--repo <substr>", "按 repo 名/路径子串过滤").option("--id <sessionId>", "钻取的会话 id(子串匹配)").option("--rollout <path>", "Codex:指定 rollout JSONL 路径").option("--top <n>", "候选清单条数", { default: 20 }).option("--include-user-prompts", "opt-in:产出单会话脱敏 prompt 预览(隐私门控)").option("--prompt-char-limit <n>", "prompt 预览截断长度(码点)", { default: 1200 }).action((options) => {
|
|
706
|
+
try {
|
|
707
|
+
const platform = String(options.platform ?? "claude-code");
|
|
708
|
+
if (platform !== "claude-code" && platform !== "codex") throw new Error(`invalid --platform ${platform} (want claude-code|codex)`);
|
|
709
|
+
const daysRaw = options.days;
|
|
710
|
+
const days = daysRaw != null ? Number(daysRaw) : void 0;
|
|
711
|
+
if (days !== void 0 && !Number.isFinite(days)) throw new Error(`invalid --days ${String(daysRaw)}`);
|
|
712
|
+
const window = resolveWindow({
|
|
713
|
+
date: options.date,
|
|
714
|
+
since: options.since,
|
|
715
|
+
days
|
|
716
|
+
}, new Date());
|
|
717
|
+
const includePrompts = options.includeUserPrompts === true;
|
|
718
|
+
if (platform === "codex" && includePrompts && !options.id && !options.rollout) throw new Error("codex --include-user-prompts requires --id or --rollout");
|
|
719
|
+
const topRaw = options.top;
|
|
720
|
+
const opts = {
|
|
721
|
+
repo: options.repo,
|
|
722
|
+
sessionId: options.id,
|
|
723
|
+
rollout: options.rollout,
|
|
724
|
+
top: topRaw != null ? Number(topRaw) : 20,
|
|
725
|
+
includePrompts,
|
|
726
|
+
promptCharLimit: options.promptCharLimit != null ? Number(options.promptCharLimit) : 1200
|
|
727
|
+
};
|
|
728
|
+
const out = platform === "codex" ? listCodexSessions(codexHome(), window, opts) : listClaudeSessions(claudeProjectsDir(), window, opts);
|
|
729
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
730
|
+
} catch (e) {
|
|
731
|
+
process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n");
|
|
732
|
+
process.exit(1);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
cli.help();
|
|
736
|
+
cli.version(VERSION);
|
|
737
|
+
cli.parse();
|
|
738
|
+
|
|
739
|
+
//#endregion
|