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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AISession, AITool } from '../types.js';
|
|
2
|
+
import { BaseScanner } from './base.js';
|
|
3
|
+
/**
|
|
4
|
+
* Scanner for Gemini CLI sessions
|
|
5
|
+
*
|
|
6
|
+
* Gemini CLI stores session data in:
|
|
7
|
+
* ~/.gemini/tmp/<project_hash>/chats/*.json
|
|
8
|
+
* or ~/.gemini/history/*.json
|
|
9
|
+
*
|
|
10
|
+
* Each JSON file contains a complete conversation with messages
|
|
11
|
+
* that include functionCall objects for file operations.
|
|
12
|
+
*/
|
|
13
|
+
export declare class GeminiScanner extends BaseScanner {
|
|
14
|
+
get tool(): AITool;
|
|
15
|
+
get storagePath(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Hash project path to match Gemini's directory naming
|
|
18
|
+
*/
|
|
19
|
+
private hashProjectPath;
|
|
20
|
+
scan(projectPath: string): AISession[];
|
|
21
|
+
parseSessionFile(filePath: string, projectPath: string): AISession | null;
|
|
22
|
+
/**
|
|
23
|
+
* Check if two paths match or are related
|
|
24
|
+
*/
|
|
25
|
+
private pathsMatch;
|
|
26
|
+
/**
|
|
27
|
+
* Parse a functionCall object to extract file changes
|
|
28
|
+
*/
|
|
29
|
+
private parseFunctionCall;
|
|
30
|
+
/**
|
|
31
|
+
* Calculate lines added and removed using simple diff (LCS)
|
|
32
|
+
*/
|
|
33
|
+
private calculateDiffStats;
|
|
34
|
+
private computeLCS;
|
|
35
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
import { AITool } from '../types.js';
|
|
6
|
+
import { BaseScanner } from './base.js';
|
|
7
|
+
/**
|
|
8
|
+
* Scanner for Gemini CLI sessions
|
|
9
|
+
*
|
|
10
|
+
* Gemini CLI stores session data in:
|
|
11
|
+
* ~/.gemini/tmp/<project_hash>/chats/*.json
|
|
12
|
+
* or ~/.gemini/history/*.json
|
|
13
|
+
*
|
|
14
|
+
* Each JSON file contains a complete conversation with messages
|
|
15
|
+
* that include functionCall objects for file operations.
|
|
16
|
+
*/
|
|
17
|
+
export class GeminiScanner extends BaseScanner {
|
|
18
|
+
get tool() {
|
|
19
|
+
return AITool.GEMINI;
|
|
20
|
+
}
|
|
21
|
+
get storagePath() {
|
|
22
|
+
return '~/.gemini';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Hash project path to match Gemini's directory naming
|
|
26
|
+
*/
|
|
27
|
+
hashProjectPath(projectPath) {
|
|
28
|
+
return crypto.createHash('md5').update(projectPath).digest('hex').substring(0, 16);
|
|
29
|
+
}
|
|
30
|
+
scan(projectPath) {
|
|
31
|
+
const sessions = [];
|
|
32
|
+
const basePath = this.resolveStoragePath();
|
|
33
|
+
if (!fs.existsSync(basePath)) {
|
|
34
|
+
return sessions;
|
|
35
|
+
}
|
|
36
|
+
const possibleDirs = [];
|
|
37
|
+
// Check tmp directory structure
|
|
38
|
+
const tmpDir = path.join(basePath, 'tmp');
|
|
39
|
+
if (fs.existsSync(tmpDir)) {
|
|
40
|
+
try {
|
|
41
|
+
const allDirs = fs.readdirSync(tmpDir);
|
|
42
|
+
for (const dir of allDirs) {
|
|
43
|
+
const fullDir = path.join(tmpDir, dir);
|
|
44
|
+
if (fs.statSync(fullDir).isDirectory()) {
|
|
45
|
+
// Check for chats subdirectory
|
|
46
|
+
const chatsDir = path.join(fullDir, 'chats');
|
|
47
|
+
if (fs.existsSync(chatsDir)) {
|
|
48
|
+
possibleDirs.push(chatsDir);
|
|
49
|
+
}
|
|
50
|
+
// Also check the directory itself for JSON files
|
|
51
|
+
possibleDirs.push(fullDir);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Ignore errors
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Also check history directory
|
|
60
|
+
const historyDir = path.join(basePath, 'history');
|
|
61
|
+
if (fs.existsSync(historyDir)) {
|
|
62
|
+
possibleDirs.push(historyDir);
|
|
63
|
+
}
|
|
64
|
+
// Check sessions directory
|
|
65
|
+
const sessionsDir = path.join(basePath, 'sessions');
|
|
66
|
+
if (fs.existsSync(sessionsDir)) {
|
|
67
|
+
possibleDirs.push(sessionsDir);
|
|
68
|
+
}
|
|
69
|
+
for (const dir of possibleDirs) {
|
|
70
|
+
try {
|
|
71
|
+
const files = glob.sync('*.json', { cwd: dir });
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const session = this.parseSessionFile(path.join(dir, file), projectPath);
|
|
74
|
+
if (session && session.changes.length > 0) {
|
|
75
|
+
sessions.push(session);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Ignore errors
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return sessions;
|
|
84
|
+
}
|
|
85
|
+
parseSessionFile(filePath, projectPath) {
|
|
86
|
+
const data = this.readJsonFile(filePath);
|
|
87
|
+
if (!data)
|
|
88
|
+
return null;
|
|
89
|
+
const changes = [];
|
|
90
|
+
let sessionTimestamp = null;
|
|
91
|
+
let sessionProjectPath = null;
|
|
92
|
+
// Extract metadata from various possible fields
|
|
93
|
+
if (data.created_at) {
|
|
94
|
+
sessionTimestamp = new Date(data.created_at);
|
|
95
|
+
}
|
|
96
|
+
else if (data.timestamp) {
|
|
97
|
+
sessionTimestamp = new Date(data.timestamp);
|
|
98
|
+
}
|
|
99
|
+
else if (data.startTime) {
|
|
100
|
+
sessionTimestamp = new Date(data.startTime);
|
|
101
|
+
}
|
|
102
|
+
// Try to find project path from various fields
|
|
103
|
+
sessionProjectPath = data.projectPath || data.project_path || data.cwd || data.working_directory || null;
|
|
104
|
+
// Filter by project path if available
|
|
105
|
+
if (sessionProjectPath) {
|
|
106
|
+
const normalizedSessionPath = path.resolve(sessionProjectPath);
|
|
107
|
+
const normalizedProjectPath = path.resolve(projectPath);
|
|
108
|
+
if (!this.pathsMatch(normalizedSessionPath, normalizedProjectPath)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Extract model if available (check root or messages)
|
|
113
|
+
let sessionModel = data.model || data.defaultModel;
|
|
114
|
+
if (!sessionModel && data.messages) {
|
|
115
|
+
for (const msg of data.messages) {
|
|
116
|
+
if (msg.model) {
|
|
117
|
+
sessionModel = msg.model;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Parse messages from various possible structures
|
|
123
|
+
const messages = data.messages || data.turns || data.conversation || data.history || [];
|
|
124
|
+
for (const message of messages) {
|
|
125
|
+
// Check for assistant/model role
|
|
126
|
+
const role = message.role || message.type;
|
|
127
|
+
if (['assistant', 'model', 'ASSISTANT', 'gemini'].includes(role)) {
|
|
128
|
+
const messageTimestamp = message.timestamp ? new Date(message.timestamp) : sessionTimestamp;
|
|
129
|
+
const parts = message.parts || message.content || message.text || [];
|
|
130
|
+
const partsArray = Array.isArray(parts) ? parts : [parts];
|
|
131
|
+
for (const part of partsArray) {
|
|
132
|
+
// Check for function calls in various formats
|
|
133
|
+
if (part.functionCall) {
|
|
134
|
+
const change = this.parseFunctionCall(part.functionCall, projectPath, messageTimestamp, sessionModel);
|
|
135
|
+
if (change) {
|
|
136
|
+
changes.push(change);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Also check for tool_use format (similar to Claude)
|
|
140
|
+
if (part.type === 'tool_use' || part.type === 'function_call') {
|
|
141
|
+
const change = this.parseFunctionCall({
|
|
142
|
+
name: part.name,
|
|
143
|
+
args: part.input || part.args || part.arguments
|
|
144
|
+
}, projectPath, messageTimestamp, sessionModel);
|
|
145
|
+
if (change) {
|
|
146
|
+
changes.push(change);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Also check for tool_calls array format (snake_case)
|
|
152
|
+
if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
|
153
|
+
const messageTimestamp = message.timestamp ? new Date(message.timestamp) : sessionTimestamp;
|
|
154
|
+
for (const toolCall of message.tool_calls) {
|
|
155
|
+
const func = toolCall.function || toolCall;
|
|
156
|
+
const change = this.parseFunctionCall({
|
|
157
|
+
name: func.name,
|
|
158
|
+
args: typeof func.arguments === 'string' ? JSON.parse(func.arguments) : func.arguments
|
|
159
|
+
}, projectPath, messageTimestamp, sessionModel);
|
|
160
|
+
if (change) {
|
|
161
|
+
changes.push(change);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Check for toolCalls array format (camelCase - common in some Gemini versions)
|
|
166
|
+
if (message.toolCalls && Array.isArray(message.toolCalls)) {
|
|
167
|
+
const messageTimestamp = message.timestamp ? new Date(message.timestamp) : sessionTimestamp;
|
|
168
|
+
for (const toolCall of message.toolCalls) {
|
|
169
|
+
const change = this.parseFunctionCall({
|
|
170
|
+
name: toolCall.name,
|
|
171
|
+
args: toolCall.args
|
|
172
|
+
}, projectPath, messageTimestamp, sessionModel);
|
|
173
|
+
if (change) {
|
|
174
|
+
changes.push(change);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (changes.length === 0)
|
|
180
|
+
return null;
|
|
181
|
+
return {
|
|
182
|
+
id: this.generateSessionId(filePath),
|
|
183
|
+
tool: this.tool,
|
|
184
|
+
timestamp: sessionTimestamp || new Date(),
|
|
185
|
+
projectPath,
|
|
186
|
+
changes,
|
|
187
|
+
totalFilesChanged: new Set(changes.map(c => c.filePath)).size,
|
|
188
|
+
totalLinesAdded: changes.reduce((sum, c) => sum + c.linesAdded, 0),
|
|
189
|
+
totalLinesRemoved: changes.reduce((sum, c) => sum + c.linesRemoved, 0),
|
|
190
|
+
model: sessionModel,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Check if two paths match or are related
|
|
195
|
+
*/
|
|
196
|
+
pathsMatch(path1, path2) {
|
|
197
|
+
// Exact match
|
|
198
|
+
if (path1 === path2)
|
|
199
|
+
return true;
|
|
200
|
+
// One contains the other
|
|
201
|
+
if (path1.startsWith(path2) || path2.startsWith(path1))
|
|
202
|
+
return true;
|
|
203
|
+
// Same basename (project name)
|
|
204
|
+
if (path.basename(path1) === path.basename(path2))
|
|
205
|
+
return true;
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Parse a functionCall object to extract file changes
|
|
210
|
+
*/
|
|
211
|
+
parseFunctionCall(funcCall, projectPath, timestamp, model) {
|
|
212
|
+
if (!funcCall)
|
|
213
|
+
return null;
|
|
214
|
+
const funcName = (funcCall.name || '').toLowerCase();
|
|
215
|
+
const args = funcCall.args || funcCall.arguments || {};
|
|
216
|
+
// Supported operations - expanded list
|
|
217
|
+
const writeOps = ['write_file', 'create_file', 'write', 'save_file', 'create', 'writefile'];
|
|
218
|
+
const editOps = ['edit_file', 'update_file', 'modify_file', 'replace_in_file', 'edit', 'patch', 'apply_diff', 'replace'];
|
|
219
|
+
// Try various field names for file path
|
|
220
|
+
let filePath = args.path || args.file_path || args.filename || args.file || args.target || '';
|
|
221
|
+
let newContent = args.content || args.newContent || args.text || args.code || args.data || args.new_string || args.new_str || '';
|
|
222
|
+
let oldContent = args.oldContent || args.original || args.old_content || args.old_string || args.old_str || '';
|
|
223
|
+
if (!filePath)
|
|
224
|
+
return null;
|
|
225
|
+
// Normalize path
|
|
226
|
+
filePath = this.normalizePath(filePath, projectPath);
|
|
227
|
+
const changeType = (!oldContent && newContent) ? 'create'
|
|
228
|
+
: (oldContent && !newContent) ? 'delete'
|
|
229
|
+
: 'modify';
|
|
230
|
+
// Calculate diff stats
|
|
231
|
+
let linesAdded = 0;
|
|
232
|
+
let linesRemoved = 0;
|
|
233
|
+
if (writeOps.includes(funcName)) {
|
|
234
|
+
linesAdded = this.countLines(newContent);
|
|
235
|
+
linesRemoved = this.countLines(oldContent); // Usually 0 for write, unless overwriting
|
|
236
|
+
}
|
|
237
|
+
else if (editOps.includes(funcName)) {
|
|
238
|
+
// Use LCS for edits to be accurate
|
|
239
|
+
const stats = this.calculateDiffStats(oldContent, newContent);
|
|
240
|
+
linesAdded = stats.added;
|
|
241
|
+
linesRemoved = stats.removed;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (newContent) {
|
|
245
|
+
linesAdded = this.countLines(newContent);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (linesAdded === 0 && linesRemoved === 0)
|
|
249
|
+
return null;
|
|
250
|
+
return {
|
|
251
|
+
filePath,
|
|
252
|
+
linesAdded,
|
|
253
|
+
linesRemoved,
|
|
254
|
+
changeType: writeOps.includes(funcName) && !oldContent ? 'create' : 'modify',
|
|
255
|
+
timestamp: timestamp || new Date(),
|
|
256
|
+
tool: this.tool,
|
|
257
|
+
content: newContent,
|
|
258
|
+
model,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Calculate lines added and removed using simple diff (LCS)
|
|
263
|
+
*/
|
|
264
|
+
calculateDiffStats(before, after) {
|
|
265
|
+
if (!before)
|
|
266
|
+
return { added: this.countLines(after), removed: 0 };
|
|
267
|
+
if (!after)
|
|
268
|
+
return { added: 0, removed: this.countLines(before) };
|
|
269
|
+
const beforeLines = before.split(/\r?\n/);
|
|
270
|
+
const afterLines = after.split(/\r?\n/);
|
|
271
|
+
// Optimization: trim matching start
|
|
272
|
+
let start = 0;
|
|
273
|
+
while (start < beforeLines.length && start < afterLines.length && beforeLines[start] === afterLines[start]) {
|
|
274
|
+
start++;
|
|
275
|
+
}
|
|
276
|
+
// Optimization: trim matching end
|
|
277
|
+
let endBefore = beforeLines.length - 1;
|
|
278
|
+
let endAfter = afterLines.length - 1;
|
|
279
|
+
while (endBefore >= start && endAfter >= start && beforeLines[endBefore] === afterLines[endAfter]) {
|
|
280
|
+
endBefore--;
|
|
281
|
+
endAfter--;
|
|
282
|
+
}
|
|
283
|
+
const remainingBefore = beforeLines.slice(start, endBefore + 1);
|
|
284
|
+
const remainingAfter = afterLines.slice(start, endAfter + 1);
|
|
285
|
+
// If nothing remaining, no changes
|
|
286
|
+
if (remainingBefore.length === 0 && remainingAfter.length === 0) {
|
|
287
|
+
return { added: 0, removed: 0 };
|
|
288
|
+
}
|
|
289
|
+
// Calculate LCS on the remaining parts
|
|
290
|
+
const lcs = this.computeLCS(remainingBefore, remainingAfter);
|
|
291
|
+
return {
|
|
292
|
+
added: remainingAfter.length - lcs,
|
|
293
|
+
removed: remainingBefore.length - lcs
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
computeLCS(lines1, lines2) {
|
|
297
|
+
const m = lines1.length;
|
|
298
|
+
const n = lines2.length;
|
|
299
|
+
// Use two rows for O(min(M,N)) space
|
|
300
|
+
let prev = new Array(n + 1).fill(0);
|
|
301
|
+
let curr = new Array(n + 1).fill(0);
|
|
302
|
+
for (let i = 1; i <= m; i++) {
|
|
303
|
+
for (let j = 1; j <= n; j++) {
|
|
304
|
+
if (lines1[i - 1] === lines2[j - 1]) {
|
|
305
|
+
curr[j] = prev[j - 1] + 1;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
curr[j] = Math.max(prev[j], curr[j - 1]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Swap references
|
|
312
|
+
const temp = prev;
|
|
313
|
+
prev = curr;
|
|
314
|
+
curr = temp;
|
|
315
|
+
}
|
|
316
|
+
return prev[n];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { BaseScanner } from './base.js';
|
|
2
|
+
export { ClaudeScanner } from './claude.js';
|
|
3
|
+
export { CodexScanner } from './codex.js';
|
|
4
|
+
export { GeminiScanner } from './gemini.js';
|
|
5
|
+
export { AiderScanner } from './aider.js';
|
|
6
|
+
export { OpencodeScanner } from './opencode.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { BaseScanner } from './base.js';
|
|
2
|
+
export { ClaudeScanner } from './claude.js';
|
|
3
|
+
export { CodexScanner } from './codex.js';
|
|
4
|
+
export { GeminiScanner } from './gemini.js';
|
|
5
|
+
export { AiderScanner } from './aider.js';
|
|
6
|
+
export { OpencodeScanner } from './opencode.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AISession, AITool } from '../types.js';
|
|
2
|
+
import { BaseScanner } from './base.js';
|
|
3
|
+
/**
|
|
4
|
+
* Scanner for Opencode (opencode.ai) sessions
|
|
5
|
+
*
|
|
6
|
+
* Opencode stores session data in:
|
|
7
|
+
* ~/.local/share/opencode/storage/
|
|
8
|
+
*
|
|
9
|
+
* Structure:
|
|
10
|
+
* - storage/session/ - session metadata files (named by session ID)
|
|
11
|
+
* - storage/message/ - individual message files with file change diffs
|
|
12
|
+
* - storage/part/ - message parts
|
|
13
|
+
*
|
|
14
|
+
* File changes are recorded in message.info.summary.diffs with:
|
|
15
|
+
* - file: relative file path
|
|
16
|
+
* - before: previous content
|
|
17
|
+
* - after: new content
|
|
18
|
+
*/
|
|
19
|
+
export declare class OpencodeScanner extends BaseScanner {
|
|
20
|
+
get tool(): AITool;
|
|
21
|
+
get storagePath(): string;
|
|
22
|
+
scan(projectPath: string): AISession[];
|
|
23
|
+
parseSessionFile(filePath: string, projectPath: string): AISession | null;
|
|
24
|
+
/**
|
|
25
|
+
* Check if two paths match (session belongs to project)
|
|
26
|
+
*/
|
|
27
|
+
private pathsMatch;
|
|
28
|
+
/**
|
|
29
|
+
* Parse a diff object to extract file change
|
|
30
|
+
*/
|
|
31
|
+
private parseDiff;
|
|
32
|
+
/**
|
|
33
|
+
* Parse message data for file changes
|
|
34
|
+
*/
|
|
35
|
+
private parseMessageChanges;
|
|
36
|
+
/**
|
|
37
|
+
* Remove duplicate file changes
|
|
38
|
+
*/
|
|
39
|
+
private deduplicateChanges;
|
|
40
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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 Opencode (opencode.ai) sessions
|
|
8
|
+
*
|
|
9
|
+
* Opencode stores session data in:
|
|
10
|
+
* ~/.local/share/opencode/storage/
|
|
11
|
+
*
|
|
12
|
+
* Structure:
|
|
13
|
+
* - storage/session/ - session metadata files (named by session ID)
|
|
14
|
+
* - storage/message/ - individual message files with file change diffs
|
|
15
|
+
* - storage/part/ - message parts
|
|
16
|
+
*
|
|
17
|
+
* File changes are recorded in message.info.summary.diffs with:
|
|
18
|
+
* - file: relative file path
|
|
19
|
+
* - before: previous content
|
|
20
|
+
* - after: new content
|
|
21
|
+
*/
|
|
22
|
+
export class OpencodeScanner extends BaseScanner {
|
|
23
|
+
get tool() {
|
|
24
|
+
return AITool.OPENCODE;
|
|
25
|
+
}
|
|
26
|
+
get storagePath() {
|
|
27
|
+
return '~/.local/share/opencode';
|
|
28
|
+
}
|
|
29
|
+
scan(projectPath) {
|
|
30
|
+
const sessions = [];
|
|
31
|
+
const basePath = this.resolveStoragePath();
|
|
32
|
+
if (!fs.existsSync(basePath)) {
|
|
33
|
+
return sessions;
|
|
34
|
+
}
|
|
35
|
+
const sessionDir = path.join(basePath, 'storage', 'session');
|
|
36
|
+
if (!fs.existsSync(sessionDir)) {
|
|
37
|
+
return sessions;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
// Find all session files recursively (they are in subdirectories by project hash)
|
|
41
|
+
const sessionFiles = glob.sync('**/*.json', { cwd: sessionDir });
|
|
42
|
+
for (const file of sessionFiles) {
|
|
43
|
+
const session = this.parseSessionFile(path.join(sessionDir, file), projectPath);
|
|
44
|
+
if (session && session.changes.length > 0) {
|
|
45
|
+
sessions.push(session);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Ignore errors
|
|
51
|
+
}
|
|
52
|
+
return sessions;
|
|
53
|
+
}
|
|
54
|
+
parseSessionFile(filePath, projectPath) {
|
|
55
|
+
const sessionData = this.readJsonFile(filePath);
|
|
56
|
+
if (!sessionData)
|
|
57
|
+
return null;
|
|
58
|
+
const basePath = this.resolveStoragePath();
|
|
59
|
+
// Extract session info
|
|
60
|
+
const sessionId = sessionData.id || path.basename(filePath, '.json');
|
|
61
|
+
const sessionProjectPath = sessionData.directory || sessionData.projectPath || null;
|
|
62
|
+
const sessionTimestamp = sessionData.time?.created
|
|
63
|
+
? new Date(sessionData.time.created)
|
|
64
|
+
: new Date();
|
|
65
|
+
// Filter by project path
|
|
66
|
+
if (sessionProjectPath) {
|
|
67
|
+
const normalizedSessionPath = path.resolve(sessionProjectPath);
|
|
68
|
+
const normalizedProjectPath = path.resolve(projectPath);
|
|
69
|
+
if (!this.pathsMatch(normalizedSessionPath, normalizedProjectPath)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Parse file changes from messages
|
|
74
|
+
const changes = [];
|
|
75
|
+
let sessionModel;
|
|
76
|
+
// First, try to parse individual message files if available (more granular)
|
|
77
|
+
const messageDir = path.join(basePath, 'storage', 'message');
|
|
78
|
+
if (fs.existsSync(messageDir) && sessionData.id) {
|
|
79
|
+
try {
|
|
80
|
+
// Messages are stored in subdirectories by session ID
|
|
81
|
+
const sessionMessageDir = path.join(messageDir, sessionData.id);
|
|
82
|
+
if (fs.existsSync(sessionMessageDir)) {
|
|
83
|
+
const messageFiles = glob.sync('*.json', { cwd: sessionMessageDir });
|
|
84
|
+
for (const msgFile of messageFiles) {
|
|
85
|
+
const msgData = this.readJsonFile(path.join(sessionMessageDir, msgFile));
|
|
86
|
+
if (msgData?.sessionID === sessionData.id) {
|
|
87
|
+
// Extract model from message
|
|
88
|
+
const msgModel = msgData.model?.modelID;
|
|
89
|
+
if (msgModel && !sessionModel) {
|
|
90
|
+
sessionModel = msgModel;
|
|
91
|
+
}
|
|
92
|
+
const msgChanges = this.parseMessageChanges(msgData, projectPath, msgModel);
|
|
93
|
+
changes.push(...msgChanges);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Ignore errors
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// If no changes found in messages, fall back to session summary
|
|
103
|
+
if (changes.length === 0 && sessionData.summary?.diffs && Array.isArray(sessionData.summary.diffs)) {
|
|
104
|
+
for (const diff of sessionData.summary.diffs) {
|
|
105
|
+
const change = this.parseDiff(diff, projectPath, sessionTimestamp, sessionModel);
|
|
106
|
+
if (change) {
|
|
107
|
+
changes.push(change);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (changes.length === 0)
|
|
112
|
+
return null;
|
|
113
|
+
// Remove duplicate changes (same file, same content)
|
|
114
|
+
const uniqueChanges = this.deduplicateChanges(changes);
|
|
115
|
+
// Update all changes with the session model if found
|
|
116
|
+
if (sessionModel) {
|
|
117
|
+
for (const change of uniqueChanges) {
|
|
118
|
+
if (!change.model) {
|
|
119
|
+
change.model = sessionModel;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
id: sessionId,
|
|
125
|
+
tool: this.tool,
|
|
126
|
+
model: sessionModel,
|
|
127
|
+
timestamp: sessionTimestamp,
|
|
128
|
+
projectPath,
|
|
129
|
+
changes: uniqueChanges,
|
|
130
|
+
totalFilesChanged: new Set(uniqueChanges.map(c => c.filePath)).size,
|
|
131
|
+
totalLinesAdded: uniqueChanges.reduce((sum, c) => sum + c.linesAdded, 0),
|
|
132
|
+
totalLinesRemoved: uniqueChanges.reduce((sum, c) => sum + c.linesRemoved, 0),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Check if two paths match (session belongs to project)
|
|
137
|
+
*/
|
|
138
|
+
pathsMatch(sessionPath, projectPath) {
|
|
139
|
+
if (sessionPath === projectPath)
|
|
140
|
+
return true;
|
|
141
|
+
if (sessionPath.startsWith(projectPath + path.sep))
|
|
142
|
+
return true;
|
|
143
|
+
if (projectPath.startsWith(sessionPath + path.sep))
|
|
144
|
+
return true;
|
|
145
|
+
if (path.basename(sessionPath) === path.basename(projectPath))
|
|
146
|
+
return true;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Parse a diff object to extract file change
|
|
151
|
+
*/
|
|
152
|
+
parseDiff(diff, projectPath, timestamp, model) {
|
|
153
|
+
if (!diff || !diff.file)
|
|
154
|
+
return null;
|
|
155
|
+
const filePath = this.normalizePath(diff.file, projectPath);
|
|
156
|
+
const beforeContent = diff.before || '';
|
|
157
|
+
const afterContent = diff.after || '';
|
|
158
|
+
const changeType = (!beforeContent && afterContent) ? 'create'
|
|
159
|
+
: (beforeContent && !afterContent) ? 'delete'
|
|
160
|
+
: 'modify';
|
|
161
|
+
// Use opencode's provided diff stats if available (most accurate)
|
|
162
|
+
// additions/deletions are pre-calculated by opencode
|
|
163
|
+
const linesAdded = typeof diff.additions === 'number' ? diff.additions : 0;
|
|
164
|
+
const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : 0;
|
|
165
|
+
return {
|
|
166
|
+
filePath,
|
|
167
|
+
linesAdded,
|
|
168
|
+
linesRemoved,
|
|
169
|
+
changeType,
|
|
170
|
+
timestamp,
|
|
171
|
+
tool: this.tool,
|
|
172
|
+
model,
|
|
173
|
+
content: afterContent,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Parse message data for file changes
|
|
178
|
+
*/
|
|
179
|
+
parseMessageChanges(msgData, projectPath, model) {
|
|
180
|
+
const changes = [];
|
|
181
|
+
const timestamp = msgData.time?.created
|
|
182
|
+
? new Date(msgData.time.created)
|
|
183
|
+
: new Date();
|
|
184
|
+
const diffs = msgData.summary?.diffs;
|
|
185
|
+
if (diffs && Array.isArray(diffs)) {
|
|
186
|
+
for (const diff of diffs) {
|
|
187
|
+
const change = this.parseDiff(diff, projectPath, timestamp, model);
|
|
188
|
+
if (change) {
|
|
189
|
+
changes.push(change);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return changes;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Remove duplicate file changes
|
|
197
|
+
*/
|
|
198
|
+
deduplicateChanges(changes) {
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
const unique = [];
|
|
201
|
+
for (const change of changes) {
|
|
202
|
+
const key = `${change.filePath}-${change.linesAdded}-${change.linesRemoved}-${change.timestamp.getTime()}`;
|
|
203
|
+
if (!seen.has(key)) {
|
|
204
|
+
seen.add(key);
|
|
205
|
+
unique.push(change);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return unique;
|
|
209
|
+
}
|
|
210
|
+
}
|