Add BRAIN_DISABLED flag and fix Ollama tool call formatting
Features: - Add BRAIN_DISABLED feature flag to hide all Brain functionality - When enabled, hides Brain banner, status indicator, menu, and commands - Flag location: src/constants/brain.ts Fixes: - Fix Ollama 400 error by properly formatting tool_calls in messages - Update OllamaMessage type to include tool_calls field - Fix Brain menu keyboard not working (add missing modes to isMenuOpen) UI Changes: - Remove "^Tab toggle mode" hint from status bar - Remove "ctrl+t to hide todos" hint from status bar Files modified: - src/constants/brain.ts (add BRAIN_DISABLED flag) - src/types/ollama.ts (add tool_calls to OllamaMessage) - src/providers/ollama/chat.ts (format tool_calls in messages) - src/tui-solid/components/header.tsx (hide Brain UI when disabled) - src/tui-solid/components/status-bar.tsx (remove hints) - src/tui-solid/components/command-menu.tsx (filter brain command) - src/tui-solid/components/input-area.tsx (fix isMenuOpen modes) - src/tui-solid/routes/session.tsx (skip brain menu when disabled) - src/services/brain.ts (early return when disabled) - src/services/chat-tui/initialize.ts (skip brain init when disabled)
This commit is contained in:
343
src/tools/multi-edit/execute.ts
Normal file
343
src/tools/multi-edit/execute.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* MultiEdit Tool Execution
|
||||
*
|
||||
* Performs batch file editing with atomic transactions
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import {
|
||||
MULTI_EDIT_DEFAULTS,
|
||||
MULTI_EDIT_MESSAGES,
|
||||
MULTI_EDIT_TITLES,
|
||||
MULTI_EDIT_DESCRIPTION,
|
||||
} from "@constants/multi-edit";
|
||||
import { isFileOpAllowed, promptFilePermission } from "@services/permissions";
|
||||
import { formatDiff, generateDiff } from "@utils/diff";
|
||||
import { multiEditParams } from "@tools/multi-edit/params";
|
||||
import type { ToolDefinition, ToolContext, ToolResult } from "@/types/tools";
|
||||
import type { EditItem, MultiEditParams } from "@tools/multi-edit/params";
|
||||
|
||||
interface FileBackup {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface EditValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
fileContent?: string;
|
||||
}
|
||||
|
||||
interface EditResult {
|
||||
path: string;
|
||||
success: boolean;
|
||||
diff?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const createErrorResult = (error: string): ToolResult => ({
|
||||
success: false,
|
||||
title: MULTI_EDIT_TITLES.FAILED,
|
||||
output: "",
|
||||
error,
|
||||
});
|
||||
|
||||
const createSuccessResult = (
|
||||
results: EditResult[],
|
||||
totalEdits: number,
|
||||
): ToolResult => {
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
|
||||
const diffOutput = successful
|
||||
.map((r) => `## ${path.basename(r.path)}\n\n${r.diff}`)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
const totalAdditions = successful.reduce((sum, r) => sum + (r.additions ?? 0), 0);
|
||||
const totalDeletions = successful.reduce((sum, r) => sum + (r.deletions ?? 0), 0);
|
||||
|
||||
const title =
|
||||
failed.length > 0
|
||||
? MULTI_EDIT_TITLES.PARTIAL(successful.length, failed.length)
|
||||
: MULTI_EDIT_TITLES.SUCCESS(successful.length);
|
||||
|
||||
let output = diffOutput;
|
||||
if (failed.length > 0) {
|
||||
output +=
|
||||
"\n\n## Failed Edits\n\n" +
|
||||
failed.map((r) => `- ${r.path}: ${r.error}`).join("\n");
|
||||
}
|
||||
|
||||
return {
|
||||
success: failed.length === 0,
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
totalEdits,
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
totalAdditions,
|
||||
totalDeletions,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a single edit
|
||||
*/
|
||||
const validateEdit = async (
|
||||
edit: EditItem,
|
||||
workingDir: string,
|
||||
): Promise<EditValidation> => {
|
||||
const fullPath = path.isAbsolute(edit.file_path)
|
||||
? edit.file_path
|
||||
: path.join(workingDir, edit.file_path);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (!stat.isFile()) {
|
||||
return { valid: false, error: `Not a file: ${edit.file_path}` };
|
||||
}
|
||||
|
||||
if (stat.size > MULTI_EDIT_DEFAULTS.MAX_FILE_SIZE) {
|
||||
return { valid: false, error: MULTI_EDIT_MESSAGES.FILE_TOO_LARGE(edit.file_path) };
|
||||
}
|
||||
|
||||
const content = await fs.readFile(fullPath, "utf-8");
|
||||
|
||||
// Check if old_string exists
|
||||
if (!content.includes(edit.old_string)) {
|
||||
const preview = edit.old_string.slice(0, 50);
|
||||
return {
|
||||
valid: false,
|
||||
error: MULTI_EDIT_MESSAGES.OLD_STRING_NOT_FOUND(edit.file_path, preview),
|
||||
};
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
const occurrences = content.split(edit.old_string).length - 1;
|
||||
if (occurrences > 1) {
|
||||
return {
|
||||
valid: false,
|
||||
error: MULTI_EDIT_MESSAGES.OLD_STRING_NOT_UNIQUE(edit.file_path, occurrences),
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, fileContent: content };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { valid: false, error: MULTI_EDIT_MESSAGES.FILE_NOT_FOUND(edit.file_path) };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { valid: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check permissions for all files
|
||||
*/
|
||||
const checkPermissions = async (
|
||||
edits: EditItem[],
|
||||
workingDir: string,
|
||||
autoApprove: boolean,
|
||||
): Promise<{ allowed: boolean; denied: string[] }> => {
|
||||
const denied: string[] = [];
|
||||
|
||||
for (const edit of edits) {
|
||||
const fullPath = path.isAbsolute(edit.file_path)
|
||||
? edit.file_path
|
||||
: path.join(workingDir, edit.file_path);
|
||||
|
||||
if (!autoApprove && !isFileOpAllowed("Edit", fullPath)) {
|
||||
const { allowed } = await promptFilePermission(
|
||||
"Edit",
|
||||
fullPath,
|
||||
`Edit file: ${edit.file_path}`,
|
||||
);
|
||||
if (!allowed) {
|
||||
denied.push(edit.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: denied.length === 0, denied };
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a single edit
|
||||
*/
|
||||
const applyEdit = async (
|
||||
edit: EditItem,
|
||||
workingDir: string,
|
||||
fileContent: string,
|
||||
): Promise<EditResult> => {
|
||||
const fullPath = path.isAbsolute(edit.file_path)
|
||||
? edit.file_path
|
||||
: path.join(workingDir, edit.file_path);
|
||||
|
||||
try {
|
||||
const newContent = fileContent.replace(edit.old_string, edit.new_string);
|
||||
const diff = generateDiff(fileContent, newContent);
|
||||
const relativePath = path.relative(workingDir, fullPath);
|
||||
const diffOutput = formatDiff(diff, relativePath);
|
||||
|
||||
await fs.writeFile(fullPath, newContent, "utf-8");
|
||||
|
||||
return {
|
||||
path: edit.file_path,
|
||||
success: true,
|
||||
diff: diffOutput,
|
||||
additions: diff.additions,
|
||||
deletions: diff.deletions,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
path: edit.file_path,
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Rollback changes using backups
|
||||
*/
|
||||
const rollback = async (backups: FileBackup[]): Promise<void> => {
|
||||
for (const backup of backups) {
|
||||
try {
|
||||
await fs.writeFile(backup.path, backup.content, "utf-8");
|
||||
} catch {
|
||||
// Best effort rollback
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute multi-edit
|
||||
*/
|
||||
export const executeMultiEdit = async (
|
||||
args: MultiEditParams,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> => {
|
||||
const { edits } = args;
|
||||
|
||||
// Validate edit count
|
||||
if (edits.length === 0) {
|
||||
return createErrorResult(MULTI_EDIT_MESSAGES.NO_EDITS);
|
||||
}
|
||||
|
||||
if (edits.length > MULTI_EDIT_DEFAULTS.MAX_EDITS) {
|
||||
return createErrorResult(
|
||||
MULTI_EDIT_MESSAGES.TOO_MANY_EDITS(MULTI_EDIT_DEFAULTS.MAX_EDITS),
|
||||
);
|
||||
}
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: MULTI_EDIT_TITLES.VALIDATING(edits.length),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
// Phase 1: Validate all edits
|
||||
const validations = new Map<string, { validation: EditValidation; edit: EditItem }>();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const edit of edits) {
|
||||
const validation = await validateEdit(edit, ctx.workingDir);
|
||||
validations.set(edit.file_path, { validation, edit });
|
||||
|
||||
if (!validation.valid) {
|
||||
errors.push(validation.error ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return createErrorResult(
|
||||
MULTI_EDIT_MESSAGES.VALIDATION_FAILED + ":\n" + errors.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2: Check permissions
|
||||
const permCheck = await checkPermissions(
|
||||
edits,
|
||||
ctx.workingDir,
|
||||
ctx.autoApprove ?? false,
|
||||
);
|
||||
|
||||
if (!permCheck.allowed) {
|
||||
return createErrorResult(
|
||||
`Permission denied for: ${permCheck.denied.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 3: Create backups and apply edits atomically
|
||||
const backups: FileBackup[] = [];
|
||||
const results: EditResult[] = [];
|
||||
let failed = false;
|
||||
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i];
|
||||
const data = validations.get(edit.file_path);
|
||||
if (!data?.validation.fileContent) continue;
|
||||
|
||||
ctx.onMetadata?.({
|
||||
title: MULTI_EDIT_TITLES.APPLYING(i + 1, edits.length),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const fullPath = path.isAbsolute(edit.file_path)
|
||||
? edit.file_path
|
||||
: path.join(ctx.workingDir, edit.file_path);
|
||||
|
||||
// Create backup
|
||||
backups.push({
|
||||
path: fullPath,
|
||||
content: data.validation.fileContent,
|
||||
});
|
||||
|
||||
// Apply edit
|
||||
const result = await applyEdit(edit, ctx.workingDir, data.validation.fileContent);
|
||||
results.push(result);
|
||||
|
||||
if (!result.success) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Update file content for subsequent edits to same file
|
||||
if (result.success) {
|
||||
const newContent = data.validation.fileContent.replace(
|
||||
edit.old_string,
|
||||
edit.new_string,
|
||||
);
|
||||
// Update the validation cache for potential subsequent edits to same file
|
||||
validations.set(edit.file_path, {
|
||||
...data,
|
||||
validation: { ...data.validation, fileContent: newContent },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Rollback if any edit failed
|
||||
if (failed) {
|
||||
ctx.onMetadata?.({
|
||||
title: MULTI_EDIT_TITLES.ROLLBACK,
|
||||
status: "running",
|
||||
});
|
||||
await rollback(backups);
|
||||
return createErrorResult(MULTI_EDIT_MESSAGES.ATOMIC_FAILURE);
|
||||
}
|
||||
|
||||
return createSuccessResult(results, edits.length);
|
||||
};
|
||||
|
||||
export const multiEditTool: ToolDefinition<typeof multiEditParams> = {
|
||||
name: "multi_edit",
|
||||
description: MULTI_EDIT_DESCRIPTION,
|
||||
parameters: multiEditParams,
|
||||
execute: executeMultiEdit,
|
||||
};
|
||||
13
src/tools/multi-edit/index.ts
Normal file
13
src/tools/multi-edit/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* MultiEdit Tool
|
||||
*
|
||||
* Batch file editing with atomic transactions
|
||||
*/
|
||||
|
||||
export { multiEditTool, executeMultiEdit } from "@tools/multi-edit/execute";
|
||||
export {
|
||||
multiEditParams,
|
||||
editItemSchema,
|
||||
type EditItem,
|
||||
type MultiEditParams,
|
||||
} from "@tools/multi-edit/params";
|
||||
21
src/tools/multi-edit/params.ts
Normal file
21
src/tools/multi-edit/params.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* MultiEdit Tool Parameters
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const editItemSchema = z.object({
|
||||
file_path: z.string().describe("Absolute path to the file to edit"),
|
||||
old_string: z.string().describe("The exact text to find and replace"),
|
||||
new_string: z.string().describe("The replacement text"),
|
||||
});
|
||||
|
||||
export const multiEditParams = z.object({
|
||||
edits: z
|
||||
.array(editItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of edits to apply atomically"),
|
||||
});
|
||||
|
||||
export type EditItem = z.infer<typeof editItemSchema>;
|
||||
export type MultiEditParams = z.infer<typeof multiEditParams>;
|
||||
Reference in New Issue
Block a user