@mesadev/agentblame 0.2.11 → 3.0.1
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/dist/agentblame.js +3500 -0
- package/dist/blame.d.ts +4 -1
- package/dist/blame.js +293 -78
- package/dist/blame.js.map +1 -1
- package/dist/capture.d.ts +4 -7
- package/dist/capture.js +464 -486
- package/dist/capture.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +248 -85
- package/dist/index.js.map +1 -1
- package/dist/lib/analytics.d.ts +179 -0
- package/dist/lib/analytics.js +833 -0
- package/dist/lib/analytics.js.map +1 -0
- package/dist/lib/attribution.d.ts +54 -0
- package/dist/lib/attribution.js +266 -0
- package/dist/lib/attribution.js.map +1 -0
- package/dist/lib/checkpoint.d.ts +97 -0
- package/dist/lib/checkpoint.js +441 -0
- package/dist/lib/checkpoint.js.map +1 -0
- package/dist/lib/config.d.ts +46 -0
- package/dist/lib/config.js +123 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/database.d.ts +115 -85
- package/dist/lib/database.js +305 -325
- package/dist/lib/database.js.map +1 -1
- package/dist/lib/delta.d.ts +78 -0
- package/dist/lib/delta.js +309 -0
- package/dist/lib/delta.js.map +1 -0
- package/dist/lib/git/gitBlame.js +9 -4
- package/dist/lib/git/gitBlame.js.map +1 -1
- package/dist/lib/git/gitConfig.d.ts +5 -3
- package/dist/lib/git/gitConfig.js +41 -6
- package/dist/lib/git/gitConfig.js.map +1 -1
- package/dist/lib/git/gitDiff.d.ts +13 -1
- package/dist/lib/git/gitDiff.js +39 -7
- package/dist/lib/git/gitDiff.js.map +1 -1
- package/dist/lib/git/gitNotes.d.ts +30 -4
- package/dist/lib/git/gitNotes.js +140 -24
- package/dist/lib/git/gitNotes.js.map +1 -1
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +148 -27
- package/dist/lib/hooks.js.map +1 -1
- package/dist/lib/index.d.ts +7 -0
- package/dist/lib/index.js +13 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/storage.d.ts +163 -0
- package/dist/lib/storage.js +823 -0
- package/dist/lib/storage.js.map +1 -0
- package/dist/lib/trace.d.ts +118 -0
- package/dist/lib/trace.js +499 -0
- package/dist/lib/trace.js.map +1 -0
- package/dist/lib/types.d.ts +322 -114
- package/dist/lib/types.js +2 -1
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/util.d.ts +8 -8
- package/dist/lib/util.js +18 -22
- package/dist/lib/util.js.map +1 -1
- package/dist/lib/watcher.d.ts +104 -0
- package/dist/lib/watcher.js +398 -0
- package/dist/lib/watcher.js.map +1 -0
- package/dist/post-merge.js +460 -421
- package/dist/post-merge.js.map +1 -1
- package/dist/process.d.ts +6 -5
- package/dist/process.js +233 -152
- package/dist/process.js.map +1 -1
- package/dist/sync.js +172 -131
- package/dist/sync.js.map +1 -1
- package/package.json +3 -2
package/dist/capture.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
* Agent Blame Hook Capture
|
|
4
|
+
* Agent Blame Hook Capture v3
|
|
5
5
|
*
|
|
6
|
-
* Captures AI-generated code from Cursor
|
|
7
|
-
*
|
|
6
|
+
* Captures AI-generated code from Cursor, Claude Code, and OpenCode hooks.
|
|
7
|
+
* Uses delta-based tracking for accurate line attribution.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* echo '{"payload": ...}' | bun run capture.ts --provider cursor --event afterFileEdit
|
|
11
11
|
* echo '{"payload": ...}' | bun run capture.ts --provider claude
|
|
12
|
-
*
|
|
13
|
-
* Note: We only track afterFileEdit (Composer/Agent mode).
|
|
14
|
-
* Tab completions (afterTabFileEdit) are NOT tracked because they fire
|
|
15
|
-
* as fragments that cannot be reliably matched to commits.
|
|
12
|
+
* echo '{"payload": ...}' | bun run capture.ts --provider opencode
|
|
16
13
|
*/
|
|
17
14
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
18
15
|
if (k2 === undefined) k2 = k;
|
|
@@ -49,164 +46,59 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
49
46
|
})();
|
|
50
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
48
|
exports.runCapture = runCapture;
|
|
52
|
-
const
|
|
53
|
-
const
|
|
49
|
+
const fs = __importStar(require("node:fs"));
|
|
50
|
+
const path = __importStar(require("node:path"));
|
|
54
51
|
const database_1 = require("./lib/database");
|
|
52
|
+
const config_1 = require("./lib/config");
|
|
53
|
+
const storage_1 = require("./lib/storage");
|
|
54
|
+
const checkpoint_1 = require("./lib/checkpoint");
|
|
55
55
|
const util_1 = require("./lib/util");
|
|
56
|
+
const delta_1 = require("./lib/delta");
|
|
56
57
|
// =============================================================================
|
|
57
58
|
// Utilities
|
|
58
59
|
// =============================================================================
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* Extract only the added lines from a diff between old and new text.
|
|
68
|
-
*/
|
|
69
|
-
function extractAddedContent(oldText, newText) {
|
|
70
|
-
// Normalize trailing newlines to avoid false positives.
|
|
71
|
-
// diffLines treats "line" and "line\n" as different, so when a line
|
|
72
|
-
// goes from being last (no \n) to having content after it (has \n),
|
|
73
|
-
// it gets marked as "added" even though the content is identical.
|
|
74
|
-
const normalize = (text) => {
|
|
75
|
-
if (!text)
|
|
76
|
-
return "";
|
|
77
|
-
return text.endsWith("\n") ? text : text + "\n";
|
|
78
|
-
};
|
|
79
|
-
const parts = (0, diff_1.diffLines)(normalize(oldText), normalize(newText));
|
|
80
|
-
const addedParts = [];
|
|
81
|
-
for (const part of parts) {
|
|
82
|
-
if (part.added) {
|
|
83
|
-
addedParts.push(part.value ?? "");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return addedParts.join("");
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Determine the edit type based on old and new content
|
|
90
|
-
*/
|
|
91
|
-
function determineEditType(oldContent, newContent) {
|
|
92
|
-
if (!oldContent || oldContent.trim() === "") {
|
|
93
|
-
return "addition";
|
|
94
|
-
}
|
|
95
|
-
if (newContent.includes(oldContent)) {
|
|
96
|
-
return "modification"; // New content contains old content (added to it)
|
|
97
|
-
}
|
|
98
|
-
return "replacement"; // Old content was replaced
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Hash each line individually for precise matching
|
|
102
|
-
*/
|
|
103
|
-
function hashLines(content) {
|
|
104
|
-
const lines = content.split("\n");
|
|
105
|
-
const result = [];
|
|
106
|
-
for (const line of lines) {
|
|
107
|
-
// Skip empty lines for hashing purposes but keep them for content
|
|
108
|
-
if (!line.trim())
|
|
109
|
-
continue;
|
|
110
|
-
result.push({
|
|
111
|
-
content: line,
|
|
112
|
-
hash: computeHash(line),
|
|
113
|
-
hashNormalized: computeNormalizedHash(line),
|
|
114
|
-
});
|
|
60
|
+
async function findRepoRoot(filePath) {
|
|
61
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
62
|
+
? filePath
|
|
63
|
+
: path.resolve(process.cwd(), filePath);
|
|
64
|
+
let dir;
|
|
65
|
+
try {
|
|
66
|
+
const stat = fs.statSync(absolutePath);
|
|
67
|
+
dir = stat.isDirectory() ? absolutePath : path.dirname(absolutePath);
|
|
115
68
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Hash lines with line numbers and context (for Claude structuredPatch)
|
|
120
|
-
*/
|
|
121
|
-
function hashLinesWithNumbers(lines, allFileLines) {
|
|
122
|
-
const result = [];
|
|
123
|
-
for (const { content, lineNumber } of lines) {
|
|
124
|
-
// Skip empty lines
|
|
125
|
-
if (!content.trim())
|
|
126
|
-
continue;
|
|
127
|
-
// Get context (3 lines before and after)
|
|
128
|
-
const contextBefore = allFileLines
|
|
129
|
-
.slice(Math.max(0, lineNumber - 4), lineNumber - 1)
|
|
130
|
-
.join("\n");
|
|
131
|
-
const contextAfter = allFileLines
|
|
132
|
-
.slice(lineNumber, Math.min(allFileLines.length, lineNumber + 3))
|
|
133
|
-
.join("\n");
|
|
134
|
-
result.push({
|
|
135
|
-
content,
|
|
136
|
-
hash: computeHash(content),
|
|
137
|
-
hashNormalized: computeNormalizedHash(content),
|
|
138
|
-
lineNumber,
|
|
139
|
-
contextBefore: contextBefore || undefined,
|
|
140
|
-
contextAfter: contextAfter || undefined,
|
|
141
|
-
});
|
|
69
|
+
catch {
|
|
70
|
+
dir = path.dirname(absolutePath);
|
|
142
71
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
*/
|
|
148
|
-
function parseStructuredPatch(hunks, originalFileLines) {
|
|
149
|
-
const addedLines = [];
|
|
150
|
-
for (const hunk of hunks) {
|
|
151
|
-
let newLineNumber = hunk.newStart;
|
|
152
|
-
for (const line of hunk.lines) {
|
|
153
|
-
if (line.startsWith("+")) {
|
|
154
|
-
// Added line - strip the + prefix
|
|
155
|
-
addedLines.push({
|
|
156
|
-
content: line.slice(1),
|
|
157
|
-
lineNumber: newLineNumber,
|
|
158
|
-
});
|
|
159
|
-
newLineNumber++;
|
|
160
|
-
}
|
|
161
|
-
else if (line.startsWith("-")) {
|
|
162
|
-
// Deleted line - don't increment new line number
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
// Context line (starts with space) - increment line number
|
|
167
|
-
newLineNumber++;
|
|
168
|
-
}
|
|
72
|
+
while (dir !== "/" && dir !== ".") {
|
|
73
|
+
const gitDir = path.join(dir, ".git");
|
|
74
|
+
if (fs.existsSync(gitDir)) {
|
|
75
|
+
return dir;
|
|
169
76
|
}
|
|
77
|
+
dir = path.dirname(dir);
|
|
170
78
|
}
|
|
171
|
-
return
|
|
79
|
+
return null;
|
|
172
80
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
async function readFileLines(filePath) {
|
|
177
|
-
try {
|
|
178
|
-
const fs = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
|
|
179
|
-
const content = await fs.readFile(filePath, "utf8");
|
|
180
|
-
return content.split("\n");
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
return null;
|
|
81
|
+
function makeRelative(repoRoot, filePath) {
|
|
82
|
+
if (filePath.startsWith(repoRoot)) {
|
|
83
|
+
return filePath.slice(repoRoot.length + 1);
|
|
184
84
|
}
|
|
85
|
+
return filePath;
|
|
185
86
|
}
|
|
186
|
-
/**
|
|
187
|
-
* Extract model name from Claude Code transcript file.
|
|
188
|
-
* The transcript is a JSONL file where assistant messages contain the model field.
|
|
189
|
-
* We read from the end to find the most recent model used.
|
|
190
|
-
*/
|
|
191
87
|
async function extractModelFromTranscript(transcriptPath) {
|
|
192
88
|
try {
|
|
193
|
-
const
|
|
194
|
-
const content = await fs.readFile(transcriptPath, "utf8");
|
|
89
|
+
const content = fs.readFileSync(transcriptPath, "utf8");
|
|
195
90
|
const lines = content.split("\n");
|
|
196
|
-
// Read from the end to find the most recent assistant message with model info
|
|
197
91
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
198
92
|
const line = lines[i].trim();
|
|
199
93
|
if (!line)
|
|
200
94
|
continue;
|
|
201
95
|
try {
|
|
202
96
|
const entry = JSON.parse(line);
|
|
203
|
-
// Assistant messages have message.model field
|
|
204
97
|
if (entry.message?.model) {
|
|
205
98
|
return entry.message.model;
|
|
206
99
|
}
|
|
207
100
|
}
|
|
208
101
|
catch {
|
|
209
|
-
// Skip malformed lines
|
|
210
102
|
continue;
|
|
211
103
|
}
|
|
212
104
|
}
|
|
@@ -216,50 +108,210 @@ async function extractModelFromTranscript(transcriptPath) {
|
|
|
216
108
|
return null;
|
|
217
109
|
}
|
|
218
110
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
111
|
+
async function extractPromptFromTranscript(transcriptPath) {
|
|
112
|
+
try {
|
|
113
|
+
const content = fs.readFileSync(transcriptPath, "utf8");
|
|
114
|
+
const lines = content.split("\n").reverse();
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (!line.trim())
|
|
117
|
+
continue;
|
|
118
|
+
try {
|
|
119
|
+
const entry = JSON.parse(line);
|
|
120
|
+
if (entry.type === "human" || entry.message?.role === "user") {
|
|
121
|
+
const msg = entry.message?.content;
|
|
122
|
+
if (Array.isArray(msg)) {
|
|
123
|
+
const textPart = msg.find((m) => m.type === "text" || typeof m === "string");
|
|
124
|
+
if (textPart) {
|
|
125
|
+
return typeof textPart === "string" ? textPart : textPart.text;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (typeof msg === "string") {
|
|
129
|
+
return msg;
|
|
130
|
+
}
|
|
131
|
+
else if (entry.content) {
|
|
132
|
+
return entry.content;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
226
140
|
return null;
|
|
227
141
|
}
|
|
228
|
-
|
|
229
|
-
const oldIndex = fileContent.indexOf(oldString);
|
|
230
|
-
if (oldIndex === -1) {
|
|
142
|
+
catch {
|
|
231
143
|
return null;
|
|
232
144
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const addedContent = extractAddedContent(oldString, newString);
|
|
241
|
-
if (!addedContent.trim()) {
|
|
145
|
+
}
|
|
146
|
+
async function setupCaptureContext(filePath, agent, conversationId, model) {
|
|
147
|
+
const repoRoot = await findRepoRoot(filePath);
|
|
148
|
+
if (!repoRoot) {
|
|
149
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
150
|
+
console.error(`[agentblame] No git repo found for ${filePath}`);
|
|
151
|
+
}
|
|
242
152
|
return null;
|
|
243
153
|
}
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
154
|
+
(0, storage_1.ensureAgentBlameDirs)(repoRoot);
|
|
155
|
+
const dbPath = (0, storage_1.getDatabasePath)(repoRoot);
|
|
156
|
+
(0, database_1.setDatabasePath)(dbPath);
|
|
157
|
+
const baseSha = await (0, storage_1.getGitHead)(repoRoot);
|
|
158
|
+
if (!baseSha) {
|
|
159
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
160
|
+
console.error(`[agentblame] No HEAD commit found in ${repoRoot}`);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// Smart cleanup: only clean up working directories for commits that:
|
|
165
|
+
// 1. Are ancestors of current HEAD (we've moved past them)
|
|
166
|
+
// 2. Have git notes (deltas were processed)
|
|
167
|
+
// This preserves deltas for reset/recommit and stash scenarios
|
|
168
|
+
await (0, storage_1.cleanupProcessedWorkingDirs)(repoRoot, baseSha);
|
|
169
|
+
const sessionId = (0, database_1.generateSessionId)(agent, conversationId);
|
|
170
|
+
const normalizedModel = (0, util_1.normalizeModelName)(model);
|
|
171
|
+
// Load config setting for prompt content storage
|
|
172
|
+
const storePromptContent = await (0, config_1.getConfig)(repoRoot, 'storePromptContent');
|
|
173
|
+
return {
|
|
174
|
+
repoRoot,
|
|
175
|
+
baseSha,
|
|
176
|
+
agent,
|
|
177
|
+
sessionId,
|
|
178
|
+
model: normalizedModel,
|
|
179
|
+
storePromptContent,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// =============================================================================
|
|
183
|
+
// Delta Capture (v3 - The Only Way)
|
|
184
|
+
// =============================================================================
|
|
185
|
+
/**
|
|
186
|
+
* Detect and record human edits since last checkpoint.
|
|
187
|
+
*
|
|
188
|
+
* IMPORTANT: We need to be careful not to mark another AI tool's edits as human.
|
|
189
|
+
* The most reliable baseline is the most recent delta's afterBlob (from any session).
|
|
190
|
+
* This handles:
|
|
191
|
+
* 1. Cross-session detection (another AI edited the file)
|
|
192
|
+
* 2. Same-session async race (PostToolUse is async, so checkpoint might be stale)
|
|
193
|
+
*
|
|
194
|
+
* Priority:
|
|
195
|
+
* 1. Last delta's afterBlob (most reliable - represents actual file state)
|
|
196
|
+
* 2. Checkpoint content (fallback if no delta exists)
|
|
197
|
+
*/
|
|
198
|
+
async function detectAndRecordHumanEdits(ctx, conversationId, filePath) {
|
|
199
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
200
|
+
? filePath
|
|
201
|
+
: path.join(ctx.repoRoot, filePath);
|
|
202
|
+
const relativePath = makeRelative(ctx.repoRoot, absolutePath);
|
|
203
|
+
// Read current file content first
|
|
204
|
+
const currentContent = (0, storage_1.readFileContent)(absolutePath);
|
|
205
|
+
if (currentContent === null) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
209
|
+
console.error(`[agentblame] detectHumanEdits: ${relativePath}`);
|
|
210
|
+
}
|
|
211
|
+
// Priority 1: Use the last delta's afterBlob as baseline (most reliable)
|
|
212
|
+
// This handles both cross-session AND same-session scenarios
|
|
213
|
+
// (e.g., when async PostToolUse writes delta but checkpoint is stale)
|
|
214
|
+
let beforeContent = null;
|
|
215
|
+
const lastDelta = (0, delta_1.getLastDeltaForFile)(ctx.repoRoot, ctx.baseSha, relativePath);
|
|
216
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
217
|
+
console.error(`[agentblame] lastDelta: ${lastDelta ? `session=${lastDelta.sessionId?.slice(0, 8) || 'human'}, afterBlob=${lastDelta.afterBlob?.slice(0, 8)}` : 'null'}`);
|
|
218
|
+
}
|
|
219
|
+
if (lastDelta?.afterBlob) {
|
|
220
|
+
try {
|
|
221
|
+
beforeContent = await (0, storage_1.loadSnapshot)(ctx.repoRoot, lastDelta.afterBlob);
|
|
222
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
223
|
+
console.error(`[agentblame] Loaded afterBlob: ${beforeContent?.length} chars`);
|
|
259
224
|
}
|
|
260
225
|
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
228
|
+
console.error(`[agentblame] Failed to load afterBlob: ${err}`);
|
|
229
|
+
}
|
|
230
|
+
// Fall through to checkpoint
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Priority 2: Fall back to checkpoint if no delta afterBlob available
|
|
234
|
+
if (beforeContent === null) {
|
|
235
|
+
beforeContent = await (0, checkpoint_1.getCheckpointContent)(ctx.repoRoot, conversationId, relativePath);
|
|
236
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
237
|
+
console.error(`[agentblame] checkpoint content: ${beforeContent ? beforeContent.length + ' chars' : 'null'}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// No baseline available - can't detect changes
|
|
241
|
+
if (beforeContent === null) {
|
|
242
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
243
|
+
console.error(`[agentblame] No baseline - skipping`);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const hunks = (0, delta_1.computeDiff)(beforeContent, currentContent);
|
|
248
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
249
|
+
console.error(`[agentblame] beforeContent: ${beforeContent.length} chars, currentContent: ${currentContent.length} chars`);
|
|
250
|
+
console.error(`[agentblame] diff hunks: ${hunks.length} (${hunks.map(h => `L${h.newStart}+${h.newCount}`).join(',')})`);
|
|
251
|
+
}
|
|
252
|
+
if (hunks.length === 0) {
|
|
253
|
+
// Update checkpoint even if no changes (keeps it current)
|
|
254
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, relativePath);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Store afterBlob for cross-session detection
|
|
258
|
+
const afterBlob = await (0, storage_1.storeSnapshot)(ctx.repoRoot, currentContent);
|
|
259
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
260
|
+
console.error(`[agentblame] RECORDING HUMAN DELTA: ${relativePath} with ${hunks.length} hunks`);
|
|
261
|
+
}
|
|
262
|
+
const humanDelta = (0, delta_1.createHumanDelta)(relativePath, hunks, afterBlob);
|
|
263
|
+
(0, delta_1.appendDelta)(ctx.repoRoot, ctx.baseSha, humanDelta);
|
|
264
|
+
// Update checkpoint to current state
|
|
265
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, relativePath);
|
|
266
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
267
|
+
console.error(`[agentblame] Detected human edit: ${relativePath} (${hunks.length} hunks)`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Record an AI edit delta.
|
|
272
|
+
*/
|
|
273
|
+
async function recordAIDelta(ctx, filePath, beforeContent, afterContent) {
|
|
274
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
275
|
+
? filePath
|
|
276
|
+
: path.join(ctx.repoRoot, filePath);
|
|
277
|
+
const relativePath = makeRelative(ctx.repoRoot, absolutePath);
|
|
278
|
+
const before = beforeContent ?? "";
|
|
279
|
+
const hunks = (0, delta_1.computeDiff)(before, afterContent);
|
|
280
|
+
if (hunks.length === 0) {
|
|
281
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
282
|
+
console.error(`[agentblame] recordAIDelta: no diff for ${relativePath} (before=${before.length} chars, after=${afterContent.length} chars)`);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Store afterBlob for cross-session human edit detection
|
|
287
|
+
const afterBlob = await (0, storage_1.storeSnapshot)(ctx.repoRoot, afterContent);
|
|
288
|
+
const latestPrompt = (0, database_1.getLatestPromptForSession)(ctx.sessionId);
|
|
289
|
+
const promptId = latestPrompt?.id ?? null;
|
|
290
|
+
const aiDelta = (0, delta_1.createAIDelta)(relativePath, ctx.sessionId, promptId, hunks, afterBlob);
|
|
291
|
+
(0, delta_1.appendDelta)(ctx.repoRoot, ctx.baseSha, aiDelta);
|
|
292
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
293
|
+
console.error(`[agentblame] Recorded AI delta: ${relativePath} (${hunks.length} hunks, prompt: ${promptId}, lines: ${hunks.map(h => `${h.newStart}+${h.newCount}`).join(',')})`);
|
|
294
|
+
console.error(`[agentblame] stored path: "${relativePath}"`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get before content from checkpoint or git HEAD.
|
|
299
|
+
*/
|
|
300
|
+
async function getBeforeContent(ctx, conversationId, filePath) {
|
|
301
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
302
|
+
? filePath
|
|
303
|
+
: path.join(ctx.repoRoot, filePath);
|
|
304
|
+
const relativePath = makeRelative(ctx.repoRoot, absolutePath);
|
|
305
|
+
const beforeBlob = await (0, checkpoint_1.getBeforeBlob)(ctx.repoRoot, conversationId, relativePath);
|
|
306
|
+
if (!beforeBlob) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
return await (0, storage_1.loadSnapshot)(ctx.repoRoot, beforeBlob);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return null;
|
|
261
314
|
}
|
|
262
|
-
return result.length > 0 ? result : null;
|
|
263
315
|
}
|
|
264
316
|
// =============================================================================
|
|
265
317
|
// Payload Processing
|
|
@@ -280,316 +332,267 @@ function parseArgs() {
|
|
|
280
332
|
}
|
|
281
333
|
return { provider, event };
|
|
282
334
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
oldContent: edit.oldContent,
|
|
297
|
-
lines: edit.lines,
|
|
298
|
-
sessionId: edit.sessionId,
|
|
299
|
-
toolUseId: edit.toolUseId,
|
|
335
|
+
async function processCursorBeforeSubmitPrompt(payload) {
|
|
336
|
+
const workspaceRoot = payload.workspace_roots?.[0];
|
|
337
|
+
if (!workspaceRoot)
|
|
338
|
+
return;
|
|
339
|
+
const conversationId = payload.conversation_id || `cursor-${Date.now()}`;
|
|
340
|
+
const ctx = await setupCaptureContext(workspaceRoot, "cursor", conversationId, payload.model || null);
|
|
341
|
+
if (!ctx)
|
|
342
|
+
return;
|
|
343
|
+
(0, database_1.upsertSession)({
|
|
344
|
+
id: ctx.sessionId,
|
|
345
|
+
agent: "cursor",
|
|
346
|
+
model: ctx.model,
|
|
347
|
+
conversationId,
|
|
300
348
|
});
|
|
349
|
+
// Detect human edits on existing checkpoints, then capture new checkpoint
|
|
350
|
+
try {
|
|
351
|
+
const existingCheckpoint = (0, checkpoint_1.loadCheckpoint)(ctx.repoRoot, conversationId);
|
|
352
|
+
if (existingCheckpoint) {
|
|
353
|
+
for (const filePath of Object.keys(existingCheckpoint.files)) {
|
|
354
|
+
await detectAndRecordHumanEdits(ctx, conversationId, filePath);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
await (0, checkpoint_1.captureBeforePromptCheckpoint)(ctx.repoRoot, conversationId);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
361
|
+
console.error(`[agentblame] Failed to capture checkpoint:`, err);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Store prompt if provided
|
|
365
|
+
const prompt = payload.prompt;
|
|
366
|
+
if (prompt) {
|
|
367
|
+
const contentHash = (0, database_1.hashPromptContent)(prompt);
|
|
368
|
+
if (!(0, database_1.promptExists)(ctx.sessionId, contentHash)) {
|
|
369
|
+
(0, database_1.insertPrompt)({
|
|
370
|
+
sessionId: ctx.sessionId,
|
|
371
|
+
content: ctx.storePromptContent ? prompt : null,
|
|
372
|
+
contentHash,
|
|
373
|
+
});
|
|
374
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
375
|
+
console.error(`[agentblame] Captured prompt: ${prompt.substring(0, 100)}...`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
301
379
|
}
|
|
302
380
|
async function processCursorPayload(payload, event) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
381
|
+
if (event === "beforeSubmitPrompt") {
|
|
382
|
+
await processCursorBeforeSubmitPrompt(payload);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
307
385
|
if (event === "afterTabFileEdit") {
|
|
308
|
-
return
|
|
386
|
+
return;
|
|
309
387
|
}
|
|
310
388
|
if (!payload.edits || payload.edits.length === 0) {
|
|
311
|
-
return
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
model: payload.model || null,
|
|
347
|
-
lines,
|
|
348
|
-
content: addedContent,
|
|
349
|
-
contentHash: computeHash(addedContent),
|
|
350
|
-
contentHashNormalized: computeNormalizedHash(addedContent),
|
|
351
|
-
editType: determineEditType(oldString, newString),
|
|
352
|
-
oldContent: oldString || undefined,
|
|
353
|
-
sessionId: payload.conversation_id,
|
|
354
|
-
toolUseId: payload.generation_id,
|
|
355
|
-
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const filePath = payload.file_path;
|
|
392
|
+
if (!filePath)
|
|
393
|
+
return;
|
|
394
|
+
const conversationId = payload.conversation_id || `cursor-${Date.now()}`;
|
|
395
|
+
const ctx = await setupCaptureContext(filePath, "cursor", conversationId, payload.model || null);
|
|
396
|
+
if (!ctx)
|
|
397
|
+
return;
|
|
398
|
+
(0, database_1.upsertSession)({
|
|
399
|
+
id: ctx.sessionId,
|
|
400
|
+
agent: "cursor",
|
|
401
|
+
model: ctx.model,
|
|
402
|
+
conversationId,
|
|
403
|
+
});
|
|
404
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
405
|
+
? filePath
|
|
406
|
+
: path.join(ctx.repoRoot, filePath);
|
|
407
|
+
const relativePath = makeRelative(ctx.repoRoot, absolutePath);
|
|
408
|
+
// Get before/after content and record delta
|
|
409
|
+
const beforeContent = await getBeforeContent(ctx, conversationId, filePath);
|
|
410
|
+
const afterContent = (0, storage_1.readFileContent)(absolutePath);
|
|
411
|
+
if (afterContent) {
|
|
412
|
+
await recordAIDelta(ctx, filePath, beforeContent, afterContent);
|
|
413
|
+
// Update checkpoint to current state so next beforeSubmitPrompt doesn't
|
|
414
|
+
// incorrectly detect this AI edit as a "human edit"
|
|
415
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, filePath);
|
|
416
|
+
}
|
|
417
|
+
(0, database_1.insertToolCall)({
|
|
418
|
+
sessionId: ctx.sessionId,
|
|
419
|
+
toolName: "edit",
|
|
420
|
+
filePath: relativePath,
|
|
421
|
+
});
|
|
422
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
423
|
+
console.error(`[agentblame] Captured Cursor edit: ${filePath}`);
|
|
356
424
|
}
|
|
357
|
-
return edits;
|
|
358
425
|
}
|
|
359
426
|
async function processClaudePayload(payload) {
|
|
360
|
-
const edits = [];
|
|
361
|
-
// CRITICAL: Skip payloads that are actually from Cursor.
|
|
362
|
-
// Both Cursor and Claude Code can trigger hooks from .claude/settings.json,
|
|
363
|
-
// so we need to detect Cursor payloads and skip them here.
|
|
364
|
-
// Cursor payloads have cursor_version field, Claude payloads don't.
|
|
365
427
|
if (payload.cursor_version) {
|
|
366
|
-
return
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
// watcher detecting external changes).
|
|
373
|
-
const toolName = payload.tool_name?.toLowerCase() || "";
|
|
374
|
-
if (toolName !== "edit" && toolName !== "write" && toolName !== "multiedit") {
|
|
375
|
-
return edits;
|
|
376
|
-
}
|
|
377
|
-
const timestamp = new Date().toISOString();
|
|
378
|
-
// Claude Code has tool_input with the actual content
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const toolName = payload.tool_name || "";
|
|
431
|
+
if (!toolName)
|
|
432
|
+
return;
|
|
433
|
+
const hookEvent = payload.hook_event_name || "PostToolUse";
|
|
379
434
|
const toolInput = payload.tool_input;
|
|
380
435
|
const toolResponse = payload.tool_response;
|
|
381
436
|
const filePath = toolResponse?.filePath || toolInput?.file_path || payload.file_path;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
437
|
+
const toolNameLower = toolName.toLowerCase();
|
|
438
|
+
const isFileModifying = toolNameLower === "edit" ||
|
|
439
|
+
toolNameLower === "write" ||
|
|
440
|
+
toolNameLower === "multiedit";
|
|
441
|
+
const pathForRepo = filePath || payload.transcript_path;
|
|
442
|
+
if (!pathForRepo)
|
|
443
|
+
return;
|
|
444
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
445
|
+
console.error(`[agentblame] Claude ${hookEvent} ${toolName}:`);
|
|
446
|
+
console.error(`[agentblame] toolResponse.filePath: ${toolResponse?.filePath}`);
|
|
447
|
+
console.error(`[agentblame] toolInput.file_path: ${toolInput?.file_path}`);
|
|
448
|
+
console.error(`[agentblame] payload.file_path: ${payload.file_path}`);
|
|
449
|
+
console.error(`[agentblame] resolved filePath: ${filePath}`);
|
|
450
|
+
}
|
|
388
451
|
let model = null;
|
|
389
452
|
if (payload.transcript_path) {
|
|
390
453
|
model = await extractModelFromTranscript(payload.transcript_path);
|
|
391
454
|
}
|
|
392
|
-
// Fallback to generic "claude" if transcript parsing fails
|
|
393
455
|
if (!model) {
|
|
394
456
|
model = "claude";
|
|
395
457
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
458
|
+
const conversationId = payload.session_id || `claude-${Date.now()}`;
|
|
459
|
+
const ctx = await setupCaptureContext(pathForRepo, "claude", conversationId, model);
|
|
460
|
+
if (!ctx)
|
|
461
|
+
return;
|
|
462
|
+
(0, database_1.upsertSession)({
|
|
463
|
+
id: ctx.sessionId,
|
|
464
|
+
agent: "claude",
|
|
465
|
+
model: ctx.model,
|
|
466
|
+
conversationId,
|
|
467
|
+
});
|
|
468
|
+
// Extract and store prompt
|
|
469
|
+
if (payload.transcript_path) {
|
|
470
|
+
const prompt = await extractPromptFromTranscript(payload.transcript_path);
|
|
471
|
+
if (prompt) {
|
|
472
|
+
const contentHash = (0, database_1.hashPromptContent)(prompt);
|
|
473
|
+
if (!(0, database_1.promptExists)(ctx.sessionId, contentHash)) {
|
|
474
|
+
(0, database_1.insertPrompt)({
|
|
475
|
+
sessionId: ctx.sessionId,
|
|
476
|
+
content: ctx.storePromptContent ? prompt : null,
|
|
477
|
+
contentHash,
|
|
478
|
+
});
|
|
407
479
|
}
|
|
408
|
-
return edits;
|
|
409
480
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
481
|
+
}
|
|
482
|
+
// PreToolUse: detect human edits and capture checkpoint
|
|
483
|
+
if (hookEvent === "PreToolUse" && isFileModifying && filePath) {
|
|
484
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
485
|
+
console.error(`[agentblame] PreToolUse: ${filePath}`);
|
|
486
|
+
}
|
|
487
|
+
await detectAndRecordHumanEdits(ctx, conversationId, filePath);
|
|
488
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, filePath);
|
|
489
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
490
|
+
console.error(`[agentblame] PreToolUse complete: ${filePath}`);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// PostToolUse: record AI delta
|
|
495
|
+
if (hookEvent === "PostToolUse" && isFileModifying && filePath) {
|
|
496
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
497
|
+
? filePath
|
|
498
|
+
: path.join(ctx.repoRoot, filePath);
|
|
499
|
+
const relativePath = makeRelative(ctx.repoRoot, absolutePath);
|
|
500
|
+
const beforeContent = await getBeforeContent(ctx, conversationId, filePath);
|
|
501
|
+
const afterContent = (0, storage_1.readFileContent)(absolutePath);
|
|
502
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
503
|
+
console.error(`[agentblame] PostToolUse ${toolName}: ${relativePath}`);
|
|
504
|
+
console.error(`[agentblame] beforeContent: ${beforeContent ? beforeContent.length + ' chars' : 'null'}`);
|
|
505
|
+
console.error(`[agentblame] afterContent: ${afterContent ? afterContent.length + ' chars' : 'null'}`);
|
|
506
|
+
}
|
|
507
|
+
if (afterContent) {
|
|
508
|
+
await recordAIDelta(ctx, filePath, beforeContent, afterContent);
|
|
509
|
+
// Update checkpoint to current state so next PreToolUse doesn't
|
|
510
|
+
// incorrectly detect this AI edit as a "human edit"
|
|
511
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, filePath);
|
|
512
|
+
}
|
|
513
|
+
else if (process.env.AGENTBLAME_DEBUG) {
|
|
514
|
+
console.error(`[agentblame] SKIPPED: no afterContent for ${relativePath}`);
|
|
515
|
+
}
|
|
516
|
+
(0, database_1.insertToolCall)({
|
|
517
|
+
sessionId: ctx.sessionId,
|
|
518
|
+
toolName: toolName.toLowerCase(),
|
|
519
|
+
filePath: relativePath,
|
|
434
520
|
});
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// Handle Write tool (new file creation)
|
|
438
|
-
// For Write, we need content
|
|
439
|
-
if (toolName === "write") {
|
|
440
|
-
const content = toolInput?.content || payload.content;
|
|
441
|
-
if (!content || !content.trim()) {
|
|
442
|
-
return edits;
|
|
521
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
522
|
+
console.error(`[agentblame] Captured Claude ${toolName}: ${filePath}`);
|
|
443
523
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
filePath,
|
|
451
|
-
model,
|
|
452
|
-
lines,
|
|
453
|
-
content,
|
|
454
|
-
contentHash: computeHash(content),
|
|
455
|
-
contentHashNormalized: computeNormalizedHash(content),
|
|
456
|
-
editType: "addition",
|
|
457
|
-
sessionId,
|
|
458
|
-
toolUseId,
|
|
524
|
+
}
|
|
525
|
+
else if (!isFileModifying) {
|
|
526
|
+
// Read-only tools
|
|
527
|
+
(0, database_1.insertToolCall)({
|
|
528
|
+
sessionId: ctx.sessionId,
|
|
529
|
+
toolName: toolName.toLowerCase(),
|
|
530
|
+
filePath: filePath ? makeRelative(ctx.repoRoot, filePath) : null,
|
|
459
531
|
});
|
|
460
|
-
return edits;
|
|
461
532
|
}
|
|
462
|
-
// Unknown tool type that passed the initial check - skip
|
|
463
|
-
return edits;
|
|
464
533
|
}
|
|
465
|
-
|
|
466
|
-
* Process OpenCode payload.
|
|
467
|
-
* OpenCode provides before/after file content which allows precise line number extraction.
|
|
468
|
-
*/
|
|
469
|
-
function processOpenCodePayload(payload) {
|
|
470
|
-
const edits = [];
|
|
471
|
-
const timestamp = new Date().toISOString();
|
|
534
|
+
async function processOpenCodePayload(payload) {
|
|
472
535
|
const filePath = payload.filePath;
|
|
473
536
|
if (!filePath)
|
|
474
|
-
return
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
timestamp,
|
|
493
|
-
provider: "opencode",
|
|
494
|
-
filePath,
|
|
495
|
-
model,
|
|
496
|
-
lines,
|
|
497
|
-
content,
|
|
498
|
-
contentHash: computeHash(content),
|
|
499
|
-
contentHashNormalized: computeNormalizedHash(content),
|
|
500
|
-
editType: "addition",
|
|
501
|
-
sessionId,
|
|
502
|
-
toolUseId,
|
|
503
|
-
});
|
|
504
|
-
return edits;
|
|
505
|
-
}
|
|
506
|
-
// Handle edit tool
|
|
507
|
-
if (payload.tool === "edit") {
|
|
508
|
-
// OpenCode provides full before/after content - use it for precise line detection
|
|
509
|
-
if (payload.before !== undefined && payload.after !== undefined) {
|
|
510
|
-
const beforeLines = payload.before.split("\n");
|
|
511
|
-
const afterLines = payload.after.split("\n");
|
|
512
|
-
// Use diffLines to find added lines with their positions
|
|
513
|
-
const parts = (0, diff_1.diffLines)(payload.before, payload.after);
|
|
514
|
-
const addedLinesWithNumbers = [];
|
|
515
|
-
let afterLineIndex = 0;
|
|
516
|
-
for (const part of parts) {
|
|
517
|
-
const partLines = part.value.split("\n");
|
|
518
|
-
// Remove empty string from split if value ends with \n
|
|
519
|
-
if (partLines[partLines.length - 1] === "") {
|
|
520
|
-
partLines.pop();
|
|
521
|
-
}
|
|
522
|
-
if (part.added) {
|
|
523
|
-
// These lines were added
|
|
524
|
-
for (const line of partLines) {
|
|
525
|
-
addedLinesWithNumbers.push({
|
|
526
|
-
content: line,
|
|
527
|
-
lineNumber: afterLineIndex + 1, // 1-indexed
|
|
528
|
-
});
|
|
529
|
-
afterLineIndex++;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
else if (part.removed) {
|
|
533
|
-
// Removed lines don't affect after line index
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
536
|
-
// Context lines - advance the after line index
|
|
537
|
-
afterLineIndex += partLines.length;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
if (addedLinesWithNumbers.length === 0)
|
|
541
|
-
return edits;
|
|
542
|
-
// Filter empty lines and hash with context
|
|
543
|
-
const nonEmptyLines = addedLinesWithNumbers.filter(l => l.content.trim());
|
|
544
|
-
if (nonEmptyLines.length === 0)
|
|
545
|
-
return edits;
|
|
546
|
-
const lines = hashLinesWithNumbers(nonEmptyLines, afterLines);
|
|
547
|
-
if (lines.length === 0)
|
|
548
|
-
return edits;
|
|
549
|
-
const addedContent = nonEmptyLines.map(l => l.content).join("\n");
|
|
550
|
-
edits.push({
|
|
551
|
-
timestamp,
|
|
552
|
-
provider: "opencode",
|
|
553
|
-
filePath,
|
|
554
|
-
model,
|
|
555
|
-
lines,
|
|
556
|
-
content: addedContent,
|
|
557
|
-
contentHash: computeHash(addedContent),
|
|
558
|
-
contentHashNormalized: computeNormalizedHash(addedContent),
|
|
559
|
-
editType: "modification",
|
|
560
|
-
oldContent: payload.oldString,
|
|
561
|
-
sessionId,
|
|
562
|
-
toolUseId,
|
|
537
|
+
return;
|
|
538
|
+
const conversationId = payload.sessionID || `opencode-${Date.now()}`;
|
|
539
|
+
const ctx = await setupCaptureContext(filePath, "opencode", conversationId, payload.model || null);
|
|
540
|
+
if (!ctx)
|
|
541
|
+
return;
|
|
542
|
+
(0, database_1.upsertSession)({
|
|
543
|
+
id: ctx.sessionId,
|
|
544
|
+
agent: "opencode",
|
|
545
|
+
model: ctx.model,
|
|
546
|
+
conversationId,
|
|
547
|
+
});
|
|
548
|
+
if (payload.prompt) {
|
|
549
|
+
const contentHash = (0, database_1.hashPromptContent)(payload.prompt);
|
|
550
|
+
if (!(0, database_1.promptExists)(ctx.sessionId, contentHash)) {
|
|
551
|
+
(0, database_1.insertPrompt)({
|
|
552
|
+
sessionId: ctx.sessionId,
|
|
553
|
+
content: ctx.storePromptContent ? payload.prompt : null,
|
|
554
|
+
contentHash,
|
|
563
555
|
});
|
|
564
|
-
return edits;
|
|
565
556
|
}
|
|
566
|
-
// Fallback: use oldString/newString if before/after not available
|
|
567
|
-
const oldString = payload.oldString || "";
|
|
568
|
-
const newString = payload.newString || "";
|
|
569
|
-
if (!newString)
|
|
570
|
-
return edits;
|
|
571
|
-
const addedContent = extractAddedContent(oldString, newString);
|
|
572
|
-
if (!addedContent.trim())
|
|
573
|
-
return edits;
|
|
574
|
-
const lines = hashLines(addedContent);
|
|
575
|
-
if (lines.length === 0)
|
|
576
|
-
return edits;
|
|
577
|
-
edits.push({
|
|
578
|
-
timestamp,
|
|
579
|
-
provider: "opencode",
|
|
580
|
-
filePath,
|
|
581
|
-
model,
|
|
582
|
-
lines,
|
|
583
|
-
content: addedContent,
|
|
584
|
-
contentHash: computeHash(addedContent),
|
|
585
|
-
contentHashNormalized: computeNormalizedHash(addedContent),
|
|
586
|
-
editType: determineEditType(oldString, newString),
|
|
587
|
-
oldContent: oldString || undefined,
|
|
588
|
-
sessionId,
|
|
589
|
-
toolUseId,
|
|
590
|
-
});
|
|
591
557
|
}
|
|
592
|
-
|
|
558
|
+
// Before hook: detect human edits and capture checkpoint
|
|
559
|
+
if (payload.hook_event === "before") {
|
|
560
|
+
await detectAndRecordHumanEdits(ctx, conversationId, filePath);
|
|
561
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, filePath);
|
|
562
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
563
|
+
console.error(`[agentblame] OpenCode before checkpoint: ${filePath}`);
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// After hook: record AI delta
|
|
568
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
569
|
+
? filePath
|
|
570
|
+
: path.join(ctx.repoRoot, filePath);
|
|
571
|
+
const relativePath = makeRelative(ctx.repoRoot, absolutePath);
|
|
572
|
+
// Get before content (OpenCode may provide it directly)
|
|
573
|
+
let beforeContent = null;
|
|
574
|
+
if (payload.before) {
|
|
575
|
+
beforeContent = payload.before;
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
beforeContent = await getBeforeContent(ctx, conversationId, filePath);
|
|
579
|
+
}
|
|
580
|
+
// Get after content
|
|
581
|
+
const afterContent = payload.after ?? (0, storage_1.readFileContent)(absolutePath);
|
|
582
|
+
if (afterContent) {
|
|
583
|
+
await recordAIDelta(ctx, filePath, beforeContent, afterContent);
|
|
584
|
+
// Update checkpoint to current state so next before hook doesn't
|
|
585
|
+
// incorrectly detect this AI edit as a "human edit"
|
|
586
|
+
await (0, checkpoint_1.captureFileCheckpoint)(ctx.repoRoot, conversationId, filePath);
|
|
587
|
+
}
|
|
588
|
+
(0, database_1.insertToolCall)({
|
|
589
|
+
sessionId: ctx.sessionId,
|
|
590
|
+
toolName: payload.tool,
|
|
591
|
+
filePath: relativePath,
|
|
592
|
+
});
|
|
593
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
594
|
+
console.error(`[agentblame] Captured OpenCode ${payload.tool}: ${filePath}`);
|
|
595
|
+
}
|
|
593
596
|
}
|
|
594
597
|
// =============================================================================
|
|
595
598
|
// Main
|
|
@@ -605,55 +608,30 @@ async function runCapture() {
|
|
|
605
608
|
try {
|
|
606
609
|
const { provider, event } = parseArgs();
|
|
607
610
|
const input = await readStdin();
|
|
611
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
612
|
+
console.error(`[agentblame] Capture: provider=${provider}, event=${event}`);
|
|
613
|
+
}
|
|
608
614
|
if (!input.trim()) {
|
|
609
615
|
process.exit(0);
|
|
610
616
|
}
|
|
611
617
|
const data = JSON.parse(input);
|
|
612
|
-
// The hook receives the payload directly or wrapped
|
|
613
618
|
const payload = data.payload || data;
|
|
614
|
-
let edits = [];
|
|
615
619
|
if (provider === "cursor") {
|
|
616
620
|
const eventName = event || data.hook_event_name || "afterFileEdit";
|
|
617
|
-
|
|
621
|
+
await processCursorPayload(payload, eventName);
|
|
618
622
|
}
|
|
619
623
|
else if (provider === "claude") {
|
|
620
|
-
|
|
624
|
+
await processClaudePayload(payload);
|
|
621
625
|
}
|
|
622
626
|
else if (provider === "opencode") {
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
// Save all edits to SQLite database
|
|
626
|
-
if (process.env.AGENTBLAME_DEBUG && edits.length === 0) {
|
|
627
|
-
console.error(`[agentblame] No edits extracted from ${provider} payload`);
|
|
628
|
-
}
|
|
629
|
-
for (const edit of edits) {
|
|
630
|
-
// Find the agentblame directory for this file
|
|
631
|
-
const agentblameDir = (0, util_1.findAgentBlameDir)(edit.filePath);
|
|
632
|
-
if (!agentblameDir) {
|
|
633
|
-
// File is not in an initialized repo, skip silently
|
|
634
|
-
if (process.env.AGENTBLAME_DEBUG) {
|
|
635
|
-
console.error(`[agentblame] No agentblame dir found for ${edit.filePath}`);
|
|
636
|
-
}
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
// Set the database directory and save
|
|
640
|
-
(0, database_1.setAgentBlameDir)(agentblameDir);
|
|
641
|
-
try {
|
|
642
|
-
saveEdit(edit);
|
|
643
|
-
if (process.env.AGENTBLAME_DEBUG) {
|
|
644
|
-
console.error(`[agentblame] Saved edit for ${edit.filePath}: ${edit.lines.length} lines`);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
catch (saveErr) {
|
|
648
|
-
// Log database errors even without debug mode since they indicate lost data
|
|
649
|
-
console.error(`[agentblame] Failed to save edit for ${edit.filePath}:`, saveErr);
|
|
650
|
-
}
|
|
627
|
+
await processOpenCodePayload(payload);
|
|
651
628
|
}
|
|
652
629
|
process.exit(0);
|
|
653
630
|
}
|
|
654
631
|
catch (err) {
|
|
655
|
-
|
|
656
|
-
|
|
632
|
+
if (process.env.AGENTBLAME_DEBUG) {
|
|
633
|
+
console.error("Agent Blame capture error:", err);
|
|
634
|
+
}
|
|
657
635
|
process.exit(0);
|
|
658
636
|
}
|
|
659
637
|
}
|