openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
510 lines (509 loc) • 22.8 kB
JavaScript
import { c as isRecord } from "../../../record-coerce-TIsquoVR.mjs";
import { r as truncateUtf16Safe } from "../../../utf16-slice-D_ngcYKd.mjs";
import { n as serveWorkerTasks } from "../../../worker-task-server-CF3aSh39.mjs";
import { t as levenshteinDistance } from "../../../levenshtein-distance-LMZfJS7z.mjs";
import { n as prepareFileDiff, r as prepareFileWriteDiff } from "../../../file-diff-Cm5KmaWf.mjs";
//#region src/agents/line-endings.ts
/**
* Line-ending detection and restoration shared by the file-mutating agent tools.
*/
function detectLineEnding(content) {
const crlfIdx = content.indexOf("\r\n");
const lfIdx = content.indexOf("\n");
if (lfIdx === -1) return "\n";
if (crlfIdx === -1) return "\n";
return crlfIdx < lfIdx ? "\r\n" : "\n";
}
function normalizeToLF(text) {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
//#endregion
//#region src/agents/sessions/tools/edit-replacements.ts
function splitLinesWithEndings(content) {
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
}
function getLineSpans(content) {
let offset = 0;
return splitLinesWithEndings(content).map((line) => {
const span = {
start: offset,
end: offset + line.length
};
offset = span.end;
return span;
});
}
function getReplacementLineRange(lines, replacement) {
const replacementStart = replacement.matchIndex;
const replacementEnd = replacement.matchIndex + replacement.matchLength;
let lower = 0;
let upper = lines.length;
while (lower < upper) {
const middle = Math.floor((lower + upper) / 2);
const line = lines[middle];
if (!line) throw new Error("Replacement range is outside the base content.");
if (line.end <= replacementStart) lower = middle + 1;
else upper = middle;
}
const firstLine = lines[lower];
const startLine = firstLine && replacementStart >= firstLine.start ? lower : -1;
if (startLine === -1) throw new Error("Replacement range is outside the base content.");
let endLine = startLine;
while (endLine < lines.length) {
const line = lines.at(endLine);
if (!line || line.end >= replacementEnd) break;
endLine++;
}
if (endLine >= lines.length) throw new Error("Replacement range is outside the base content.");
return {
startLine,
endLine: endLine + 1
};
}
function applyReplacements(content, replacements, offset = 0) {
const parts = [];
let cursor = 0;
for (const replacement of replacements) {
const matchIndex = replacement.matchIndex - offset;
parts.push(content.slice(cursor, matchIndex), replacement.newText);
cursor = matchIndex + replacement.matchLength;
}
parts.push(content.slice(cursor));
return parts.join("");
}
function groupReplacementsByLine(baseContent, replacements) {
const lines = getLineSpans(baseContent);
const groups = [];
const sortedReplacements = replacements.toSorted((a, b) => a.matchIndex - b.matchIndex);
for (const replacement of sortedReplacements) {
const range = getReplacementLineRange(lines, replacement);
const current = groups.at(-1);
if (current && range.startLine < current.endLine) {
current.endLine = Math.max(current.endLine, range.endLine);
current.replacements.push(replacement);
} else groups.push({
...range,
replacements: [replacement]
});
}
return {
lines,
groups
};
}
function splitLinesWithTerminators(content) {
return content.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+/g) ?? [];
}
function getLineTerminator(line) {
if (line === void 0) return;
if (line.endsWith("\r\n")) return "\r\n";
if (line.endsWith("\n")) return "\n";
return line.endsWith("\r") ? "\r" : void 0;
}
function restoreNormalizedLineEndings(normalizedContent, sourceLines, fallback) {
let sourceIndex = 0;
return normalizedContent.replace(/\n/g, () => {
const source = sourceLines[sourceIndex] ?? sourceLines.at(-1);
sourceIndex++;
return getLineTerminator(source) ?? fallback;
});
}
function countLineBreaks(content) {
return content.match(/\n/g)?.length ?? 0;
}
function applyReplacementsPreservingLineEndings(originalContent, baseContent, replacements) {
const originalLines = splitLinesWithTerminators(originalContent);
const { lines: baseLines, groups } = groupReplacementsByLine(baseContent, replacements);
if (originalLines.length !== baseLines.length) throw new Error("Cannot preserve original line endings because the base content has a different line count.");
const fileFallback = detectLineEnding(originalContent);
let originalIndex = 0;
let result = "";
for (const group of groups) {
result += originalLines.slice(originalIndex, group.startLine).join("");
const firstLine = baseLines.at(group.startLine);
const lastLine = baseLines.at(group.endLine - 1);
if (!firstLine || !lastLine) throw new Error("Replacement group is outside the base content.");
const groupStartOffset = firstLine.start;
const normalizedGroup = baseContent.slice(groupStartOffset, lastLine.end);
const sourceGroup = originalLines.slice(group.startLine, group.endLine);
const groupFallback = getLineTerminator(sourceGroup[0]) ?? getLineTerminator(originalLines[group.startLine - 1]) ?? fileFallback;
const restoredGroup = restoreNormalizedLineEndings(normalizedGroup, sourceGroup, groupFallback);
const restoredReplacements = group.replacements.map((replacement) => {
const relativeStart = replacement.matchIndex - groupStartOffset;
const relativeEnd = relativeStart + replacement.matchLength;
const restoredStart = restoreNormalizedLineEndings(normalizedGroup.slice(0, relativeStart), sourceGroup, groupFallback).length;
const restoredEnd = restoreNormalizedLineEndings(normalizedGroup.slice(0, relativeEnd), sourceGroup, groupFallback).length;
const range = getReplacementLineRange(baseLines, replacement);
const replacementSource = originalLines.slice(range.startLine, range.endLine);
const replacementFallback = getLineTerminator(replacementSource[0]) ?? getLineTerminator(originalLines[range.startLine - 1]) ?? fileFallback;
const consumedTerminatorCount = countLineBreaks(normalizedGroup.slice(relativeStart, relativeEnd));
const replacementTerminatorCount = countLineBreaks(replacement.newText);
const terminatorSources = consumedTerminatorCount > 0 ? replacementSource.slice(0, consumedTerminatorCount) : replacementSource.slice(0, 1);
const sourceOffset = Math.max(0, terminatorSources.length - replacementTerminatorCount);
return {
matchIndex: restoredStart,
matchLength: restoredEnd - restoredStart,
newText: restoreNormalizedLineEndings(replacement.newText, terminatorSources.slice(sourceOffset), replacementFallback)
};
});
result += applyReplacements(restoredGroup, restoredReplacements);
originalIndex = group.endLine;
}
return result + originalLines.slice(originalIndex).join("");
}
//#endregion
//#region src/agents/sessions/tools/edit-diff.ts
/** Pure file edit planning and shared display/unified-patch receipts. */
const fuzzyGraphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
function foldFuzzyCharacters(text) {
return text.replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, "\"").replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-").replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
}
/**
* Normalize text for fuzzy matching. Applies progressive transformations:
* - Strip trailing whitespace from each line
* - Normalize smart quotes to ASCII equivalents
* - Normalize Unicode dashes/hyphens to ASCII hyphen
* - Normalize special Unicode spaces to regular space
*
*/
function normalizeForFuzzyMatch(text) {
return foldFuzzyCharacters(text.normalize("NFKC").split("\n").map((line) => line.trimEnd()).join("\n"));
}
function buildNfkcBoundaries(text, authoritativeNfkc) {
const boundaries = [];
const normalizedSegments = [];
let normalizedOffset = 0;
for (const segment of fuzzyGraphemeSegmenter.segment(text)) {
const sourceStart = segment.index;
const sourceEnd = sourceStart + segment.segment.length;
const normalizedSegment = segment.segment.normalize("NFKC");
normalizedSegments.push(normalizedSegment);
const boundary = boundaries[normalizedOffset] ?? {};
if (normalizedSegment.length === 0) {
boundaries[normalizedOffset] = {
end: boundary.end ?? sourceStart,
start: sourceEnd
};
continue;
}
boundaries[normalizedOffset] = {
start: boundary.start ?? sourceStart,
end: boundary.end ?? sourceStart
};
normalizedOffset += normalizedSegment.length;
boundaries[normalizedOffset] = {
start: sourceEnd,
end: sourceEnd
};
}
if (normalizedSegments.join("") !== authoritativeNfkc) return;
return boundaries;
}
function buildFuzzyBoundaries(text, normalizedText) {
const authoritativeNfkc = text.normalize("NFKC");
const nfkcBoundaries = buildNfkcBoundaries(text, authoritativeNfkc);
if (!nfkcBoundaries) return;
const boundaries = [];
let sourceLineStart = 0;
let normalizedLineStart = 0;
while (sourceLineStart <= authoritativeNfkc.length) {
const newlineIndex = authoritativeNfkc.indexOf("\n", sourceLineStart);
const sourceLineEnd = newlineIndex === -1 ? authoritativeNfkc.length : newlineIndex;
const line = authoritativeNfkc.slice(sourceLineStart, sourceLineEnd);
const keptLineEnd = sourceLineStart + line.trimEnd().length;
for (let offset = sourceLineStart; offset <= keptLineEnd; offset++) {
const boundary = nfkcBoundaries[offset];
if (boundary) boundaries[normalizedLineStart + offset - sourceLineStart] = boundary;
}
const normalizedLineEnd = normalizedLineStart + keptLineEnd - sourceLineStart;
if (keptLineEnd < sourceLineEnd) {
const beforeTrim = nfkcBoundaries[keptLineEnd];
const afterTrim = nfkcBoundaries[sourceLineEnd];
boundaries[normalizedLineEnd] = {
end: beforeTrim?.end,
start: afterTrim?.start
};
}
if (newlineIndex === -1) break;
const afterNewline = nfkcBoundaries[sourceLineEnd + 1];
boundaries[normalizedLineEnd + 1] = afterNewline;
normalizedLineStart = normalizedLineEnd + 1;
sourceLineStart = sourceLineEnd + 1;
}
if (boundaries.length > normalizedText.length + 1) return;
return boundaries;
}
function translateFuzzySpan(normalized, fuzzyStart, fuzzyLength) {
const fuzzyEnd = fuzzyStart + fuzzyLength;
const originalStart = normalized.boundaries?.[fuzzyStart]?.start;
const originalEnd = normalized.boundaries?.[fuzzyEnd]?.end;
if (originalStart === void 0 || originalEnd === void 0) return;
return {
originalStart,
originalLength: originalEnd - originalStart
};
}
/**
* Find oldText in content, trying exact match first, then fuzzy match.
* When fuzzy matching is used and an offsetMap is provided, the returned
* index and matchLength are translated back to original-content coordinates
* so the caller can apply replacements against the un-normalized content.
*/
function fuzzyFindText(content, oldText, normalizedFile) {
const exactIndex = content.indexOf(oldText);
if (exactIndex !== -1) return {
found: true,
index: exactIndex,
matchLength: oldText.length,
usedFuzzyMatch: false
};
const fuzzyContent = normalizedFile?.text ?? normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
if (!fuzzyOldText) return {
found: false,
index: -1,
matchLength: 0,
usedFuzzyMatch: true,
unsafeBoundary: true
};
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
if (fuzzyIndex === -1) return {
found: false,
index: -1,
matchLength: 0,
usedFuzzyMatch: false
};
if (normalizedFile && !normalizedFile.boundaries) normalizedFile.boundaries = buildFuzzyBoundaries(content, normalizedFile.text);
const translated = normalizedFile ? translateFuzzySpan(normalizedFile, fuzzyIndex, fuzzyOldText.length) : void 0;
if (!translated) return {
found: false,
index: -1,
matchLength: 0,
usedFuzzyMatch: true,
unsafeBoundary: true
};
return {
found: true,
index: translated.originalStart,
matchLength: translated.originalLength,
usedFuzzyMatch: true
};
}
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
function stripBom(content) {
return content.startsWith("") ? {
bom: "",
text: content.slice(1)
} : {
bom: "",
text: content
};
}
function countOccurrences(fuzzyContent, oldText) {
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
if (!fuzzyOldText) return 0;
return fuzzyContent.split(fuzzyOldText).length - 1;
}
function countExactOccurrences(content, oldText) {
return content.split(oldText).length - 1;
}
const EDIT_CANDIDATE_LIMIT = 3;
const EDIT_CANDIDATE_MAX_LINES = 1e3;
const EDIT_CANDIDATE_MAX_SCAN_CHARS = 131072;
const EDIT_CANDIDATE_MAX_LINE_CHARS = 120;
const EDIT_CANDIDATE_MIN_SCORE = .45;
function getBoundedLines(text, maxLines, maxScanChars) {
return truncateUtf16Safe(text, maxScanChars).split("\n", maxLines).map((line) => truncateUtf16Safe(line, EDIT_CANDIDATE_MAX_LINE_CHARS));
}
function scoreCandidate(expected, candidate) {
const normalizedExpected = expected.trim();
const normalizedCandidate = candidate.trim();
const maxLength = Math.max(normalizedExpected.length, normalizedCandidate.length);
if (maxLength === 0) return 0;
if (Math.min(normalizedExpected.length, normalizedCandidate.length) / maxLength < EDIT_CANDIDATE_MIN_SCORE) return 0;
return 1 - levenshteinDistance(normalizedExpected, normalizedCandidate) / maxLength;
}
function describeIndentation(line) {
const indentation = line.match(/^[ \t]*/)?.[0] ?? "";
if (!indentation) return "none";
const tabs = indentation.match(/\t/g)?.length ?? 0;
const spaces = indentation.length - tabs;
return tabs === 0 ? `${spaces} spaces` : `${spaces} spaces and ${tabs} tabs`;
}
function firstDifferenceIndex(left, right) {
const sharedLength = Math.min(left.length, right.length);
for (let index = 0; index < sharedLength; index++) if (left.charAt(index) !== right.charAt(index)) return index;
return left.length === right.length ? -1 : sharedLength;
}
function describeCandidateDifference(expected, found) {
if ((expected.match(/^[ \t]*/)?.[0] ?? "") !== (found.match(/^[ \t]*/)?.[0] ?? "")) return `indentation differs (expected ${describeIndentation(expected)}, found ${describeIndentation(found)})`;
const expectedBackslashes = expected.match(/\\/g)?.length ?? 0;
const foundBackslashes = found.match(/\\/g)?.length ?? 0;
if (expectedBackslashes !== foundBackslashes) return `escaping differs (expected ${expectedBackslashes} backslashes, found ${foundBackslashes})`;
const differenceIndex = firstDifferenceIndex(expected, found);
return differenceIndex === -1 ? "this line matches; surrounding lines differ" : `first difference at column ${differenceIndex + 1}`;
}
function getCandidateHint(content, oldText) {
const expected = getBoundedLines(oldText, 32, 4096).reduce((best, line) => line.trim().length > best.trim().length ? line : best, "");
if (!expected.trim()) return "";
const candidates = getBoundedLines(content, EDIT_CANDIDATE_MAX_LINES, EDIT_CANDIDATE_MAX_SCAN_CHARS).map((line, index) => {
const score = scoreCandidate(expected, line);
return score >= EDIT_CANDIDATE_MIN_SCORE ? {
lineNumber: index + 1,
line,
score
} : void 0;
}).filter((candidate) => candidate !== void 0).toSorted((left, right) => right.score - left.score || left.lineNumber - right.lineNumber).slice(0, EDIT_CANDIDATE_LIMIT);
if (candidates.length === 0) return "";
const expectedDisplay = JSON.stringify(expected);
return "\nClosest matching lines:\n" + candidates.map((candidate) => {
const foundDisplay = JSON.stringify(candidate.line);
const differenceIndex = firstDifferenceIndex(expectedDisplay, foundDisplay);
const markerIndex = differenceIndex === -1 ? Math.min(expectedDisplay.length, foundDisplay.length) : differenceIndex;
const markerWidth = Math.max(1, Math.min(12, Math.max(expectedDisplay.length, foundDisplay.length) - markerIndex));
return [
` near line ${candidate.lineNumber} (${Math.round(candidate.score * 100)}% match):`,
` expected: ${expectedDisplay}`,
` found: ${foundDisplay}`,
` ${" ".repeat(markerIndex)}${"^".repeat(markerWidth)}`,
` hint: ${describeCandidateDifference(expected, candidate.line)}`
].join("\n");
}).join("\n");
}
function getNotFoundError(path, editIndex, totalEdits, content, oldText) {
const prefix = totalEdits === 1 ? "Could not find the exact text" : `Could not find edits[${editIndex}]`;
const hint = getCandidateHint(content, oldText);
return /* @__PURE__ */ new Error(`${prefix} in ${path}. The old text must match exactly including all whitespace and newlines.${hint}`);
}
function getDuplicateError(path, editIndex, totalEdits, occurrences) {
if (totalEdits === 1) return /* @__PURE__ */ new Error(`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`);
return /* @__PURE__ */ new Error(`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
}
function getEmptyOldTextError(path, editIndex, totalEdits) {
if (totalEdits === 1) return /* @__PURE__ */ new Error(`oldText must not be empty in ${path}.`);
return /* @__PURE__ */ new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
}
function getUnsafeFuzzyBoundaryError(path, editIndex, totalEdits) {
const target = totalEdits === 1 ? "The fuzzy match" : `The fuzzy match for edits[${editIndex}]`;
return /* @__PURE__ */ new Error(`${target} in ${path} crosses an ambiguous Unicode-normalization or trimmed-whitespace boundary. Copy the exact source text or use a span whose normalized boundaries map cleanly.`);
}
/**
* Apply one or more exact-text replacements to LF-normalized content.
*
* All edits are matched against the same original content. Replacements are
* assembled from original spans so offsets remain stable. Fuzzy matching is
* lookup-only: replacements always splice into the original content.
*/
function applyEdits(normalizedContent, edits, path) {
const normalizedEdits = edits.map((edit) => ({
oldText: normalizeToLF(edit.oldText),
newText: normalizeToLF(edit.newText)
}));
for (const [i, edit] of normalizedEdits.entries()) if (edit.oldText.length === 0) throw getEmptyOldTextError(path, i, normalizedEdits.length);
const fuzzyFile = normalizedEdits.some((edit) => !normalizedContent.includes(edit.oldText)) ? {
text: normalizeForFuzzyMatch(normalizedContent),
boundaries: void 0
} : void 0;
const matchedEdits = [];
for (const [i, edit] of normalizedEdits.entries()) {
const matchResult = fuzzyFindText(normalizedContent, edit.oldText, fuzzyFile);
const occurrences = fuzzyFile && matchResult.usedFuzzyMatch ? countOccurrences(fuzzyFile.text, edit.oldText) : countExactOccurrences(normalizedContent, edit.oldText);
if (occurrences > 1) throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
if (matchResult.unsafeBoundary) throw getUnsafeFuzzyBoundaryError(path, i, normalizedEdits.length);
if (!matchResult.found) throw getNotFoundError(path, i, normalizedEdits.length, normalizedContent, edit.oldText);
matchedEdits.push({
editIndex: i,
matchIndex: matchResult.index,
matchLength: matchResult.matchLength,
newText: edit.newText
});
}
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
for (let i = 1; i < matchedEdits.length; i++) {
const previous = matchedEdits.at(i - 1);
const current = matchedEdits.at(i);
if (!previous || !current) continue;
if (previous.matchIndex + previous.matchLength > current.matchIndex) throw new Error(`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
}
return {
baseContent: normalizedContent,
newContent: applyReplacements(normalizedContent, matchedEdits),
replacements: matchedEdits
};
}
function validateNoOpEditTargets(normalizedContent, noOpEdits, realEdits, path) {
if (noOpEdits.length > 0) applyEdits(normalizedContent, noOpEdits.map((edit) => ({
oldText: edit.oldText,
newText: ""
})), path);
const exactNoOpEdits = noOpEdits.filter((edit) => normalizedContent.includes(normalizeToLF(edit.oldText)));
if (exactNoOpEdits.length > 0 && realEdits.length > 0) applyEdits(normalizedContent, [...exactNoOpEdits, ...realEdits].map((edit) => ({
oldText: edit.oldText,
newText: ""
})), path);
}
function splitNoOpEdits(normalizedContent, edits, path) {
const noOpEdits = [];
const realEdits = [];
for (const edit of edits) if (edit.oldText === edit.newText) {
applyEdits(normalizedContent, [{
oldText: edit.oldText,
newText: ""
}], path);
noOpEdits.push(edit);
} else realEdits.push(edit);
return {
noOpEdits,
realEdits
};
}
function prepareFileEdit(content, edits, path) {
const { bom, text } = stripBom(content);
const normalized = normalizeToLF(text);
const { noOpEdits, realEdits } = splitNoOpEdits(normalized, edits, path);
validateNoOpEditTargets(normalized, noOpEdits, realEdits, path);
if (realEdits.length === 0) return {
changed: false,
message: `No changes made to ${path}. The replacement text is identical to the original.`
};
const { baseContent, newContent, replacements } = applyEdits(normalized, realEdits, path);
if (baseContent === newContent) return {
changed: false,
message: `No changes made to ${path}. The replacement produced identical content.`
};
const finalContent = applyReplacementsPreservingLineEndings(text, baseContent, replacements);
if (normalizeToLF(finalContent) !== newContent) throw new Error("Line-ending restoration changed the normalized edit result.");
const receipt = prepareFileDiff(path, baseContent, newContent);
if (!receipt) throw new Error("Unbounded edit diff did not produce a patch");
return {
changed: true,
content: bom + finalContent,
editCount: realEdits.length,
receipt
};
}
//#endregion
//#region src/agents/sessions/tools/file-tool-planning.worker.ts
function isEdit(value) {
return isRecord(value) && typeof value.oldText === "string" && typeof value.newText === "string";
}
serveWorkerTasks((input) => {
if (isRecord(input) && typeof input.path === "string" && typeof input.content === "string") {
if (input.kind === "edit" && Array.isArray(input.edits) && input.edits.every(isEdit)) return {
kind: "edit",
plan: prepareFileEdit(input.content, input.edits, input.path)
};
if (input.kind === "write" && (input.beforeText === void 0 || typeof input.beforeText === "string") && (input.created === void 0 || typeof input.created === "boolean")) return {
kind: "write",
receipt: prepareFileWriteDiff({
path: input.path,
content: input.content,
beforeText: input.beforeText,
created: input.created
})
};
}
throw new Error("Invalid file-tool planning request");
});
//#endregion
export {};