sica-fondt/brain/reasoning/momoa/utils/fuzzyStringReplacer.ts
Claude 24f816b6a3
Consolidate 22 sibling repos into layered organism structure
Place useful parts of the surrounding repos into sica-fondt by layer, per the
body model (Ada = membrane; brain/endocrine/capabilities/knowledge non-Ada):

- brain/        LLM reasoning + providers (dapr, hermes, MoMoA)
- capabilities/ REPRAG sidecars: hermes tools/skills, dapr tools, parallel
                dispatch, A51 channels, and the OSINT cluster
- knowledge/    LORAG corpus: 754 cyber-skills, agency personas, secure-coding,
                MITRE ATT&CK data
- reference/    defensive threat-reference (C3, shhbruh doc) + AdaYaml parser

License handling: AGPL sources (worldosint, advanced_evolution, mercury,
Reticulum) and GPL DeTTECT are SPEC-only clean-room/port descriptions — no
copyleft code copied. MIT/Apache/data parts copied as working trees.

Safety: shhbruh escape/persistence material and C3 covert-C2 kept as reference
only, not wired into the running organism. See CONSOLIDATION.md.

https://claude.ai/code/session_01UehUqEXXJJCsHoA4voCU5c
2026-06-10 06:53:01 +00:00

237 lines
9.9 KiB
TypeScript

/**
* Copyright 2026 Reto Meier
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FuzzyReplaceResult } from "../momoa_core/types.js";
interface Match {
startIndex: number;
endIndex: number;
matchedText: string;
}
function escapeRegExp(str: string): string {
// $& means the whole matched string
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getLineStartIndices(text: string): number[] {
const indices: number[] = [0];
const newlineRegex = /\r?\n/g;
let match: RegExpExecArray | null;
while ((match = newlineRegex.exec(text)) !== null) {
indices.push(match.index + match[0].length);
}
return indices;
}
function findLineNumber(charIndex: number, lineStartIndices: number[]): number {
// Search from the end for efficiency
for (let i = lineStartIndices.length - 1; i >= 0; i--) {
if (charIndex >= lineStartIndices[i]) {
return i + 1;
}
}
return 1;
}
function generateNoMatchError(originalString: string, textToReplace: string): string {
// Attempt to find a match for an initial segment of the text to provide a better error.
const ttrLines = textToReplace.split(/\r?\n/);
let ttrSearchPrefixContent = ttrLines[0];
if (ttrLines.length > 1 && ttrSearchPrefixContent.length < 30) {
ttrSearchPrefixContent += `\n${ttrLines[1]}`;
}
ttrSearchPrefixContent = ttrSearchPrefixContent.substring(0, 120);
const searchPrefixParts = ttrSearchPrefixContent.split(/\s+/).filter(p => p.length > 0);
if (searchPrefixParts.length > 0) {
try {
const prefixPatternRegex = new RegExp(searchPrefixParts.map(escapeRegExp).join('\\s+'), 'i');
const prefixMatchResult = prefixPatternRegex.exec(originalString);
if (prefixMatchResult) {
const matchedPrefixInOS = prefixMatchResult[0];
let displaySearchedPrefix = searchPrefixParts.join(' ');
if (displaySearchedPrefix.length > 40) displaySearchedPrefix = `${displaySearchedPrefix.substring(0, 37)}...`;
let displayMatchedPrefixInOS = matchedPrefixInOS.replace(/\s+/g, ' ');
if (displayMatchedPrefixInOS.length > 60) displayMatchedPrefixInOS = `${displayMatchedPrefixInOS.substring(0, 57)}...`;
return `The text to replace was not found. A segment starting with content similar to '${displaySearchedPrefix}' (found in file as '${displayMatchedPrefixInOS}') was identified, but the full block did not match. Please check for discrepancies after this initial segment.`;
}
} catch (e) {
}
}
// Generic fallback error.
let ttrExcerptForDisplay = textToReplace.replace(/\s+/g, ' ').trim().substring(0, 70);
if (textToReplace.length > 70) ttrExcerptForDisplay += "...";
return `The text to replace (starting with '${ttrExcerptForDisplay}') was not found. Key components seem to be missing or significantly different in the file.`;
}
function performSingleFuzzyReplace(originalString: string, match: Match, textToReplace: string, replacementText: string): string {
const coreMatchStartInOS = match.startIndex;
const coreContentMatchedInOS = match.matchedText;
const coreContentEndInOS = coreMatchStartInOS + coreContentMatchedInOS.length;
// --- Inlined Helper Logic for Trailing Whitespace Analysis ---
const trailingWhitespaceMatch = textToReplace.match(/(\s*)$/);
const ttrTrailingWhitespaceText = trailingWhitespaceMatch ? (trailingWhitespaceMatch[1] || "") : "";
const newlineMatches = ttrTrailingWhitespaceText.match(/\r?\n/g);
const numExpectedNewlinesInTTR = newlineMatches ? newlineMatches.length : 0;
const ttrTrailIsPurelyNewlines = /^[\r\n]*$/.test(ttrTrailingWhitespaceText);
// --- End Inlined Logic ---
// The regex only matches content, not surrounding whitespace.
// We must now match the TRAILING whitespace of `textToReplace` in the `originalString`
// to determine the precise end of the segment to be replaced.
let suffixStartIndex = coreContentEndInOS;
let newlinesConsumedFromOS = 0;
let tempOsIndex = coreContentEndInOS;
while (tempOsIndex < originalString.length) {
if (newlinesConsumedFromOS >= numExpectedNewlinesInTTR && ttrTrailIsPurelyNewlines) break;
const char = originalString[tempOsIndex];
if (char === '\n' || (char === '\r' && originalString[tempOsIndex + 1] === '\n')) {
tempOsIndex += (char === '\r' ? 2 : 1);
newlinesConsumedFromOS++;
} else if (char === ' ' || char === '\t') {
if (ttrTrailIsPurelyNewlines) break;
tempOsIndex++;
} else {
break;
}
}
suffixStartIndex = tempOsIndex;
const prefix = originalString.substring(0, coreMatchStartInOS);
const suffix = originalString.substring(suffixStartIndex);
const modifiedString = prefix + replacementText + suffix;
return modifiedString;
}
function generateDisambiguationSnippets(originalString: string, matches: Match[]): string[] {
const disambiguationSnippets: string[] = [];
const originalLinesArray = originalString.split(/\r?\n/);
const lineStartIndices = getLineStartIndices(originalString);
for (const match of matches) {
const matchStartLineNum = findLineNumber(match.startIndex, lineStartIndices);
const matchEndLineNum = findLineNumber(Math.max(0, match.endIndex - 1), lineStartIndices);
// Find the first non-empty preceding line for context
let contextStartLineNum = matchStartLineNum;
for (let i = matchStartLineNum - 2; i >= 0; i--) {
if (originalLinesArray[i].trim() !== '') {
contextStartLineNum = i + 1;
break;
}
}
contextStartLineNum = Math.max(1, contextStartLineNum);
// Find the first non-empty succeeding line for context
let contextEndLineNum = matchEndLineNum;
for (let i = matchEndLineNum; i < originalLinesArray.length; i++) {
if (originalLinesArray[i].trim() !== '') {
contextEndLineNum = i + 1;
break;
}
}
contextEndLineNum = Math.min(originalLinesArray.length, contextEndLineNum);
const snippetStartCharIndex = lineStartIndices[contextStartLineNum - 1];
const snippetEndCharIndex = (contextEndLineNum < lineStartIndices.length)
? lineStartIndices[contextEndLineNum]
: originalString.length;
let snippet = originalString.slice(snippetStartCharIndex, snippetEndCharIndex);
// Trim trailing newline that comes from slicing up to the start of the next line
if (snippet.endsWith('\r\n')) {
snippet = snippet.slice(0, -2);
} else if (snippet.endsWith('\n')) {
snippet = snippet.slice(0, -1);
}
disambiguationSnippets.push(snippet);
}
return disambiguationSnippets;
}
/**
* Replaces a substring within an original string, ignoring whitespace differences
* (including line breaks) and case differences. It preserves original indentation on replacement.
*
* @param originalString The string to perform the replacement in.
* @param textToReplace The substring to find (potentially with different whitespace/case).
* @param replacementText The text to insert.
*/
export function fuzzyReplace(
originalString: string,
textToReplace: string,
replacementText: string
): FuzzyReplaceResult {
const parts = textToReplace.split(/\s+/).filter(part => part.length > 0);
if (parts.length === 0) {
return {error: `The specified text to replace is empty or contains only whitespace. No replacement possible.`};
}
// Fast path: If there's exactly one literal (but case-insensitive) match, perform a simple replacement.
if (textToReplace) {
const escapedSubStringForCount = escapeRegExp(textToReplace);
// A positive lookahead `(?=...)` allows for overlapping matches because it doesn't consume characters.
const countRegex = new RegExp(`(?=(${escapedSubStringForCount}))`, 'gi');
const occurrences = Array.from(originalString.matchAll(countRegex)).length;
if (occurrences === 1) {
const replaceRegex = new RegExp(escapedSubStringForCount, 'i');
const simpleReplaceString = originalString.replace(replaceRegex, replacementText);
return {modifiedString: simpleReplaceString };
}
}
// Fuzzy match path:
const pattern = parts.map(escapeRegExp).join('\\s+');
const searchRegex = new RegExp(pattern, 'gi');
const matches: Match[] = [];
let matchResult: RegExpExecArray | null;
while ((matchResult = searchRegex.exec(originalString)) !== null) {
matches.push({
startIndex: matchResult.index,
endIndex: matchResult.index + matchResult[0].length,
matchedText: matchResult[0]
});
// Prevent infinite loops on zero-length matches
if (matchResult[0].length === 0) {
searchRegex.lastIndex++;
}
}
// Dispatch to the appropriate handler based on the number of matches.
if (matches.length === 0) {
return { error: generateNoMatchError(originalString, textToReplace) };
} else if (matches.length === 1) {
return { modifiedString: performSingleFuzzyReplace(originalString, matches[0], textToReplace, replacementText) };
} else {
return { multipleMatches : generateDisambiguationSnippets(originalString, matches) };
}
}