diff --git a/src/config-field-definitions.ts b/src/config-field-definitions.ts index 77efa9bf..8244c6fb 100644 --- a/src/config-field-definitions.ts +++ b/src/config-field-definitions.ts @@ -38,6 +38,11 @@ export const CONFIG_FIELD_DEFINITIONS = { description: 'Maximum number of lines that can be written in one edit operation. This helps prevent accidental oversized writes and keeps file changes predictable.', valueType: 'number', }, + editMode: { + label: 'Edit Mode', + description: 'Controls which file editing tools are registered. "string-replace" (default) uses edit_block with fuzzy matching. "line-replace" uses replace_lines with line numbers. "both" registers both tools. Fewer tools means a leaner system prompt.', + valueType: 'string', + }, } as const satisfies Record; export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS; diff --git a/src/config-manager.ts b/src/config-manager.ts index 1c88700c..e2f3c4b0 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -13,6 +13,7 @@ export interface ServerConfig { telemetryEnabled?: boolean; // New field for telemetry control fileWriteLineLimit?: number; // Line limit for file write operations fileReadLineLimit?: number; // Default line limit for file read operations (changed from character-based) + editMode?: 'string-replace' | 'line-replace' | 'both'; // Controls which editing tools are registered clientId?: string; // Unique client identifier for analytics currentClient?: ClientInfo; // Current connected client information [key: string]: any; // Allow for arbitrary configuration keys (including abTest_* keys) @@ -44,6 +45,14 @@ export function isTelemetryDisabledValue(value: unknown): boolean { return normalizeTelemetryEnabledValue(value) === false; } +const VALID_EDIT_MODES = ['string-replace', 'line-replace', 'both']; + +function validateEditMode(value: unknown): void { + if (value !== undefined && !VALID_EDIT_MODES.includes(value as string)) { + throw new Error(`Invalid editMode "${value}". Must be one of: ${VALID_EDIT_MODES.join(', ')}`); + } +} + /** * Singleton config manager for the server */ @@ -87,6 +96,11 @@ class ConfigManager { } this.config['version'] = VERSION; + // Sanitize editMode from disk - invalid values reset to default + if (this.config.editMode && !VALID_EDIT_MODES.includes(this.config.editMode)) { + delete this.config.editMode; + } + this.initialized = true; } catch (error) { console.error('Failed to initialize config:', error); @@ -210,6 +224,11 @@ class ConfigManager { if (key === 'telemetryEnabled') { value = normalizeTelemetryEnabledValue(value); } + + // Validate editMode values + if (key === 'editMode') { + validateEditMode(value); + } // Special handling for telemetry opt-out if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) { @@ -241,6 +260,7 @@ class ConfigManager { */ async updateConfig(updates: Partial): Promise { await this.init(); + if (updates.editMode !== undefined) validateEditMode(updates.editMode); this.config = { ...this.config, ...updates }; await this.saveConfig(); return { ...this.config }; diff --git a/src/handlers/edit-search-handlers.ts b/src/handlers/edit-search-handlers.ts index 7f677c65..e21a4fde 100644 --- a/src/handlers/edit-search-handlers.ts +++ b/src/handlers/edit-search-handlers.ts @@ -2,7 +2,7 @@ import { EditBlockArgsSchema } from '../tools/schemas.js'; -import { handleEditBlock } from '../tools/edit.js'; +import { handleEditBlock, handleReplaceLines } from '../tools/edit.js'; import { ServerResult } from '../types.js'; @@ -10,4 +10,4 @@ import { ServerResult } from '../types.js'; * Handle edit_block command * Uses the enhanced implementation with multiple occurrence support and fuzzy matching */ -export { handleEditBlock }; \ No newline at end of file +export { handleEditBlock, handleReplaceLines }; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 975574b2..cc479bf1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,6 +41,7 @@ import { SetConfigValueArgsSchema, ListProcessesArgsSchema, EditBlockArgsSchema, + ReplaceLinesArgsSchema, GetUsageStatsArgsSchema, GiveFeedbackArgsSchema, StartSearchArgsSchema, @@ -57,6 +58,7 @@ import { giveFeedbackToDesktopCommander } from './tools/feedback.js'; import { getPrompts } from './tools/prompts.js'; import { trackToolCall } from './utils/trackTools.js'; import { usageTracker } from './utils/usageTracker.js'; +import { configManager } from './config-manager.js'; import { processDockerPrompt } from './utils/dockerPrompt.js'; import { toolHistory } from './utils/toolHistory.js'; import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js'; @@ -204,16 +206,21 @@ export { currentClient }; deferLog('info', 'Setting up request handlers...'); /** - * Check if a tool should be included based on current client + * Check if a tool should be included based on current client and config */ -function shouldIncludeTool(toolName: string): boolean { +async function shouldIncludeTool(toolName: string): Promise { // Exclude give_feedback_to_desktop_commander for desktop-commander client if (toolName === 'give_feedback_to_desktop_commander' && currentClient?.name === 'desktop-commander') { return false; } - // Add more conditional tool logic here as needed - // Example: if (toolName === 'some_tool' && currentClient?.name === 'some_client') return false; + // Edit mode controls which editing tools are registered + if (toolName === 'edit_block' || toolName === 'replace_lines') { + const editMode = (await configManager.getValue('editMode')) || 'string-replace'; + if (editMode === 'string-replace' && toolName === 'replace_lines') return false; + if (editMode === 'line-replace' && toolName === 'edit_block') return false; + // 'both' keeps both tools + } return true; } @@ -788,6 +795,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { openWorldHint: false, }, }, + { + name: "replace_lines", + description: ` + Replace lines in a text file by line number range. + + Token-efficient alternative to edit_block when you already know line numbers + from a previous read_file call. No need to send old_string - just specify + which lines to replace. + + PARAMETERS: + - path: File path + - startLine: First line to replace (1-based, from read_file output) + - endLine: Last line to replace (1-based, inclusive) + - newContent: Replacement text (can be more or fewer lines than removed) + + EXAMPLES: + - Replace lines 10-15: startLine=10, endLine=15, newContent="new code here" + - Delete lines 5-8: startLine=5, endLine=8, newContent="" + - Insert after line 3: Use edit_block or write_file instead + + WARNING - LINE NUMBER SHIFTING: + After every replace_lines call where newContent has a different number of + lines than the replaced range, ALL subsequent line numbers shift. + ALWAYS re-read the file before making another replace_lines call on the + same file. The response includes context lines to verify correctness. + + IMPORTANT: Line numbers must match the read_file output exactly. + If the file has been modified since the last read_file, re-read first. + + NOTE: This tool is only available when editMode is set to "line-replace" or "both" + in the configuration. Default editMode is "string-replace" (edit_block only). + + ${PATH_GUIDANCE} + ${CMD_PREFIX_DESCRIPTION}`, + inputSchema: zodToJsonSchema(ReplaceLinesArgsSchema), + annotations: { + title: "Replace Lines", + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false, + }, + }, // Terminal tools { @@ -1147,8 +1196,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { } ]; - // Filter tools based on current client - const filteredTools = allTools.filter(tool => shouldIncludeTool(tool.name)); + // Filter tools based on current client and config + const includeResults = await Promise.all(allTools.map(tool => shouldIncludeTool(tool.name))); + const filteredTools = allTools.filter((_, i) => includeResults[i]); // logToStderr('debug', `Returning ${filteredTools.length} tools (filtered from ${allTools.length} total) for client: ${currentClient?.name || 'unknown'}`); @@ -1415,6 +1465,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) result = await handlers.handleEditBlock(args); break; + case "replace_lines": + result = await handlers.handleReplaceLines(args); + break; + default: capture('server_unknown_tool', { name }); result = { diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 5933b9cc..aac6652a 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -21,7 +21,7 @@ import { ServerResult } from '../types.js'; import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js'; import { capture } from '../utils/capture.js'; import { createErrorResponse } from '../error-handlers.js'; -import { EditBlockArgsSchema } from "./schemas.js"; +import { EditBlockArgsSchema, ReplaceLinesArgsSchema } from "./schemas.js"; import path from 'path'; import { detectLineEnding, normalizeLineEndings } from '../utils/lineEndingHandler.js'; import { configManager } from '../config-manager.js'; @@ -457,4 +457,79 @@ export async function handleEditBlock(args: unknown): Promise { search: parsed.old_string, replace: parsed.new_string }, parsed.expected_replacements); -} \ No newline at end of file +} + +/** + * Handle replace_lines command - line-based editing + * Replaces lines startLine..endLine (1-based, inclusive) with newContent + */ +export async function handleReplaceLines(args: unknown): Promise { + const parsed = ReplaceLinesArgsSchema.parse(args); + const validPath = await validatePath(parsed.path); + const content = await readFileInternal(validPath, 0, Number.MAX_SAFE_INTEGER); + + if (typeof content !== 'string') { + return createErrorResponse('Cannot read file as text: ' + parsed.path); + } + + const fileLineEnding = detectLineEnding(content); + const lines = content.split(/\r\n|\r|\n/); + const totalLines = lines.length; + + if (parsed.startLine > totalLines) { + return createErrorResponse(`startLine ${parsed.startLine} exceeds file length (${totalLines} lines)`); + } + if (parsed.endLine > totalLines) { + return createErrorResponse(`endLine ${parsed.endLine} exceeds file length (${totalLines} lines)`); + } + + const before = lines.slice(0, parsed.startLine - 1); + const after = lines.slice(parsed.endLine); + const newLines = parsed.newContent === '' ? [] : parsed.newContent.split(/\r\n|\r|\n/); + const result = [...before, ...newLines, ...after]; + const sep = fileLineEnding; + const newContent = result.join(sep); + + await writeFile(validPath, newContent); + + const removedCount = parsed.endLine - parsed.startLine + 1; + const insertedCount = newLines.length; + const lineDelta = insertedCount - removedCount; + + capture('server_replace_lines', { + fileExtension: path.extname(parsed.path).toLowerCase(), + removedLines: removedCount, + insertedLines: insertedCount, + }); + + // Build response with context around the replacement + const CONTEXT_LINES = 3; + const newTotalLines = result.length; + const insertStart = parsed.startLine; + const insertEnd = insertedCount > 0 ? parsed.startLine + insertedCount - 1 : parsed.startLine - 1; + + const ctxStart = Math.max(0, parsed.startLine - 1 - CONTEXT_LINES); + const ctxEnd = Math.min(newTotalLines, (insertedCount > 0 ? insertEnd : parsed.startLine) + CONTEXT_LINES); + const contextSlice = result.slice(ctxStart, ctxEnd); + const contextOutput = contextSlice.map((line, i) => { + const lineNum = ctxStart + i + 1; + const marker = (insertedCount > 0 && lineNum >= insertStart && lineNum <= insertEnd) ? '+' : ' '; + return `${marker} ${String(lineNum).padStart(4)} ${line}`; + }).join('\n'); + + let msg = `Replaced lines ${parsed.startLine}-${parsed.endLine} (${removedCount} lines) with ${insertedCount} lines in ${parsed.path}`; + + if (lineDelta !== 0) { + const shiftAnchor = insertEnd > 0 ? insertEnd : parsed.startLine; + msg += `\n\nWARNING: Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. All line numbers after line ${shiftAnchor} have shifted. Re-read before further edits.`; + } + + msg += `\n\nContext (lines ${ctxStart + 1}-${ctxEnd}, + = new content):\n${contextOutput}`; + + return { + content: [{ + type: "text", + text: msg + }], + }; +} diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 774fb99e..dada8f78 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -204,6 +204,16 @@ export const GetPromptsArgsSchema = z.object({ // anonymous_user_use_case: z.string().optional(), }); +// Replace lines tool schema - line-based editing (token-efficient alternative to edit_block) +export const ReplaceLinesArgsSchema = z.object({ + path: z.string(), + startLine: z.number().int().min(1), + endLine: z.number().int().min(1), + newContent: z.string(), +}).refine(data => data.endLine >= data.startLine, { + message: "endLine must be >= startLine" +}); + // Tool history schema export const GetRecentToolCallsArgsSchema = z.object({ maxResults: z.number().min(1).max(1000).optional().default(50),