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,88 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ /**
5
+ * Base class for AI tool scanners
6
+ */
7
+ export class BaseScanner {
8
+ homeDir;
9
+ constructor() {
10
+ this.homeDir = os.homedir();
11
+ }
12
+ /**
13
+ * Check if this tool has data available
14
+ */
15
+ isAvailable() {
16
+ const fullPath = this.resolveStoragePath();
17
+ return fs.existsSync(fullPath);
18
+ }
19
+ /**
20
+ * Resolve the full storage path
21
+ */
22
+ resolveStoragePath() {
23
+ return this.storagePath.replace('~', this.homeDir);
24
+ }
25
+ /**
26
+ * Count lines in a string
27
+ */
28
+ countLines(content) {
29
+ if (!content)
30
+ return 0;
31
+ return content.split('\n').filter(line => line.length > 0).length;
32
+ }
33
+ /**
34
+ * Normalize file path relative to project
35
+ */
36
+ normalizePath(filePath, projectPath) {
37
+ if (path.isAbsolute(filePath)) {
38
+ return path.relative(projectPath, filePath);
39
+ }
40
+ return filePath;
41
+ }
42
+ /**
43
+ * Check if a file path belongs to the project
44
+ */
45
+ isProjectFile(filePath, projectPath) {
46
+ const normalizedPath = this.normalizePath(filePath, projectPath);
47
+ // Exclude paths that go outside the project
48
+ return !normalizedPath.startsWith('..');
49
+ }
50
+ /**
51
+ * Read and parse JSON file safely
52
+ */
53
+ readJsonFile(filePath) {
54
+ try {
55
+ const content = fs.readFileSync(filePath, 'utf-8');
56
+ return JSON.parse(content);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Read and parse JSONL file safely
64
+ */
65
+ readJsonlFile(filePath) {
66
+ try {
67
+ const content = fs.readFileSync(filePath, 'utf-8');
68
+ const lines = content.split('\n').filter(line => line.trim());
69
+ return lines.map(line => {
70
+ try {
71
+ return JSON.parse(line);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }).filter(Boolean);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ /**
83
+ * Generate a unique session ID
84
+ */
85
+ generateSessionId(filePath) {
86
+ return `${this.tool}-${path.basename(filePath, path.extname(filePath))}`;
87
+ }
88
+ }
@@ -0,0 +1,30 @@
1
+ import { AISession, AITool } from '../types.js';
2
+ import { BaseScanner } from './base.js';
3
+ /**
4
+ * Scanner for Claude Code sessions
5
+ *
6
+ * Claude Code stores session data in:
7
+ * ~/.claude/projects/<path-encoded-project-name>/*.jsonl
8
+ *
9
+ * Each JSONL file contains conversation turns with tool_use blocks
10
+ * that record file operations (write, edit, etc.)
11
+ */
12
+ export declare class ClaudeScanner extends BaseScanner {
13
+ get tool(): AITool;
14
+ get storagePath(): string;
15
+ /**
16
+ * Encode project path to match Claude's directory naming convention
17
+ * Claude encodes paths by replacing / with -
18
+ */
19
+ private encodeProjectPath;
20
+ /**
21
+ * Decode Claude's directory name back to a path
22
+ */
23
+ private decodeProjectPath;
24
+ scan(projectPath: string): AISession[];
25
+ parseSessionFile(filePath: string, projectPath: string): AISession | null;
26
+ /**
27
+ * Parse a tool_use block to extract file changes
28
+ */
29
+ private parseToolUse;
30
+ }
@@ -0,0 +1,203 @@
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 Claude Code sessions
8
+ *
9
+ * Claude Code stores session data in:
10
+ * ~/.claude/projects/<path-encoded-project-name>/*.jsonl
11
+ *
12
+ * Each JSONL file contains conversation turns with tool_use blocks
13
+ * that record file operations (write, edit, etc.)
14
+ */
15
+ export class ClaudeScanner extends BaseScanner {
16
+ get tool() {
17
+ return AITool.CLAUDE_CODE;
18
+ }
19
+ get storagePath() {
20
+ return '~/.claude/projects';
21
+ }
22
+ /**
23
+ * Encode project path to match Claude's directory naming convention
24
+ * Claude encodes paths by replacing / with -
25
+ */
26
+ encodeProjectPath(projectPath) {
27
+ return projectPath.replace(/\//g, '-').replace(/^-/, '');
28
+ }
29
+ /**
30
+ * Decode Claude's directory name back to a path
31
+ */
32
+ decodeProjectPath(encodedPath) {
33
+ return '/' + encodedPath.replace(/-/g, '/');
34
+ }
35
+ scan(projectPath) {
36
+ const sessions = [];
37
+ const basePath = this.resolveStoragePath();
38
+ if (!fs.existsSync(basePath)) {
39
+ return sessions;
40
+ }
41
+ // Try to find the project directory
42
+ const encodedPath = this.encodeProjectPath(projectPath);
43
+ const projectDir = path.join(basePath, encodedPath);
44
+ const projectBasename = path.basename(projectPath);
45
+ // Collect all possible matching directories
46
+ const possibleDirs = new Set();
47
+ // Add exact match
48
+ if (fs.existsSync(projectDir)) {
49
+ possibleDirs.add(projectDir);
50
+ }
51
+ // Scan all directories to find matches
52
+ try {
53
+ const allDirs = fs.readdirSync(basePath);
54
+ for (const dir of allDirs) {
55
+ const fullDir = path.join(basePath, dir);
56
+ if (!fs.statSync(fullDir).isDirectory())
57
+ continue;
58
+ // Check various matching criteria
59
+ const decodedPath = this.decodeProjectPath(dir);
60
+ // Match by:
61
+ // 1. Directory name contains project basename
62
+ // 2. Decoded path ends with project path
63
+ // 3. Project path ends with decoded path
64
+ // 4. Same basename
65
+ if (dir.includes(projectBasename) ||
66
+ dir.toLowerCase().includes(projectBasename.toLowerCase()) ||
67
+ decodedPath.endsWith(projectPath) ||
68
+ projectPath.endsWith(decodedPath.slice(1)) ||
69
+ path.basename(decodedPath) === projectBasename) {
70
+ possibleDirs.add(fullDir);
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // Ignore errors
76
+ }
77
+ // Parse all session files from matching directories
78
+ for (const dir of possibleDirs) {
79
+ try {
80
+ const files = glob.sync('*.jsonl', { cwd: dir });
81
+ for (const file of files) {
82
+ const session = this.parseSessionFile(path.join(dir, file), projectPath);
83
+ if (session && session.changes.length > 0) {
84
+ sessions.push(session);
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ // Ignore errors
90
+ }
91
+ }
92
+ return sessions;
93
+ }
94
+ parseSessionFile(filePath, projectPath) {
95
+ const entries = this.readJsonlFile(filePath);
96
+ if (entries.length === 0)
97
+ return null;
98
+ const changes = [];
99
+ let sessionTimestamp = null;
100
+ let sessionModel = undefined;
101
+ for (const entry of entries) {
102
+ // Extract timestamp from various possible fields
103
+ if (!sessionTimestamp) {
104
+ if (entry.timestamp) {
105
+ sessionTimestamp = new Date(entry.timestamp);
106
+ }
107
+ else if (entry.created_at) {
108
+ sessionTimestamp = new Date(entry.created_at);
109
+ }
110
+ }
111
+ // Extract model (try entry.model or entry.message.model)
112
+ if (!sessionModel) {
113
+ if (entry.model) {
114
+ sessionModel = entry.model;
115
+ }
116
+ else if (entry.message && entry.message.model) {
117
+ sessionModel = entry.message.model;
118
+ }
119
+ }
120
+ // Look for assistant messages with tool_use
121
+ if (entry.type === 'assistant' && entry.message?.content) {
122
+ const content = Array.isArray(entry.message.content)
123
+ ? entry.message.content
124
+ : [entry.message.content];
125
+ for (const block of content) {
126
+ if (block.type === 'tool_use') {
127
+ const change = this.parseToolUse(block, projectPath, entry.timestamp, sessionModel);
128
+ if (change) {
129
+ changes.push(change);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ // Also check for tool_result entries that might contain file info
135
+ if (entry.type === 'tool_result' && entry.content) {
136
+ // Tool results might indicate successful file operations
137
+ // but we primarily track from tool_use
138
+ }
139
+ }
140
+ if (changes.length === 0)
141
+ return null;
142
+ return {
143
+ id: this.generateSessionId(filePath),
144
+ tool: this.tool,
145
+ timestamp: sessionTimestamp || new Date(),
146
+ projectPath,
147
+ changes,
148
+ totalFilesChanged: new Set(changes.map(c => c.filePath)).size,
149
+ totalLinesAdded: changes.reduce((sum, c) => sum + c.linesAdded, 0),
150
+ totalLinesRemoved: changes.reduce((sum, c) => sum + c.linesRemoved, 0),
151
+ model: sessionModel,
152
+ };
153
+ }
154
+ /**
155
+ * Parse a tool_use block to extract file changes
156
+ */
157
+ parseToolUse(block, projectPath, timestamp, model) {
158
+ const toolName = block.name?.toLowerCase() || '';
159
+ const input = block.input || {};
160
+ // Supported write operations - expanded list
161
+ const writeOps = ['write', 'write_file', 'create_file', 'str_replace_editor', 'save_file', 'create'];
162
+ const editOps = ['edit', 'edit_file', 'str_replace', 'apply_diff', 'patch', 'update_file'];
163
+ // Try various field names for file path
164
+ let filePath = input.path || input.file_path || input.filename || input.file || input.target || '';
165
+ let newContent = input.content || input.new_str || input.new_string || input.text || input.code || '';
166
+ let oldContent = input.old_str || input.old_string || input.old_content || input.original || '';
167
+ if (!filePath)
168
+ return null;
169
+ // Normalize path
170
+ filePath = this.normalizePath(filePath, projectPath);
171
+ let changeType = 'modify';
172
+ let linesAdded = 0;
173
+ let linesRemoved = 0;
174
+ if (writeOps.includes(toolName)) {
175
+ changeType = oldContent ? 'modify' : 'create';
176
+ linesAdded = this.countLines(newContent);
177
+ linesRemoved = this.countLines(oldContent);
178
+ }
179
+ else if (editOps.includes(toolName)) {
180
+ changeType = 'modify';
181
+ linesAdded = this.countLines(newContent);
182
+ linesRemoved = this.countLines(oldContent);
183
+ }
184
+ else {
185
+ // Unknown tool, try to extract what we can
186
+ if (newContent) {
187
+ linesAdded = this.countLines(newContent);
188
+ }
189
+ }
190
+ if (linesAdded === 0 && linesRemoved === 0)
191
+ return null;
192
+ return {
193
+ filePath,
194
+ linesAdded,
195
+ linesRemoved,
196
+ changeType,
197
+ timestamp: timestamp ? new Date(timestamp) : new Date(),
198
+ tool: this.tool,
199
+ content: newContent,
200
+ model,
201
+ };
202
+ }
203
+ }
@@ -0,0 +1,54 @@
1
+ import { AISession, AITool } from '../types.js';
2
+ import { BaseScanner } from './base.js';
3
+ /**
4
+ * Scanner for OpenAI Codex CLI sessions
5
+ *
6
+ * Codex CLI stores session data in:
7
+ * ~/.codex/sessions/YYYY/MM/DD/*.jsonl
8
+ *
9
+ * Each JSONL entry has a top-level `type` and `payload` field:
10
+ * - type: "session_meta" | "turn_context" | "response_item" | "event_msg"
11
+ * - turn_context entries contain { payload: { cwd, model, ... } }
12
+ * - response_item entries with payload.type "custom_tool_call" contain
13
+ * apply_patch operations with a custom patch format
14
+ * - response_item entries with payload.type "function_call" contain
15
+ * shell_command calls
16
+ */
17
+ export declare class CodexScanner extends BaseScanner {
18
+ get tool(): AITool;
19
+ get storagePath(): string;
20
+ scan(projectPath: string): AISession[];
21
+ parseSessionFile(filePath: string, projectPath: string): AISession | null;
22
+ /**
23
+ * Check if the session path belongs to the target project.
24
+ * The session cwd must be exactly the project path or a subdirectory of it.
25
+ */
26
+ private pathsMatch;
27
+ /**
28
+ * Parse Codex apply_patch custom_tool_call entries.
29
+ *
30
+ * The patch format looks like:
31
+ * *** Begin Patch
32
+ * *** Update File: path/to/file.swift
33
+ * @@
34
+ * context line
35
+ * +added line
36
+ * -removed line
37
+ * *** Add File: path/to/new_file.swift
38
+ * +new file content
39
+ * *** End Patch
40
+ */
41
+ private parseApplyPatch;
42
+ /**
43
+ * Parse a function_call payload (e.g. shell_command with file write operations)
44
+ */
45
+ private parseFunctionCall;
46
+ /**
47
+ * Parse a legacy tool_call object to extract file changes
48
+ */
49
+ private parseToolCall;
50
+ /**
51
+ * Parse a unified diff to count added/removed lines
52
+ */
53
+ private parseDiff;
54
+ }
@@ -0,0 +1,311 @@
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 OpenAI Codex CLI sessions
8
+ *
9
+ * Codex CLI stores session data in:
10
+ * ~/.codex/sessions/YYYY/MM/DD/*.jsonl
11
+ *
12
+ * Each JSONL entry has a top-level `type` and `payload` field:
13
+ * - type: "session_meta" | "turn_context" | "response_item" | "event_msg"
14
+ * - turn_context entries contain { payload: { cwd, model, ... } }
15
+ * - response_item entries with payload.type "custom_tool_call" contain
16
+ * apply_patch operations with a custom patch format
17
+ * - response_item entries with payload.type "function_call" contain
18
+ * shell_command calls
19
+ */
20
+ export class CodexScanner extends BaseScanner {
21
+ get tool() {
22
+ return AITool.CODEX;
23
+ }
24
+ get storagePath() {
25
+ return '~/.codex/sessions';
26
+ }
27
+ scan(projectPath) {
28
+ const sessions = [];
29
+ const basePath = this.resolveStoragePath();
30
+ if (!fs.existsSync(basePath)) {
31
+ return sessions;
32
+ }
33
+ try {
34
+ // Recursively find all JSONL files
35
+ const files = glob.sync('**/*.jsonl', { cwd: basePath });
36
+ for (const file of files) {
37
+ const fullPath = path.join(basePath, file);
38
+ const session = this.parseSessionFile(fullPath, projectPath);
39
+ if (session && session.changes.length > 0) {
40
+ sessions.push(session);
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // Ignore errors
46
+ }
47
+ return sessions;
48
+ }
49
+ parseSessionFile(filePath, projectPath) {
50
+ const entries = this.readJsonlFile(filePath);
51
+ if (entries.length === 0)
52
+ return null;
53
+ const changes = [];
54
+ let sessionTimestamp = null;
55
+ let sessionProjectPath = null;
56
+ for (const entry of entries) {
57
+ const payload = entry.payload || {};
58
+ // Extract timestamp
59
+ if (!sessionTimestamp && entry.timestamp) {
60
+ sessionTimestamp = new Date(entry.timestamp);
61
+ }
62
+ // Extract project path from turn_context or session_meta
63
+ if (!sessionProjectPath) {
64
+ if (entry.type === 'turn_context' && payload.cwd) {
65
+ sessionProjectPath = payload.cwd;
66
+ }
67
+ else if (entry.type === 'session_meta' && payload.cwd) {
68
+ sessionProjectPath = payload.cwd;
69
+ }
70
+ }
71
+ // Handle custom_tool_call (apply_patch) — the primary way Codex writes files
72
+ if (entry.type === 'response_item' && payload.type === 'custom_tool_call') {
73
+ const patchChanges = this.parseApplyPatch(payload, projectPath, entry.timestamp);
74
+ changes.push(...patchChanges);
75
+ continue;
76
+ }
77
+ // Handle function_call (shell_command with file writes)
78
+ if (entry.type === 'response_item' && payload.type === 'function_call') {
79
+ const funcName = (payload.name || '').toLowerCase();
80
+ let args = {};
81
+ try {
82
+ args = typeof payload.arguments === 'string'
83
+ ? JSON.parse(payload.arguments)
84
+ : payload.arguments || {};
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ const change = this.parseFunctionCall(funcName, args, projectPath, entry.timestamp);
90
+ if (change) {
91
+ changes.push(change);
92
+ }
93
+ continue;
94
+ }
95
+ // Legacy format: top-level tool_calls / function_calls arrays
96
+ const toolCalls = entry.tool_calls || entry.function_calls || [];
97
+ if (Array.isArray(toolCalls)) {
98
+ for (const toolCall of toolCalls) {
99
+ const change = this.parseToolCall(toolCall, projectPath, entry.timestamp);
100
+ if (change) {
101
+ changes.push(change);
102
+ }
103
+ }
104
+ }
105
+ // Legacy: direct function format
106
+ if (entry.function && entry.function.name) {
107
+ const change = this.parseToolCall({ function: entry.function }, projectPath, entry.timestamp);
108
+ if (change) {
109
+ changes.push(change);
110
+ }
111
+ }
112
+ }
113
+ // Filter: only include sessions that match the project path
114
+ if (sessionProjectPath) {
115
+ const normalizedSessionPath = path.resolve(sessionProjectPath);
116
+ const normalizedProjectPath = path.resolve(projectPath);
117
+ if (!this.pathsMatch(normalizedSessionPath, normalizedProjectPath)) {
118
+ return null;
119
+ }
120
+ }
121
+ if (changes.length === 0)
122
+ return null;
123
+ return {
124
+ id: this.generateSessionId(filePath),
125
+ tool: this.tool,
126
+ timestamp: sessionTimestamp || new Date(),
127
+ projectPath,
128
+ changes,
129
+ totalFilesChanged: new Set(changes.map(c => c.filePath)).size,
130
+ totalLinesAdded: changes.reduce((sum, c) => sum + c.linesAdded, 0),
131
+ totalLinesRemoved: changes.reduce((sum, c) => sum + c.linesRemoved, 0),
132
+ };
133
+ }
134
+ /**
135
+ * Check if the session path belongs to the target project.
136
+ * The session cwd must be exactly the project path or a subdirectory of it.
137
+ */
138
+ pathsMatch(sessionPath, projectPath) {
139
+ if (sessionPath === projectPath)
140
+ return true;
141
+ // Session opened inside a subdirectory of the project
142
+ if (sessionPath.startsWith(projectPath + '/'))
143
+ return true;
144
+ return false;
145
+ }
146
+ /**
147
+ * Parse Codex apply_patch custom_tool_call entries.
148
+ *
149
+ * The patch format looks like:
150
+ * *** Begin Patch
151
+ * *** Update File: path/to/file.swift
152
+ * @@
153
+ * context line
154
+ * +added line
155
+ * -removed line
156
+ * *** Add File: path/to/new_file.swift
157
+ * +new file content
158
+ * *** End Patch
159
+ */
160
+ parseApplyPatch(payload, projectPath, timestamp) {
161
+ const name = (payload.name || '').toLowerCase();
162
+ if (name !== 'apply_patch')
163
+ return [];
164
+ const input = payload.input || '';
165
+ if (!input)
166
+ return [];
167
+ const changes = [];
168
+ const lines = input.split('\n');
169
+ let currentFile = null;
170
+ let changeType = 'modify';
171
+ let linesAdded = 0;
172
+ let linesRemoved = 0;
173
+ const flushFile = () => {
174
+ if (currentFile && (linesAdded > 0 || linesRemoved > 0)) {
175
+ const filePath = this.normalizePath(currentFile, projectPath);
176
+ changes.push({
177
+ filePath,
178
+ linesAdded,
179
+ linesRemoved,
180
+ changeType,
181
+ timestamp: timestamp ? new Date(timestamp) : new Date(),
182
+ tool: this.tool,
183
+ content: '',
184
+ });
185
+ }
186
+ currentFile = null;
187
+ linesAdded = 0;
188
+ linesRemoved = 0;
189
+ changeType = 'modify';
190
+ };
191
+ for (const line of lines) {
192
+ if (line.startsWith('*** Update File:')) {
193
+ flushFile();
194
+ currentFile = line.replace('*** Update File:', '').trim();
195
+ changeType = 'modify';
196
+ }
197
+ else if (line.startsWith('*** Add File:')) {
198
+ flushFile();
199
+ currentFile = line.replace('*** Add File:', '').trim();
200
+ changeType = 'create';
201
+ }
202
+ else if (line.startsWith('*** Delete File:')) {
203
+ flushFile();
204
+ currentFile = line.replace('*** Delete File:', '').trim();
205
+ changeType = 'delete';
206
+ }
207
+ else if (line.startsWith('*** ')) {
208
+ // Other directives (Begin Patch, End Patch, etc.) — skip
209
+ continue;
210
+ }
211
+ else if (line.startsWith('@@')) {
212
+ // Hunk header — skip
213
+ continue;
214
+ }
215
+ else if (currentFile) {
216
+ if (line.startsWith('+')) {
217
+ linesAdded++;
218
+ }
219
+ else if (line.startsWith('-')) {
220
+ linesRemoved++;
221
+ }
222
+ // Context lines (starting with ' ') are ignored
223
+ }
224
+ }
225
+ flushFile();
226
+ return changes;
227
+ }
228
+ /**
229
+ * Parse a function_call payload (e.g. shell_command with file write operations)
230
+ */
231
+ parseFunctionCall(funcName, args, projectPath, timestamp) {
232
+ const writeOps = ['write_file', 'create_file', 'write', 'save_file', 'create', 'writefile'];
233
+ const editOps = ['edit_file', 'apply_diff', 'patch', 'replace_in_file', 'edit', 'update_file', 'modify_file'];
234
+ let filePath = args.path || args.file_path || args.filename || args.file || args.target || '';
235
+ let newContent = args.content || args.new_content || args.text || args.code || args.data || '';
236
+ let oldContent = args.old_content || args.original || args.old_text || '';
237
+ if (!filePath)
238
+ return null;
239
+ filePath = this.normalizePath(filePath, projectPath);
240
+ let changeType = 'modify';
241
+ let linesAdded = 0;
242
+ let linesRemoved = 0;
243
+ if (writeOps.includes(funcName)) {
244
+ changeType = 'create';
245
+ linesAdded = this.countLines(newContent);
246
+ }
247
+ else if (editOps.includes(funcName)) {
248
+ changeType = 'modify';
249
+ linesAdded = this.countLines(newContent);
250
+ linesRemoved = this.countLines(oldContent);
251
+ if ((funcName === 'apply_diff' || funcName === 'patch') && args.diff) {
252
+ const diffStats = this.parseDiff(args.diff);
253
+ linesAdded = diffStats.added;
254
+ linesRemoved = diffStats.removed;
255
+ }
256
+ }
257
+ else {
258
+ return null;
259
+ }
260
+ if (linesAdded === 0 && linesRemoved === 0)
261
+ return null;
262
+ return {
263
+ filePath,
264
+ linesAdded,
265
+ linesRemoved,
266
+ changeType,
267
+ timestamp: timestamp ? new Date(timestamp) : new Date(),
268
+ tool: this.tool,
269
+ content: newContent,
270
+ };
271
+ }
272
+ /**
273
+ * Parse a legacy tool_call object to extract file changes
274
+ */
275
+ parseToolCall(toolCall, projectPath, timestamp) {
276
+ const func = toolCall.function;
277
+ if (!func)
278
+ return null;
279
+ const funcName = (func.name || '').toLowerCase();
280
+ let args = {};
281
+ try {
282
+ if (typeof func.arguments === 'string') {
283
+ args = JSON.parse(func.arguments);
284
+ }
285
+ else if (typeof func.arguments === 'object') {
286
+ args = func.arguments || {};
287
+ }
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ return this.parseFunctionCall(funcName, args, projectPath, timestamp);
293
+ }
294
+ /**
295
+ * Parse a unified diff to count added/removed lines
296
+ */
297
+ parseDiff(diff) {
298
+ let added = 0;
299
+ let removed = 0;
300
+ const lines = diff.split('\n');
301
+ for (const line of lines) {
302
+ if (line.startsWith('+') && !line.startsWith('+++')) {
303
+ added++;
304
+ }
305
+ else if (line.startsWith('-') && !line.startsWith('---')) {
306
+ removed++;
307
+ }
308
+ }
309
+ return { added, removed };
310
+ }
311
+ }