diff --git a/docs/designs/2025-12-31-recap-command-implementation.md b/docs/designs/2025-12-31-recap-command-implementation.md new file mode 100644 index 000000000..8cc9a0275 --- /dev/null +++ b/docs/designs/2025-12-31-recap-command-implementation.md @@ -0,0 +1,114 @@ +# Recap Command Implementation + +**Date:** 2025-12-31 + +## Context + +用户希望能够长期查看使用 Neovate 进行开发的统计数据,包括代码行数变更、工具使用情况等。这是一个"年度回顾"性质的功能,用于了解自己通过 AI 辅助开发的产出量。 + +通过分析现有日志系统,发现 `~/.neovate/projects/*/` 下的 jsonl 文件已经记录了完整的 `write` 和 `edit` 工具调用数据,包含文件内容,具备统计代码行数变更的数据基础。 + +## Discussion + +### 触发方式选择 + +讨论了四种触发方式: +1. `/stats` slash command - 手动执行查看 +2. 每次会话结束时自动显示 +3. 状态栏常驻显示 +4. 多种方式组合 + +**决定**:采用手动命令方式,因为这不是核心流程功能。 + +### 实现方案对比 + +| 方案 | 优点 | 缺点 | +|-----|------|------| +| A: Slash Command | 最小改动,与现有架构一致 | 每次需扫描所有日志 | +| B: Plugin + 增量统计 | 查询速度快 | 需维护额外状态文件 | +| C: 独立 CLI 子命令 | 可在 shell 中使用,与其他命令风格一致 | 无法在会话中实时查看 | + +**决定**:采用方案 C,因为这是偏产品层的"玩具性"功能,独立子命令更合适。 + +### 命名选择 + +原始命名 `stats` 可能与未来功能冲突,讨论了以下备选: +- `neovate recap` - 年度回顾/报告 +- `neovate codestats` - 代码统计 +- `neovate insights` - 使用分析 +- `neovate usage` - 使用情况 + +**决定**:采用 `recap`,有"年度回顾"的语义。 + +## Approach + +实现 `neovate recap` 独立子命令,从现有日志文件中解析统计数据,支持按项目和年份过滤。 + +### 命令接口 + +```bash +neovate recap # 全局统计(所有项目) +neovate recap --project # 当前目录项目 +neovate recap --year 2025 # 按年过滤 +neovate recap --json # JSON 输出(方便脚本使用) +``` + +### 统计指标 + +1. **代码行数变更**:新增/删除/净变化 +2. **工具调用次数**:各工具使用频率 +3. **Token 使用量**:prompt/completion tokens +4. **按文件类型分布**:按扩展名分类统计 + +## Architecture + +### 文件结构 + +``` +src/commands/recap.ts # 主逻辑 +src/index.ts # 注册命令 +``` + +### 数据解析逻辑 + +**行数计算规则**: + +| 工具 | 计算方式 | +|-----|---------| +| `write` | content 的行数(新增文件) | +| `edit` | new_string 行数 - old_string 行数 | + +**Token 统计**: +从消息的 `usage` 字段累加 `input_tokens` / `output_tokens` + +**数据源**: +扫描 `~/.neovate/projects/*/*.jsonl`,按文件 mtime 过滤年份 + +### 输出示例 + +``` +📊 Neovate Code Recap (2025) + +──────────────────────────────────────── + Projects: 19 + Sessions: 48 + Messages: 2.9K +──────────────────────────────────────── + +Code Changes: + Lines added: +27.6K + Lines deleted: -309 + Net change: +27.3K + +Tool Usage: + read: 426 bash: 284 write: 241 edit: 240 + +By File Type: + .ts 7.7K lines (28%) + .md 7.6K lines (27%) + .vue 4.1K lines (15%) + +Token Usage: + Prompt: 76.0M tokens + Completion: 727.7K tokens +``` diff --git a/src/commands/recap.ts b/src/commands/recap.ts new file mode 100644 index 000000000..cef948c50 --- /dev/null +++ b/src/commands/recap.ts @@ -0,0 +1,255 @@ +import chalk from 'chalk'; +import fs from 'fs'; +import os from 'os'; +import path from 'pathe'; +import type { Context } from '../context'; + +interface RecapStats { + projects: number; + sessions: number; + messages: number; + linesAdded: number; + linesDeleted: number; + toolCalls: Record; + byFileType: Record; + tokenUsage: { + prompt: number; + completion: number; + }; +} + +interface RecapOptions { + project?: boolean; + year?: number; + json?: boolean; +} + +function countLines(content: string): number { + if (!content) return 0; + return content.split('\n').length; +} + +function getFileExtension(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + return ext || 'other'; +} + +function parseLogFile( + filePath: string, + stats: RecapStats, + year?: number, +): void { + const fileStats = fs.statSync(filePath); + if (year && fileStats.mtime.getFullYear() !== year) { + return; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').filter(Boolean); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + + if (entry.type === 'message') { + stats.messages++; + + if (entry.usage) { + stats.tokenUsage.prompt += entry.usage.input_tokens || 0; + stats.tokenUsage.completion += entry.usage.output_tokens || 0; + } + + if (entry.role === 'assistant' && Array.isArray(entry.content)) { + for (const part of entry.content) { + if (part.type === 'tool_use' || part.type === 'tool-call') { + const toolName = part.name || part.toolName; + stats.toolCalls[toolName] = (stats.toolCalls[toolName] || 0) + 1; + + if (toolName === 'write' && part.input?.content) { + const ext = getFileExtension(part.input.file_path || ''); + const added = countLines(part.input.content); + stats.linesAdded += added; + if (!stats.byFileType[ext]) { + stats.byFileType[ext] = { added: 0, deleted: 0 }; + } + stats.byFileType[ext].added += added; + } + + if (toolName === 'edit' && part.input) { + const ext = getFileExtension(part.input.file_path || ''); + const oldLines = countLines(part.input.old_string || ''); + const newLines = countLines(part.input.new_string || ''); + const added = Math.max(0, newLines - oldLines); + const deleted = Math.max(0, oldLines - newLines); + stats.linesAdded += added; + stats.linesDeleted += deleted; + if (!stats.byFileType[ext]) { + stats.byFileType[ext] = { added: 0, deleted: 0 }; + } + stats.byFileType[ext].added += added; + stats.byFileType[ext].deleted += deleted; + } + } + } + } + } + } catch { + // skip invalid line + } + } +} + +function collectStats( + neovateDir: string, + options: RecapOptions, + cwd: string, +): RecapStats { + const stats: RecapStats = { + projects: 0, + sessions: 0, + messages: 0, + linesAdded: 0, + linesDeleted: 0, + toolCalls: {}, + byFileType: {}, + tokenUsage: { + prompt: 0, + completion: 0, + }, + }; + + if (!fs.existsSync(neovateDir)) { + return stats; + } + + const projects = fs.readdirSync(neovateDir); + + for (const project of projects) { + const projectDir = path.join(neovateDir, project); + if (!fs.statSync(projectDir).isDirectory()) continue; + + if (options.project) { + const cwdFormatted = cwd + .replace(/^\/+|\/+$/g, '') + .replace(/[^a-zA-Z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + if (project !== cwdFormatted) continue; + } + + stats.projects++; + + const files = fs + .readdirSync(projectDir) + .filter((f) => f.endsWith('.jsonl') && !f.includes('requests')); + + for (const file of files) { + stats.sessions++; + parseLogFile(path.join(projectDir, file), stats, options.year); + } + } + + return stats; +} + +function formatNumber(num: number): string { + if (num >= 1_000_000) { + return (num / 1_000_000).toFixed(1) + 'M'; + } + if (num >= 1_000) { + return (num / 1_000).toFixed(1) + 'K'; + } + return num.toLocaleString(); +} + +function printStats(stats: RecapStats, options: RecapOptions): void { + if (options.json) { + console.log(JSON.stringify(stats, null, 2)); + return; + } + + const yearLabel = options.year ? ` (${options.year})` : ''; + const scopeLabel = options.project ? ' - Current Project' : ''; + + console.log(''); + console.log( + chalk.bold.cyan(`📊 Neovate Code Recap${yearLabel}${scopeLabel}`), + ); + console.log(''); + + console.log(chalk.dim('─'.repeat(40))); + console.log(` Projects: ${chalk.white(stats.projects)}`); + console.log(` Sessions: ${chalk.white(stats.sessions)}`); + console.log(` Messages: ${chalk.white(formatNumber(stats.messages))}`); + console.log(chalk.dim('─'.repeat(40))); + + console.log(''); + console.log(chalk.bold('Code Changes:')); + console.log( + ` Lines added: ${chalk.green('+' + formatNumber(stats.linesAdded))}`, + ); + console.log( + ` Lines deleted: ${chalk.red('-' + formatNumber(stats.linesDeleted))}`, + ); + const netChange = stats.linesAdded - stats.linesDeleted; + const netColor = netChange >= 0 ? chalk.green : chalk.red; + const netSign = netChange >= 0 ? '+' : ''; + console.log( + ` Net change: ${netColor(netSign + formatNumber(netChange))}`, + ); + + console.log(''); + console.log(chalk.bold('Tool Usage:')); + const sortedTools = Object.entries(stats.toolCalls) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6); + const toolLine = sortedTools + .map(([name, count]) => `${name}: ${formatNumber(count)}`) + .join(' '); + console.log(` ${toolLine}`); + + if (Object.keys(stats.byFileType).length > 0) { + console.log(''); + console.log(chalk.bold('By File Type:')); + const totalLines = stats.linesAdded + stats.linesDeleted; + const sortedTypes = Object.entries(stats.byFileType) + .map(([ext, data]) => ({ + ext, + total: data.added + data.deleted, + added: data.added, + })) + .sort((a, b) => b.total - a.total) + .slice(0, 5); + + for (const { ext, total, added } of sortedTypes) { + const pct = totalLines > 0 ? ((total / totalLines) * 100).toFixed(0) : 0; + console.log( + ` ${ext.padEnd(10)} ${formatNumber(added).padStart(8)} lines (${pct}%)`, + ); + } + } + + console.log(''); + console.log(chalk.bold('Token Usage:')); + console.log(` Prompt: ${formatNumber(stats.tokenUsage.prompt)} tokens`); + console.log( + ` Completion: ${formatNumber(stats.tokenUsage.completion)} tokens`, + ); + console.log(''); +} + +export async function runRecap( + context: Context, + options: RecapOptions = {}, +): Promise { + const neovateDir = path.join(os.homedir(), '.neovate', 'projects'); + const stats = collectStats(neovateDir, options, context.cwd); + + if (stats.sessions === 0) { + console.log(chalk.yellow('No session data found.')); + return; + } + + printStats(stats, options); +} diff --git a/src/index.ts b/src/index.ts index a6a458663..2c33b6890 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,6 +154,7 @@ Commands: commit Commit changes to the repository log [file] View session logs in HTML (optional file path) mcp Manage MCP servers + recap Show usage statistics and code changes run Run a command skill Manage skills update Check for and apply updates @@ -405,6 +406,7 @@ export async function runNeovate(opts: { 'commit', 'mcp', 'log', + 'recap', 'run', 'server', 'skill', @@ -437,6 +439,22 @@ export async function runNeovate(opts: { await runLog(context, logFilePath); break; } + case 'recap': { + const { runRecap } = await import('./commands/recap'); + const recapArgs = process.argv.slice(2); + await runRecap(context, { + project: recapArgs.includes('--project'), + year: (() => { + const yearIdx = recapArgs.indexOf('--year'); + if (yearIdx !== -1 && recapArgs[yearIdx + 1]) { + return parseInt(String(recapArgs[yearIdx + 1]), 10); + } + return undefined; + })(), + json: recapArgs.includes('--json'), + }); + break; + } case 'run': { const { runRun } = await import('./commands/run'); await runRun(context);