Skip to content

Commit f553217

Browse files
committed
fix(reference): improve line attribution accuracy and add Claude Code model extraction
The reference implementation had two issues affecting trace accuracy: 1. Line Attribution: When processing edits with context lines (common in Claude Code's Edit tool), the entire new_string was attributed to AI, including unchanged surrounding lines. This produced inflated attribution ranges. 2. Model Identification: Claude Code does not include the model identifier in hook payloads. Traces were created with missing model_id, making it impossible to distinguish which model produced the code. Changes: - Add diffToFindChangedLines() to compute actual changed lines by comparing old_string and new_string, excluding context lines from attribution - Add extractModelFromTranscript() to parse Claude Code's JSONL transcript files and extract the model identifier from message entries - Add resolveModel() helper to transparently handle model resolution for both Cursor (direct payload) and Claude Code (transcript extraction) - Update PostToolUse, SessionStart, and SessionEnd handlers to use the new model resolution logic
1 parent e65936d commit f553217

2 files changed

Lines changed: 198 additions & 10 deletions

File tree

reference/trace-hook.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
#!/usr/bin/env bun
22

3+
/**
4+
* Agent Trace Hook Handler
5+
*
6+
* This script processes hook events from AI coding tools (Cursor, Claude Code)
7+
* and generates trace records for attribution tracking. It reads JSON input
8+
* from stdin and dispatches to the appropriate handler based on hook_event_name.
9+
*
10+
* Supported tools:
11+
* - Cursor: afterFileEdit, afterTabFileEdit, afterShellExecution, sessionStart, sessionEnd
12+
* - Claude Code: PostToolUse, SessionStart, SessionEnd
13+
*/
14+
315
import {
416
createTrace,
517
appendTrace,
618
computeRangePositions,
719
tryReadFile,
8-
type ContributorType,
20+
extractModelFromTranscript,
921
type FileEdit,
1022
} from "./trace-store";
1123

@@ -32,6 +44,25 @@ interface HookInput {
3244
cwd?: string;
3345
}
3446

47+
/**
48+
* Resolves the model identifier from hook input.
49+
*
50+
* Different tools provide model information differently:
51+
* - Cursor: Sends model directly in the hook payload via `input.model`
52+
* - Claude Code: Does not include model in payload; must be extracted from transcript
53+
*
54+
* This function handles both cases transparently.
55+
*/
56+
function resolveModel(input: HookInput): string | undefined {
57+
if (input.model) {
58+
return input.model;
59+
}
60+
if (input.transcript_path) {
61+
return extractModelFromTranscript(input.transcript_path);
62+
}
63+
return undefined;
64+
}
65+
3566
const handlers: Record<string, (input: HookInput) => void> = {
3667
afterFileEdit: (input) => {
3768
const rangePositions = computeRangePositions(input.edits ?? [], tryReadFile(input.file_path!));
@@ -108,7 +139,7 @@ const handlers: Record<string, (input: HookInput) => void> = {
108139
: undefined;
109140

110141
appendTrace(createTrace("ai", file, {
111-
model: input.model,
142+
model: resolveModel(input),
112143
rangePositions,
113144
transcript: input.transcript_path,
114145
metadata: {
@@ -122,14 +153,14 @@ const handlers: Record<string, (input: HookInput) => void> = {
122153

123154
SessionStart: (input) => {
124155
appendTrace(createTrace("ai", ".sessions", {
125-
model: input.model,
156+
model: resolveModel(input),
126157
metadata: { event: "session_start", session_id: input.session_id, source: input.source },
127158
}));
128159
},
129160

130161
SessionEnd: (input) => {
131162
appendTrace(createTrace("ai", ".sessions", {
132-
model: input.model,
163+
model: resolveModel(input),
133164
metadata: { event: "session_end", session_id: input.session_id, reason: input.reason },
134165
}));
135166
},

reference/trace-store.ts

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execFileSync } from "child_process";
2-
import { existsSync, mkdirSync, appendFileSync, readFileSync } from "fs";
2+
import { existsSync, mkdirSync, appendFileSync, readFileSync, openSync, fstatSync, readSync, closeSync } from "fs";
33
import { join, relative } from "path";
44

55
export interface Range {
@@ -94,30 +94,187 @@ export function normalizeModelId(model?: string): string | undefined {
9494
return model;
9595
}
9696

97+
/**
98+
* Extracts the model identifier from a Claude Code transcript file.
99+
*
100+
* Claude Code stores conversation transcripts as JSONL files where each line
101+
* represents a message exchange. The model identifier is stored at `entry.message.model`.
102+
* This function reads only the tail of the file to efficiently get the most recent model,
103+
* which handles cases where the model may have changed during a session.
104+
*
105+
* @param transcriptPath - Absolute path to the Claude Code transcript JSONL file
106+
* @returns The model identifier (e.g., "claude-opus-4-5-20251101") or undefined if not found
107+
*
108+
* @example
109+
* ```typescript
110+
* const model = extractModelFromTranscript("/path/to/transcript.jsonl");
111+
* // Returns: "claude-opus-4-5-20251101"
112+
* ```
113+
*/
114+
export function extractModelFromTranscript(transcriptPath: string): string | undefined {
115+
try {
116+
const fd = openSync(transcriptPath, "r");
117+
const stats = fstatSync(fd);
118+
119+
// Start with 1KB, expand if needed (handles varying line sizes)
120+
let readSize = Math.min(stats.size, 1024);
121+
122+
while (readSize <= stats.size) {
123+
const buffer = Buffer.alloc(readSize);
124+
readSync(fd, buffer, 0, readSize, stats.size - readSize);
125+
126+
const content = buffer.toString("utf-8");
127+
const lines = content.split("\n");
128+
129+
// Iterate from end to get the most recent model
130+
for (let i = lines.length - 1; i >= 0; i--) {
131+
const line = lines[i].trim();
132+
if (!line) continue;
133+
134+
try {
135+
const entry = JSON.parse(line);
136+
if (entry.message?.model) {
137+
closeSync(fd);
138+
return entry.message.model;
139+
}
140+
} catch {
141+
// Skip malformed/partial JSON lines
142+
continue;
143+
}
144+
}
145+
146+
// No model found, try larger chunk
147+
if (readSize >= stats.size) break;
148+
readSize = Math.min(stats.size, readSize * 2);
149+
}
150+
151+
closeSync(fd);
152+
return undefined;
153+
} catch {
154+
// File doesn't exist or isn't readable
155+
return undefined;
156+
}
157+
}
158+
97159
export interface RangePosition {
98160
start_line: number;
99161
end_line: number;
100162
}
101163

164+
/**
165+
* Computes which lines in `newStr` are actually new or modified compared to `oldStr`.
166+
*
167+
* This function performs a simple line-by-line diff to distinguish between:
168+
* - Context lines: Lines that exist in both old and new strings (not attributed)
169+
* - Changed lines: Lines that are new or modified (attributed to AI)
170+
*
171+
* This is necessary because some tools (like Claude Code's Edit tool) include
172+
* surrounding context lines in both `old_string` and `new_string`. Without this
173+
* diff, we would incorrectly attribute unchanged context lines to the AI.
174+
*
175+
* @param oldStr - The original string before the edit
176+
* @param newStr - The new string after the edit
177+
* @returns Array of 0-indexed line offsets within `newStr` that are new or modified
178+
*
179+
* @example
180+
* ```typescript
181+
* // old: "line1\nline2\nline3"
182+
* // new: "line1\nNEW LINE\nline3"
183+
* diffToFindChangedLines(old, new); // Returns [1] - only the middle line changed
184+
* ```
185+
*/
186+
function diffToFindChangedLines(oldStr: string, newStr: string): number[] {
187+
const oldLines = oldStr.split("\n");
188+
const newLines = newStr.split("\n");
189+
const changedOffsets: number[] = [];
190+
191+
let oldIdx = 0;
192+
193+
for (let newIdx = 0; newIdx < newLines.length; newIdx++) {
194+
if (oldIdx < oldLines.length && oldLines[oldIdx] === newLines[newIdx]) {
195+
// Matching line - this is context, not a change
196+
oldIdx++;
197+
} else {
198+
// Check if this line from newStr exists later in oldStr (handles deletions)
199+
let foundAhead = false;
200+
for (let lookAhead = oldIdx; lookAhead < oldLines.length; lookAhead++) {
201+
if (oldLines[lookAhead] === newLines[newIdx]) {
202+
oldIdx = lookAhead + 1;
203+
foundAhead = true;
204+
break;
205+
}
206+
}
207+
208+
if (!foundAhead) {
209+
// Line is genuinely new or modified - attribute to AI
210+
changedOffsets.push(newIdx);
211+
}
212+
}
213+
}
214+
215+
return changedOffsets;
216+
}
217+
102218
export function computeRangePositions(edits: FileEdit[], fileContent?: string): RangePosition[] {
103219
return edits
104220
.filter((e) => e.new_string)
105-
.map((edit) => {
221+
.flatMap((edit) => {
222+
// Case 1: Has explicit range from tool → use it
106223
if (edit.range) {
107-
return {
224+
return [{
108225
start_line: edit.range.start_line_number,
109226
end_line: edit.range.end_line_number,
110-
};
227+
}];
111228
}
229+
230+
// Case 2: Has both old_string and new_string → diff them to find actual changes
231+
if (edit.old_string && edit.new_string && fileContent) {
232+
const idx = fileContent.indexOf(edit.new_string);
233+
if (idx !== -1) {
234+
const startLine = fileContent.substring(0, idx).split("\n").length;
235+
const changedOffsets = diffToFindChangedLines(edit.old_string, edit.new_string);
236+
237+
if (changedOffsets.length === 0) {
238+
return [];
239+
}
240+
241+
// Convert offsets to line ranges, merging adjacent lines
242+
const ranges: RangePosition[] = [];
243+
let rangeStart = changedOffsets[0];
244+
let rangeEnd = changedOffsets[0];
245+
246+
for (let i = 1; i < changedOffsets.length; i++) {
247+
if (changedOffsets[i] === rangeEnd + 1) {
248+
rangeEnd = changedOffsets[i];
249+
} else {
250+
ranges.push({
251+
start_line: startLine + rangeStart,
252+
end_line: startLine + rangeEnd,
253+
});
254+
rangeStart = changedOffsets[i];
255+
rangeEnd = changedOffsets[i];
256+
}
257+
}
258+
259+
ranges.push({
260+
start_line: startLine + rangeStart,
261+
end_line: startLine + rangeEnd,
262+
});
263+
264+
return ranges;
265+
}
266+
}
267+
268+
// Case 3: Fallback - attribute entire new_string (original behavior)
112269
const lineCount = edit.new_string.split("\n").length;
113270
if (fileContent) {
114271
const idx = fileContent.indexOf(edit.new_string);
115272
if (idx !== -1) {
116273
const startLine = fileContent.substring(0, idx).split("\n").length;
117-
return { start_line: startLine, end_line: startLine + lineCount - 1 };
274+
return [{ start_line: startLine, end_line: startLine + lineCount - 1 }];
118275
}
119276
}
120-
return { start_line: 1, end_line: lineCount };
277+
return [{ start_line: 1, end_line: lineCount }];
121278
});
122279
}
123280

0 commit comments

Comments
 (0)