mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
export type NativeChatDiffLineKind = 'add' | 'del' | 'context' | 'meta'
|
|
|
|
export type NativeChatDiffLine = {
|
|
kind: NativeChatDiffLineKind
|
|
text: string
|
|
}
|
|
|
|
/** Tools whose call carries the edit itself, so its row is a file change. */
|
|
export const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch'])
|
|
const MAX_DIFF_CHARS = 32_000
|
|
const DEFAULT_MAX_DIFF_LINES = 120
|
|
const DIFF_TRUNCATED_LINE: NativeChatDiffLine = {
|
|
kind: 'meta',
|
|
text: '… diff truncated …'
|
|
}
|
|
|
|
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/
|
|
/** Lines that open a new file section, so any hunk before them has ended.
|
|
* `--- `/`+++ ` are deliberately absent: inside a hunk they are content — a
|
|
* removed `-- comment` is emitted as `--- comment` — so they go through
|
|
* `isFileHeaderPair` instead. */
|
|
export const FILE_SECTION_START =
|
|
/^(?:diff |index |old mode |new mode |new file mode |deleted file mode |similarity index |dissimilarity index |rename |copy |Binary files )/
|
|
// Markdown thematic break or YAML document separator, not a marker.
|
|
const BARE_RULE = /^(?:-{3,}|\+{3,})$/
|
|
|
|
type DiffStructure = {
|
|
/** Lines that are structure rather than content, so they never count as add/del. */
|
|
metaIndices: Set<number>
|
|
/** The text carries a hunk header or `diff --git`, so it is provably a diff. */
|
|
isStructuredDiff: boolean
|
|
}
|
|
|
|
/**
|
|
* True when the row at `index` opens a `--- <old>` / `+++ <new>` file header. A
|
|
* bare `---`/`+++` prefix is not enough to spot one: a removed line whose
|
|
* content began with `--` (SQL/Lua `-- comment`, C `--i`) is emitted as
|
|
* `---<content>`. Real headers always come as an adjacent pair and never appear
|
|
* inside a hunk, so callers must check this only outside one.
|
|
*/
|
|
export function isFileHeaderPair(lines: readonly string[], index: number): boolean {
|
|
return (lines[index] ?? '').startsWith('--- ') && (lines[index + 1] ?? '').startsWith('+++ ')
|
|
}
|
|
|
|
/** Locates the file headers and rules that are structure rather than content. */
|
|
function scanDiffStructure(lines: string[]): DiffStructure {
|
|
const metaIndices = new Set<number>()
|
|
let isStructuredDiff = false
|
|
let inHunk = false
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index] ?? ''
|
|
if (line.startsWith('@@')) {
|
|
inHunk = true
|
|
isStructuredDiff ||= HUNK_HEADER.test(line)
|
|
continue
|
|
}
|
|
if (FILE_SECTION_START.test(line)) {
|
|
inHunk = false
|
|
isStructuredDiff ||= line.startsWith('diff --git ')
|
|
continue
|
|
}
|
|
// Only marker lines can be a header or a rule; skip context and prose early.
|
|
if (inHunk || !(line.startsWith('-') || line.startsWith('+'))) {
|
|
continue
|
|
}
|
|
if (BARE_RULE.test(line)) {
|
|
metaIndices.add(index)
|
|
continue
|
|
}
|
|
if (isFileHeaderPair(lines, index)) {
|
|
metaIndices.add(index)
|
|
metaIndices.add(index + 1)
|
|
index += 1
|
|
}
|
|
}
|
|
return { metaIndices, isStructuredDiff }
|
|
}
|
|
|
|
function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } {
|
|
if (typeof value !== 'string') {
|
|
return { lines: [], truncated: false }
|
|
}
|
|
const clipped = value.slice(0, MAX_DIFF_CHARS)
|
|
const lines = clipped.split('\n', maxLines + 1)
|
|
const truncated = value.length > MAX_DIFF_CHARS || lines.length > maxLines
|
|
const bounded = lines.slice(0, maxLines)
|
|
if (!truncated && bounded.at(-1) === '') {
|
|
bounded.pop()
|
|
}
|
|
return { lines: bounded, truncated }
|
|
}
|
|
|
|
function patchTextFromToolInput(value: Record<string, unknown>): string | null {
|
|
if (typeof value.patch === 'string') {
|
|
return value.patch
|
|
}
|
|
if (typeof value.diff === 'string') {
|
|
return value.diff
|
|
}
|
|
if (!Array.isArray(value.changes)) {
|
|
return null
|
|
}
|
|
const sections: string[] = []
|
|
let length = 0
|
|
for (const entry of value.changes) {
|
|
if (typeof entry !== 'object' || entry === null) {
|
|
continue
|
|
}
|
|
const change = entry as Record<string, unknown>
|
|
if (typeof change.diff !== 'string') {
|
|
continue
|
|
}
|
|
const path = typeof change.path === 'string' ? change.path : 'file'
|
|
const kind =
|
|
typeof change.kind === 'object' && change.kind !== null
|
|
? (change.kind as Record<string, unknown>)
|
|
: null
|
|
const nextPath = kind && typeof kind.move_path === 'string' ? kind.move_path : path
|
|
const section = `--- ${path}\n+++ ${nextPath}\n${change.diff}`
|
|
length += section.length + (sections.length > 0 ? 1 : 0)
|
|
sections.push(section)
|
|
// Keep the extra character that tells toLines the diff was truncated.
|
|
if (length > MAX_DIFF_CHARS) {
|
|
break
|
|
}
|
|
}
|
|
return sections.length > 0 ? sections.join('\n') : null
|
|
}
|
|
|
|
export function diffFromToolCall(
|
|
name: string,
|
|
input: unknown,
|
|
maxLines = DEFAULT_MAX_DIFF_LINES
|
|
): NativeChatDiffLine[] | null {
|
|
if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) {
|
|
return null
|
|
}
|
|
const value = input as Record<string, unknown>
|
|
const patchText = patchTextFromToolInput(value)
|
|
if (patchText !== null) {
|
|
return diffFromText(patchText, maxLines)
|
|
}
|
|
const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines)
|
|
const newLines = toLines(
|
|
value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text,
|
|
maxLines
|
|
)
|
|
const deleted = oldLines.lines.map((text): NativeChatDiffLine => ({ kind: 'del', text }))
|
|
const added = newLines.lines.map((text): NativeChatDiffLine => ({ kind: 'add', text }))
|
|
if (deleted.length === 0 && added.length === 0) {
|
|
return null
|
|
}
|
|
const path = value.file_path ?? value.path
|
|
const prefix: NativeChatDiffLine[] =
|
|
typeof path === 'string' ? [{ kind: 'meta', text: path }] : []
|
|
const combined = [...prefix, ...deleted, ...added]
|
|
const truncated = oldLines.truncated || newLines.truncated || combined.length > maxLines
|
|
return truncated ? [...combined.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : combined
|
|
}
|
|
|
|
export function diffFromText(
|
|
text: string,
|
|
maxLines = DEFAULT_MAX_DIFF_LINES
|
|
): NativeChatDiffLine[] | null {
|
|
if (text.length === 0) {
|
|
return null
|
|
}
|
|
const bounded = toLines(text, maxLines)
|
|
const { metaIndices, isStructuredDiff } = scanDiffStructure(bounded.lines)
|
|
let added = 0
|
|
let removed = 0
|
|
const lines = bounded.lines.map((line, index): NativeChatDiffLine => {
|
|
if (
|
|
metaIndices.has(index) ||
|
|
line.startsWith('@@') ||
|
|
line.startsWith('diff ') ||
|
|
line.startsWith('index ')
|
|
) {
|
|
return { kind: 'meta', text: line }
|
|
}
|
|
if (line.startsWith('+')) {
|
|
added += 1
|
|
return { kind: 'add', text: line.slice(1) }
|
|
}
|
|
if (line.startsWith('-')) {
|
|
removed += 1
|
|
return { kind: 'del', text: line.slice(1) }
|
|
}
|
|
return { kind: 'context', text: line }
|
|
})
|
|
// Proven diff text renders a single-line change; without that proof, two
|
|
// markers guard against colouring prose that merely opens a line with `-`.
|
|
if (added + removed < (isStructuredDiff ? 1 : 2)) {
|
|
return null
|
|
}
|
|
return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines
|
|
}
|