/** * UsageTracker — 工具使用统计核心 * * agent_end 时从 session jsonl 增量扫描,只处理当前 turn 新增的行。 * 用 cursor(byte offset)记录每个 session 处理到哪了,避免重复累加。 */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { withFileLock } from "@pi-atelier/shared-utils/file-lock"; import { parseNewEntries } from "./session-parser"; import type { UsageStatsData, } from "./types"; /** 空统计数据 */ function emptyData(): UsageStatsData { return { version: 2, cursors: {}, daily: {}, tools: {} }; } /** 从 version=1 数据迁移 */ function migrateV1(raw: any): UsageStatsData { return { version: 2, cursors: {}, daily: raw.daily ?? {}, tools: raw.tools ?? {}, }; } /** 获取今天的 YYYY-MM-DD */ function today(): string { return new Date().toISOString().slice(0, 10); } /** 获取 N 天前的 YYYY-MM-DD */ function daysAgo(n: number): string { return new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); } export class UsageTracker { private filePath: string; private data: UsageStatsData; constructor(filePath: string) { this.filePath = filePath; this.data = this.load(); } /** * 从 session jsonl 文件增量扫描 toolCall / toolResult, * 只处理 cursor 之后的新行,更新 daily 统计。 */ processSessionFile(sessionId: string, jsonlPath: string): void { const cursor = this.data.cursors[sessionId] ?? 0; const result = parseNewEntries(jsonlPath, cursor); if (!result) return; const day = today(); for (const [toolName, delta] of Object.entries(result.stats)) { this.ensureDay(day); this.ensureToolDaily(day, toolName); this.data.daily[day][toolName].ai += delta.ai; this.data.daily[day][toolName].errors += delta.errors; if (delta.ai > 0) this.updateMeta(toolName); } this.data.cursors[sessionId] = result.newOffset; } /** 记录一次用户命令调用 */ recordUserCall(toolName: string): void { const day = today(); this.ensureDay(day); this.ensureToolDaily(day, toolName); this.data.daily[day][toolName].user += 1; this.updateMeta(toolName); } /** 查询已迁移到 query.ts 的 buildReport(tracker.getStats(), options) */ /** 持久化到磁盘 */ flush(): void { try { const dir = dirname(this.filePath); mkdirSync(dir, { recursive: true }); withFileLock(this.filePath, () => { writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), "utf-8"); }); } catch { // 持久化失败不应阻塞主流程 } } /** 清理超过 maxDays 的 daily 数据和过期 cursor */ cleanup(maxDays: number): void { const cutoff = daysAgo(maxDays); for (const day of Object.keys(this.data.daily)) { if (day < cutoff) { delete this.data.daily[day]; } } // 清理超过 30 天的 cursor(session 已无参考价值) const cursorCutoff = Object.keys(this.data.cursors).length; if (cursorCutoff > 100) { // 只保留最近 50 个 session 的 cursor const keys = Object.keys(this.data.cursors); for (const key of keys.slice(0, keys.length - 50)) { delete this.data.cursors[key]; } } } /** 获取内部数据引用(测试用) */ getStats(): UsageStatsData { return this.data; } /** 设置内部数据(测试用) */ _setData(data: UsageStatsData): void { this.data = data; } // ---- private ---- private ensureDay(day: string): void { if (!this.data.daily[day]) { this.data.daily[day] = {}; } } private ensureToolDaily(day: string, toolName: string): void { if (!this.data.daily[day][toolName]) { this.data.daily[day][toolName] = { ai: 0, user: 0, errors: 0 }; } } private updateMeta(toolName: string): void { const now = new Date().toISOString(); if (!this.data.tools[toolName]) { this.data.tools[toolName] = { firstUsed: now, lastUsed: now, totalAi: 0, totalUser: 0, }; } else { this.data.tools[toolName].lastUsed = now; } // 从 daily 重新计算 total let totalAi = 0; let totalUser = 0; for (const dayData of Object.values(this.data.daily)) { const stats = dayData[toolName]; if (stats) { totalAi += stats.ai; totalUser += stats.user; } } this.data.tools[toolName].totalAi = totalAi; this.data.tools[toolName].totalUser = totalUser; } private load(): UsageStatsData { try { const raw = readFileSync(this.filePath, "utf-8"); const data = JSON.parse(raw); if (data) { if (data.version === 2 && data.cursors && data.daily && data.tools) { return data as UsageStatsData; } if (data.version === 1) { return migrateV1(data); } } } catch { // 文件不存在或损坏 → 空 } return emptyData(); } }