UNPKG

obsidian-mcp-server

Version:

Obsidian Knowledge-Management MCP (Model Context Protocol) server that enables AI agents and development tools to interact with an Obsidian vault. It provides a comprehensive suite of tools for reading, writing, searching, and managing notes, tags, and fr

584 lines (583 loc) 35.5 kB
import path from "node:path"; // For file path fallback logic using POSIX separators import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; import { createFormattedStatWithTokenCount, logger, retryWithDelay, } from "../../../utils/index.js"; // ==================================================================================== // Schema Definitions for Input Validation // ==================================================================================== /** Defines the possible types of targets for the search/replace operation. */ const TargetTypeSchema = z .enum(["filePath", "activeFile", "periodicNote"]) .describe("Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'."); /** Defines the valid periods for periodic notes. */ const PeriodicNotePeriodSchema = z .enum(["daily", "weekly", "monthly", "quarterly", "yearly"]) .describe("Valid periods for 'periodicNote' target type."); /** * Defines the structure for a single search and replace operation block. */ const ReplacementBlockSchema = z.object({ /** The exact string or regex pattern to search for within the note content. Cannot be empty. */ search: z .string() .min(1, "Search pattern cannot be empty.") .describe("The exact string or regex pattern to search for."), /** The string to replace each match with. An empty string effectively deletes the matched text. */ replace: z.string().describe("The string to replace matches with."), }); /** * Base Zod schema object containing fields common to the search/replace tool input. * This is used as the foundation for both the registration shape and the refined internal schema. */ const BaseObsidianSearchReplaceInputSchema = z.object({ /** Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'. */ targetType: TargetTypeSchema, /** * Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'. * Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'. * Not used if targetType is 'activeFile'. The tool attempts a case-insensitive fallback if the exact filePath is not found. */ targetIdentifier: z .string() .optional() .describe("Required if targetType is 'filePath' (vault-relative path) or 'periodicNote' (period string: 'daily', etc.). Tries case-insensitive fallback for filePath."), /** An array of one or more search/replace operations to perform sequentially on the note content. */ replacements: z .array(ReplacementBlockSchema) .min(1, "Replacements array cannot be empty.") .describe("An array of search/replace operations to perform sequentially."), /** If true, treats the 'search' field in each replacement block as a JavaScript regular expression pattern. Defaults to false (exact string matching). */ useRegex: z .boolean() .optional() .default(false) .describe("If true, treat the 'search' field in replacements as JavaScript regex patterns. Defaults to false (exact string matching)."), /** If true (default), replaces all occurrences matching each search pattern within the note. If false, replaces only the first occurrence of each pattern. */ replaceAll: z .boolean() .optional() .default(true) .describe("If true (default), replace all occurrences for each search pattern. If false, replace only the first occurrence."), /** If true (default), the search operation is case-sensitive. If false, it's case-insensitive. Applies to both string and regex searches. */ caseSensitive: z .boolean() .optional() .default(true) .describe("If true (default), the search is case-sensitive. If false, it's case-insensitive. Applies to both string and regex search."), /** If true (and useRegex is false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\s+). Defaults to false. Cannot be true if useRegex is true. */ flexibleWhitespace: z .boolean() .optional() .default(false) .describe("If true (and useRegex=false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\\s+). Defaults to false."), /** If true, ensures the search term matches only whole words by implicitly adding word boundaries (\b) around the pattern (unless boundaries already exist in regex). Applies to both regex and non-regex modes. Defaults to false. */ wholeWord: z .boolean() .optional() .default(false) .describe("If true, ensures the search term matches only whole words using word boundaries (\\b). Applies to both regex and non-regex modes. Defaults to false."), /** If true, includes the final content of the modified file in the response. Defaults to false. */ returnContent: z .boolean() .optional() .default(false) .describe("If true, returns the final content of the file in the response. Defaults to false."), }); // ==================================================================================== // Refined Schema for Internal Logic and Strict Validation // ==================================================================================== /** * Refined Zod schema used internally within the tool's logic for strict validation. * It builds upon `BaseObsidianSearchReplaceInputSchema` and adds cross-field validation rules: * 1. Ensures `targetIdentifier` is provided and valid when required by `targetType`. * 2. Ensures `flexibleWhitespace` is not used concurrently with `useRegex`. */ export const ObsidianSearchReplaceInputSchema = BaseObsidianSearchReplaceInputSchema.refine((data) => { // Rule 1: Validate targetIdentifier based on targetType if ((data.targetType === "filePath" || data.targetType === "periodicNote") && !data.targetIdentifier) { return false; // Missing targetIdentifier } if (data.targetType === "periodicNote" && data.targetIdentifier && !PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success) { return false; // Invalid period } // Rule 2: flexibleWhitespace cannot be true if useRegex is true if (data.flexibleWhitespace && data.useRegex) { return false; // Conflicting options } return true; // All checks passed }, { // Custom error message for refinement failures message: "Validation failed: targetIdentifier is required and must be valid for 'filePath' or 'periodicNote'. Also, 'flexibleWhitespace' cannot be true if 'useRegex' is true.", // Point error reporting to potentially problematic fields path: ["targetIdentifier", "flexibleWhitespace", "useRegex"], }).describe("Performs one or more search-and-replace operations within a target Obsidian note (file path, active, or periodic). Reads the file, applies replacements sequentially in memory, and writes the modified content back, overwriting the original. Supports string/regex search, case sensitivity toggle, replacing first/all occurrences, flexible whitespace matching (non-regex), and whole word matching."); // ==================================================================================== // Schema Shape and Type Exports for Registration and Logic // ==================================================================================== /** * The shape of the base input schema, used by `server.tool` for registration and initial validation. * @see BaseObsidianSearchReplaceInputSchema */ export const ObsidianSearchReplaceInputSchemaShape = BaseObsidianSearchReplaceInputSchema.shape; // ==================================================================================== // Helper Functions // ==================================================================================== /** * Escapes characters that have special meaning in regular expressions. * This allows treating a literal string as a pattern in a regex. * * @param {string} str - The input string to escape. * @returns {string} The string with regex special characters escaped. */ function escapeRegex(str) { // Escape characters: . * + ? ^ $ { } ( ) | [ ] \ - return str.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); // $& inserts the matched character } /** * Attempts to retrieve the final state (content and stats) of the target note after a search/replace operation. * Uses the appropriate Obsidian API method based on the target type and the potentially corrected file path. * Logs a warning and returns null if fetching the final state fails, preventing failure of the entire operation. * * @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note. * @param {string | undefined} effectiveFilePath - The vault-relative path (potentially corrected by case-insensitive fallback). Undefined for non-filePath targets. * @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'. * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance. * @param {RequestContext} context - The request context for logging and correlation. * @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails. */ async function getFinalState(targetType, effectiveFilePath, period, obsidianService, context) { const operation = "getFinalStateAfterSearchReplace"; const targetDesc = effectiveFilePath ?? (period ? `periodic ${period}` : "active file"); logger.debug(`Attempting to retrieve final state for target: ${targetDesc}`, { ...context, operation, }); try { let noteJson = null; // Call the appropriate API method if (targetType === "filePath" && effectiveFilePath) { noteJson = (await obsidianService.getFileContent(effectiveFilePath, "json", context)); } else if (targetType === "activeFile") { noteJson = (await obsidianService.getActiveFile("json", context)); } else if (targetType === "periodicNote" && period) { noteJson = (await obsidianService.getPeriodicNote(period, "json", context)); } logger.debug(`Successfully retrieved final state for ${targetDesc}`, { ...context, operation, }); return noteJson; } catch (error) { // Log the error but return null to avoid failing the main operation. const errorMsg = error instanceof Error ? error.message : String(error); logger.warning(`Could not retrieve final state after search/replace for target: ${targetDesc}. Error: ${errorMsg}`, { ...context, operation, error: errorMsg }); return null; } } // ==================================================================================== // Core Logic Function // ==================================================================================== /** * Processes the core logic for the 'obsidian_search_replace' tool. * Reads the target note content, performs a sequence of search and replace operations * based on the provided parameters (handling regex, case sensitivity, etc.), * writes the modified content back to Obsidian, retrieves the final state, * and constructs the response object. * * @param {ObsidianSearchReplaceInput} params - The validated input parameters conforming to the refined schema. * @param {RequestContext} context - The request context for logging and correlation. * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service. * @returns {Promise<ObsidianSearchReplaceResponse>} A promise resolving to the structured success response. * @throws {McpError} Throws an McpError if validation fails, reading/writing fails, or an unexpected error occurs during processing. */ export const processObsidianSearchReplace = async (params, // Use the refined, validated type context, obsidianService, vaultCacheService) => { // Destructure validated parameters for easier access const { targetType, targetIdentifier, replacements, useRegex: initialUseRegex, // Rename to avoid shadowing loop variable replaceAll, caseSensitive, flexibleWhitespace, // Note: Cannot be true if initialUseRegex is true (enforced by schema) wholeWord, returnContent, } = params; let effectiveFilePath = targetIdentifier; // Store the path used (might be updated by fallback) let targetDescription = targetIdentifier ?? "active file"; // For logging and error messages let targetPeriod; logger.debug(`Processing obsidian_search_replace request`, { ...context, targetType, targetIdentifier, initialUseRegex, flexibleWhitespace, wholeWord, returnContent, }); // --- Step 1: Read Initial Content (with case-insensitive fallback for filePath) --- let originalContent; const readContext = { ...context, operation: "readFileContent" }; try { if (targetType === "filePath") { if (!targetIdentifier) { // Should be caught by schema, but double-check throw new McpError(BaseErrorCode.VALIDATION_ERROR, "targetIdentifier is required for targetType 'filePath'.", readContext); } targetDescription = targetIdentifier; // Initial description try { // Attempt 1: Case-sensitive read logger.debug(`Attempting to read file (case-sensitive): ${targetIdentifier}`, readContext); originalContent = (await obsidianService.getFileContent(targetIdentifier, "markdown", readContext)); effectiveFilePath = targetIdentifier; // Confirm exact path worked logger.debug(`Successfully read file using exact path: ${targetIdentifier}`, readContext); } catch (readError) { // Attempt 2: Case-insensitive fallback if NOT_FOUND if (readError instanceof McpError && readError.code === BaseErrorCode.NOT_FOUND) { logger.info(`File not found with exact path: ${targetIdentifier}. Attempting case-insensitive fallback.`, readContext); const dirname = path.posix.dirname(targetIdentifier); const filenameLower = path.posix .basename(targetIdentifier) .toLowerCase(); // List directory contents (use root '/' if dirname is '.') const dirToList = dirname === "." ? "/" : dirname; const filesInDir = await obsidianService.listFiles(dirToList, readContext); // Filter for files matching the lowercase basename const matches = filesInDir.filter((f) => !f.endsWith("/") && path.posix.basename(f).toLowerCase() === filenameLower); if (matches.length === 1) { // Found exactly one match const correctFilename = path.posix.basename(matches[0]); effectiveFilePath = path.posix.join(dirname, correctFilename); // Construct the correct path targetDescription = effectiveFilePath; // Update description for subsequent logs/errors logger.info(`Found case-insensitive match: ${effectiveFilePath}. Reading content.`, readContext); originalContent = (await obsidianService.getFileContent(effectiveFilePath, "markdown", readContext)); logger.debug(`Successfully read file using fallback path: ${effectiveFilePath}`, readContext); } else { // Handle ambiguous (multiple matches) or no match found const errorMsg = matches.length > 1 ? `Read failed: Ambiguous case-insensitive matches found for '${targetIdentifier}' in directory '${dirToList}'. Matches: [${matches.join(", ")}]` : `Read failed: File not found for '${targetIdentifier}' (case-insensitive fallback also failed in directory '${dirToList}').`; logger.error(errorMsg, { ...readContext, matches }); // Use NOT_FOUND for no match, CONFLICT for ambiguity throw new McpError(matches.length > 1 ? BaseErrorCode.CONFLICT : BaseErrorCode.NOT_FOUND, errorMsg, readContext); } } else { // Re-throw errors other than NOT_FOUND during the initial read attempt throw readError; } } } else if (targetType === "activeFile") { logger.debug(`Reading content from active file.`, readContext); originalContent = (await obsidianService.getActiveFile("markdown", readContext)); targetDescription = "the active file"; effectiveFilePath = undefined; // Not applicable logger.debug(`Successfully read active file content.`, readContext); } else { // periodicNote if (!targetIdentifier) { // Should be caught by schema throw new McpError(BaseErrorCode.VALIDATION_ERROR, "targetIdentifier is required for targetType 'periodicNote'.", readContext); } // Parse period (already validated by refined schema) targetPeriod = PeriodicNotePeriodSchema.parse(targetIdentifier); targetDescription = `periodic note '${targetPeriod}'`; effectiveFilePath = undefined; // Not applicable logger.debug(`Reading content from ${targetDescription}.`, readContext); originalContent = (await obsidianService.getPeriodicNote(targetPeriod, "markdown", readContext)); logger.debug(`Successfully read ${targetDescription} content.`, readContext); } } catch (error) { // Catch and handle errors during the initial read phase if (error instanceof McpError) throw error; // Re-throw known McpErrors const errorMessage = `Unexpected error reading target ${targetDescription} before search/replace.`; logger.error(errorMessage, error instanceof Error ? error : undefined, readContext); throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, readContext); } // --- Step 2: Perform Sequential Replacements --- let modifiedContent = originalContent; let totalReplacementsMade = 0; const replaceContext = { ...context, operation: "performReplacements" }; logger.debug(`Starting ${replacements.length} replacement operations.`, replaceContext); for (let i = 0; i < replacements.length; i++) { const rep = replacements[i]; const repContext = { ...replaceContext, replacementIndex: i, searchPattern: rep.search, }; let currentReplacementsInBlock = 0; let finalSearchPattern = rep.search; // Start with the raw search string let useRegexForThisRep = initialUseRegex; // Use the overall setting initially try { // --- 2a: Prepare the Search Pattern (Apply options) --- const patternPrepContext = { ...repContext, subOperation: "prepareSearchPattern", }; if (!initialUseRegex) { // Handle non-regex specific options: flexibleWhitespace and wholeWord let searchStr = rep.search; // Work with a mutable string if (flexibleWhitespace) { // Convert to a regex string: escape special chars, then replace whitespace sequences with \s+ searchStr = escapeRegex(searchStr).replace(/\s+/g, "\\s+"); useRegexForThisRep = true; // Now treat it as a regex pattern string logger.debug(`Applying flexibleWhitespace: "${rep.search}" -> /${searchStr}/`, patternPrepContext); } if (wholeWord) { // Add word boundaries (\b) to the pattern string // If flexibleWhitespace was applied, searchStr is already a regex string. // Otherwise, escape the original search string first. const basePattern = useRegexForThisRep ? searchStr : escapeRegex(searchStr); searchStr = `\\b${basePattern}\\b`; useRegexForThisRep = true; // Definitely treat as a regex pattern string now logger.debug(`Applying wholeWord: "${rep.search}" -> /${searchStr}/`, patternPrepContext); } finalSearchPattern = searchStr; // Update the pattern to use } else if (wholeWord) { // Initial useRegex is true, but wholeWord is also requested. // Apply wholeWord boundaries if the user's regex doesn't obviously have them. let searchStr = rep.search; // Heuristic check: Does the pattern likely already account for boundaries? // Looks for ^, $, \b at start/end, or patterns matching full lines/non-whitespace sequences. const hasBoundary = /(?:^|\\b)\S.*\S(?:$|\\b)|^\S$|^\S.*\S$|^$/.test(searchStr) || /^\^|\\b/.test(searchStr) || /\$|\\b$/.test(searchStr); if (!hasBoundary) { searchStr = `\\b${searchStr}\\b`; // Log a warning as this might interfere with complex user regex. logger.warning(`Applying wholeWord=true to user-provided regex. Original: /${rep.search}/, Modified: /${searchStr}/. This might affect complex regex behavior.`, patternPrepContext); finalSearchPattern = searchStr; // Update the pattern string } else { logger.debug(`wholeWord=true requested, but user regex /${searchStr}/ appears to already contain boundary anchors. Using original regex.`, patternPrepContext); finalSearchPattern = rep.search; // Keep original regex string } } // If it's still not treated as regex, finalSearchPattern remains the original rep.search string. // --- 2b: Execute the Replacement --- const execContext = { ...repContext, subOperation: "executeReplacement", isRegex: useRegexForThisRep, }; if (useRegexForThisRep) { // --- Regex Replacement --- let flags = ""; if (replaceAll) flags += "g"; // Global flag for all matches if (!caseSensitive) flags += "i"; // Ignore case flag const regex = new RegExp(finalSearchPattern, flags); // Create RegExp object logger.debug(`Executing regex replacement: /${finalSearchPattern}/${flags}`, execContext); // Count matches *before* replacing to report accurately const matches = modifiedContent.match(regex); currentReplacementsInBlock = matches ? matches.length : 0; // If replaceAll is false, we only perform/count one replacement, even if regex matches more if (!replaceAll && currentReplacementsInBlock > 0) { currentReplacementsInBlock = 1; } // Perform the replacement if (currentReplacementsInBlock > 0) { if (replaceAll) { modifiedContent = modifiedContent.replace(regex, rep.replace); } else { // Replace only the first occurrence found by the regex modifiedContent = modifiedContent.replace(regex, rep.replace); } } } else { // --- Simple String Replacement --- // Note: wholeWord and flexibleWhitespace would have set useRegexForThisRep = true const searchString = finalSearchPattern; // It's just a string here const comparisonString = caseSensitive ? searchString : searchString.toLowerCase(); let startIndex = 0; logger.debug(`Executing string replacement: "${searchString}" (caseSensitive: ${caseSensitive})`, execContext); while (true) { const contentToSearch = caseSensitive ? modifiedContent : modifiedContent.toLowerCase(); const index = contentToSearch.indexOf(comparisonString, startIndex); if (index === -1) { break; // No more occurrences found } currentReplacementsInBlock++; // Perform replacement using original indices and search string length modifiedContent = modifiedContent.substring(0, index) + rep.replace + modifiedContent.substring(index + searchString.length); if (!replaceAll) { break; // Stop after the first replacement } // Move start index past the inserted replacement to find the next match startIndex = index + rep.replace.length; // Safety break for empty search string or potential infinite loops if (searchString.length === 0) { logger.warning(`Search string is empty. Breaking replacement loop to prevent infinite execution.`, execContext); break; } // Basic check if replacement could cause infinite loop (e.g., replacing 'a' with 'ba') if (rep.replace.includes(searchString) && rep.replace.length >= searchString.length) { // This is a heuristic, might not catch all cases but prevents common ones. logger.warning(`Replacement string "${rep.replace}" contains search string "${searchString}". Potential infinite loop detected. Breaking loop for this block.`, execContext); break; } } } totalReplacementsMade += currentReplacementsInBlock; logger.debug(`Block ${i}: Performed ${currentReplacementsInBlock} replacements for search: "${rep.search}"`, repContext); } catch (error) { // Catch errors during a specific replacement block const errorMessage = `Error during replacement block ${i} (search: "${rep.search}")`; logger.error(errorMessage, error instanceof Error ? error : undefined, repContext); // Fail fast: Stop processing further replacements if one block fails. throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : "Unknown error"}`, repContext); } } // End of replacements loop logger.debug(`Finished all replacement operations. Total replacements made: ${totalReplacementsMade}`, replaceContext); // --- Step 3: Write Modified Content Back to Obsidian --- let finalState = null; const POST_UPDATE_DELAY_MS = 500; // Delay before trying to read the file back // Only write back if the content actually changed to avoid unnecessary file operations. if (modifiedContent !== originalContent) { const writeContext = { ...context, operation: "writeFileContent" }; try { logger.debug(`Content changed. Writing modified content back to ${targetDescription}`, writeContext); // Use the effectiveFilePath determined during the read phase for filePath targets if (targetType === "filePath") { await obsidianService.updateFileContent(effectiveFilePath, modifiedContent, writeContext); if (vaultCacheService) { await vaultCacheService.updateCacheForFile(effectiveFilePath, writeContext); } } else if (targetType === "activeFile") { await obsidianService.updateActiveFile(modifiedContent, writeContext); } else { // periodicNote await obsidianService.updatePeriodicNote(targetPeriod, modifiedContent, writeContext); } logger.info(`Successfully updated ${targetDescription} with ${totalReplacementsMade} replacement(s).`, writeContext); // Attempt to get the final state *after* successfully writing. logger.debug(`Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state after write...`, { ...writeContext, subOperation: "postWriteDelay" }); await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS)); try { finalState = await retryWithDelay(async () => getFinalState(targetType, effectiveFilePath, targetPeriod, obsidianService, context), { operationName: "getFinalStateAfterSearchReplaceWrite", context: { ...context, operation: "getFinalStateAfterSearchReplaceWriteAttempt", }, maxRetries: 3, delayMs: 300, shouldRetry: (err) => err instanceof McpError && (err.code === BaseErrorCode.NOT_FOUND || err.code === BaseErrorCode.SERVICE_UNAVAILABLE || err.code === BaseErrorCode.TIMEOUT), onRetry: (attempt, err) => logger.warning(`getFinalStateAfterSearchReplaceWrite (attempt ${attempt}) failed. Error: ${err.message}. Retrying...`, writeContext), }); } catch (retryError) { finalState = null; logger.error(`Failed to retrieve final state for ${targetDescription} after write, even after retries. Error: ${retryError.message}`, retryError instanceof Error ? retryError : undefined, writeContext); } } catch (error) { // Handle errors during the write phase if (error instanceof McpError) throw error; // Re-throw known McpErrors const errorMessage = `Unexpected error writing modified content to ${targetDescription}.`; logger.error(errorMessage, error instanceof Error ? error : undefined, writeContext); throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, writeContext); } } else { // Content did not change, no need to write. logger.info(`No changes detected in ${targetDescription} after search/replace operations. Skipping write.`, context); // Still attempt to get the state, as the user might want stats even if content is unchanged. logger.debug(`Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state (no change)...`, { ...context, subOperation: "postNoChangeDelay" }); await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS)); try { finalState = await retryWithDelay(async () => getFinalState(targetType, effectiveFilePath, targetPeriod, obsidianService, context), { operationName: "getFinalStateAfterSearchReplaceNoChange", context: { ...context, operation: "getFinalStateAfterSearchReplaceNoChangeAttempt", }, maxRetries: 3, delayMs: 300, shouldRetry: (err) => err instanceof McpError && (err.code === BaseErrorCode.NOT_FOUND || err.code === BaseErrorCode.SERVICE_UNAVAILABLE || err.code === BaseErrorCode.TIMEOUT), onRetry: (attempt, err) => logger.warning(`getFinalStateAfterSearchReplaceNoChange (attempt ${attempt}) failed. Error: ${err.message}. Retrying...`, context), }); } catch (retryError) { finalState = null; logger.error(`Failed to retrieve final state for ${targetDescription} (no change), even after retries. Error: ${retryError.message}`, retryError instanceof Error ? retryError : undefined, context); } } // --- Step 4: Construct and Return the Response --- const responseContext = { ...context, operation: "buildResponse" }; let message; if (totalReplacementsMade > 0) { message = `Search/replace completed on ${targetDescription}. Successfully made ${totalReplacementsMade} replacement(s).`; } else if (modifiedContent !== originalContent) { // This case should ideally not happen if totalReplacementsMade is 0, but as a safeguard: message = `Search/replace completed on ${targetDescription}. Content was modified, but replacement count is zero. Please review.`; } else { message = `Search/replace completed on ${targetDescription}. No matching text was found, so no replacements were made.`; } // Append a warning if the final state couldn't be retrieved if (finalState === null) { const warningMsg = " (Warning: Could not retrieve final file stats/content after update.)"; message += warningMsg; logger.warning(`Appending warning to response message: ${warningMsg}`, responseContext); } // Format the file statistics using the shared utility. // Use final state content if available, otherwise use the (potentially modified) content in memory for token count. const finalContentForStat = finalState?.content ?? modifiedContent; const formattedStatResult = finalState?.stat ? await createFormattedStatWithTokenCount(finalState.stat, finalContentForStat, responseContext) // Await the async utility : undefined; // Ensure stat is undefined if the utility returned null (e.g., token counting failed) const formattedStat = formattedStatResult === null ? undefined : formattedStatResult; // Build the final response object const response = { success: true, message: message, totalReplacementsMade, stats: formattedStat, }; // Include final content if requested and available. if (returnContent) { // Prefer content from final state read, fallback to in-memory modified content. response.finalContent = finalState?.content ?? modifiedContent; logger.debug(`Including final content in response as requested.`, responseContext); } logger.debug(`Search/replace process completed successfully.`, responseContext); return response; };