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.
@@ -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
+ }