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,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
|
+
}
|