import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; /** 一条用量记录 = 一次带 usage 的模型响应 */ export interface UsageRecord { /** 条目时间戳(ms),决定日期归属:跨天会话会被拆到不同天 */ ts: number; /** 项目 = 会话的 cwd 绝对路径 */ project: string; provider: string; model: string; sessionId: string; sessionFile: string; /** 会话首条用户消息摘要,用于在会话层辨认「这是哪次对话」 */ sessionTitle: string; input: number; output: number; cacheRead: number; cacheWrite: number; } /** 会话目录名反解出的路径不可靠:原路径中的 `-` 与被转义的 `/` 无法区分,仅在缺 session header 时兜底 */ function projectFromDirName(dirName: string): string { return `/${dirName.replace(/^--/, "").replace(/--$/, "")}`; } function num(v: unknown): number { return typeof v === "number" && Number.isFinite(v) ? v : 0; } /** * 解析单个会话文件的内容。纯函数,不碰文件系统,便于测试。 * @param dirName 会话所在目录名,仅当文件缺少 session header 时用于兜底推断项目 */ export function parseSessionText(text: string, dirName: string, sessionFile: string): UsageRecord[] { const records: UsageRecord[] = []; let project = ""; let sessionId = ""; let title = ""; for (const line of text.split("\n")) { if (!line.trim()) continue; // 会话文件混有各扩展写入的自定义条目,坏行是已知边界而非异常,跳过 let entry: any; try { entry = JSON.parse(line); } catch { continue; } if (entry?.type === "session") { if (typeof entry.cwd === "string") project = entry.cwd; if (typeof entry.id === "string") sessionId = entry.id; continue; } if (!title && entry?.message?.role === "user") { const blocks = entry.message.content; const text = Array.isArray(blocks) ? blocks.find((b: any) => b?.type === "text")?.text : typeof blocks === "string" ? blocks : ""; title = String(text ?? "") .replace(/\s+/g, " ") .trim() .slice(0, 60); } const usage = entry?.message?.usage; if (!usage) continue; const ts = Date.parse(entry.timestamp ?? ""); records.push({ ts: Number.isNaN(ts) ? 0 : ts, project: project || projectFromDirName(dirName), provider: entry.message.provider ?? "unknown", // 路由型 provider 的实际模型与请求模型不同,以实际响应的为准 model: entry.message.responseModel ?? entry.message.model ?? "unknown", sessionId, sessionFile, sessionTitle: "", input: num(usage.input), output: num(usage.output), cacheRead: num(usage.cacheRead), cacheWrite: num(usage.cacheWrite), }); } // 回填:sessionId 与 title 可能在部分 usage 条目之后才被读到 for (const r of records) { r.sessionId = sessionId; r.sessionTitle = title; } return records; } /** 扫描本机全部会话文件。目录读不到直接抛,由命令入口边界处理 */ export function scanUsage(sessionsDir = join(getAgentDir(), "sessions")): UsageRecord[] { const records: UsageRecord[] = []; for (const dir of readdirSync(sessionsDir, { withFileTypes: true })) { if (!dir.isDirectory()) continue; const dirPath = join(sessionsDir, dir.name); for (const file of readdirSync(dirPath)) { if (!file.endsWith(".jsonl")) continue; const filePath = join(dirPath, file); records.push(...parseSessionText(readFileSync(filePath, "utf8"), dir.name, filePath)); } } return records; }