ai-credit 1.0.4 → 1.0.5

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/README.md CHANGED
@@ -3,7 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/ai-credit.svg)](https://www.npmjs.com/package/ai-credit)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- A command-line tool to track and analyze AI coding assistants' contributions in your codebase (macOS/Linux). Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **Opencode**.
6
+ A command-line tool to track and analyze AI coding assistants' contributions in your codebase (macOS/Linux/Windows). Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **Opencode**.
7
+
8
+ <img width="700" height="700" alt="image" src="https://github.com/user-attachments/assets/48545b91-8d20-4946-bc1c-f55762c01539" />
7
9
 
8
10
  ## Quick Start
9
11
 
@@ -18,7 +20,7 @@ ai-credit
18
20
 
19
21
  ## Features
20
22
 
21
- - 🔍 **Auto-detection**: Automatically finds AI tool session data on your system (macOS/Linux)
23
+ - 🔍 **Auto-detection**: Automatically finds AI tool session data on your system (macOS/Linux/Windows)
22
24
  - 📊 **Detailed Statistics**: Lines of code, files modified, contribution ratios
23
25
  - 🤖 **Multi-tool Support**: Claude Code, Codex CLI, Gemini CLI, Opencode
24
26
  - 📈 **Visual Reports**: Console, JSON, and Markdown output formats
@@ -274,6 +276,46 @@ Here's a detailed breakdown of the parsing method for each supported tool:
274
276
  }
275
277
  ```
276
278
 
279
+ ### 4. Opencode
280
+
281
+ - **File Format**: JSON (`.json`) session and message files.
282
+ - **Scan Paths**:
283
+ - Sessions: `~/.local/share/opencode/storage/session/**/*.json`
284
+ - Messages: `~/.local/share/opencode/storage/message/<session-id>/*.json`
285
+ - **Parsing Logic**:
286
+ 1. The scanner reads all session JSON files (stored under project-hash subfolders).
287
+ 2. It filters sessions by project path using `directory` or `projectPath` in the session metadata.
288
+ 3. If message files exist for the session, it parses each message and looks for `summary.diffs`.
289
+ 4. If no message-level diffs are found, it falls back to `summary.diffs` in the session file.
290
+ 5. Each diff entry provides:
291
+ - `file`: relative file path
292
+ - `before`: previous content
293
+ - `after`: new content
294
+ - `additions` / `deletions`: optional precomputed line counts
295
+ 6. Lines added/removed are taken from `additions`/`deletions` when present, otherwise computed from `before`/`after`.
296
+ 7. The scanner also extracts the model from message data (e.g., `model.modelID`) when available.
297
+
298
+ **Example (Simplified Opencode Message JSON):**
299
+
300
+ ```json
301
+ {
302
+ "sessionID": "sess_abc123",
303
+ "time": { "created": "2026-02-02T10:15:00Z" },
304
+ "model": { "modelID": "kimi-k2.5-free" },
305
+ "summary": {
306
+ "diffs": [
307
+ {
308
+ "file": "src/index.ts",
309
+ "before": "console.log('old');\n",
310
+ "after": "console.log('new');\n",
311
+ "additions": 1,
312
+ "deletions": 1
313
+ }
314
+ ]
315
+ }
316
+ }
317
+ ```
318
+
277
319
  ### Summary
278
320
 
279
321
  In essence, `ai-credit` features a specialized scanner for each supported AI tool. Each scanner is programmed to know its corresponding tool's log storage location and data structure. During analysis, the main program invokes all available scanners, collects all `FileChange` events related to the target project, and then aggregates, deduplicates, and analyzes these events to generate the final contribution report.
@@ -340,6 +382,7 @@ This methodology ensures that:
340
382
  - Accuracy depends on the completeness of AI tool session logs
341
383
  - Some AI tools may not record all file operations
342
384
  - Files ignored by the root `.gitignore` are excluded from Total Files/Lines
385
+ - Windows support for some tools depends on their session storage format compatibility
343
386
 
344
387
  ## Contributing
345
388
 
@@ -0,0 +1,35 @@
1
+ import { AISession, AITool } from '../types.js';
2
+ import { BaseScanner } from './base.js';
3
+ /**
4
+ * Scanner for Aider sessions
5
+ *
6
+ * Aider stores chat history in:
7
+ * <project>/.aider.chat.history.md
8
+ * <project>/.aider.input.history
9
+ * <project>/.aider/
10
+ *
11
+ * The file contains markdown-formatted conversation with
12
+ * code blocks that show file changes.
13
+ */
14
+ export declare class AiderScanner extends BaseScanner {
15
+ get tool(): AITool;
16
+ get storagePath(): string;
17
+ /**
18
+ * For Aider, storage is project-local
19
+ */
20
+ protected resolveStoragePath(): string;
21
+ scan(projectPath: string): AISession[];
22
+ /**
23
+ * Check if Aider history exists in the project
24
+ */
25
+ isAvailable(): boolean;
26
+ /**
27
+ * Check if Aider history exists for a specific project
28
+ */
29
+ isAvailableForProject(projectPath: string): boolean;
30
+ parseSessionFile(filePath: string, projectPath: string): AISession | null;
31
+ /**
32
+ * Deduplicate changes, keeping the latest for each file
33
+ */
34
+ private deduplicateChanges;
35
+ }
@@ -0,0 +1,205 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { glob } from 'glob';
4
+ import { AITool } from '../types.js';
5
+ import { BaseScanner } from './base.js';
6
+ /**
7
+ * Scanner for Aider sessions
8
+ *
9
+ * Aider stores chat history in:
10
+ * <project>/.aider.chat.history.md
11
+ * <project>/.aider.input.history
12
+ * <project>/.aider/
13
+ *
14
+ * The file contains markdown-formatted conversation with
15
+ * code blocks that show file changes.
16
+ */
17
+ export class AiderScanner extends BaseScanner {
18
+ get tool() {
19
+ return AITool.AIDER;
20
+ }
21
+ get storagePath() {
22
+ return '.aider.chat.history.md';
23
+ }
24
+ /**
25
+ * For Aider, storage is project-local
26
+ */
27
+ resolveStoragePath() {
28
+ return this.storagePath;
29
+ }
30
+ scan(projectPath) {
31
+ const sessions = [];
32
+ // Check for main history file
33
+ const historyFile = path.join(projectPath, '.aider.chat.history.md');
34
+ if (fs.existsSync(historyFile)) {
35
+ const session = this.parseSessionFile(historyFile, projectPath);
36
+ if (session && session.changes.length > 0) {
37
+ sessions.push(session);
38
+ }
39
+ }
40
+ // Also check .aider directory for additional history
41
+ const aiderDir = path.join(projectPath, '.aider');
42
+ if (fs.existsSync(aiderDir)) {
43
+ try {
44
+ const files = glob.sync('**/*.md', { cwd: aiderDir });
45
+ for (const file of files) {
46
+ if (file.includes('history') || file.includes('chat')) {
47
+ const session = this.parseSessionFile(path.join(aiderDir, file), projectPath);
48
+ if (session && session.changes.length > 0) {
49
+ sessions.push(session);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // Ignore errors
56
+ }
57
+ }
58
+ return sessions;
59
+ }
60
+ /**
61
+ * Check if Aider history exists in the project
62
+ */
63
+ isAvailable() {
64
+ // For Aider, we can't check globally - it's project-specific
65
+ return true;
66
+ }
67
+ /**
68
+ * Check if Aider history exists for a specific project
69
+ */
70
+ isAvailableForProject(projectPath) {
71
+ const historyFile = path.join(projectPath, this.storagePath);
72
+ const aiderDir = path.join(projectPath, '.aider');
73
+ return fs.existsSync(historyFile) || fs.existsSync(aiderDir);
74
+ }
75
+ parseSessionFile(filePath, projectPath) {
76
+ let content;
77
+ try {
78
+ content = fs.readFileSync(filePath, 'utf-8');
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ const changes = [];
84
+ const fileStats = fs.statSync(filePath);
85
+ const sessionTimestamp = fileStats.mtime;
86
+ // Parse the markdown content to find file changes
87
+ // Aider uses various patterns:
88
+ // Pattern 1: ```language path/to/file.py
89
+ const codeBlockRegex = /```(\w+)?\s+([^\n`]+)\n([\s\S]*?)```/g;
90
+ let match;
91
+ while ((match = codeBlockRegex.exec(content)) !== null) {
92
+ const [, language, filePathRaw, code] = match;
93
+ // Skip if it looks like a diff or command output
94
+ if (filePathRaw.startsWith('>') || filePathRaw.startsWith('$') || filePathRaw.startsWith('#')) {
95
+ continue;
96
+ }
97
+ // Clean up the file path
98
+ const cleanPath = filePathRaw.trim();
99
+ // Skip if path contains spaces (likely not a real path) or is too long
100
+ if (!cleanPath || cleanPath.includes(' ') || cleanPath.length > 200) {
101
+ continue;
102
+ }
103
+ // Skip common non-file patterns
104
+ if (cleanPath.match(/^(bash|shell|console|output|diff|patch|error|warning|note|example)/i)) {
105
+ continue;
106
+ }
107
+ const linesAdded = this.countLines(code);
108
+ if (linesAdded > 0) {
109
+ changes.push({
110
+ filePath: this.normalizePath(cleanPath, projectPath),
111
+ linesAdded,
112
+ linesRemoved: 0,
113
+ changeType: 'modify',
114
+ timestamp: sessionTimestamp,
115
+ tool: this.tool,
116
+ content: code,
117
+ addedLines: this.extractNonEmptyLines(code),
118
+ });
119
+ }
120
+ }
121
+ // Pattern 2: SEARCH/REPLACE blocks (Aider's edit format)
122
+ // Look for file context before SEARCH/REPLACE
123
+ const fileEditRegex = /(?:^|\n)([^\n]+\.[a-zA-Z]+)\n```[^\n]*\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
124
+ while ((match = fileEditRegex.exec(content)) !== null) {
125
+ const [, filePathRaw, searchContent, replaceContent] = match;
126
+ const cleanPath = filePathRaw.trim();
127
+ if (cleanPath && !cleanPath.includes(' ')) {
128
+ const linesRemoved = this.countLines(searchContent);
129
+ const linesAdded = this.countLines(replaceContent);
130
+ if (linesAdded > 0 || linesRemoved > 0) {
131
+ changes.push({
132
+ filePath: this.normalizePath(cleanPath, projectPath),
133
+ linesAdded,
134
+ linesRemoved,
135
+ changeType: 'modify',
136
+ timestamp: sessionTimestamp,
137
+ tool: this.tool,
138
+ content: replaceContent,
139
+ addedLines: this.extractNonEmptyLines(replaceContent),
140
+ });
141
+ }
142
+ }
143
+ }
144
+ // Pattern 3: Standalone SEARCH/REPLACE without file context
145
+ const standaloneEditRegex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
146
+ while ((match = standaloneEditRegex.exec(content)) !== null) {
147
+ const [fullMatch, searchContent, replaceContent] = match;
148
+ // Skip if already captured by fileEditRegex
149
+ if (content.indexOf(fullMatch) !== match.index)
150
+ continue;
151
+ const linesRemoved = this.countLines(searchContent);
152
+ const linesAdded = this.countLines(replaceContent);
153
+ if (linesAdded > 0 || linesRemoved > 0) {
154
+ changes.push({
155
+ filePath: 'unknown',
156
+ linesAdded,
157
+ linesRemoved,
158
+ changeType: 'modify',
159
+ timestamp: sessionTimestamp,
160
+ tool: this.tool,
161
+ content: replaceContent,
162
+ addedLines: this.extractNonEmptyLines(replaceContent),
163
+ });
164
+ }
165
+ }
166
+ // Deduplicate changes by file path
167
+ const uniqueChanges = this.deduplicateChanges(changes);
168
+ if (uniqueChanges.length === 0)
169
+ return null;
170
+ return {
171
+ id: this.generateSessionId(filePath),
172
+ tool: this.tool,
173
+ timestamp: sessionTimestamp,
174
+ projectPath,
175
+ changes: uniqueChanges,
176
+ totalFilesChanged: new Set(uniqueChanges.map(c => c.filePath)).size,
177
+ totalLinesAdded: uniqueChanges.reduce((sum, c) => sum + c.linesAdded, 0),
178
+ totalLinesRemoved: uniqueChanges.reduce((sum, c) => sum + c.linesRemoved, 0),
179
+ };
180
+ }
181
+ /**
182
+ * Deduplicate changes, keeping the latest for each file
183
+ */
184
+ deduplicateChanges(changes) {
185
+ const byFile = new Map();
186
+ for (const change of changes) {
187
+ const existing = byFile.get(change.filePath);
188
+ if (!existing) {
189
+ byFile.set(change.filePath, { ...change });
190
+ }
191
+ else {
192
+ // Accumulate lines
193
+ existing.linesAdded += change.linesAdded;
194
+ existing.linesRemoved += change.linesRemoved;
195
+ if (change.addedLines && change.addedLines.length > 0) {
196
+ if (!existing.addedLines) {
197
+ existing.addedLines = [];
198
+ }
199
+ existing.addedLines.push(...change.addedLines);
200
+ }
201
+ }
202
+ }
203
+ return Array.from(byFile.values()).filter(c => c.filePath !== 'unknown');
204
+ }
205
+ }
@@ -45,6 +45,13 @@ export declare abstract class BaseScanner {
45
45
  * Compute added lines using LCS diff (non-empty lines only)
46
46
  */
47
47
  protected diffAddedLines(before: string | undefined, after: string | undefined): string[];
48
+ /**
49
+ * Compute added/removed line counts using LCS diff (non-empty lines only)
50
+ */
51
+ protected diffLineCounts(before: string | undefined, after: string | undefined): {
52
+ added: number;
53
+ removed: number;
54
+ };
48
55
  /**
49
56
  * Extract added lines from a unified diff
50
57
  */
@@ -89,6 +89,40 @@ export class BaseScanner {
89
89
  }
90
90
  return added.reverse();
91
91
  }
92
+ /**
93
+ * Compute added/removed line counts using LCS diff (non-empty lines only)
94
+ */
95
+ diffLineCounts(before, after) {
96
+ const beforeLines = this.extractNonEmptyLines(before);
97
+ const afterLines = this.extractNonEmptyLines(after);
98
+ if (beforeLines.length === 0)
99
+ return { added: afterLines.length, removed: 0 };
100
+ if (afterLines.length === 0)
101
+ return { added: 0, removed: beforeLines.length };
102
+ const m = beforeLines.length;
103
+ const n = afterLines.length;
104
+ let prev = new Array(n + 1).fill(0);
105
+ let curr = new Array(n + 1).fill(0);
106
+ for (let i = 1; i <= m; i++) {
107
+ for (let j = 1; j <= n; j++) {
108
+ if (beforeLines[i - 1] === afterLines[j - 1]) {
109
+ curr[j] = prev[j - 1] + 1;
110
+ }
111
+ else {
112
+ curr[j] = Math.max(prev[j], curr[j - 1]);
113
+ }
114
+ }
115
+ const temp = prev;
116
+ prev = curr;
117
+ curr = temp;
118
+ curr.fill(0);
119
+ }
120
+ const lcs = prev[n];
121
+ return {
122
+ added: afterLines.length - lcs,
123
+ removed: beforeLines.length - lcs,
124
+ };
125
+ }
92
126
  /**
93
127
  * Extract added lines from a unified diff
94
128
  */
@@ -187,18 +187,28 @@ export class ClaudeScanner extends BaseScanner {
187
187
  let addedLines = [];
188
188
  if (writeOps.includes(toolName)) {
189
189
  changeType = oldContent ? 'modify' : 'create';
190
- linesAdded = this.countLines(newContent);
191
- linesRemoved = this.countLines(oldContent);
192
- addedLines = this.extractNonEmptyLines(newContent);
190
+ const stats = this.diffLineCounts(oldContent, newContent);
191
+ linesAdded = stats.added;
192
+ linesRemoved = stats.removed;
193
+ if (oldContent && newContent) {
194
+ addedLines = this.diffAddedLines(oldContent, newContent);
195
+ }
196
+ else {
197
+ addedLines = this.extractNonEmptyLines(newContent);
198
+ }
193
199
  }
194
200
  else if (editOps.includes(toolName)) {
195
201
  changeType = 'modify';
196
- linesAdded = this.countLines(newContent);
197
- linesRemoved = this.countLines(oldContent);
198
202
  if (oldContent && newContent) {
203
+ const stats = this.diffLineCounts(oldContent, newContent);
204
+ linesAdded = stats.added;
205
+ linesRemoved = stats.removed;
199
206
  addedLines = this.diffAddedLines(oldContent, newContent);
200
207
  }
201
208
  else {
209
+ const stats = this.diffLineCounts(oldContent, newContent);
210
+ linesAdded = stats.added;
211
+ linesRemoved = stats.removed;
202
212
  addedLines = this.extractNonEmptyLines(newContent);
203
213
  }
204
214
  }
@@ -316,14 +316,19 @@ export class CodexScanner extends BaseScanner {
316
316
  let linesRemoved = 0;
317
317
  let addedLines = [];
318
318
  if (writeOps.includes(funcName)) {
319
- changeType = 'create';
320
- linesAdded = this.countLines(newContent);
321
- addedLines = this.extractNonEmptyLines(newContent);
319
+ changeType = oldContent ? 'modify' : 'create';
320
+ const stats = this.diffLineCounts(oldContent, newContent);
321
+ linesAdded = stats.added;
322
+ linesRemoved = stats.removed;
323
+ if (oldContent && newContent) {
324
+ addedLines = this.diffAddedLines(oldContent, newContent);
325
+ }
326
+ else {
327
+ addedLines = this.extractNonEmptyLines(newContent);
328
+ }
322
329
  }
323
330
  else if (editOps.includes(funcName)) {
324
331
  changeType = 'modify';
325
- linesAdded = this.countLines(newContent);
326
- linesRemoved = this.countLines(oldContent);
327
332
  if ((funcName === 'apply_diff' || funcName === 'patch') && args.diff) {
328
333
  const diffStats = this.parseDiff(args.diff);
329
334
  linesAdded = diffStats.added;
@@ -331,9 +336,15 @@ export class CodexScanner extends BaseScanner {
331
336
  addedLines = this.extractAddedLinesFromDiff(args.diff);
332
337
  }
333
338
  else if (oldContent && newContent) {
339
+ const stats = this.diffLineCounts(oldContent, newContent);
340
+ linesAdded = stats.added;
341
+ linesRemoved = stats.removed;
334
342
  addedLines = this.diffAddedLines(oldContent, newContent);
335
343
  }
336
344
  else {
345
+ const stats = this.diffLineCounts(oldContent, newContent);
346
+ linesAdded = stats.added;
347
+ linesRemoved = stats.removed;
337
348
  addedLines = this.extractNonEmptyLines(newContent);
338
349
  }
339
350
  }
@@ -284,9 +284,15 @@ export class GeminiScanner extends BaseScanner {
284
284
  let linesRemoved = 0;
285
285
  let addedLines = [];
286
286
  if (writeOps.includes(funcName)) {
287
- linesAdded = this.countLines(newContent);
288
- linesRemoved = this.countLines(oldContent); // Usually 0 for write, unless overwriting
289
- addedLines = this.extractNonEmptyLines(newContent);
287
+ const stats = this.calculateDiffStats(oldContent, newContent);
288
+ linesAdded = stats.added;
289
+ linesRemoved = stats.removed;
290
+ if (oldContent && newContent) {
291
+ addedLines = this.diffAddedLines(oldContent, newContent);
292
+ }
293
+ else {
294
+ addedLines = this.extractNonEmptyLines(newContent);
295
+ }
290
296
  }
291
297
  else if (editOps.includes(funcName)) {
292
298
  // Use LCS for edits to be accurate
@@ -162,9 +162,10 @@ export class OpencodeScanner extends BaseScanner {
162
162
  : 'modify';
163
163
  // Use opencode's provided diff stats if available (most accurate)
164
164
  // additions/deletions are pre-calculated by opencode
165
+ const diffStats = this.diffLineCounts(beforeContent, afterContent);
165
166
  const addedLines = this.diffAddedLines(beforeContent, afterContent);
166
- const linesAdded = typeof diff.additions === 'number' ? diff.additions : addedLines.length;
167
- const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : 0;
167
+ const linesAdded = typeof diff.additions === 'number' ? diff.additions : diffStats.added;
168
+ const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : diffStats.removed;
168
169
  return {
169
170
  filePath,
170
171
  linesAdded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-credit",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "CLI tool to track and analyze AI coding assistants' contributions in your codebase",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -11,7 +11,10 @@
11
11
  "build": "tsc",
12
12
  "start": "node dist/cli.js",
13
13
  "dev": "tsc && node dist/cli.js",
14
- "pub": "npm version patch && npm run build && npm publish --access public",
14
+ "v-patch": "npm version patch",
15
+ "v-minor": "npm version minor",
16
+ "v-major": "npm version major",
17
+ "pub": "npm run build && npm publish --access public",
15
18
  "prepublishOnly": "npm run build"
16
19
  },
17
20
  "keywords": [