Spaces:
Sleeping
Sleeping
Sync from enhance: d795216 feat: implement patch response parser for search and replace functionality
9bd4242 | import { createLogger } from '../utils/logger' | |
| const logger = createLogger('PatchResponseParser') | |
| export interface SearchReplacePatch { | |
| originalSnippet: string | |
| replacementSnippet: string | |
| } | |
| export interface SearchReplacePatchSet { | |
| patches: SearchReplacePatch[] | |
| } | |
| type NoopBehavior = 'skip' | 'error' | |
| export interface ParseSearchReplacePatchResponseOptions { | |
| responseLabel: string | |
| blockLabel: string | |
| emptyError: string | |
| noPatchBlocksError: string | |
| noopBehavior?: NoopBehavior | |
| noopError?: string | |
| allowMalformedTrailingBlock?: boolean | |
| } | |
| function stripCodeFence(text: string): string { | |
| return text | |
| .replace(/^```(?:json|text|python)?\s*/i, '') | |
| .replace(/\s*```$/i, '') | |
| .trim() | |
| } | |
| function trimPatchBoundary(text: string): string { | |
| return text | |
| .replace(/^\s*\r?\n/, '') | |
| .replace(/\r?\n\s*$/, '') | |
| .replace(/\r\n/g, '\n') | |
| } | |
| function buildMissingMarkerError(blockLabel: string, marker: string): string { | |
| return `${blockLabel} missing ${marker} marker` | |
| } | |
| function warnMalformedTrailingBlock( | |
| responseLabel: string, | |
| blockIndex: number, | |
| missingMarker: string, | |
| parsedPatchCount: number, | |
| remainderPreview: string | |
| ): void { | |
| logger.warn(`${responseLabel} ignored malformed trailing patch block`, { | |
| blockIndex, | |
| missingMarker, | |
| parsedPatchCount, | |
| remainderPreview | |
| }) | |
| } | |
| export function parseSearchReplacePatchResponse( | |
| text: string, | |
| options: ParseSearchReplacePatchResponseOptions | |
| ): SearchReplacePatchSet { | |
| const normalized = stripCodeFence(text) | |
| if (!normalized) { | |
| throw new Error(options.emptyError) | |
| } | |
| if (/^\s*<!DOCTYPE\s+html/i.test(normalized) || /^\s*<html/i.test(normalized)) { | |
| throw new Error(`${options.responseLabel} response was HTML, not patch text`) | |
| } | |
| const patches: SearchReplacePatch[] = [] | |
| let skippedNoopCount = 0 | |
| let blockCount = 0 | |
| let cursor = 0 | |
| const hasPatchMarkers = | |
| normalized.includes('[[PATCH]]') || | |
| normalized.includes('[[SEARCH]]') || | |
| normalized.includes('[[REPLACE]]') || | |
| normalized.includes('[[END]]') | |
| while (true) { | |
| const patchStart = normalized.indexOf('[[PATCH]]', cursor) | |
| if (patchStart < 0) { | |
| break | |
| } | |
| blockCount += 1 | |
| const searchStart = normalized.indexOf('[[SEARCH]]', patchStart + '[[PATCH]]'.length) | |
| if (searchStart < 0) { | |
| if (options.allowMalformedTrailingBlock && patches.length > 0) { | |
| warnMalformedTrailingBlock( | |
| options.responseLabel, | |
| blockCount, | |
| '[[SEARCH]]', | |
| patches.length, | |
| normalized.slice(patchStart, patchStart + 300) | |
| ) | |
| break | |
| } | |
| throw new Error(buildMissingMarkerError(options.blockLabel, '[[SEARCH]]')) | |
| } | |
| const replaceStart = normalized.indexOf('[[REPLACE]]', searchStart + '[[SEARCH]]'.length) | |
| if (replaceStart < 0) { | |
| if (options.allowMalformedTrailingBlock && patches.length > 0) { | |
| warnMalformedTrailingBlock( | |
| options.responseLabel, | |
| blockCount, | |
| '[[REPLACE]]', | |
| patches.length, | |
| normalized.slice(patchStart, patchStart + 300) | |
| ) | |
| break | |
| } | |
| throw new Error(buildMissingMarkerError(options.blockLabel, '[[REPLACE]]')) | |
| } | |
| const endStart = normalized.indexOf('[[END]]', replaceStart + '[[REPLACE]]'.length) | |
| if (endStart < 0) { | |
| if (options.allowMalformedTrailingBlock && patches.length > 0) { | |
| warnMalformedTrailingBlock( | |
| options.responseLabel, | |
| blockCount, | |
| '[[END]]', | |
| patches.length, | |
| normalized.slice(patchStart, patchStart + 300) | |
| ) | |
| break | |
| } | |
| throw new Error(buildMissingMarkerError(options.blockLabel, '[[END]]')) | |
| } | |
| const originalSnippet = trimPatchBoundary( | |
| normalized.slice(searchStart + '[[SEARCH]]'.length, replaceStart) | |
| ) | |
| const replacementSnippet = trimPatchBoundary( | |
| normalized.slice(replaceStart + '[[REPLACE]]'.length, endStart) | |
| ) | |
| if (!originalSnippet.trim()) { | |
| throw new Error(`${options.blockLabel} missing SEARCH content`) | |
| } | |
| if (originalSnippet === replacementSnippet) { | |
| if (options.noopBehavior === 'skip') { | |
| skippedNoopCount += 1 | |
| cursor = endStart + '[[END]]'.length | |
| continue | |
| } | |
| throw new Error(options.noopError || `${options.responseLabel} patch produced no change`) | |
| } | |
| patches.push({ originalSnippet, replacementSnippet }) | |
| cursor = endStart + '[[END]]'.length | |
| } | |
| if (skippedNoopCount > 0) { | |
| logger.warn(`${options.responseLabel} skipped no-op SEARCH/REPLACE blocks`, { | |
| skippedNoopCount | |
| }) | |
| } | |
| if (blockCount > 0) { | |
| return { patches } | |
| } | |
| if (hasPatchMarkers) { | |
| throw new Error(`${options.responseLabel} markers detected but no complete patch block could be parsed`) | |
| } | |
| throw new Error(options.noPatchBlocksError) | |
| } | |