ai-credit 1.0.3 โ 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 +105 -34
- package/dist/analyzer.js +50 -24
- package/dist/cli.js +5 -5
- package/dist/reporter.js +1 -0
- package/dist/scanners/base.d.ts +11 -0
- package/dist/scanners/base.js +43 -3
- package/dist/scanners/claude.d.ts +5 -1
- package/dist/scanners/claude.js +32 -9
- package/dist/scanners/codex.d.ts +12 -0
- package/dist/scanners/codex.js +96 -30
- package/dist/scanners/gemini.d.ts +5 -0
- package/dist/scanners/gemini.js +71 -13
- package/dist/scanners/opencode.js +8 -5
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ai-credit)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
A command-line tool to track and analyze AI coding assistants' contributions in your codebase. 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
|
|
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
|
|
@@ -72,6 +74,7 @@ Shows which AI tools have data available on your system:
|
|
|
72
74
|
Claude Code ~/.claude/projects/ โ Available
|
|
73
75
|
Codex CLI ~/.codex/sessions/ โ Available
|
|
74
76
|
Gemini CLI ~/.gemini/tmp/ โ Not found
|
|
77
|
+
Opencode ~/.local/share/opencode/ โ Available
|
|
75
78
|
```
|
|
76
79
|
|
|
77
80
|
### File-level Analysis
|
|
@@ -101,35 +104,57 @@ Lists all AI sessions for the repository.
|
|
|
101
104
|
## Output Example
|
|
102
105
|
|
|
103
106
|
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
โ
|
|
115
|
-
|
|
116
|
-
โ
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
โ
|
|
126
|
-
|
|
107
|
+
ai-credit (main) npx ai-credit
|
|
108
|
+
Leave a ๐ star if you like it: https://github.com/debugtheworldbot/ai-credit
|
|
109
|
+
|
|
110
|
+
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
|
|
111
|
+
โ AI Contribution Analysis โ
|
|
112
|
+
โ Repository: /Users/eric/Developer/ai-credit โ
|
|
113
|
+
โ Scan time: 2/2/2026, 4:22:53 PM โ
|
|
114
|
+
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
|
|
115
|
+
๐ Overview
|
|
116
|
+
โโโโโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโโโโโ
|
|
117
|
+
โ Metric โ Value โ AI Contribution โ
|
|
118
|
+
โโโโโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโโโโค
|
|
119
|
+
โ Total Files โ 18 โ 15 (83.3%) โ
|
|
120
|
+
โโโโโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโโโโค
|
|
121
|
+
โ Total Lines โ 3496 โ 1660 (47.5%) โ
|
|
122
|
+
โโโโโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโโโโค
|
|
123
|
+
โ AI Sessions โ 6 โ - โ
|
|
124
|
+
โโโโโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโโโโโ
|
|
125
|
+
|
|
126
|
+
๐ค Contribution by AI Tool
|
|
127
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
|
|
128
|
+
โ Tool / Model โ Sessions โ Files โ Lines Added โ Lines Removed โ Share โ
|
|
129
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
130
|
+
โ Opencode โ 2 โ 12 โ +558 โ -128 โ 32.2% โ
|
|
131
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
132
|
+
โ โโ kimi-k2.5-free โ 2 โ 12 โ +558 โ -128 โ 100.0% (of tool) โ
|
|
133
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
134
|
+
โ Codex CLI โ 1 โ 11 โ +482 โ -303 โ 27.8% โ
|
|
135
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
136
|
+
โ โโ gpt-5.2-codex โ 1 โ 11 โ +482 โ -303 โ 100.0% (of tool) โ
|
|
137
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
138
|
+
โ Gemini CLI โ 2 โ 11 โ +357 โ -262 โ 20.6% โ
|
|
139
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
140
|
+
โ โโ gemini-2.5-pro โ 1 โ 8 โ +330 โ -237 โ 92.4% (of tool) โ
|
|
141
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
142
|
+
โ โโ gemini-3-pro-preview โ 1 โ 5 โ +27 โ -25 โ 7.6% (of tool) โ
|
|
143
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
144
|
+
โ Claude Code โ 1 โ 5 โ +338 โ -452 โ 19.5% โ
|
|
145
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโค
|
|
146
|
+
โ โโ claude-opus-4-5-20251101 โ 1 โ 5 โ +338 โ -452 โ 100.0% (of tool) โ
|
|
147
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโ
|
|
127
148
|
|
|
128
149
|
๐ Contribution Distribution
|
|
129
150
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Opencode
|
|
151
|
+
๐ง๐ง๐ง๐ง๐ง๐ง๐ฆ๐ฆ๐ฆ๐ฆ๐ฆ๐ช๐ช๐ช๐ช๐ช๐ช๐ฉ๐ฉ๐ฉโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌโฌ
|
|
152
|
+
|
|
153
|
+
๐ Opencode 15.3% (534 lines)
|
|
154
|
+
๐ต Codex CLI 13.2% (461 lines)
|
|
155
|
+
๐ฃ Gemini CLI 9.8% (342 lines)
|
|
156
|
+
๐ข Claude Code 9.2% (323 lines)
|
|
157
|
+
โช Unknown/Human 52.5% (1836 lines)
|
|
133
158
|
```
|
|
134
159
|
|
|
135
160
|
## Supported AI Tools
|
|
@@ -217,13 +242,14 @@ Here's a detailed breakdown of the parsing method for each supported tool:
|
|
|
217
242
|
### 3. Gemini CLI
|
|
218
243
|
|
|
219
244
|
- **File Format**: JSON (`.json`), where one file represents a complete session.
|
|
220
|
-
- **Scan
|
|
245
|
+
- **Scan Paths**: `~/.gemini/tmp/<hash>/chats/*.json`, `~/.gemini/history/*.json`, `~/.gemini/sessions/*.json`
|
|
221
246
|
- **Parsing Logic**:
|
|
222
|
-
1. The
|
|
247
|
+
1. The scanner searches Geminiโs session JSON files under common locations (`tmp`, `history`, `sessions`).
|
|
223
248
|
2. It parses the entire JSON file, which typically contains a `"messages"` or `"turns"` array logging the conversation history.
|
|
224
249
|
3. It iterates through the `messages` array, looking for `"parts"` arrays within messages from the `"assistant"` role.
|
|
225
|
-
4. Within the `parts` array, it searches for an object containing a `"functionCall"
|
|
250
|
+
4. Within the `parts` array, it searches for an object containing a `"functionCall"` (or `toolCalls`). This object's structure is similar to Codex CLI.
|
|
226
251
|
5. It extracts the `"name"` (function name) and `"args"` (arguments dictionary) from the `functionCall` object.
|
|
252
|
+
6. **Project matching**: if the session JSON has an explicit project path (`projectPath/cwd/...`), it must match the target repo. If not, the scanner only keeps tool calls whose `file_path` is inside the target repo.
|
|
227
253
|
|
|
228
254
|
**Example (Simplified Gemini CLI JSON Fragment):**
|
|
229
255
|
|
|
@@ -250,6 +276,46 @@ Here's a detailed breakdown of the parsing method for each supported tool:
|
|
|
250
276
|
}
|
|
251
277
|
```
|
|
252
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
|
+
|
|
253
319
|
### Summary
|
|
254
320
|
|
|
255
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.
|
|
@@ -266,20 +332,23 @@ The tool applies a strict verification rule when calculating AI contribution sta
|
|
|
266
332
|
|
|
267
333
|
1. **Parse AI Session Logs**: The scanner reads session files from each AI tool and extracts file change events (writes, edits, patches).
|
|
268
334
|
|
|
269
|
-
2. **
|
|
335
|
+
2. **Build Repository File Set**: The tool gathers repository files using text-file extensions, excluding common build/vendor folders and honoring the root `.gitignore`.
|
|
336
|
+
|
|
337
|
+
3. **Extract Changed Content**: For each file change, the tool captures:
|
|
270
338
|
- The file path
|
|
271
339
|
- Lines added (new content)
|
|
272
340
|
- Lines removed (old content)
|
|
273
341
|
|
|
274
|
-
|
|
342
|
+
4. **Verify Against Current Codebase**: Before counting any line as an AI contribution, the tool:
|
|
275
343
|
- Reads the current content of the target file from the repository
|
|
276
344
|
- For each line that AI claims to have added, checks if an **identical line** exists in the current file
|
|
277
345
|
- Only lines that match exactly (character-for-character) are counted
|
|
278
346
|
|
|
279
|
-
|
|
347
|
+
5. **Calculate Statistics**: The verified lines are then aggregated into:
|
|
280
348
|
- Per-file contribution counts
|
|
281
349
|
- Per-tool contribution totals
|
|
282
350
|
- Overall repository contribution ratios
|
|
351
|
+
- **Sessions, files, and models are counted only when at least one verified line exists**
|
|
283
352
|
|
|
284
353
|
### Example
|
|
285
354
|
|
|
@@ -312,6 +381,8 @@ This methodology ensures that:
|
|
|
312
381
|
- Cannot detect AI-generated code that was copy-pasted manually
|
|
313
382
|
- Accuracy depends on the completeness of AI tool session logs
|
|
314
383
|
- Some AI tools may not record all file operations
|
|
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
|
|
315
386
|
|
|
316
387
|
## Contributing
|
|
317
388
|
|
package/dist/analyzer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { glob } from 'glob';
|
|
4
|
+
import ignore from 'ignore';
|
|
4
5
|
import { ClaudeScanner, CodexScanner, GeminiScanner, OpencodeScanner, } from './scanners/index.js';
|
|
5
6
|
/**
|
|
6
7
|
* Main analyzer that coordinates all scanners and computes statistics
|
|
@@ -59,9 +60,19 @@ export class ContributionAnalyzer {
|
|
|
59
60
|
const repoFiles = this.getRepoFiles();
|
|
60
61
|
const repoFileIndex = this.buildRepoFileIndex(repoFiles);
|
|
61
62
|
const totalLines = this.sumRepoLines(repoFileIndex);
|
|
63
|
+
// Filter sessions to those with verified contributions
|
|
64
|
+
const verifiedSessions = sessions.filter(session => {
|
|
65
|
+
for (const change of session.changes) {
|
|
66
|
+
const fileInfo = repoFileIndex.get(change.filePath);
|
|
67
|
+
if (this.countVerifiedAddedLines(change, fileInfo) > 0) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
});
|
|
62
73
|
// Compute statistics
|
|
63
|
-
const byTool = this.computeToolStats(
|
|
64
|
-
const byFile = this.computeFileStats(
|
|
74
|
+
const byTool = this.computeToolStats(verifiedSessions, repoFileIndex);
|
|
75
|
+
const byFile = this.computeFileStats(verifiedSessions, repoFileIndex);
|
|
65
76
|
// Count AI-touched files and lines (only count files that exist in repo)
|
|
66
77
|
let aiTouchedFiles = 0;
|
|
67
78
|
let aiContributedLines = 0;
|
|
@@ -80,7 +91,7 @@ export class ContributionAnalyzer {
|
|
|
80
91
|
totalLines,
|
|
81
92
|
aiTouchedFiles,
|
|
82
93
|
aiContributedLines,
|
|
83
|
-
sessions,
|
|
94
|
+
sessions: verifiedSessions,
|
|
84
95
|
byTool,
|
|
85
96
|
byFile,
|
|
86
97
|
};
|
|
@@ -106,13 +117,13 @@ export class ContributionAnalyzer {
|
|
|
106
117
|
'**/yarn.lock',
|
|
107
118
|
];
|
|
108
119
|
try {
|
|
109
|
-
|
|
120
|
+
let files = glob.sync('**/*', {
|
|
110
121
|
cwd: this.projectPath,
|
|
111
122
|
nodir: true,
|
|
112
123
|
ignore: ignorePatterns,
|
|
113
124
|
});
|
|
114
125
|
// Filter to only include text files
|
|
115
|
-
|
|
126
|
+
files = files.filter(file => {
|
|
116
127
|
const ext = path.extname(file).toLowerCase();
|
|
117
128
|
const textExtensions = [
|
|
118
129
|
'.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs',
|
|
@@ -132,6 +143,23 @@ export class ContributionAnalyzer {
|
|
|
132
143
|
];
|
|
133
144
|
return textExtensions.includes(ext) || !ext;
|
|
134
145
|
});
|
|
146
|
+
// Normalize to forward slashes so keys match scanner output on all platforms
|
|
147
|
+
files = files.map(file => file.replace(/\\/g, '/'));
|
|
148
|
+
const gitignorePath = path.join(this.projectPath, '.gitignore');
|
|
149
|
+
if (fs.existsSync(gitignorePath)) {
|
|
150
|
+
try {
|
|
151
|
+
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
|
|
152
|
+
const ignoreFactory = ignore.default
|
|
153
|
+
?? ignore;
|
|
154
|
+
const ig = ignoreFactory();
|
|
155
|
+
ig.add(gitignoreContent.split(/\r?\n/));
|
|
156
|
+
files = files.filter(file => !ig.ignores(file));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Ignore gitignore parsing errors
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return files;
|
|
135
163
|
}
|
|
136
164
|
catch {
|
|
137
165
|
return [];
|
|
@@ -219,6 +247,18 @@ export class ContributionAnalyzer {
|
|
|
219
247
|
// Track unique files per model across all sessions
|
|
220
248
|
const filesByModel = new Map();
|
|
221
249
|
for (const session of sessions) {
|
|
250
|
+
const sessionContribs = [];
|
|
251
|
+
for (const change of session.changes) {
|
|
252
|
+
const fileInfo = repoFileIndex.get(change.filePath);
|
|
253
|
+
const verifiedAdded = this.countVerifiedAddedLines(change, fileInfo);
|
|
254
|
+
if (verifiedAdded <= 0)
|
|
255
|
+
continue;
|
|
256
|
+
const modelName = change.model || session.model || 'unknown';
|
|
257
|
+
sessionContribs.push({ change, verifiedAdded, modelName });
|
|
258
|
+
}
|
|
259
|
+
if (sessionContribs.length === 0) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
222
262
|
let toolStats = stats.get(session.tool);
|
|
223
263
|
if (!toolStats) {
|
|
224
264
|
toolStats = {
|
|
@@ -237,10 +277,9 @@ export class ContributionAnalyzer {
|
|
|
237
277
|
}
|
|
238
278
|
toolStats.sessionsCount++;
|
|
239
279
|
const toolFiles = filesByTool.get(session.tool);
|
|
240
|
-
|
|
280
|
+
const modelsInSession = new Set();
|
|
281
|
+
for (const { change, verifiedAdded, modelName } of sessionContribs) {
|
|
241
282
|
toolFiles.add(change.filePath);
|
|
242
|
-
const fileInfo = repoFileIndex.get(change.filePath);
|
|
243
|
-
const verifiedAdded = this.countVerifiedAddedLines(change, fileInfo);
|
|
244
283
|
toolStats.linesAdded += verifiedAdded;
|
|
245
284
|
toolStats.linesRemoved += change.linesRemoved;
|
|
246
285
|
if (change.changeType === 'create') {
|
|
@@ -249,13 +288,13 @@ export class ContributionAnalyzer {
|
|
|
249
288
|
else {
|
|
250
289
|
toolStats.filesModified++;
|
|
251
290
|
}
|
|
291
|
+
modelsInSession.add(modelName);
|
|
252
292
|
// Aggregate by model
|
|
253
|
-
const modelName = change.model || session.model || 'unknown';
|
|
254
293
|
let modelStats = toolStats.byModel.get(modelName);
|
|
255
294
|
if (!modelStats) {
|
|
256
295
|
modelStats = {
|
|
257
296
|
model: modelName,
|
|
258
|
-
sessionsCount: 0, // Will be counted below
|
|
297
|
+
sessionsCount: 0, // Will be counted below
|
|
259
298
|
filesCreated: 0,
|
|
260
299
|
filesModified: 0,
|
|
261
300
|
totalFiles: 0,
|
|
@@ -277,21 +316,8 @@ export class ContributionAnalyzer {
|
|
|
277
316
|
modelStats.filesModified++;
|
|
278
317
|
}
|
|
279
318
|
}
|
|
280
|
-
// Count sessions per model (if any change in session is attributed to model)
|
|
281
|
-
// This is a bit tricky if a session has mixed models, but usually it's one per session
|
|
282
|
-
const sessionModel = session.model || 'unknown';
|
|
283
|
-
// Use a set to track which models appeared in this session to avoid double counting if mixed
|
|
284
|
-
const modelsInSession = new Set();
|
|
285
|
-
if (session.model)
|
|
286
|
-
modelsInSession.add(session.model);
|
|
287
|
-
for (const change of session.changes) {
|
|
288
|
-
if (change.model)
|
|
289
|
-
modelsInSession.add(change.model);
|
|
290
|
-
}
|
|
291
|
-
if (modelsInSession.size === 0)
|
|
292
|
-
modelsInSession.add('unknown');
|
|
293
319
|
for (const modelName of modelsInSession) {
|
|
294
|
-
|
|
320
|
+
const modelStats = toolStats.byModel.get(modelName);
|
|
295
321
|
if (modelStats) {
|
|
296
322
|
modelStats.sessionsCount++;
|
|
297
323
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command } from 'commander';
|
|
3
|
-
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
2
|
import chalk from 'chalk';
|
|
6
|
-
import
|
|
3
|
+
import { Command } from 'commander';
|
|
7
4
|
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
|
8
8
|
import { ContributionAnalyzer } from './analyzer.js';
|
|
9
9
|
import { ConsoleReporter, JsonReporter, MarkdownReporter } from './reporter.js';
|
|
10
10
|
import { AITool } from './types.js';
|
|
@@ -161,7 +161,7 @@ function parseTools(toolStr) {
|
|
|
161
161
|
}
|
|
162
162
|
const program = new Command();
|
|
163
163
|
program
|
|
164
|
-
.name('ai-
|
|
164
|
+
.name('ai-credit')
|
|
165
165
|
.description('CLI tool to track and analyze AI coding assistants\' contributions in your codebase')
|
|
166
166
|
.version('1.0.0');
|
|
167
167
|
// Main scan command
|
package/dist/reporter.js
CHANGED
package/dist/scanners/base.d.ts
CHANGED
|
@@ -45,10 +45,21 @@ 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
|
*/
|
|
51
58
|
protected extractAddedLinesFromDiff(diff: string | undefined): string[];
|
|
59
|
+
/**
|
|
60
|
+
* Normalize separators to forward slashes (consistent with glob output)
|
|
61
|
+
*/
|
|
62
|
+
protected toForwardSlash(p: string): string;
|
|
52
63
|
/**
|
|
53
64
|
* Normalize file path relative to project
|
|
54
65
|
*/
|
package/dist/scanners/base.js
CHANGED
|
@@ -20,7 +20,7 @@ export class BaseScanner {
|
|
|
20
20
|
* Resolve the full storage path
|
|
21
21
|
*/
|
|
22
22
|
resolveStoragePath() {
|
|
23
|
-
return this.storagePath.replace('~', this.homeDir);
|
|
23
|
+
return path.join(this.storagePath.replace('~', this.homeDir));
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Count lines in a string
|
|
@@ -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
|
*/
|
|
@@ -107,14 +141,20 @@ export class BaseScanner {
|
|
|
107
141
|
}
|
|
108
142
|
return added;
|
|
109
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Normalize separators to forward slashes (consistent with glob output)
|
|
146
|
+
*/
|
|
147
|
+
toForwardSlash(p) {
|
|
148
|
+
return p.replace(/\\/g, '/');
|
|
149
|
+
}
|
|
110
150
|
/**
|
|
111
151
|
* Normalize file path relative to project
|
|
112
152
|
*/
|
|
113
153
|
normalizePath(filePath, projectPath) {
|
|
114
154
|
if (path.isAbsolute(filePath)) {
|
|
115
|
-
return path.relative(projectPath, filePath);
|
|
155
|
+
return this.toForwardSlash(path.relative(projectPath, filePath));
|
|
116
156
|
}
|
|
117
|
-
return filePath;
|
|
157
|
+
return this.toForwardSlash(filePath);
|
|
118
158
|
}
|
|
119
159
|
/**
|
|
120
160
|
* Check if a file path belongs to the project
|
|
@@ -14,13 +14,17 @@ export declare class ClaudeScanner extends BaseScanner {
|
|
|
14
14
|
get storagePath(): string;
|
|
15
15
|
/**
|
|
16
16
|
* Encode project path to match Claude's directory naming convention
|
|
17
|
-
* Claude encodes paths by replacing / with -
|
|
17
|
+
* Claude encodes paths by replacing / (and \ on Windows) with -
|
|
18
18
|
*/
|
|
19
19
|
private encodeProjectPath;
|
|
20
20
|
/**
|
|
21
21
|
* Decode Claude's directory name back to a path
|
|
22
22
|
*/
|
|
23
23
|
private decodeProjectPath;
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a path for comparison (lowercase drive letter, forward slashes)
|
|
26
|
+
*/
|
|
27
|
+
private normForCompare;
|
|
24
28
|
scan(projectPath: string): AISession[];
|
|
25
29
|
parseSessionFile(filePath: string, projectPath: string): AISession | null;
|
|
26
30
|
/**
|
package/dist/scanners/claude.js
CHANGED
|
@@ -21,10 +21,10 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Encode project path to match Claude's directory naming convention
|
|
24
|
-
* Claude encodes paths by replacing / with -
|
|
24
|
+
* Claude encodes paths by replacing / (and \ on Windows) with -
|
|
25
25
|
*/
|
|
26
26
|
encodeProjectPath(projectPath) {
|
|
27
|
-
return projectPath.replace(
|
|
27
|
+
return projectPath.replace(/[\\/]/g, '-').replace(/^-/, '');
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Decode Claude's directory name back to a path
|
|
@@ -32,6 +32,17 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
32
32
|
decodeProjectPath(encodedPath) {
|
|
33
33
|
return '/' + encodedPath.replace(/-/g, '/');
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a path for comparison (lowercase drive letter, forward slashes)
|
|
37
|
+
*/
|
|
38
|
+
normForCompare(p) {
|
|
39
|
+
let s = this.toForwardSlash(p);
|
|
40
|
+
// Normalize Windows drive letter to lowercase: C:/... -> c:/...
|
|
41
|
+
if (/^[A-Z]:\//.test(s)) {
|
|
42
|
+
s = s[0].toLowerCase() + s.slice(1);
|
|
43
|
+
}
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
35
46
|
scan(projectPath) {
|
|
36
47
|
const sessions = [];
|
|
37
48
|
const basePath = this.resolveStoragePath();
|
|
@@ -57,6 +68,8 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
57
68
|
continue;
|
|
58
69
|
// Check various matching criteria
|
|
59
70
|
const decodedPath = this.decodeProjectPath(dir);
|
|
71
|
+
const normProject = this.normForCompare(projectPath);
|
|
72
|
+
const normDecoded = this.normForCompare(decodedPath);
|
|
60
73
|
// Match by:
|
|
61
74
|
// 1. Directory name contains project basename
|
|
62
75
|
// 2. Decoded path ends with project path
|
|
@@ -64,8 +77,8 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
64
77
|
// 4. Same basename
|
|
65
78
|
if (dir.includes(projectBasename) ||
|
|
66
79
|
dir.toLowerCase().includes(projectBasename.toLowerCase()) ||
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
normDecoded.endsWith(normProject) ||
|
|
81
|
+
normProject.endsWith(normDecoded.slice(1)) ||
|
|
69
82
|
path.basename(decodedPath) === projectBasename) {
|
|
70
83
|
possibleDirs.add(fullDir);
|
|
71
84
|
}
|
|
@@ -174,18 +187,28 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
174
187
|
let addedLines = [];
|
|
175
188
|
if (writeOps.includes(toolName)) {
|
|
176
189
|
changeType = oldContent ? 'modify' : 'create';
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
}
|
|
180
199
|
}
|
|
181
200
|
else if (editOps.includes(toolName)) {
|
|
182
201
|
changeType = 'modify';
|
|
183
|
-
linesAdded = this.countLines(newContent);
|
|
184
|
-
linesRemoved = this.countLines(oldContent);
|
|
185
202
|
if (oldContent && newContent) {
|
|
203
|
+
const stats = this.diffLineCounts(oldContent, newContent);
|
|
204
|
+
linesAdded = stats.added;
|
|
205
|
+
linesRemoved = stats.removed;
|
|
186
206
|
addedLines = this.diffAddedLines(oldContent, newContent);
|
|
187
207
|
}
|
|
188
208
|
else {
|
|
209
|
+
const stats = this.diffLineCounts(oldContent, newContent);
|
|
210
|
+
linesAdded = stats.added;
|
|
211
|
+
linesRemoved = stats.removed;
|
|
189
212
|
addedLines = this.extractNonEmptyLines(newContent);
|
|
190
213
|
}
|
|
191
214
|
}
|
package/dist/scanners/codex.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export declare class CodexScanner extends BaseScanner {
|
|
|
24
24
|
* The session cwd must be exactly the project path or a subdirectory of it.
|
|
25
25
|
*/
|
|
26
26
|
private pathsMatch;
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a path for comparison (trim trailing slash, normalize Windows case).
|
|
29
|
+
*/
|
|
30
|
+
private normForCompare;
|
|
27
31
|
/**
|
|
28
32
|
* Parse Codex apply_patch custom_tool_call entries.
|
|
29
33
|
*
|
|
@@ -39,6 +43,14 @@ export declare class CodexScanner extends BaseScanner {
|
|
|
39
43
|
* *** End Patch
|
|
40
44
|
*/
|
|
41
45
|
private parseApplyPatch;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the patch text from a Codex custom_tool_call payload.
|
|
48
|
+
*/
|
|
49
|
+
private resolvePatchInput;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a file path against session cwd, then normalize to project-relative.
|
|
52
|
+
*/
|
|
53
|
+
private resolveFilePath;
|
|
42
54
|
/**
|
|
43
55
|
* Parse a function_call payload (e.g. shell_command with file write operations)
|
|
44
56
|
*/
|
package/dist/scanners/codex.js
CHANGED
|
@@ -84,7 +84,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
84
84
|
}
|
|
85
85
|
// Handle custom_tool_call (apply_patch) โ the primary way Codex writes files
|
|
86
86
|
if (entry.type === 'response_item' && payload.type === 'custom_tool_call') {
|
|
87
|
-
const patchChanges = this.parseApplyPatch(payload, projectPath, entry.timestamp);
|
|
87
|
+
const patchChanges = this.parseApplyPatch(payload, projectPath, sessionProjectPath, entry.timestamp);
|
|
88
88
|
changes.push(...patchChanges);
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
@@ -100,7 +100,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
100
100
|
catch {
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
103
|
-
const change = this.parseFunctionCall(funcName, args, projectPath, entry.timestamp);
|
|
103
|
+
const change = this.parseFunctionCall(funcName, args, projectPath, sessionProjectPath, entry.timestamp);
|
|
104
104
|
if (change) {
|
|
105
105
|
changes.push(change);
|
|
106
106
|
}
|
|
@@ -110,7 +110,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
110
110
|
const toolCalls = entry.tool_calls || entry.function_calls || [];
|
|
111
111
|
if (Array.isArray(toolCalls)) {
|
|
112
112
|
for (const toolCall of toolCalls) {
|
|
113
|
-
const change = this.parseToolCall(toolCall, projectPath, entry.timestamp);
|
|
113
|
+
const change = this.parseToolCall(toolCall, projectPath, sessionProjectPath, entry.timestamp);
|
|
114
114
|
if (change) {
|
|
115
115
|
changes.push(change);
|
|
116
116
|
}
|
|
@@ -118,7 +118,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
118
118
|
}
|
|
119
119
|
// Legacy: direct function format
|
|
120
120
|
if (entry.function && entry.function.name) {
|
|
121
|
-
const change = this.parseToolCall({ function: entry.function }, projectPath, entry.timestamp);
|
|
121
|
+
const change = this.parseToolCall({ function: entry.function }, projectPath, sessionProjectPath, entry.timestamp);
|
|
122
122
|
if (change) {
|
|
123
123
|
changes.push(change);
|
|
124
124
|
}
|
|
@@ -129,7 +129,9 @@ export class CodexScanner extends BaseScanner {
|
|
|
129
129
|
const normalizedSessionPath = path.resolve(sessionProjectPath);
|
|
130
130
|
const normalizedProjectPath = path.resolve(projectPath);
|
|
131
131
|
if (!this.pathsMatch(normalizedSessionPath, normalizedProjectPath)) {
|
|
132
|
-
|
|
132
|
+
if (changes.length === 0) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
if (changes.length === 0)
|
|
@@ -151,13 +153,26 @@ export class CodexScanner extends BaseScanner {
|
|
|
151
153
|
* The session cwd must be exactly the project path or a subdirectory of it.
|
|
152
154
|
*/
|
|
153
155
|
pathsMatch(sessionPath, projectPath) {
|
|
154
|
-
|
|
156
|
+
const s = this.normForCompare(sessionPath);
|
|
157
|
+
const p = this.normForCompare(projectPath);
|
|
158
|
+
if (s === p)
|
|
155
159
|
return true;
|
|
156
160
|
// Session opened inside a subdirectory of the project
|
|
157
|
-
if (
|
|
161
|
+
if (s.startsWith(p + '/'))
|
|
158
162
|
return true;
|
|
159
163
|
return false;
|
|
160
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Normalize a path for comparison (trim trailing slash, normalize Windows case).
|
|
167
|
+
*/
|
|
168
|
+
normForCompare(p) {
|
|
169
|
+
let s = this.toForwardSlash(p).replace(/\/+$/, '');
|
|
170
|
+
const isWindowsPath = /^[A-Za-z]:\//.test(s) || s.startsWith('//');
|
|
171
|
+
if (isWindowsPath) {
|
|
172
|
+
s = s.toLowerCase();
|
|
173
|
+
}
|
|
174
|
+
return s;
|
|
175
|
+
}
|
|
161
176
|
/**
|
|
162
177
|
* Parse Codex apply_patch custom_tool_call entries.
|
|
163
178
|
*
|
|
@@ -172,11 +187,11 @@ export class CodexScanner extends BaseScanner {
|
|
|
172
187
|
* +new file content
|
|
173
188
|
* *** End Patch
|
|
174
189
|
*/
|
|
175
|
-
parseApplyPatch(payload, projectPath, timestamp) {
|
|
190
|
+
parseApplyPatch(payload, projectPath, sessionCwd, timestamp) {
|
|
176
191
|
const name = (payload.name || '').toLowerCase();
|
|
177
192
|
if (name !== 'apply_patch')
|
|
178
193
|
return [];
|
|
179
|
-
const input = payload
|
|
194
|
+
const input = this.resolvePatchInput(payload);
|
|
180
195
|
if (!input)
|
|
181
196
|
return [];
|
|
182
197
|
const changes = [];
|
|
@@ -188,17 +203,19 @@ export class CodexScanner extends BaseScanner {
|
|
|
188
203
|
let addedLines = [];
|
|
189
204
|
const flushFile = () => {
|
|
190
205
|
if (currentFile && (linesAdded > 0 || linesRemoved > 0)) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
const resolvedPath = this.resolveFilePath(currentFile, projectPath, sessionCwd);
|
|
207
|
+
if (resolvedPath) {
|
|
208
|
+
changes.push({
|
|
209
|
+
filePath: resolvedPath,
|
|
210
|
+
linesAdded,
|
|
211
|
+
linesRemoved,
|
|
212
|
+
changeType,
|
|
213
|
+
timestamp: timestamp ? new Date(timestamp) : new Date(),
|
|
214
|
+
tool: this.tool,
|
|
215
|
+
content: addedLines.join('\n'),
|
|
216
|
+
addedLines,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
202
219
|
}
|
|
203
220
|
currentFile = null;
|
|
204
221
|
linesAdded = 0;
|
|
@@ -244,10 +261,46 @@ export class CodexScanner extends BaseScanner {
|
|
|
244
261
|
flushFile();
|
|
245
262
|
return changes;
|
|
246
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the patch text from a Codex custom_tool_call payload.
|
|
266
|
+
*/
|
|
267
|
+
resolvePatchInput(payload) {
|
|
268
|
+
const rawInput = payload?.input;
|
|
269
|
+
if (!rawInput)
|
|
270
|
+
return null;
|
|
271
|
+
if (typeof rawInput === 'string')
|
|
272
|
+
return rawInput;
|
|
273
|
+
if (Array.isArray(rawInput)) {
|
|
274
|
+
const parts = rawInput.filter(part => typeof part === 'string');
|
|
275
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
276
|
+
}
|
|
277
|
+
if (typeof rawInput === 'object') {
|
|
278
|
+
const patch = rawInput.patch ||
|
|
279
|
+
rawInput.diff ||
|
|
280
|
+
rawInput.text ||
|
|
281
|
+
rawInput.content ||
|
|
282
|
+
rawInput.input;
|
|
283
|
+
return typeof patch === 'string' && patch ? patch : null;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Resolve a file path against session cwd, then normalize to project-relative.
|
|
289
|
+
*/
|
|
290
|
+
resolveFilePath(filePath, projectPath, sessionCwd) {
|
|
291
|
+
let resolvedPath = filePath;
|
|
292
|
+
if (!path.isAbsolute(resolvedPath) && sessionCwd) {
|
|
293
|
+
resolvedPath = path.resolve(sessionCwd, resolvedPath);
|
|
294
|
+
}
|
|
295
|
+
if (!this.isProjectFile(resolvedPath, projectPath)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return this.normalizePath(resolvedPath, projectPath);
|
|
299
|
+
}
|
|
247
300
|
/**
|
|
248
301
|
* Parse a function_call payload (e.g. shell_command with file write operations)
|
|
249
302
|
*/
|
|
250
|
-
parseFunctionCall(funcName, args, projectPath, timestamp) {
|
|
303
|
+
parseFunctionCall(funcName, args, projectPath, sessionCwd, timestamp) {
|
|
251
304
|
const writeOps = ['write_file', 'create_file', 'write', 'save_file', 'create', 'writefile'];
|
|
252
305
|
const editOps = ['edit_file', 'apply_diff', 'patch', 'replace_in_file', 'edit', 'update_file', 'modify_file'];
|
|
253
306
|
let filePath = args.path || args.file_path || args.filename || args.file || args.target || '';
|
|
@@ -255,20 +308,27 @@ export class CodexScanner extends BaseScanner {
|
|
|
255
308
|
let oldContent = args.old_content || args.original || args.old_text || '';
|
|
256
309
|
if (!filePath)
|
|
257
310
|
return null;
|
|
258
|
-
|
|
311
|
+
const resolvedPath = this.resolveFilePath(filePath, projectPath, sessionCwd);
|
|
312
|
+
if (!resolvedPath)
|
|
313
|
+
return null;
|
|
259
314
|
let changeType = 'modify';
|
|
260
315
|
let linesAdded = 0;
|
|
261
316
|
let linesRemoved = 0;
|
|
262
317
|
let addedLines = [];
|
|
263
318
|
if (writeOps.includes(funcName)) {
|
|
264
|
-
changeType = 'create';
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
}
|
|
267
329
|
}
|
|
268
330
|
else if (editOps.includes(funcName)) {
|
|
269
331
|
changeType = 'modify';
|
|
270
|
-
linesAdded = this.countLines(newContent);
|
|
271
|
-
linesRemoved = this.countLines(oldContent);
|
|
272
332
|
if ((funcName === 'apply_diff' || funcName === 'patch') && args.diff) {
|
|
273
333
|
const diffStats = this.parseDiff(args.diff);
|
|
274
334
|
linesAdded = diffStats.added;
|
|
@@ -276,9 +336,15 @@ export class CodexScanner extends BaseScanner {
|
|
|
276
336
|
addedLines = this.extractAddedLinesFromDiff(args.diff);
|
|
277
337
|
}
|
|
278
338
|
else if (oldContent && newContent) {
|
|
339
|
+
const stats = this.diffLineCounts(oldContent, newContent);
|
|
340
|
+
linesAdded = stats.added;
|
|
341
|
+
linesRemoved = stats.removed;
|
|
279
342
|
addedLines = this.diffAddedLines(oldContent, newContent);
|
|
280
343
|
}
|
|
281
344
|
else {
|
|
345
|
+
const stats = this.diffLineCounts(oldContent, newContent);
|
|
346
|
+
linesAdded = stats.added;
|
|
347
|
+
linesRemoved = stats.removed;
|
|
282
348
|
addedLines = this.extractNonEmptyLines(newContent);
|
|
283
349
|
}
|
|
284
350
|
}
|
|
@@ -288,7 +354,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
288
354
|
if (linesAdded === 0 && linesRemoved === 0)
|
|
289
355
|
return null;
|
|
290
356
|
return {
|
|
291
|
-
filePath,
|
|
357
|
+
filePath: resolvedPath,
|
|
292
358
|
linesAdded,
|
|
293
359
|
linesRemoved,
|
|
294
360
|
changeType,
|
|
@@ -301,7 +367,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
301
367
|
/**
|
|
302
368
|
* Parse a legacy tool_call object to extract file changes
|
|
303
369
|
*/
|
|
304
|
-
parseToolCall(toolCall, projectPath, timestamp) {
|
|
370
|
+
parseToolCall(toolCall, projectPath, sessionCwd, timestamp) {
|
|
305
371
|
const func = toolCall.function;
|
|
306
372
|
if (!func)
|
|
307
373
|
return null;
|
|
@@ -318,7 +384,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
318
384
|
catch {
|
|
319
385
|
return null;
|
|
320
386
|
}
|
|
321
|
-
return this.parseFunctionCall(funcName, args, projectPath, timestamp);
|
|
387
|
+
return this.parseFunctionCall(funcName, args, projectPath, sessionCwd, timestamp);
|
|
322
388
|
}
|
|
323
389
|
/**
|
|
324
390
|
* Parse a unified diff to count added/removed lines
|
|
@@ -15,6 +15,7 @@ export declare class GeminiScanner extends BaseScanner {
|
|
|
15
15
|
get storagePath(): string;
|
|
16
16
|
/**
|
|
17
17
|
* Hash project path to match Gemini's directory naming
|
|
18
|
+
* Normalize to forward slashes so the hash is consistent across platforms
|
|
18
19
|
*/
|
|
19
20
|
private hashProjectPath;
|
|
20
21
|
scan(projectPath: string): AISession[];
|
|
@@ -23,6 +24,10 @@ export declare class GeminiScanner extends BaseScanner {
|
|
|
23
24
|
* Check if two paths match or are related
|
|
24
25
|
*/
|
|
25
26
|
private pathsMatch;
|
|
27
|
+
/**
|
|
28
|
+
* Find explicit project path from known fields in the session data
|
|
29
|
+
*/
|
|
30
|
+
private findProjectPath;
|
|
26
31
|
/**
|
|
27
32
|
* Parse a functionCall object to extract file changes
|
|
28
33
|
*/
|
package/dist/scanners/gemini.js
CHANGED
|
@@ -23,9 +23,11 @@ export class GeminiScanner extends BaseScanner {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Hash project path to match Gemini's directory naming
|
|
26
|
+
* Normalize to forward slashes so the hash is consistent across platforms
|
|
26
27
|
*/
|
|
27
28
|
hashProjectPath(projectPath) {
|
|
28
|
-
|
|
29
|
+
const normalized = this.toForwardSlash(projectPath);
|
|
30
|
+
return crypto.createHash('md5').update(normalized).digest('hex').substring(0, 16);
|
|
29
31
|
}
|
|
30
32
|
scan(projectPath) {
|
|
31
33
|
const sessions = [];
|
|
@@ -100,8 +102,8 @@ export class GeminiScanner extends BaseScanner {
|
|
|
100
102
|
sessionTimestamp = new Date(data.startTime);
|
|
101
103
|
}
|
|
102
104
|
// Try to find project path from various fields
|
|
103
|
-
sessionProjectPath =
|
|
104
|
-
//
|
|
105
|
+
sessionProjectPath = this.findProjectPath(data);
|
|
106
|
+
// Require explicit project path match when available
|
|
105
107
|
if (sessionProjectPath) {
|
|
106
108
|
const normalizedSessionPath = path.resolve(sessionProjectPath);
|
|
107
109
|
const normalizedProjectPath = path.resolve(projectPath);
|
|
@@ -178,6 +180,15 @@ export class GeminiScanner extends BaseScanner {
|
|
|
178
180
|
}
|
|
179
181
|
if (changes.length === 0)
|
|
180
182
|
return null;
|
|
183
|
+
// If no explicit project path, only keep changes that belong to the target project
|
|
184
|
+
if (!sessionProjectPath) {
|
|
185
|
+
const filteredChanges = changes.filter(change => this.isProjectFile(change.filePath, projectPath));
|
|
186
|
+
if (filteredChanges.length === 0) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
changes.length = 0;
|
|
190
|
+
changes.push(...filteredChanges);
|
|
191
|
+
}
|
|
181
192
|
return {
|
|
182
193
|
id: this.generateSessionId(filePath),
|
|
183
194
|
tool: this.tool,
|
|
@@ -194,17 +205,58 @@ export class GeminiScanner extends BaseScanner {
|
|
|
194
205
|
* Check if two paths match or are related
|
|
195
206
|
*/
|
|
196
207
|
pathsMatch(path1, path2) {
|
|
197
|
-
|
|
198
|
-
|
|
208
|
+
const p1 = this.toForwardSlash(path1);
|
|
209
|
+
const p2 = this.toForwardSlash(path2);
|
|
210
|
+
if (p1 === p2)
|
|
199
211
|
return true;
|
|
200
|
-
|
|
201
|
-
if (path1.startsWith(path2) || path2.startsWith(path1))
|
|
202
|
-
return true;
|
|
203
|
-
// Same basename (project name)
|
|
204
|
-
if (path.basename(path1) === path.basename(path2))
|
|
212
|
+
if (p1.startsWith(p2 + '/'))
|
|
205
213
|
return true;
|
|
206
214
|
return false;
|
|
207
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Find explicit project path from known fields in the session data
|
|
218
|
+
*/
|
|
219
|
+
findProjectPath(data) {
|
|
220
|
+
if (!data || typeof data !== 'object')
|
|
221
|
+
return null;
|
|
222
|
+
const keys = new Set([
|
|
223
|
+
'projectPath',
|
|
224
|
+
'project_path',
|
|
225
|
+
'cwd',
|
|
226
|
+
'working_directory',
|
|
227
|
+
'workspace',
|
|
228
|
+
'workspacePath',
|
|
229
|
+
'rootPath',
|
|
230
|
+
'repoPath',
|
|
231
|
+
]);
|
|
232
|
+
const queue = [{ value: data, depth: 0 }];
|
|
233
|
+
const maxDepth = 6;
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const current = queue.shift();
|
|
236
|
+
if (!current)
|
|
237
|
+
break;
|
|
238
|
+
const { value, depth } = current;
|
|
239
|
+
if (!value || typeof value !== 'object')
|
|
240
|
+
continue;
|
|
241
|
+
if (depth > maxDepth)
|
|
242
|
+
continue;
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
for (const item of value) {
|
|
245
|
+
queue.push({ value: item, depth: depth + 1 });
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
for (const [key, val] of Object.entries(value)) {
|
|
250
|
+
if (keys.has(key) && typeof val === 'string' && path.isAbsolute(val)) {
|
|
251
|
+
return val;
|
|
252
|
+
}
|
|
253
|
+
if (val && typeof val === 'object') {
|
|
254
|
+
queue.push({ value: val, depth: depth + 1 });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
208
260
|
/**
|
|
209
261
|
* Parse a functionCall object to extract file changes
|
|
210
262
|
*/
|
|
@@ -232,9 +284,15 @@ export class GeminiScanner extends BaseScanner {
|
|
|
232
284
|
let linesRemoved = 0;
|
|
233
285
|
let addedLines = [];
|
|
234
286
|
if (writeOps.includes(funcName)) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
}
|
|
238
296
|
}
|
|
239
297
|
else if (editOps.includes(funcName)) {
|
|
240
298
|
// Use LCS for edits to be accurate
|
|
@@ -136,11 +136,13 @@ export class OpencodeScanner extends BaseScanner {
|
|
|
136
136
|
* Check if two paths match (session belongs to project)
|
|
137
137
|
*/
|
|
138
138
|
pathsMatch(sessionPath, projectPath) {
|
|
139
|
-
|
|
139
|
+
const s = this.toForwardSlash(sessionPath);
|
|
140
|
+
const p = this.toForwardSlash(projectPath);
|
|
141
|
+
if (s === p)
|
|
140
142
|
return true;
|
|
141
|
-
if (
|
|
143
|
+
if (s.startsWith(p + '/'))
|
|
142
144
|
return true;
|
|
143
|
-
if (
|
|
145
|
+
if (p.startsWith(s + '/'))
|
|
144
146
|
return true;
|
|
145
147
|
if (path.basename(sessionPath) === path.basename(projectPath))
|
|
146
148
|
return true;
|
|
@@ -160,9 +162,10 @@ export class OpencodeScanner extends BaseScanner {
|
|
|
160
162
|
: 'modify';
|
|
161
163
|
// Use opencode's provided diff stats if available (most accurate)
|
|
162
164
|
// additions/deletions are pre-calculated by opencode
|
|
165
|
+
const diffStats = this.diffLineCounts(beforeContent, afterContent);
|
|
163
166
|
const addedLines = this.diffAddedLines(beforeContent, afterContent);
|
|
164
|
-
const linesAdded = typeof diff.additions === 'number' ? diff.additions :
|
|
165
|
-
const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions :
|
|
167
|
+
const linesAdded = typeof diff.additions === 'number' ? diff.additions : diffStats.added;
|
|
168
|
+
const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : diffStats.removed;
|
|
166
169
|
return {
|
|
167
170
|
filePath,
|
|
168
171
|
linesAdded,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-credit",
|
|
3
|
-
"version": "1.0.
|
|
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
|
-
"
|
|
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": [
|
|
@@ -37,7 +40,8 @@
|
|
|
37
40
|
"chalk": "^5.3.0",
|
|
38
41
|
"cli-table3": "^0.6.5",
|
|
39
42
|
"commander": "^12.1.0",
|
|
40
|
-
"glob": "^10.4.5"
|
|
43
|
+
"glob": "^10.4.5",
|
|
44
|
+
"ignore": "^5.3.1"
|
|
41
45
|
},
|
|
42
46
|
"devDependencies": {
|
|
43
47
|
"@types/node": "^20.14.0",
|