SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
6.0 KB · 205 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tools/diff.ts4 * Description: Unified diff generation with addition/deletion counts for write/edit metadata.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export interface UnifiedDiff {11  text: string;12  additions: number;13  deletions: number;14}1516const CONTEXT_LINES = 3;17/** LCS matrix guard: beyond this the diff degrades to a single replace hunk. */18const MAX_LCS_CELLS = 4_000_000;1920function splitLines(text: string): string[] {21  if (text.length === 0) return [];22  const lines = text.split("\n");23  if (lines[lines.length - 1] === "") lines.pop();24  return lines;25}2627type DiffOp = { kind: "equal" | "delete" | "insert"; line: string };2829function diffOps(oldLines: string[], newLines: string[]): DiffOp[] {30  // Trim common prefix/suffix to keep the DP small.31  let start = 0;32  while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) {33    start += 1;34  }35  let endOld = oldLines.length;36  let endNew = newLines.length;37  while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) {38    endOld -= 1;39    endNew -= 1;40  }4142  const a = oldLines.slice(start, endOld);43  const b = newLines.slice(start, endNew);44  const ops: DiffOp[] = [];45  for (let i = 0; i < start; i++) ops.push({ kind: "equal", line: oldLines[i] as string });4647  if ((a.length + 1) * (b.length + 1) > MAX_LCS_CELLS) {48    for (const line of a) ops.push({ kind: "delete", line });49    for (const line of b) ops.push({ kind: "insert", line });50  } else {51    // LCS lengths table.52    const m = a.length;53    const n = b.length;54    const table = new Uint32Array((m + 1) * (n + 1));55    const at = (i: number, j: number): number => table[i * (n + 1) + j] as number;56    for (let i = m - 1; i >= 0; i--) {57      for (let j = n - 1; j >= 0; j--) {58        table[i * (n + 1) + j] =59          a[i] === b[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));60      }61    }62    let i = 0;63    let j = 0;64    while (i < m && j < n) {65      if (a[i] === b[j]) {66        ops.push({ kind: "equal", line: a[i] as string });67        i += 1;68        j += 1;69      } else if (at(i + 1, j) >= at(i, j + 1)) {70        ops.push({ kind: "delete", line: a[i] as string });71        i += 1;72      } else {73        ops.push({ kind: "insert", line: b[j] as string });74        j += 1;75      }76    }77    while (i < m) ops.push({ kind: "delete", line: a[i++] as string });78    while (j < n) ops.push({ kind: "insert", line: b[j++] as string });79  }8081  for (let i = endOld; i < oldLines.length; i++) ops.push({ kind: "equal", line: oldLines[i] as string });82  return ops;83}8485/** Compute a unified diff (3 lines of context) between two texts. */86export function unifiedDiff(87  oldText: string,88  newText: string,89  oldLabel: string,90  newLabel: string,91): UnifiedDiff {92  const ops = diffOps(splitLines(oldText), splitLines(newText));93  let additions = 0;94  let deletions = 0;95  for (const op of ops) {96    if (op.kind === "insert") additions += 1;97    if (op.kind === "delete") deletions += 1;98  }99  if (additions === 0 && deletions === 0) {100    return { text: "", additions: 0, deletions: 0 };101  }102103  // Group ops into hunks with context.104  interface Hunk {105    oldStart: number;106    oldCount: number;107    newStart: number;108    newCount: number;109    lines: string[];110  }111  const hunks: Hunk[] = [];112  let oldLine = 1;113  let newLine = 1;114  let index = 0;115116  while (index < ops.length) {117    const op = ops[index] as DiffOp;118    if (op.kind === "equal") {119      oldLine += 1;120      newLine += 1;121      index += 1;122      continue;123    }124    // Start of a change: back up for leading context.125    const contextStart = Math.max(0, index - CONTEXT_LINES);126    let leading = 0;127    for (let k = contextStart; k < index; k++) {128      if ((ops[k] as DiffOp).kind === "equal") leading += 1;129    }130    const hunk: Hunk = {131      oldStart: oldLine - leading,132      oldCount: 0,133      newStart: newLine - leading,134      newCount: 0,135      lines: [],136    };137    for (let k = index - leading; k < index; k++) {138      hunk.lines.push(` ${(ops[k] as DiffOp).line}`);139      hunk.oldCount += 1;140      hunk.newCount += 1;141    }142    // Consume changes and interleaved context until a gap of > 2×context equals.143    let equalRun = 0;144    while (index < ops.length) {145      const current = ops[index] as DiffOp;146      if (current.kind === "equal") {147        equalRun += 1;148        if (equalRun > CONTEXT_LINES * 2) break;149      } else {150        equalRun = 0;151      }152      index += 1;153      if (current.kind === "equal") {154        hunk.lines.push(` ${current.line}`);155        hunk.oldCount += 1;156        hunk.newCount += 1;157        oldLine += 1;158        newLine += 1;159      } else if (current.kind === "delete") {160        hunk.lines.push(`-${current.line}`);161        hunk.oldCount += 1;162        oldLine += 1;163      } else {164        hunk.lines.push(`+${current.line}`);165        hunk.newCount += 1;166        newLine += 1;167      }168    }169    // Trim trailing context beyond CONTEXT_LINES.170    let trailing = 0;171    while (172      trailing < hunk.lines.length &&173      (hunk.lines[hunk.lines.length - 1 - trailing] as string).startsWith(" ")174    ) {175      trailing += 1;176    }177    const excess = Math.max(0, trailing - CONTEXT_LINES);178    if (excess > 0) {179      hunk.lines.length -= excess;180      hunk.oldCount -= excess;181      hunk.newCount -= excess;182      oldLine -= excess;183      newLine -= excess;184      // Re-advance the outer scan past the trimmed equals.185      index -= excess;186    }187    hunks.push(hunk);188  }189190  const header = `--- ${oldLabel}\n+++ ${newLabel}`;191  const body = hunks192    .map(193      (h) =>194        `@@ -${h.oldStart},${h.oldCount} +${h.newStart},${h.newCount} @@\n${h.lines.join("\n")}`,195    )196    .join("\n");197  return { text: `${header}\n${body}`, additions, deletions };198}199200/** Cap a diff string for durable events (default 32 KiB) with an explicit marker. */201export function capDiff(diff: string, maxBytes = 32 * 1024): string {202  if (Buffer.byteLength(diff, "utf8") <= maxBytes) return diff;203  return `${diff.slice(0, maxBytes)}\n[diff truncated]`;204}205