514 lines
11 KiB
TypeScript
514 lines
11 KiB
TypeScript
export type SessionHookEvent = {
|
|
hookEventName?:
|
|
| "tool_call"
|
|
| "tool_result"
|
|
| "agent_end"
|
|
| "agent_error"
|
|
| "session_shutdown"
|
|
| string;
|
|
hookName?:
|
|
| "tool_call"
|
|
| "tool_result"
|
|
| "agent_end"
|
|
| "agent_error"
|
|
| "session_shutdown"
|
|
| string;
|
|
toolName?: string;
|
|
toolInput?: unknown;
|
|
toolOutput?: unknown;
|
|
toolError?: string;
|
|
};
|
|
|
|
export type SessionDiffHunk = {
|
|
oldStart: number;
|
|
newStart: number;
|
|
old: string;
|
|
new: string;
|
|
};
|
|
|
|
export type SessionFileDiff = {
|
|
path: string;
|
|
additions: number;
|
|
deletions: number;
|
|
hunks: SessionDiffHunk[];
|
|
};
|
|
|
|
export type SessionDiffSummary = {
|
|
additions: number;
|
|
deletions: number;
|
|
};
|
|
|
|
export type SessionDiffState = {
|
|
fileDiffs: SessionFileDiff[];
|
|
summary: SessionDiffSummary;
|
|
};
|
|
|
|
export const EMPTY_DIFF_SUMMARY: SessionDiffSummary = {
|
|
additions: 0,
|
|
deletions: 0,
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object") {
|
|
return null;
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function toStringValue(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* Tool outputs arrive in several shapes depending on the source: a plain
|
|
* record (live hook events), a JSON-encoded string, or a list of content
|
|
* blocks such as [{ type: "text", text: "<json>" }] (persisted history).
|
|
*/
|
|
function normalizeToolOutput(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value === "string") {
|
|
try {
|
|
return asRecord(JSON.parse(value));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
const record = asRecord(entry);
|
|
if (!record) {
|
|
continue;
|
|
}
|
|
if (typeof record.text === "string") {
|
|
const inner = normalizeToolOutput(record.text);
|
|
if (inner) {
|
|
return inner;
|
|
}
|
|
continue;
|
|
}
|
|
return record;
|
|
}
|
|
return null;
|
|
}
|
|
return asRecord(value);
|
|
}
|
|
|
|
function getHookEventName(event: SessionHookEvent): string {
|
|
return event.hookEventName ?? event.hookName ?? "";
|
|
}
|
|
|
|
function countAddedLines(value: string | undefined): number {
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
return value.split("\n").filter((line) => line.length > 0).length;
|
|
}
|
|
|
|
function stripApplyPatchWrapperLines(lines: string[]): string[] {
|
|
const result: string[] = [];
|
|
let insidePatch = false;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("*** Begin Patch")) {
|
|
insidePatch = true;
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
if (line === "*** End Patch") {
|
|
insidePatch = false;
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
|
|
const isBashWrapperLine =
|
|
line.startsWith("%%bash") ||
|
|
line.startsWith("apply_patch") ||
|
|
line === "EOF" ||
|
|
line.startsWith("```");
|
|
if (isBashWrapperLine && !insidePatch) {
|
|
continue;
|
|
}
|
|
|
|
result.push(line);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function parseApplyPatchInput(input: string): SessionFileDiff[] {
|
|
const lines = stripApplyPatchWrapperLines(
|
|
input.split("\n").map((line) => line.replace(/\r$/, "")),
|
|
);
|
|
const fileDiffs: SessionFileDiff[] = [];
|
|
|
|
let index = 0;
|
|
const findNextActionLine = (start: number): number => {
|
|
for (let i = start; i < lines.length; i += 1) {
|
|
const line = lines[i] ?? "";
|
|
if (
|
|
line.startsWith("*** Add File: ") ||
|
|
line.startsWith("*** Update File: ") ||
|
|
line.startsWith("*** Delete File: ") ||
|
|
line === "*** End Patch"
|
|
) {
|
|
return i;
|
|
}
|
|
}
|
|
return lines.length;
|
|
};
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? "";
|
|
if (line.startsWith("*** Add File: ")) {
|
|
const path = line.substring("*** Add File: ".length).trim();
|
|
const end = findNextActionLine(index + 1);
|
|
const addedLines: string[] = [];
|
|
for (let i = index + 1; i < end; i += 1) {
|
|
const bodyLine = lines[i] ?? "";
|
|
if (bodyLine.startsWith("+")) {
|
|
addedLines.push(bodyLine.slice(1));
|
|
}
|
|
}
|
|
fileDiffs.push({
|
|
path,
|
|
additions: addedLines.length,
|
|
deletions: 0,
|
|
hunks:
|
|
addedLines.length > 0
|
|
? [
|
|
{
|
|
oldStart: 1,
|
|
newStart: 1,
|
|
old: "",
|
|
new: addedLines.join("\n"),
|
|
},
|
|
]
|
|
: [],
|
|
});
|
|
index = end;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith("*** Delete File: ")) {
|
|
const path = line.substring("*** Delete File: ".length).trim();
|
|
fileDiffs.push({
|
|
path,
|
|
additions: 0,
|
|
deletions: 0,
|
|
hunks: [],
|
|
});
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith("*** Update File: ")) {
|
|
const path = line.substring("*** Update File: ".length).trim();
|
|
const end = findNextActionLine(index + 1);
|
|
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
let oldLineNo = 1;
|
|
let newLineNo = 1;
|
|
let activeHunk:
|
|
| {
|
|
oldStart: number;
|
|
newStart: number;
|
|
old: string[];
|
|
new: string[];
|
|
}
|
|
| undefined;
|
|
const hunks: SessionDiffHunk[] = [];
|
|
|
|
const flushActiveHunk = () => {
|
|
if (!activeHunk) {
|
|
return;
|
|
}
|
|
hunks.push({
|
|
oldStart: activeHunk.oldStart,
|
|
newStart: activeHunk.newStart,
|
|
old: activeHunk.old.join("\n"),
|
|
new: activeHunk.new.join("\n"),
|
|
});
|
|
activeHunk = undefined;
|
|
};
|
|
|
|
for (let i = index + 1; i < end; i += 1) {
|
|
const bodyLine = lines[i] ?? "";
|
|
if (bodyLine.startsWith("@@")) {
|
|
flushActiveHunk();
|
|
continue;
|
|
}
|
|
if (bodyLine.startsWith("+")) {
|
|
additions += 1;
|
|
if (!activeHunk) {
|
|
activeHunk = {
|
|
oldStart: oldLineNo,
|
|
newStart: newLineNo,
|
|
old: [],
|
|
new: [],
|
|
};
|
|
}
|
|
activeHunk.new.push(bodyLine.slice(1));
|
|
newLineNo += 1;
|
|
continue;
|
|
}
|
|
if (bodyLine.startsWith("-")) {
|
|
deletions += 1;
|
|
if (!activeHunk) {
|
|
activeHunk = {
|
|
oldStart: oldLineNo,
|
|
newStart: newLineNo,
|
|
old: [],
|
|
new: [],
|
|
};
|
|
}
|
|
activeHunk.old.push(bodyLine.slice(1));
|
|
oldLineNo += 1;
|
|
continue;
|
|
}
|
|
if (bodyLine.startsWith(" ")) {
|
|
flushActiveHunk();
|
|
oldLineNo += 1;
|
|
newLineNo += 1;
|
|
continue;
|
|
}
|
|
if (bodyLine === "*** End of File") {
|
|
flushActiveHunk();
|
|
}
|
|
}
|
|
flushActiveHunk();
|
|
|
|
fileDiffs.push({
|
|
path,
|
|
additions,
|
|
deletions,
|
|
hunks,
|
|
});
|
|
index = end;
|
|
continue;
|
|
}
|
|
|
|
index += 1;
|
|
}
|
|
|
|
return fileDiffs;
|
|
}
|
|
|
|
function parseDiffFromEditorResult(
|
|
resultText: string,
|
|
): Pick<SessionFileDiff, "additions" | "deletions" | "hunks"> {
|
|
const lines = resultText.split("\n");
|
|
const startIdx = lines.findIndex((line) => line.trim() === "```diff");
|
|
if (startIdx < 0) {
|
|
return { additions: 0, deletions: 0, hunks: [] };
|
|
}
|
|
const endIdx = lines.findIndex(
|
|
(line, idx) => idx > startIdx && line.trim() === "```",
|
|
);
|
|
const body = lines.slice(
|
|
startIdx + 1,
|
|
endIdx > startIdx ? endIdx : undefined,
|
|
);
|
|
|
|
const old: string[] = [];
|
|
const next: string[] = [];
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
let oldStart: number | undefined;
|
|
let newStart: number | undefined;
|
|
|
|
for (const raw of body) {
|
|
const match = raw.match(/^([+-])(\d+):\s?(.*)$/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
|
|
const op = match[1];
|
|
const lineNo = Number.parseInt(match[2], 10);
|
|
const text = match[3] ?? "";
|
|
if (op === "-") {
|
|
deletions += 1;
|
|
old.push(text);
|
|
oldStart = oldStart ?? lineNo;
|
|
continue;
|
|
}
|
|
|
|
additions += 1;
|
|
next.push(text);
|
|
newStart = newStart ?? lineNo;
|
|
}
|
|
|
|
if (additions + deletions === 0) {
|
|
return { additions: 0, deletions: 0, hunks: [] };
|
|
}
|
|
|
|
return {
|
|
additions,
|
|
deletions,
|
|
hunks: [
|
|
{
|
|
oldStart: oldStart ?? 1,
|
|
newStart: newStart ?? 1,
|
|
old: old.join("\n"),
|
|
new: next.join("\n"),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
|
|
if (
|
|
getHookEventName(event) !== "tool_result" ||
|
|
event.toolName !== "editor" ||
|
|
event.toolError
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const input = asRecord(event.toolInput);
|
|
const output = normalizeToolOutput(event.toolOutput);
|
|
if (!input || !output || output.success === false) {
|
|
return null;
|
|
}
|
|
|
|
// Current editor schema has no `command` field; derive the operation from
|
|
// the input shape (legacy `command` values still take precedence).
|
|
const command =
|
|
toStringValue(input.command) ??
|
|
(input.insert_line != null
|
|
? "insert"
|
|
: toStringValue(input.old_text) != null
|
|
? "str_replace"
|
|
: "create");
|
|
const pathFromInput = toStringValue(input.path);
|
|
const query = toStringValue(output.query);
|
|
const pathFromQuery = query?.includes(":")
|
|
? query.split(":").slice(1).join(":")
|
|
: undefined;
|
|
const path = pathFromInput || pathFromQuery;
|
|
if (!path) {
|
|
return null;
|
|
}
|
|
|
|
const resultText = toStringValue(output.result) ?? "";
|
|
|
|
if (command === "str_replace" && !resultText.startsWith("File created")) {
|
|
const parsed = parseDiffFromEditorResult(resultText);
|
|
return {
|
|
path,
|
|
additions: parsed.additions,
|
|
deletions: parsed.deletions,
|
|
hunks: parsed.hunks,
|
|
};
|
|
}
|
|
|
|
if (
|
|
command === "create" ||
|
|
command === "insert" ||
|
|
command === "str_replace"
|
|
) {
|
|
const newContent =
|
|
toStringValue(input.new_text) ??
|
|
toStringValue(input.file_text) ??
|
|
toStringValue(input.new_str) ??
|
|
"";
|
|
return {
|
|
path,
|
|
additions: countAddedLines(newContent),
|
|
deletions: 0,
|
|
hunks: newContent
|
|
? [
|
|
{
|
|
oldStart: 1,
|
|
newStart: 1,
|
|
old: "",
|
|
new: newContent,
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseApplyPatchFileDiffs(event: SessionHookEvent): SessionFileDiff[] {
|
|
if (
|
|
getHookEventName(event) !== "tool_result" ||
|
|
event.toolName !== "apply_patch" ||
|
|
event.toolError
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const output = normalizeToolOutput(event.toolOutput);
|
|
if (!output || output.success === false) {
|
|
return [];
|
|
}
|
|
|
|
// apply_patch accepts either { input: string } or a raw patch string.
|
|
const patchInput =
|
|
toStringValue(event.toolInput) ??
|
|
toStringValue(asRecord(event.toolInput)?.input);
|
|
if (!patchInput) {
|
|
return [];
|
|
}
|
|
|
|
return parseApplyPatchInput(patchInput);
|
|
}
|
|
|
|
export function mergeToolDiffs(events: SessionHookEvent[]): SessionFileDiff[] {
|
|
const byPath = new Map<string, SessionFileDiff>();
|
|
|
|
for (const event of events) {
|
|
const diffs: SessionFileDiff[] = [];
|
|
const editorDiff = parseEditorFileDiff(event);
|
|
if (editorDiff) {
|
|
diffs.push(editorDiff);
|
|
}
|
|
diffs.push(...parseApplyPatchFileDiffs(event));
|
|
if (diffs.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
for (const diff of diffs) {
|
|
const existing = byPath.get(diff.path);
|
|
if (!existing) {
|
|
byPath.set(diff.path, diff);
|
|
continue;
|
|
}
|
|
|
|
byPath.set(diff.path, {
|
|
...existing,
|
|
additions: existing.additions + diff.additions,
|
|
deletions: existing.deletions + diff.deletions,
|
|
hunks: [...existing.hunks, ...diff.hunks].slice(-30),
|
|
});
|
|
}
|
|
}
|
|
|
|
return Array.from(byPath.values());
|
|
}
|
|
|
|
export function summarizeFileDiffs(
|
|
fileDiffs: SessionFileDiff[],
|
|
): SessionDiffSummary {
|
|
return fileDiffs.reduce(
|
|
(acc, fileDiff) => {
|
|
acc.additions += fileDiff.additions;
|
|
acc.deletions += fileDiff.deletions;
|
|
return acc;
|
|
},
|
|
{ ...EMPTY_DIFF_SUMMARY },
|
|
);
|
|
}
|
|
|
|
export function buildSessionDiffState(
|
|
events: SessionHookEvent[],
|
|
): SessionDiffState {
|
|
const fileDiffs = mergeToolDiffs(events);
|
|
return {
|
|
fileDiffs,
|
|
summary: summarizeFileDiffs(fileDiffs),
|
|
};
|
|
}
|