summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/chat-view.ts155
-rw-r--r--src/ollama-client.ts9
-rw-r--r--src/tool-modal.ts3
-rw-r--r--src/tools.ts493
4 files changed, 651 insertions, 9 deletions
diff --git a/src/chat-view.ts b/src/chat-view.ts
index b76fb8e..d48e04b 100644
--- a/src/chat-view.ts
+++ b/src/chat-view.ts
@@ -162,7 +162,12 @@ export class ChatView extends ItemView {
private getEnabledTools(): OllamaToolDefinition[] {
const tools: OllamaToolDefinition[] = [];
for (const tool of TOOL_REGISTRY) {
- if (this.plugin.settings.enabledTools[tool.id] === true) {
+ if (tool.batchOf !== undefined) {
+ // Batch tool: include if the parent tool is enabled
+ if (this.plugin.settings.enabledTools[tool.batchOf] === true) {
+ tools.push(tool.definition);
+ }
+ } else if (this.plugin.settings.enabledTools[tool.id] === true) {
tools.push(tool.definition);
}
}
@@ -497,8 +502,12 @@ export class ChatView extends ItemView {
container.createDiv({ text: event.message, cls: "ai-pulse-approval-message" });
- // Show details for edit_file so the user can review the change
- if (event.toolName === "edit_file" || event.toolName === "create_file" || event.toolName === "set_frontmatter") {
+ // Show details for review-worthy tools
+ const detailTools = [
+ "edit_file", "create_file", "set_frontmatter",
+ "batch_delete_file", "batch_move_file", "batch_set_frontmatter", "batch_edit_file",
+ ];
+ if (detailTools.includes(event.toolName)) {
const collapse = container.createDiv({ cls: "ai-pulse-collapse ai-pulse-collapse-arrow" });
const collapseId = `approval-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const checkbox = collapse.createEl("input", {
@@ -507,12 +516,15 @@ export class ChatView extends ItemView {
});
checkbox.addClass("ai-pulse-collapse-toggle");
checkbox.checked = true;
+
+ const collapseTitleText = event.toolName === "create_file" ? "Review content"
+ : event.toolName === "set_frontmatter" ? "Review properties"
+ : event.toolName.startsWith("batch_") ? "Review all operations"
+ : "Review changes";
const titleEl = collapse.createEl("label", {
cls: "ai-pulse-collapse-title",
attr: { for: collapseId },
- text: event.toolName === "create_file" ? "Review content"
- : event.toolName === "set_frontmatter" ? "Review properties"
- : "Review changes",
+ text: collapseTitleText,
});
void titleEl;
@@ -545,8 +557,7 @@ export class ChatView extends ItemView {
text: propsStr,
cls: "ai-pulse-tool-call-result",
});
- } else {
- // create_file
+ } else if (event.toolName === "create_file") {
const content = typeof event.args.content === "string" ? event.args.content : "";
contentInner.createEl("div", { text: "Content:", cls: "ai-pulse-tool-call-label" });
@@ -554,6 +565,14 @@ export class ChatView extends ItemView {
text: content === "" ? "(empty file)" : content,
cls: "ai-pulse-tool-call-result",
});
+ } else if (event.toolName === "batch_delete_file") {
+ this.renderBatchDeleteApproval(contentInner, event.args);
+ } else if (event.toolName === "batch_move_file") {
+ this.renderBatchMoveApproval(contentInner, event.args);
+ } else if (event.toolName === "batch_set_frontmatter") {
+ this.renderBatchSetFrontmatterApproval(contentInner, event.args);
+ } else if (event.toolName === "batch_edit_file") {
+ this.renderBatchEditApproval(contentInner, event.args);
}
}
@@ -586,6 +605,126 @@ export class ChatView extends ItemView {
});
}
+ private renderBatchDeleteApproval(container: HTMLDivElement, args: Record<string, unknown>): void {
+ let filePaths: unknown[] = [];
+ if (Array.isArray(args.file_paths)) {
+ filePaths = args.file_paths;
+ } else if (typeof args.file_paths === "string") {
+ try { filePaths = JSON.parse(args.file_paths) as unknown[]; } catch { /* empty */ }
+ }
+
+ container.createEl("div", {
+ text: `Files to delete (${filePaths.length}):`,
+ cls: "ai-pulse-tool-call-label",
+ });
+
+ const list = container.createEl("ul", { cls: "ai-pulse-batch-list" });
+ for (const fp of filePaths) {
+ list.createEl("li", { text: typeof fp === "string" ? fp : "(invalid)" });
+ }
+ }
+
+ private renderBatchMoveApproval(container: HTMLDivElement, args: Record<string, unknown>): void {
+ let operations: unknown[] = [];
+ if (Array.isArray(args.operations)) {
+ operations = args.operations;
+ } else if (typeof args.operations === "string") {
+ try { operations = JSON.parse(args.operations) as unknown[]; } catch { /* empty */ }
+ }
+
+ container.createEl("div", {
+ text: `Files to move (${operations.length}):`,
+ cls: "ai-pulse-tool-call-label",
+ });
+
+ const list = container.createEl("ul", { cls: "ai-pulse-batch-list" });
+ for (const op of operations) {
+ if (typeof op !== "object" || op === null) {
+ list.createEl("li", { text: "(invalid entry)" });
+ continue;
+ }
+ const o = op as Record<string, unknown>;
+ const from = typeof o.file_path === "string" ? o.file_path : "?";
+ const to = typeof o.new_path === "string" ? o.new_path : "?";
+ const li = list.createEl("li");
+ li.createSpan({ text: from, cls: "ai-pulse-batch-path" });
+ li.createSpan({ text: " \u2192 " });
+ li.createSpan({ text: to, cls: "ai-pulse-batch-path" });
+ }
+ }
+
+ private renderBatchSetFrontmatterApproval(container: HTMLDivElement, args: Record<string, unknown>): void {
+ let operations: unknown[] = [];
+ if (Array.isArray(args.operations)) {
+ operations = args.operations;
+ } else if (typeof args.operations === "string") {
+ try { operations = JSON.parse(args.operations) as unknown[]; } catch { /* empty */ }
+ }
+
+ container.createEl("div", {
+ text: `Frontmatter updates (${operations.length} file${operations.length === 1 ? "" : "s"}):`,
+ cls: "ai-pulse-tool-call-label",
+ });
+
+ for (const op of operations) {
+ if (typeof op !== "object" || op === null) {
+ container.createEl("div", { text: "(invalid entry)", cls: "ai-pulse-tool-call-label" });
+ continue;
+ }
+ const o = op as Record<string, unknown>;
+ const fp = typeof o.file_path === "string" ? o.file_path : "?";
+
+ let propsStr = "{}";
+ if (typeof o.properties === "object" && o.properties !== null) {
+ propsStr = JSON.stringify(o.properties, null, 2);
+ } else if (typeof o.properties === "string") {
+ propsStr = o.properties;
+ }
+
+ container.createEl("div", { text: fp, cls: "ai-pulse-tool-call-label ai-pulse-batch-file-header" });
+ container.createEl("pre", { text: propsStr, cls: "ai-pulse-tool-call-result" });
+ }
+ }
+
+ private renderBatchEditApproval(container: HTMLDivElement, args: Record<string, unknown>): void {
+ let operations: unknown[] = [];
+ if (Array.isArray(args.operations)) {
+ operations = args.operations;
+ } else if (typeof args.operations === "string") {
+ try { operations = JSON.parse(args.operations) as unknown[]; } catch { /* empty */ }
+ }
+
+ container.createEl("div", {
+ text: `File edits (${operations.length} file${operations.length === 1 ? "" : "s"}):`,
+ cls: "ai-pulse-tool-call-label",
+ });
+
+ for (const op of operations) {
+ if (typeof op !== "object" || op === null) {
+ container.createEl("div", { text: "(invalid entry)", cls: "ai-pulse-tool-call-label" });
+ continue;
+ }
+ const o = op as Record<string, unknown>;
+ const fp = typeof o.file_path === "string" ? o.file_path : "?";
+ const oldText = typeof o.old_text === "string" ? o.old_text : "";
+ const newText = typeof o.new_text === "string" ? o.new_text : "";
+
+ container.createEl("div", { text: fp, cls: "ai-pulse-tool-call-label ai-pulse-batch-file-header" });
+
+ container.createEl("div", { text: "Old text:", cls: "ai-pulse-tool-call-label" });
+ container.createEl("pre", {
+ text: oldText === "" ? "(empty \u2014 new file)" : oldText,
+ cls: "ai-pulse-tool-call-args",
+ });
+
+ container.createEl("div", { text: "New text:", cls: "ai-pulse-tool-call-label" });
+ container.createEl("pre", {
+ text: newText,
+ cls: "ai-pulse-tool-call-result",
+ });
+ }
+ }
+
private scrollToBottom(): void {
if (this.messageContainer !== null) {
this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index c01b5bc..155df84 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -116,7 +116,14 @@ const TOOL_SYSTEM_PROMPT =
"ALWAYS prefer set_frontmatter over edit_file when modifying tags, aliases, or other frontmatter fields. " +
"RECOMMENDED: Read the file first to see existing frontmatter before calling set_frontmatter.\n\n" +
"Some tools (such as delete_file, edit_file, create_file, and move_file) require user approval before they execute. " +
- "If the user declines an action, ask them why so you can better assist them.";
+ "If the user declines an action, ask them why so you can better assist them.\n\n" +
+ "BATCH TOOLS:\n" +
+ "When you need to perform the same type of operation on multiple files, prefer batch tools over calling individual tools repeatedly. " +
+ "Available batch tools: batch_search_files, batch_grep_search, batch_delete_file, batch_move_file, batch_set_frontmatter, batch_edit_file. " +
+ "Batch tools accept an array of operations and execute them all in one call, reporting per-item success/failure. " +
+ "Batch tools that modify files (delete, move, edit, set_frontmatter) require a single user approval for the entire batch. " +
+ "The parameters for batch tools use JSON arrays passed as strings. " +
+ "IMPORTANT: For batch_edit_file, you MUST still read each file first to get exact content before editing.";
/**
* Shared agent loop: injects the system prompt, calls the strategy for each
diff --git a/src/tool-modal.ts b/src/tool-modal.ts
index 4da8edb..6a45ae0 100644
--- a/src/tool-modal.ts
+++ b/src/tool-modal.ts
@@ -23,6 +23,9 @@ export class ToolModal extends Modal {
});
for (const tool of TOOL_REGISTRY) {
+ // Batch tools auto-enable with their parent — no separate toggle
+ if (tool.batchOf !== undefined) continue;
+
new Setting(contentEl)
.setName(tool.label)
.setDesc(tool.description)
diff --git a/src/tools.ts b/src/tools.ts
index 70deb0e..9bc2f53 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -26,6 +26,8 @@ export interface ToolEntry {
description: string;
friendlyName: string;
requiresApproval: boolean;
+ /** If set, this batch tool is auto-enabled when the named base tool is enabled. */
+ batchOf?: string;
approvalMessage?: (args: Record<string, unknown>) => string;
summarize: (args: Record<string, unknown>) => string;
summarizeResult: (result: string) => string;
@@ -405,6 +407,211 @@ async function executeSetFrontmatter(app: App, args: Record<string, unknown>): P
}
}
+// ---------------------------------------------------------------------------
+// Batch tool execute functions
+// ---------------------------------------------------------------------------
+
+/**
+ * Helper: parse an array-typed argument that may arrive as a JSON string.
+ */
+function parseArrayArg(value: unknown): unknown[] | null {
+ if (Array.isArray(value)) return value;
+ if (typeof value === "string") {
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ if (Array.isArray(parsed)) return parsed;
+ } catch { /* fall through */ }
+ }
+ return null;
+}
+
+/**
+ * Execute the "batch_search_files" tool.
+ * Runs multiple search queries and returns combined results.
+ */
+async function executeBatchSearchFiles(app: App, args: Record<string, unknown>): Promise<string> {
+ const queries = parseArrayArg(args.queries);
+ if (queries === null || queries.length === 0) {
+ return "Error: queries parameter must be a non-empty array of strings.";
+ }
+
+ const results: string[] = [];
+ for (let i = 0; i < queries.length; i++) {
+ const q = queries[i];
+ const query = typeof q === "string" ? q : "";
+ const result = await executeSearchFiles(app, { query });
+ results.push(`--- Query ${i + 1}: "${query}" ---\n${result}`);
+ }
+
+ return results.join("\n\n");
+}
+
+/**
+ * Execute the "batch_grep_search" tool.
+ * Runs multiple content searches and returns combined results.
+ */
+async function executeBatchGrepSearch(app: App, args: Record<string, unknown>): Promise<string> {
+ const queries = parseArrayArg(args.queries);
+ if (queries === null || queries.length === 0) {
+ return "Error: queries parameter must be a non-empty array of search query objects.";
+ }
+
+ const results: string[] = [];
+ for (let i = 0; i < queries.length; i++) {
+ const q = queries[i];
+ if (typeof q !== "object" || q === null) {
+ results.push(`--- Query ${i + 1} ---\nError: each query must be an object with a "query" field.`);
+ continue;
+ }
+ const queryObj = q as Record<string, unknown>;
+ const result = await executeGrepSearch(app, queryObj);
+ const queryText = typeof queryObj.query === "string" ? queryObj.query : "";
+ const filePattern = typeof queryObj.file_pattern === "string" ? ` (in "${queryObj.file_pattern}")` : "";
+ results.push(`--- Query ${i + 1}: "${queryText}"${filePattern} ---\n${result}`);
+ }
+
+ return results.join("\n\n");
+}
+
+/**
+ * Execute the "batch_delete_file" tool.
+ * Deletes multiple files, continuing on failure and reporting per-file results.
+ */
+async function executeBatchDeleteFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const filePaths = parseArrayArg(args.file_paths);
+ if (filePaths === null || filePaths.length === 0) {
+ return "Error: file_paths parameter must be a non-empty array of strings.";
+ }
+
+ const results: string[] = [];
+ let successes = 0;
+ let failures = 0;
+
+ for (const fp of filePaths) {
+ const filePath = typeof fp === "string" ? fp : "";
+ const result = await executeDeleteFile(app, { file_path: filePath });
+ if (result.startsWith("Error")) {
+ failures++;
+ } else {
+ successes++;
+ }
+ results.push(`${filePath}: ${result}`);
+ }
+
+ const summary = `Batch delete complete: ${successes} succeeded, ${failures} failed.`;
+ return `${summary}\n\n${results.join("\n")}`;
+}
+
+/**
+ * Execute the "batch_move_file" tool.
+ * Moves/renames multiple files, continuing on failure.
+ */
+async function executeBatchMoveFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const operations = parseArrayArg(args.operations);
+ if (operations === null || operations.length === 0) {
+ return "Error: operations parameter must be a non-empty array of {file_path, new_path} objects.";
+ }
+
+ const results: string[] = [];
+ let successes = 0;
+ let failures = 0;
+
+ for (const op of operations) {
+ if (typeof op !== "object" || op === null) {
+ results.push("(invalid entry): Error: each operation must be an object with file_path and new_path.");
+ failures++;
+ continue;
+ }
+ const opObj = op as Record<string, unknown>;
+ const filePath = typeof opObj.file_path === "string" ? opObj.file_path : "";
+ const newPath = typeof opObj.new_path === "string" ? opObj.new_path : "";
+ const result = await executeMoveFile(app, { file_path: filePath, new_path: newPath });
+ if (result.startsWith("Error")) {
+ failures++;
+ } else {
+ successes++;
+ }
+ results.push(`${filePath} → ${newPath}: ${result}`);
+ }
+
+ const summary = `Batch move complete: ${successes} succeeded, ${failures} failed.`;
+ return `${summary}\n\n${results.join("\n")}`;
+}
+
+/**
+ * Execute the "batch_set_frontmatter" tool.
+ * Sets frontmatter on multiple files, continuing on failure.
+ */
+async function executeBatchSetFrontmatter(app: App, args: Record<string, unknown>): Promise<string> {
+ const operations = parseArrayArg(args.operations);
+ if (operations === null || operations.length === 0) {
+ return "Error: operations parameter must be a non-empty array of {file_path, properties} objects.";
+ }
+
+ const results: string[] = [];
+ let successes = 0;
+ let failures = 0;
+
+ for (const op of operations) {
+ if (typeof op !== "object" || op === null) {
+ results.push("(invalid entry): Error: each operation must be an object with file_path and properties.");
+ failures++;
+ continue;
+ }
+ const opObj = op as Record<string, unknown>;
+ const filePath = typeof opObj.file_path === "string" ? opObj.file_path : "";
+ const result = await executeSetFrontmatter(app, { file_path: filePath, properties: opObj.properties });
+ if (result.startsWith("Error")) {
+ failures++;
+ } else {
+ successes++;
+ }
+ results.push(`${filePath}: ${result}`);
+ }
+
+ const summary = `Batch frontmatter update complete: ${successes} succeeded, ${failures} failed.`;
+ return `${summary}\n\n${results.join("\n")}`;
+}
+
+/**
+ * Execute the "batch_edit_file" tool.
+ * Performs multiple file edits, continuing on failure.
+ */
+async function executeBatchEditFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const operations = parseArrayArg(args.operations);
+ if (operations === null || operations.length === 0) {
+ return "Error: operations parameter must be a non-empty array of {file_path, old_text, new_text} objects.";
+ }
+
+ const results: string[] = [];
+ let successes = 0;
+ let failures = 0;
+
+ for (const op of operations) {
+ if (typeof op !== "object" || op === null) {
+ results.push("(invalid entry): Error: each operation must be an object with file_path, old_text, and new_text.");
+ failures++;
+ continue;
+ }
+ const opObj = op as Record<string, unknown>;
+ const filePath = typeof opObj.file_path === "string" ? opObj.file_path : "";
+ const result = await executeEditFile(app, {
+ file_path: filePath,
+ old_text: opObj.old_text,
+ new_text: opObj.new_text,
+ });
+ if (result.startsWith("Error")) {
+ failures++;
+ } else {
+ successes++;
+ }
+ results.push(`${filePath}: ${result}`);
+ }
+
+ const summary = `Batch edit complete: ${successes} succeeded, ${failures} failed.`;
+ return `${summary}\n\n${results.join("\n")}`;
+}
+
/**
* All available tools for the plugin.
*/
@@ -810,6 +1017,290 @@ export const TOOL_REGISTRY: ToolEntry[] = [
},
execute: executeSetFrontmatter,
},
+ // --- Batch tools ---
+ {
+ id: "batch_search_files",
+ label: "Batch Search File Names",
+ description: "Run multiple file-name searches in one call.",
+ friendlyName: "Batch Search Files",
+ requiresApproval: false,
+ batchOf: "search_files",
+ summarize: (args) => {
+ const queries = parseArrayArg(args.queries);
+ const count = queries !== null ? queries.length : 0;
+ return `${count} search quer${count === 1 ? "y" : "ies"}`;
+ },
+ summarizeResult: (result) => {
+ const sections = result.split("--- Query").length - 1;
+ return `${sections} search${sections === 1 ? "" : "es"} completed`;
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "batch_search_files",
+ description: "Run multiple file-name searches in a single call. Each query searches vault file names/paths independently. Use this when you need to search for several different terms at once instead of calling search_files repeatedly.",
+ parameters: {
+ type: "object",
+ required: ["queries"],
+ properties: {
+ queries: {
+ type: "string",
+ description: 'A JSON array of search query strings. Example: ["meeting notes", "project plan", "2024"]',
+ },
+ },
+ },
+ },
+ },
+ execute: executeBatchSearchFiles,
+ },
+ {
+ id: "batch_grep_search",
+ label: "Batch Search File Contents",
+ description: "Run multiple content searches in one call.",
+ friendlyName: "Batch Search Contents",
+ requiresApproval: false,
+ batchOf: "grep_search",
+ summarize: (args) => {
+ const queries = parseArrayArg(args.queries);
+ const count = queries !== null ? queries.length : 0;
+ return `${count} content search${count === 1 ? "" : "es"}`;
+ },
+ summarizeResult: (result) => {
+ const sections = result.split("--- Query").length - 1;
+ return `${sections} search${sections === 1 ? "" : "es"} completed`;
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "batch_grep_search",
+ description: "Run multiple content searches across vault markdown files in a single call. Each query searches independently. Use this when you need to search for several different text patterns at once instead of calling grep_search repeatedly.",
+ parameters: {
+ type: "object",
+ required: ["queries"],
+ properties: {
+ queries: {
+ type: "string",
+ description: 'A JSON array of query objects. Each object must have a "query" field and optionally a "file_pattern" field. Example: [{"query": "TODO", "file_pattern": "projects/"}, {"query": "meeting agenda"}]',
+ },
+ },
+ },
+ },
+ },
+ execute: executeBatchGrepSearch,
+ },
+ {
+ id: "batch_delete_file",
+ label: "Batch Delete Files",
+ description: "Delete multiple files at once (requires approval).",
+ friendlyName: "Batch Delete Files",
+ requiresApproval: true,
+ batchOf: "delete_file",
+ approvalMessage: (args) => {
+ const filePaths = parseArrayArg(args.file_paths);
+ if (filePaths === null || filePaths.length === 0) return "Delete files?";
+ const list = filePaths.map((fp) => ` • ${typeof fp === "string" ? fp : "(invalid)"}`);
+ return `Delete ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
+ },
+ summarize: (args) => {
+ const filePaths = parseArrayArg(args.file_paths);
+ const count = filePaths !== null ? filePaths.length : 0;
+ return `${count} file${count === 1 ? "" : "s"}`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) return result;
+ if (result.includes("declined")) return "Declined by user";
+ const match = result.match(/(\d+) succeeded, (\d+) failed/);
+ if (match !== null) return `${match[1]} deleted, ${match[2]} failed`;
+ return "Batch delete complete";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "batch_delete_file",
+ description: "Delete multiple files from the Obsidian vault in a single call. Files are moved to the system trash. If some files fail (e.g. not found), the operation continues with the remaining files and reports per-file results. All file paths must be exact paths as returned by search_files. This action requires user approval for the entire batch.",
+ parameters: {
+ type: "object",
+ required: ["file_paths"],
+ properties: {
+ file_paths: {
+ type: "string",
+ description: 'A JSON array of vault-relative file paths to delete. Example: ["folder/note1.md", "folder/note2.md"]',
+ },
+ },
+ },
+ },
+ },
+ execute: executeBatchDeleteFile,
+ },
+ {
+ id: "batch_move_file",
+ label: "Batch Move/Rename Files",
+ description: "Move or rename multiple files at once (requires approval).",
+ friendlyName: "Batch Move Files",
+ requiresApproval: true,
+ batchOf: "move_file",
+ approvalMessage: (args) => {
+ const operations = parseArrayArg(args.operations);
+ if (operations === null || operations.length === 0) return "Move files?";
+ const list = operations.map((op) => {
+ if (typeof op !== "object" || op === null) return " • (invalid entry)";
+ const o = op as Record<string, unknown>;
+ const from = typeof o.file_path === "string" ? o.file_path : "?";
+ const to = typeof o.new_path === "string" ? o.new_path : "?";
+ return ` • ${from} → ${to}`;
+ });
+ return `Move ${operations.length} file${operations.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
+ },
+ summarize: (args) => {
+ const operations = parseArrayArg(args.operations);
+ const count = operations !== null ? operations.length : 0;
+ return `${count} file${count === 1 ? "" : "s"}`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) return result;
+ if (result.includes("declined")) return "Declined by user";
+ const match = result.match(/(\d+) succeeded, (\d+) failed/);
+ if (match !== null) return `${match[1]} moved, ${match[2]} failed`;
+ return "Batch move complete";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "batch_move_file",
+ description: "Move or rename multiple files in the Obsidian vault in a single call. All internal links are automatically updated for each file. If some operations fail, the rest continue and per-file results are reported. Target folders are created automatically. All file paths must be exact paths as returned by search_files. This action requires user approval for the entire batch.",
+ parameters: {
+ type: "object",
+ required: ["operations"],
+ properties: {
+ operations: {
+ type: "string",
+ description: 'A JSON array of move operations. Each object must have "file_path" (current path) and "new_path" (destination). Example: [{"file_path": "old/note.md", "new_path": "new/note.md"}, {"file_path": "a.md", "new_path": "archive/a.md"}]',
+ },
+ },
+ },
+ },
+ },
+ execute: executeBatchMoveFile,
+ },
+ {
+ id: "batch_set_frontmatter",
+ label: "Batch Set Frontmatter",
+ description: "Update frontmatter on multiple files at once (requires approval).",
+ friendlyName: "Batch Set Frontmatter",
+ requiresApproval: true,
+ batchOf: "set_frontmatter",
+ approvalMessage: (args) => {
+ const operations = parseArrayArg(args.operations);
+ if (operations === null || operations.length === 0) return "Update frontmatter?";
+ const list = operations.map((op) => {
+ if (typeof op !== "object" || op === null) return " • (invalid entry)";
+ const o = op as Record<string, unknown>;
+ const fp = typeof o.file_path === "string" ? o.file_path : "?";
+ let propsStr = "";
+ if (typeof o.properties === "object" && o.properties !== null) {
+ propsStr = Object.keys(o.properties as Record<string, unknown>).join(", ");
+ } else if (typeof o.properties === "string") {
+ try {
+ const parsed = JSON.parse(o.properties) as Record<string, unknown>;
+ propsStr = Object.keys(parsed).join(", ");
+ } catch { propsStr = "(properties)"; }
+ }
+ return ` • ${fp}: ${propsStr}`;
+ });
+ return `Update frontmatter on ${operations.length} file${operations.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
+ },
+ summarize: (args) => {
+ const operations = parseArrayArg(args.operations);
+ const count = operations !== null ? operations.length : 0;
+ return `${count} file${count === 1 ? "" : "s"}`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) return result;
+ if (result.includes("declined")) return "Declined by user";
+ const match = result.match(/(\d+) succeeded, (\d+) failed/);
+ if (match !== null) return `${match[1]} updated, ${match[2]} failed`;
+ return "Batch frontmatter update complete";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "batch_set_frontmatter",
+ description: "Update YAML frontmatter properties on multiple files in a single call. " +
+ "Each operation specifies a file and the properties to set. " +
+ "Existing properties not mentioned are left unchanged. Set a value to null to remove it. " +
+ "If some operations fail, the rest continue and per-file results are reported. " +
+ "Use this instead of calling set_frontmatter repeatedly when updating multiple files. " +
+ "RECOMMENDED: Read files first to see existing frontmatter before updating. " +
+ "This action requires user approval for the entire batch.",
+ parameters: {
+ type: "object",
+ required: ["operations"],
+ properties: {
+ operations: {
+ type: "string",
+ description: 'A JSON array of frontmatter operations. Each object must have "file_path" and "properties" (a JSON object of key-value pairs). Example: [{"file_path": "note1.md", "properties": {"tags": ["ai"], "status": "done"}}, {"file_path": "note2.md", "properties": {"tags": ["research"]}}]',
+ },
+ },
+ },
+ },
+ },
+ execute: executeBatchSetFrontmatter,
+ },
+ {
+ id: "batch_edit_file",
+ label: "Batch Edit Files",
+ description: "Edit multiple files at once (requires approval).",
+ friendlyName: "Batch Edit Files",
+ requiresApproval: true,
+ batchOf: "edit_file",
+ approvalMessage: (args) => {
+ const operations = parseArrayArg(args.operations);
+ if (operations === null || operations.length === 0) return "Edit files?";
+ const list = operations.map((op) => {
+ if (typeof op !== "object" || op === null) return " • (invalid entry)";
+ const o = op as Record<string, unknown>;
+ const fp = typeof o.file_path === "string" ? o.file_path : "?";
+ return ` • ${fp}`;
+ });
+ return `Edit ${operations.length} file${operations.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
+ },
+ summarize: (args) => {
+ const operations = parseArrayArg(args.operations);
+ const count = operations !== null ? operations.length : 0;
+ return `${count} file${count === 1 ? "" : "s"}`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) return result;
+ if (result.includes("declined")) return "Declined by user";
+ const match = result.match(/(\d+) succeeded, (\d+) failed/);
+ if (match !== null) return `${match[1]} edited, ${match[2]} failed`;
+ return "Batch edit complete";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "batch_edit_file",
+ description: "Edit multiple files in the Obsidian vault in a single call. " +
+ "Each operation performs a find-and-replace on one file. " +
+ "IMPORTANT: You MUST call read_file on each target file BEFORE using this tool. " +
+ "Copy the exact text from read_file output for each old_text. " +
+ "If some operations fail, the rest continue and per-file results are reported. " +
+ "Use this instead of calling edit_file repeatedly when making changes across multiple files. " +
+ "This action requires user approval for the entire batch.",
+ parameters: {
+ type: "object",
+ required: ["operations"],
+ properties: {
+ operations: {
+ type: "string",
+ description: 'A JSON array of edit operations. Each object must have "file_path", "old_text", and "new_text". Example: [{"file_path": "note1.md", "old_text": "old content", "new_text": "new content"}, {"file_path": "note2.md", "old_text": "foo", "new_text": "bar"}]',
+ },
+ },
+ },
+ },
+ },
+ execute: executeBatchEditFile,
+ },
];
/**
@@ -818,6 +1309,8 @@ export const TOOL_REGISTRY: ToolEntry[] = [
export function getDefaultToolStates(): Record<string, boolean> {
const states: Record<string, boolean> = {};
for (const tool of TOOL_REGISTRY) {
+ // Batch tools inherit from their parent — no separate toggle
+ if (tool.batchOf !== undefined) continue;
states[tool.id] = false;
}
return states;