Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/designs/2025-12-31-recap-command-implementation.md
Original file line number Diff line number Diff line change
@@ -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
```
255 changes: 255 additions & 0 deletions src/commands/recap.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
byFileType: Record<string, { added: number; deleted: number }>;
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<void> {
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);
}
Loading
Loading