ai-credit 1.0.0
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/LICENSE +21 -0
- package/README.md +330 -0
- package/dist/analyzer.d.ts +37 -0
- package/dist/analyzer.js +357 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +383 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/reporter.d.ts +60 -0
- package/dist/reporter.js +372 -0
- package/dist/scanners/aider.d.ts +35 -0
- package/dist/scanners/aider.js +194 -0
- package/dist/scanners/base.d.ts +56 -0
- package/dist/scanners/base.js +88 -0
- package/dist/scanners/claude.d.ts +30 -0
- package/dist/scanners/claude.js +203 -0
- package/dist/scanners/codex.d.ts +54 -0
- package/dist/scanners/codex.js +311 -0
- package/dist/scanners/gemini.d.ts +35 -0
- package/dist/scanners/gemini.js +318 -0
- package/dist/scanners/index.d.ts +6 -0
- package/dist/scanners/index.js +6 -0
- package/dist/scanners/opencode.d.ts +40 -0
- package/dist/scanners/opencode.js +210 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.js +11 -0
- package/package.json +46 -0
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
import { AITool } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Tool display names
|
|
7
|
+
*/
|
|
8
|
+
const TOOL_NAMES = {
|
|
9
|
+
[AITool.CLAUDE_CODE]: 'Claude Code',
|
|
10
|
+
[AITool.CODEX]: 'Codex CLI',
|
|
11
|
+
[AITool.GEMINI]: 'Gemini CLI',
|
|
12
|
+
[AITool.AIDER]: 'Aider',
|
|
13
|
+
[AITool.OPENCODE]: 'Opencode',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Tool colors for console output
|
|
17
|
+
*/
|
|
18
|
+
const TOOL_COLORS = {
|
|
19
|
+
[AITool.CLAUDE_CODE]: chalk.hex('#D97757'),
|
|
20
|
+
[AITool.CODEX]: chalk.hex('#00A67E'),
|
|
21
|
+
[AITool.GEMINI]: chalk.hex('#4796E3'),
|
|
22
|
+
[AITool.AIDER]: chalk.hex('#D93B3B'),
|
|
23
|
+
[AITool.OPENCODE]: chalk.yellow,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Console reporter for terminal output
|
|
27
|
+
*/
|
|
28
|
+
export class ConsoleReporter {
|
|
29
|
+
/**
|
|
30
|
+
* Print the full summary report
|
|
31
|
+
*/
|
|
32
|
+
printSummary(stats) {
|
|
33
|
+
this.printHeader(stats);
|
|
34
|
+
this.printOverview(stats);
|
|
35
|
+
this.printToolBreakdown(stats);
|
|
36
|
+
this.printDistributionBar(stats);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Print report header
|
|
40
|
+
*/
|
|
41
|
+
printHeader(stats) {
|
|
42
|
+
const boxWidth = 50;
|
|
43
|
+
const title = 'AI Contribution Analysis';
|
|
44
|
+
const repoLine = `Repository: ${stats.repoPath}`;
|
|
45
|
+
const timeLine = `Scan time: ${stats.scanTime.toLocaleString()}`;
|
|
46
|
+
console.log();
|
|
47
|
+
console.log(chalk.cyan('╭' + '─'.repeat(boxWidth) + '╮'));
|
|
48
|
+
console.log(chalk.cyan('│') + ' ' + chalk.bold(title.padEnd(boxWidth - 1)) + chalk.cyan('│'));
|
|
49
|
+
console.log(chalk.cyan('│') + ' ' + repoLine.substring(0, boxWidth - 1).padEnd(boxWidth - 1) + chalk.cyan('│'));
|
|
50
|
+
console.log(chalk.cyan('│') + ' ' + timeLine.padEnd(boxWidth - 1) + chalk.cyan('│'));
|
|
51
|
+
console.log(chalk.cyan('╰' + '─'.repeat(boxWidth) + '╯'));
|
|
52
|
+
console.log();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Print overview statistics
|
|
56
|
+
*/
|
|
57
|
+
printOverview(stats) {
|
|
58
|
+
console.log(chalk.bold(' 📊 Overview'));
|
|
59
|
+
const table = new Table({
|
|
60
|
+
head: ['Metric', 'Value', 'AI Contribution'].map(h => chalk.bold(h)),
|
|
61
|
+
style: { head: [], border: [] },
|
|
62
|
+
});
|
|
63
|
+
const fileRatio = stats.totalFiles > 0
|
|
64
|
+
? ((stats.aiTouchedFiles / stats.totalFiles) * 100).toFixed(1)
|
|
65
|
+
: '0.0';
|
|
66
|
+
const lineRatio = stats.totalLines > 0
|
|
67
|
+
? ((stats.aiContributedLines / stats.totalLines) * 100).toFixed(1)
|
|
68
|
+
: '0.0';
|
|
69
|
+
table.push(['Total Files', stats.totalFiles.toString(), `${stats.aiTouchedFiles} (${fileRatio}%)`], ['Total Lines', stats.totalLines.toString(), `${stats.aiContributedLines} (${lineRatio}%)`], ['AI Sessions', stats.sessions.length.toString(), '-']);
|
|
70
|
+
console.log(table.toString());
|
|
71
|
+
console.log();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Print breakdown by AI tool
|
|
75
|
+
*/
|
|
76
|
+
printToolBreakdown(stats) {
|
|
77
|
+
if (stats.byTool.size === 0) {
|
|
78
|
+
console.log(chalk.yellow('No AI contributions found.'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(chalk.bold(' 🤖 Contribution by AI Tool'));
|
|
82
|
+
const table = new Table({
|
|
83
|
+
head: ['Tool / Model', 'Sessions', 'Files', 'Lines Added', 'Lines Removed', 'Share'].map(h => chalk.bold(h)),
|
|
84
|
+
style: { head: [], border: [] },
|
|
85
|
+
});
|
|
86
|
+
const totalLines = Array.from(stats.byTool.values())
|
|
87
|
+
.reduce((sum, t) => sum + t.linesAdded, 0);
|
|
88
|
+
for (const [tool, toolStats] of stats.byTool) {
|
|
89
|
+
const share = totalLines > 0
|
|
90
|
+
? ((toolStats.linesAdded / totalLines) * 100).toFixed(1)
|
|
91
|
+
: '0.0';
|
|
92
|
+
const color = TOOL_COLORS[tool] || chalk.white;
|
|
93
|
+
// Add tool row
|
|
94
|
+
table.push([
|
|
95
|
+
color(TOOL_NAMES[tool]),
|
|
96
|
+
toolStats.sessionsCount.toString(),
|
|
97
|
+
toolStats.totalFiles.toString(),
|
|
98
|
+
chalk.green(`+${toolStats.linesAdded}`),
|
|
99
|
+
chalk.red(`-${toolStats.linesRemoved}`),
|
|
100
|
+
`${share}%`,
|
|
101
|
+
]);
|
|
102
|
+
// Add model rows (if known and more than just "unknown" or if explicitly wanted)
|
|
103
|
+
if (toolStats.byModel.size > 0) {
|
|
104
|
+
// Sort models by lines added
|
|
105
|
+
const sortedModels = Array.from(toolStats.byModel.entries())
|
|
106
|
+
.sort((a, b) => b[1].linesAdded - a[1].linesAdded);
|
|
107
|
+
for (const [modelName, modelStats] of sortedModels) {
|
|
108
|
+
// Skip if model is 'unknown' and it's the only one (redundant)
|
|
109
|
+
if (modelName === 'unknown' && toolStats.byModel.size === 1)
|
|
110
|
+
continue;
|
|
111
|
+
const modelShare = toolStats.linesAdded > 0
|
|
112
|
+
? ((modelStats.linesAdded / toolStats.linesAdded) * 100).toFixed(1)
|
|
113
|
+
: '0.0';
|
|
114
|
+
table.push([
|
|
115
|
+
chalk.dim(` └─ ${modelName}`),
|
|
116
|
+
chalk.dim(modelStats.sessionsCount.toString()),
|
|
117
|
+
chalk.dim(modelStats.totalFiles.toString()),
|
|
118
|
+
chalk.dim(`+${modelStats.linesAdded}`),
|
|
119
|
+
chalk.dim(`-${modelStats.linesRemoved}`),
|
|
120
|
+
chalk.dim(`${modelShare}% (of tool)`),
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.log(table.toString());
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Print distribution pie chart showing all code proportions
|
|
130
|
+
*/
|
|
131
|
+
printDistributionBar(stats) {
|
|
132
|
+
if (stats.totalLines === 0)
|
|
133
|
+
return;
|
|
134
|
+
console.log(chalk.bold('📈 Contribution Distribution'));
|
|
135
|
+
console.log();
|
|
136
|
+
// Build slices: proportion each AI tool's share of repo lines + Unknown/Human
|
|
137
|
+
// Now using Verified Lines (Verified Existence logic)
|
|
138
|
+
const slices = [];
|
|
139
|
+
for (const [tool, toolStats] of stats.byTool) {
|
|
140
|
+
if (toolStats.verifiedLines > 0) {
|
|
141
|
+
const color = TOOL_COLORS[tool] || chalk.white;
|
|
142
|
+
slices.push({ label: TOOL_NAMES[tool], value: toolStats.verifiedLines, color: (s) => color(s) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const humanLines = Math.max(0, stats.totalLines - stats.aiContributedLines);
|
|
146
|
+
if (humanLines > 0) {
|
|
147
|
+
slices.push({ label: 'Unknown/Human', value: humanLines, color: (s) => chalk.gray(s) });
|
|
148
|
+
}
|
|
149
|
+
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
|
150
|
+
// Note: total should be approx stats.totalLines (or equal if we assume aiContributedLines is sum of verifiedLines)
|
|
151
|
+
if (total === 0)
|
|
152
|
+
return;
|
|
153
|
+
// Render stacked horizontal bar
|
|
154
|
+
const barWidth = 60;
|
|
155
|
+
let bar = '';
|
|
156
|
+
const segments = [];
|
|
157
|
+
for (const slice of slices) {
|
|
158
|
+
const width = Math.max(1, Math.round((slice.value / total) * barWidth));
|
|
159
|
+
segments.push({ width, color: slice.color });
|
|
160
|
+
}
|
|
161
|
+
// Adjust rounding so total width matches barWidth
|
|
162
|
+
const totalWidth = segments.reduce((s, seg) => s + seg.width, 0);
|
|
163
|
+
if (totalWidth !== barWidth && segments.length > 0) {
|
|
164
|
+
// Just subtract/add from the largest or last segment to make it fit
|
|
165
|
+
segments[segments.length - 1].width += barWidth - totalWidth;
|
|
166
|
+
}
|
|
167
|
+
for (const seg of segments) {
|
|
168
|
+
// Ensure width is not negative if adjustment failed heavily
|
|
169
|
+
if (seg.width > 0) {
|
|
170
|
+
bar += seg.color('█'.repeat(seg.width));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
console.log(` ${bar}`);
|
|
174
|
+
console.log();
|
|
175
|
+
// Legend with percentage bars per slice
|
|
176
|
+
for (const slice of slices) {
|
|
177
|
+
const pct = (slice.value / total) * 100;
|
|
178
|
+
const dot = slice.color('●');
|
|
179
|
+
console.log(` ${dot} ${slice.label.padEnd(14)} ${pct.toFixed(1).padStart(5)}% (${slice.value} lines)`);
|
|
180
|
+
}
|
|
181
|
+
console.log();
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Print file-level statistics
|
|
185
|
+
*/
|
|
186
|
+
printFiles(stats, limit = 20) {
|
|
187
|
+
console.log(chalk.bold(' 📁 Top AI-Contributed Files'));
|
|
188
|
+
const table = new Table({
|
|
189
|
+
head: ['File', 'Total Lines', 'AI Lines', 'AI Ratio', 'Contributors'].map(h => chalk.bold(h)),
|
|
190
|
+
style: { head: [], border: [] },
|
|
191
|
+
colWidths: [30, 12, 10, 10, 15],
|
|
192
|
+
});
|
|
193
|
+
// Sort files by AI contribution ratio
|
|
194
|
+
const sortedFiles = Array.from(stats.byFile.entries())
|
|
195
|
+
.filter(([, s]) => s.aiContributedLines > 0)
|
|
196
|
+
.sort((a, b) => b[1].aiContributionRatio - a[1].aiContributionRatio)
|
|
197
|
+
.slice(0, limit);
|
|
198
|
+
for (const [filePath, fileStats] of sortedFiles) {
|
|
199
|
+
const ratio = (fileStats.aiContributionRatio * 100).toFixed(1) + '%';
|
|
200
|
+
const contributors = Array.from(fileStats.contributions.keys())
|
|
201
|
+
.map(t => TOOL_NAMES[t])
|
|
202
|
+
.join(', ');
|
|
203
|
+
// Truncate long file paths
|
|
204
|
+
const displayPath = filePath.length > 27
|
|
205
|
+
? '...' + filePath.slice(-24)
|
|
206
|
+
: filePath;
|
|
207
|
+
table.push([
|
|
208
|
+
displayPath,
|
|
209
|
+
fileStats.totalLines.toString(),
|
|
210
|
+
fileStats.aiContributedLines.toString(),
|
|
211
|
+
ratio,
|
|
212
|
+
contributors.length > 12 ? contributors.slice(0, 12) + '...' : contributors,
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
console.log(table.toString());
|
|
216
|
+
console.log();
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Print timeline of AI activity
|
|
220
|
+
*/
|
|
221
|
+
printTimeline(stats, limit = 20) {
|
|
222
|
+
console.log(chalk.bold(' 📅 Recent AI Activity'));
|
|
223
|
+
const table = new Table({
|
|
224
|
+
head: ['Date', 'Tool', 'Files', 'Changes'].map(h => chalk.bold(h)),
|
|
225
|
+
style: { head: [], border: [] },
|
|
226
|
+
});
|
|
227
|
+
const recentSessions = stats.sessions.slice(-limit).reverse();
|
|
228
|
+
for (const session of recentSessions) {
|
|
229
|
+
const date = session.timestamp.toLocaleString('en-US', {
|
|
230
|
+
year: 'numeric',
|
|
231
|
+
month: '2-digit',
|
|
232
|
+
day: '2-digit',
|
|
233
|
+
hour: '2-digit',
|
|
234
|
+
minute: '2-digit',
|
|
235
|
+
});
|
|
236
|
+
const color = TOOL_COLORS[session.tool] || chalk.white;
|
|
237
|
+
table.push([
|
|
238
|
+
chalk.dim(date),
|
|
239
|
+
color(TOOL_NAMES[session.tool]),
|
|
240
|
+
session.totalFilesChanged.toString(),
|
|
241
|
+
chalk.green(`+${session.totalLinesAdded}`) + ' ' + chalk.red(`-${session.totalLinesRemoved}`),
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
console.log(table.toString());
|
|
245
|
+
console.log();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* JSON reporter for structured output
|
|
250
|
+
*/
|
|
251
|
+
export class JsonReporter {
|
|
252
|
+
/**
|
|
253
|
+
* Generate JSON report
|
|
254
|
+
*/
|
|
255
|
+
generate(stats) {
|
|
256
|
+
const output = {
|
|
257
|
+
repo_path: stats.repoPath,
|
|
258
|
+
scan_time: stats.scanTime.toISOString(),
|
|
259
|
+
overview: {
|
|
260
|
+
total_files: stats.totalFiles,
|
|
261
|
+
total_lines: stats.totalLines,
|
|
262
|
+
ai_touched_files: stats.aiTouchedFiles,
|
|
263
|
+
ai_contributed_lines: stats.aiContributedLines,
|
|
264
|
+
ai_file_ratio: stats.totalFiles > 0 ? stats.aiTouchedFiles / stats.totalFiles : 0,
|
|
265
|
+
ai_line_ratio: stats.totalLines > 0 ? stats.aiContributedLines / stats.totalLines : 0,
|
|
266
|
+
total_sessions: stats.sessions.length,
|
|
267
|
+
},
|
|
268
|
+
by_tool: Object.fromEntries(Array.from(stats.byTool.entries()).map(([tool, toolStats]) => [
|
|
269
|
+
tool,
|
|
270
|
+
{
|
|
271
|
+
sessions_count: toolStats.sessionsCount,
|
|
272
|
+
files_created: toolStats.filesCreated,
|
|
273
|
+
files_modified: toolStats.filesModified,
|
|
274
|
+
total_files: toolStats.totalFiles,
|
|
275
|
+
lines_added: toolStats.linesAdded,
|
|
276
|
+
lines_removed: toolStats.linesRemoved,
|
|
277
|
+
net_lines: toolStats.netLines,
|
|
278
|
+
},
|
|
279
|
+
])),
|
|
280
|
+
by_file: Object.fromEntries(Array.from(stats.byFile.entries())
|
|
281
|
+
.filter(([, s]) => s.aiContributedLines > 0)
|
|
282
|
+
.map(([filePath, fileStats]) => [
|
|
283
|
+
filePath,
|
|
284
|
+
{
|
|
285
|
+
total_lines: fileStats.totalLines,
|
|
286
|
+
ai_contributed_lines: fileStats.aiContributedLines,
|
|
287
|
+
ai_contribution_ratio: fileStats.aiContributionRatio,
|
|
288
|
+
contributions: Object.fromEntries(fileStats.contributions),
|
|
289
|
+
},
|
|
290
|
+
])),
|
|
291
|
+
};
|
|
292
|
+
return JSON.stringify(output, null, 2);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Save JSON report to file
|
|
296
|
+
*/
|
|
297
|
+
save(stats, outputPath) {
|
|
298
|
+
const json = this.generate(stats);
|
|
299
|
+
fs.writeFileSync(outputPath, json, 'utf-8');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Markdown reporter for documentation
|
|
304
|
+
*/
|
|
305
|
+
export class MarkdownReporter {
|
|
306
|
+
/**
|
|
307
|
+
* Generate Markdown report
|
|
308
|
+
*/
|
|
309
|
+
generate(stats) {
|
|
310
|
+
const lines = [];
|
|
311
|
+
lines.push('# AI Contribution Report');
|
|
312
|
+
lines.push('');
|
|
313
|
+
lines.push(`**Repository:** \`${stats.repoPath}\``);
|
|
314
|
+
lines.push(`**Generated:** ${stats.scanTime.toLocaleString()}`);
|
|
315
|
+
lines.push('');
|
|
316
|
+
// Overview
|
|
317
|
+
lines.push('## Overview');
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push('| Metric | Total | AI Contribution |');
|
|
320
|
+
lines.push('|--------|-------|-----------------|');
|
|
321
|
+
const fileRatio = stats.totalFiles > 0
|
|
322
|
+
? ((stats.aiTouchedFiles / stats.totalFiles) * 100).toFixed(1)
|
|
323
|
+
: '0.0';
|
|
324
|
+
const lineRatio = stats.totalLines > 0
|
|
325
|
+
? ((stats.aiContributedLines / stats.totalLines) * 100).toFixed(1)
|
|
326
|
+
: '0.0';
|
|
327
|
+
lines.push(`| Files | ${stats.totalFiles} | ${stats.aiTouchedFiles} (${fileRatio}%) |`);
|
|
328
|
+
lines.push(`| Lines | ${stats.totalLines} | ${stats.aiContributedLines} (${lineRatio}%) |`);
|
|
329
|
+
lines.push(`| Sessions | ${stats.sessions.length} | - |`);
|
|
330
|
+
lines.push('');
|
|
331
|
+
// By Tool
|
|
332
|
+
if (stats.byTool.size > 0) {
|
|
333
|
+
lines.push('## Contribution by AI Tool');
|
|
334
|
+
lines.push('');
|
|
335
|
+
lines.push('| Tool | Sessions | Files | Lines Added | Lines Removed | Share |');
|
|
336
|
+
lines.push('|------|----------|-------|-------------|---------------|-------|');
|
|
337
|
+
const totalLines = Array.from(stats.byTool.values())
|
|
338
|
+
.reduce((sum, t) => sum + t.linesAdded, 0);
|
|
339
|
+
for (const [tool, toolStats] of stats.byTool) {
|
|
340
|
+
const share = totalLines > 0
|
|
341
|
+
? ((toolStats.linesAdded / totalLines) * 100).toFixed(1)
|
|
342
|
+
: '0.0';
|
|
343
|
+
lines.push(`| ${TOOL_NAMES[tool]} | ${toolStats.sessionsCount} | ${toolStats.totalFiles} | +${toolStats.linesAdded} | -${toolStats.linesRemoved} | ${share}% |`);
|
|
344
|
+
}
|
|
345
|
+
lines.push('');
|
|
346
|
+
}
|
|
347
|
+
// Top Files
|
|
348
|
+
const topFiles = Array.from(stats.byFile.entries())
|
|
349
|
+
.filter(([, s]) => s.aiContributedLines > 0)
|
|
350
|
+
.sort((a, b) => b[1].aiContributionRatio - a[1].aiContributionRatio)
|
|
351
|
+
.slice(0, 10);
|
|
352
|
+
if (topFiles.length > 0) {
|
|
353
|
+
lines.push('## Top AI-Contributed Files');
|
|
354
|
+
lines.push('');
|
|
355
|
+
lines.push('| File | Total Lines | AI Lines | AI Ratio |');
|
|
356
|
+
lines.push('|------|-------------|----------|----------|');
|
|
357
|
+
for (const [filePath, fileStats] of topFiles) {
|
|
358
|
+
const ratio = (fileStats.aiContributionRatio * 100).toFixed(1) + '%';
|
|
359
|
+
lines.push(`| \`${filePath}\` | ${fileStats.totalLines} | ${fileStats.aiContributedLines} | ${ratio} |`);
|
|
360
|
+
}
|
|
361
|
+
lines.push('');
|
|
362
|
+
}
|
|
363
|
+
return lines.join('\n');
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Save Markdown report to file
|
|
367
|
+
*/
|
|
368
|
+
save(stats, outputPath) {
|
|
369
|
+
const markdown = this.generate(stats);
|
|
370
|
+
fs.writeFileSync(outputPath, markdown, 'utf-8');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -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,194 @@
|
|
|
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
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Pattern 2: SEARCH/REPLACE blocks (Aider's edit format)
|
|
121
|
+
// Look for file context before SEARCH/REPLACE
|
|
122
|
+
const fileEditRegex = /(?:^|\n)([^\n]+\.[a-zA-Z]+)\n```[^\n]*\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
|
|
123
|
+
while ((match = fileEditRegex.exec(content)) !== null) {
|
|
124
|
+
const [, filePathRaw, searchContent, replaceContent] = match;
|
|
125
|
+
const cleanPath = filePathRaw.trim();
|
|
126
|
+
if (cleanPath && !cleanPath.includes(' ')) {
|
|
127
|
+
const linesRemoved = this.countLines(searchContent);
|
|
128
|
+
const linesAdded = this.countLines(replaceContent);
|
|
129
|
+
if (linesAdded > 0 || linesRemoved > 0) {
|
|
130
|
+
changes.push({
|
|
131
|
+
filePath: this.normalizePath(cleanPath, projectPath),
|
|
132
|
+
linesAdded,
|
|
133
|
+
linesRemoved,
|
|
134
|
+
changeType: 'modify',
|
|
135
|
+
timestamp: sessionTimestamp,
|
|
136
|
+
tool: this.tool,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Pattern 3: Standalone SEARCH/REPLACE without file context
|
|
142
|
+
const standaloneEditRegex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
|
|
143
|
+
while ((match = standaloneEditRegex.exec(content)) !== null) {
|
|
144
|
+
const [fullMatch, searchContent, replaceContent] = match;
|
|
145
|
+
// Skip if already captured by fileEditRegex
|
|
146
|
+
if (content.indexOf(fullMatch) !== match.index)
|
|
147
|
+
continue;
|
|
148
|
+
const linesRemoved = this.countLines(searchContent);
|
|
149
|
+
const linesAdded = this.countLines(replaceContent);
|
|
150
|
+
if (linesAdded > 0 || linesRemoved > 0) {
|
|
151
|
+
changes.push({
|
|
152
|
+
filePath: 'unknown',
|
|
153
|
+
linesAdded,
|
|
154
|
+
linesRemoved,
|
|
155
|
+
changeType: 'modify',
|
|
156
|
+
timestamp: sessionTimestamp,
|
|
157
|
+
tool: this.tool,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Deduplicate changes by file path
|
|
162
|
+
const uniqueChanges = this.deduplicateChanges(changes);
|
|
163
|
+
if (uniqueChanges.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
return {
|
|
166
|
+
id: this.generateSessionId(filePath),
|
|
167
|
+
tool: this.tool,
|
|
168
|
+
timestamp: sessionTimestamp,
|
|
169
|
+
projectPath,
|
|
170
|
+
changes: uniqueChanges,
|
|
171
|
+
totalFilesChanged: new Set(uniqueChanges.map(c => c.filePath)).size,
|
|
172
|
+
totalLinesAdded: uniqueChanges.reduce((sum, c) => sum + c.linesAdded, 0),
|
|
173
|
+
totalLinesRemoved: uniqueChanges.reduce((sum, c) => sum + c.linesRemoved, 0),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Deduplicate changes, keeping the latest for each file
|
|
178
|
+
*/
|
|
179
|
+
deduplicateChanges(changes) {
|
|
180
|
+
const byFile = new Map();
|
|
181
|
+
for (const change of changes) {
|
|
182
|
+
const existing = byFile.get(change.filePath);
|
|
183
|
+
if (!existing) {
|
|
184
|
+
byFile.set(change.filePath, { ...change });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// Accumulate lines
|
|
188
|
+
existing.linesAdded += change.linesAdded;
|
|
189
|
+
existing.linesRemoved += change.linesRemoved;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return Array.from(byFile.values()).filter(c => c.filePath !== 'unknown');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AISession, AITool } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Base class for AI tool scanners
|
|
4
|
+
*/
|
|
5
|
+
export declare abstract class BaseScanner {
|
|
6
|
+
protected homeDir: string;
|
|
7
|
+
constructor();
|
|
8
|
+
/**
|
|
9
|
+
* The AI tool this scanner handles
|
|
10
|
+
*/
|
|
11
|
+
abstract get tool(): AITool;
|
|
12
|
+
/**
|
|
13
|
+
* The storage path for this tool's session data
|
|
14
|
+
*/
|
|
15
|
+
abstract get storagePath(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Check if this tool has data available
|
|
18
|
+
*/
|
|
19
|
+
isAvailable(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the full storage path
|
|
22
|
+
*/
|
|
23
|
+
protected resolveStoragePath(): string;
|
|
24
|
+
/**
|
|
25
|
+
* Scan for sessions related to a specific project
|
|
26
|
+
*/
|
|
27
|
+
abstract scan(projectPath: string): AISession[];
|
|
28
|
+
/**
|
|
29
|
+
* Parse a session file and extract file changes
|
|
30
|
+
*/
|
|
31
|
+
abstract parseSessionFile(filePath: string, projectPath: string): AISession | null;
|
|
32
|
+
/**
|
|
33
|
+
* Count lines in a string
|
|
34
|
+
*/
|
|
35
|
+
protected countLines(content: string | undefined): number;
|
|
36
|
+
/**
|
|
37
|
+
* Normalize file path relative to project
|
|
38
|
+
*/
|
|
39
|
+
protected normalizePath(filePath: string, projectPath: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Check if a file path belongs to the project
|
|
42
|
+
*/
|
|
43
|
+
protected isProjectFile(filePath: string, projectPath: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Read and parse JSON file safely
|
|
46
|
+
*/
|
|
47
|
+
protected readJsonFile(filePath: string): any;
|
|
48
|
+
/**
|
|
49
|
+
* Read and parse JSONL file safely
|
|
50
|
+
*/
|
|
51
|
+
protected readJsonlFile(filePath: string): any[];
|
|
52
|
+
/**
|
|
53
|
+
* Generate a unique session ID
|
|
54
|
+
*/
|
|
55
|
+
protected generateSessionId(filePath: string): string;
|
|
56
|
+
}
|