diff options
| author | Adam Malczewski <[email protected]> | 2026-03-24 16:32:44 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-24 16:32:44 +0900 |
| commit | dfe26f42be0c37591246d4a26e607d9fbecfef33 (patch) | |
| tree | a9aaea3982f0fdc6b9ef721c4009a7c86af8e6f6 /src | |
| parent | 6a6bc5379a3a4620df07b4bb196ec0ded636fc8b (diff) | |
| download | ai-pulse-obsidian-plugin-dfe26f42be0c37591246d4a26e607d9fbecfef33.tar.gz ai-pulse-obsidian-plugin-dfe26f42be0c37591246d4a26e607d9fbecfef33.zip | |
Improve edit_file prompts, empty-file support, approval review
Diffstat (limited to 'src')
| -rw-r--r-- | src/chat-view.ts | 71 | ||||
| -rw-r--r-- | src/ollama-client.ts | 16 | ||||
| -rw-r--r-- | src/tools.ts | 36 |
3 files changed, 108 insertions, 15 deletions
diff --git a/src/chat-view.ts b/src/chat-view.ts index fbb6360..526a6c1 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -387,13 +387,36 @@ export class ChatView extends ItemView { const collapseContent = collapse.createDiv({ cls: "ai-organizer-collapse-content" }); const contentInner = collapseContent.createDiv({ cls: "ai-organizer-collapse-content-inner" }); - const argsStr = JSON.stringify(event.args, null, 2); - contentInner.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" }); + if (event.toolName === "edit_file") { + // For edit_file, show old_text / new_text in dedicated labeled blocks + const filePath = typeof event.args.file_path === "string" ? event.args.file_path : ""; + const oldText = typeof event.args.old_text === "string" ? event.args.old_text : ""; + const newText = typeof event.args.new_text === "string" ? event.args.new_text : ""; + + if (filePath !== "") { + contentInner.createEl("div", { text: `File: ${filePath}`, cls: "ai-organizer-tool-call-label" }); + } + + contentInner.createEl("div", { text: "Old text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: oldText === "" ? "(empty — new file)" : oldText, + cls: "ai-organizer-tool-call-args", + }); - const resultPreview = event.result.length > 500 - ? event.result.substring(0, 500) + "..." - : event.result; - contentInner.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); + contentInner.createEl("div", { text: "New text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: newText, + cls: "ai-organizer-tool-call-result", + }); + } else { + const argsStr = JSON.stringify(event.args, null, 2); + contentInner.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" }); + + const resultPreview = event.result.length > 500 + ? event.result.substring(0, 500) + "..." + : event.result; + contentInner.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); + } } private showApprovalRequest(event: ApprovalRequestEvent): Promise<boolean> { @@ -411,6 +434,42 @@ export class ChatView extends ItemView { container.createDiv({ text: event.message, cls: "ai-organizer-approval-message" }); + // Show details for edit_file so the user can review the change + if (event.toolName === "edit_file") { + const oldText = typeof event.args.old_text === "string" ? event.args.old_text : ""; + const newText = typeof event.args.new_text === "string" ? event.args.new_text : ""; + + const collapse = container.createDiv({ cls: "ai-organizer-collapse ai-organizer-collapse-arrow" }); + const collapseId = `approval-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const checkbox = collapse.createEl("input", { + type: "checkbox", + attr: { id: collapseId, checked: "" }, + }); + checkbox.addClass("ai-organizer-collapse-toggle"); + checkbox.checked = true; + const titleEl = collapse.createEl("label", { + cls: "ai-organizer-collapse-title", + attr: { for: collapseId }, + text: "Review changes", + }); + void titleEl; + + const collapseContent = collapse.createDiv({ cls: "ai-organizer-collapse-content" }); + const contentInner = collapseContent.createDiv({ cls: "ai-organizer-collapse-content-inner" }); + + contentInner.createEl("div", { text: "Old text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: oldText === "" ? "(empty \u2014 new file)" : oldText, + cls: "ai-organizer-tool-call-args", + }); + + contentInner.createEl("div", { text: "New text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: newText, + cls: "ai-organizer-tool-call-result", + }); + } + const buttonRow = container.createDiv({ cls: "ai-organizer-approval-buttons" }); const approveBtn = buttonRow.createEl("button", { diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 01651e0..3bbb35b 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -79,8 +79,20 @@ const TOOL_SYSTEM_PROMPT = "You are a helpful assistant with access to tools for interacting with an Obsidian vault. " + "When you use the search_files tool, the results contain exact file paths. " + "You MUST use these exact paths when calling read_file, edit_file, or referencing files. " + - "NEVER guess or modify file paths \u2014 always use the paths returned by search_files or get_current_note verbatim. " + - "When the user asks you to edit the note they are currently viewing, use get_current_note first to obtain the path, then read_file to see its content, then edit_file to make changes. " + + "NEVER guess or modify file paths — always use the paths returned by search_files or get_current_note verbatim.\n\n" + + "EDITING FILES — MANDATORY WORKFLOW:\n" + + "The edit_file tool performs a find-and-replace. You provide old_text (the exact text currently in the file) and new_text (what to replace it with). " + + "If old_text does not match the file contents exactly, the edit WILL FAIL.\n" + + "Therefore you MUST follow this sequence every time you edit a file:\n" + + "1. Get the file path (use search_files or get_current_note).\n" + + "2. Call read_file to see the CURRENT content of the file.\n" + + "3. Copy the exact text you want to change from the read_file output and use it as old_text.\n" + + "4. Call edit_file with the correct old_text and your new_text.\n" + + "NEVER skip step 2. NEVER guess what the file contains — always read it first.\n" + + "If the file is empty (read_file returned no content), you may set old_text to an empty string to write initial content.\n" + + "If the file is NOT empty, old_text MUST NOT be empty — copy the exact passage you want to change from the read_file output.\n" + + "old_text must include enough surrounding context (a few lines) to uniquely identify the location in the file. " + + "Preserve the exact whitespace, indentation, and newlines from the read_file output.\n\n" + "Some tools (such as delete_file and edit_file) require user approval before they execute. " + "If the user declines an action, ask them why so you can better assist them."; diff --git a/src/tools.ts b/src/tools.ts index 68937bd..94a136d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -139,16 +139,30 @@ async function executeEditFile(app: App, args: Record<string, unknown>): Promise const oldText = typeof args.old_text === "string" ? args.old_text : ""; const newText = typeof args.new_text === "string" ? args.new_text : ""; - if (oldText === "") { - return "Error: old_text parameter is required and cannot be empty."; - } - const file = app.vault.getAbstractFileByPath(filePath); if (file === null || !(file instanceof TFile)) { return `Error: File not found at path "${filePath}".`; } try { + if (oldText === "") { + // Empty old_text: only allowed when the file is empty (write initial content) + let replaced = false; + await app.vault.process(file, (data) => { + if (data.length !== 0) { + return data; + } + replaced = true; + return newText; + }); + + if (!replaced) { + return `Error: old_text is empty but "${filePath}" is not empty. You must read the file first with read_file and provide the exact text you want to replace as old_text.`; + } + + return `Successfully wrote content to empty file "${filePath}".`; + } + let replaced = false; await app.vault.process(file, (data) => { if (!data.includes(oldText)) { @@ -159,7 +173,7 @@ async function executeEditFile(app: App, args: Record<string, unknown>): Promise }); if (!replaced) { - return `Error: The specified old_text was not found in "${filePath}".`; + return `Error: The specified old_text was not found in "${filePath}". Make sure you read the file first with read_file and copy the exact text.`; } return `Successfully edited "${filePath}".`; @@ -344,7 +358,15 @@ export const TOOL_REGISTRY: ToolEntry[] = [ type: "function", function: { name: "edit_file", - description: "Edit a file in the Obsidian vault by finding and replacing text. Provide the exact text to find (old_text) and the replacement (new_text). The old_text must match exactly as it appears in the file, including whitespace and newlines. Only the first occurrence is replaced. The file_path must be an exact path as returned by search_files or get_current_note. This action requires user approval.", + description: "Edit a file in the Obsidian vault by finding and replacing text. " + + "IMPORTANT: You MUST call read_file on the target file BEFORE calling edit_file so you can see its exact current content. " + + "Copy the exact text you want to change from the read_file output and use it as old_text. " + + "old_text must match a passage in the file exactly (including whitespace and newlines). " + + "Only the first occurrence of old_text is replaced with new_text. " + + "SPECIAL CASE: If the file is empty (read_file returned no content), set old_text to an empty string to write initial content. " + + "If old_text is empty but the file is NOT empty, the edit will be rejected. " + + "The file_path must be an exact path from search_files or get_current_note. " + + "This action requires user approval.", parameters: { type: "object", required: ["file_path", "old_text", "new_text"], @@ -355,7 +377,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [ }, old_text: { type: "string", - description: "The exact text to find in the file. Must match exactly including whitespace.", + description: "The exact text to find in the file, copied verbatim from read_file output. Include enough surrounding lines to uniquely identify the location. Preserve all whitespace and newlines exactly. Only set to an empty string when the file itself is empty.", }, new_text: { type: "string", |
