summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-24 17:19:52 +0900
committerAdam Malczewski <[email protected]>2026-03-24 17:19:52 +0900
commit3690c97ceaf8a20bb2c6d38bd600e5ae8bc2dac6 (patch)
tree6f3c792e74505045ec73009b988c30614e43dcc0 /src
parentdfe26f42be0c37591246d4a26e607d9fbecfef33 (diff)
downloadai-pulse-obsidian-plugin-3690c97ceaf8a20bb2c6d38bd600e5ae8bc2dac6.tar.gz
ai-pulse-obsidian-plugin-3690c97ceaf8a20bb2c6d38bd600e5ae8bc2dac6.zip
Add new tools, wiki-links, system prompt file, model badge
Diffstat (limited to 'src')
-rw-r--r--src/chat-view.ts98
-rw-r--r--src/ollama-client.ts41
-rw-r--r--src/settings-modal.ts50
-rw-r--r--src/settings.ts4
-rw-r--r--src/tools.ts274
5 files changed, 443 insertions, 24 deletions
diff --git a/src/chat-view.ts b/src/chat-view.ts
index 526a6c1..7d34fcb 100644
--- a/src/chat-view.ts
+++ b/src/chat-view.ts
@@ -1,4 +1,4 @@
-import { ItemView, MarkdownRenderer, Notice, WorkspaceLeaf, setIcon } from "obsidian";
+import { ItemView, MarkdownRenderer, Notice, TFile, WorkspaceLeaf, setIcon } from "obsidian";
import type AIOrganizer from "./main";
import type { ChatMessage, ToolCallEvent, ApprovalRequestEvent } from "./ollama-client";
import { sendChatMessageStreaming } from "./ollama-client";
@@ -19,6 +19,7 @@ export class ChatView extends ItemView {
private abortController: AbortController | null = null;
private scrollDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private bubbleContent: Map<HTMLDivElement, string> = new Map();
+ private modelBadge: HTMLDivElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: AIOrganizer) {
super(leaf);
@@ -46,6 +47,10 @@ export class ChatView extends ItemView {
const messagesArea = contentEl.createDiv({ cls: "ai-organizer-messages-area" });
this.messageContainer = messagesArea.createDiv({ cls: "ai-organizer-messages" });
+ // --- Model Badge (top left) ---
+ this.modelBadge = messagesArea.createDiv({ cls: "ai-organizer-model-badge" });
+ this.updateModelBadge();
+
// --- FAB Speed Dial ---
const fab = messagesArea.createDiv({ cls: "ai-organizer-fab" });
@@ -66,7 +71,11 @@ export class ChatView extends ItemView {
});
setIcon(settingsBtn, "sliders-horizontal");
settingsBtn.addEventListener("click", () => {
- new SettingsModal(this.plugin).open();
+ const modal = new SettingsModal(this.plugin);
+ modal.onClose = () => {
+ this.updateModelBadge();
+ };
+ modal.open();
// Blur to close the FAB
(document.activeElement as HTMLElement)?.blur();
});
@@ -145,6 +154,7 @@ export class ChatView extends ItemView {
this.textarea = null;
this.sendButton = null;
this.toolsButton = null;
+ this.modelBadge = null;
this.abortController = null;
}
@@ -169,6 +179,18 @@ export class ChatView extends ItemView {
this.toolsButton.toggleClass("ai-organizer-tools-active", this.hasAnyToolEnabled());
}
+ private updateModelBadge(): void {
+ if (this.modelBadge === null) return;
+ const model = this.plugin.settings.model;
+ if (model === "") {
+ this.modelBadge.setText("No model selected");
+ this.modelBadge.addClass("ai-organizer-model-badge-empty");
+ } else {
+ this.modelBadge.setText(model);
+ this.modelBadge.removeClass("ai-organizer-model-badge-empty");
+ }
+ }
+
private async handleSend(): Promise<void> {
if (this.textarea === null || this.sendButton === null || this.messageContainer === null) {
return;
@@ -198,6 +220,22 @@ export class ChatView extends ItemView {
let currentBubble: HTMLDivElement | null = null;
+ // Read custom system prompt from vault file if enabled
+ let userSystemPrompt: string | undefined;
+ if (this.plugin.settings.useSystemPromptFile) {
+ const promptPath = this.plugin.settings.systemPromptFile;
+ if (promptPath !== "") {
+ const promptFile = this.plugin.app.vault.getAbstractFileByPath(promptPath);
+ if (promptFile !== null && promptFile instanceof TFile) {
+ try {
+ userSystemPrompt = await this.plugin.app.vault.cachedRead(promptFile);
+ } catch {
+ // Silently skip if file can't be read
+ }
+ }
+ }
+ }
+
try {
const enabledTools = this.getEnabledTools();
const hasTools = enabledTools.length > 0;
@@ -252,6 +290,7 @@ export class ChatView extends ItemView {
num_ctx: this.plugin.settings.numCtx,
num_predict: this.plugin.settings.numPredict,
},
+ userSystemPrompt,
onChunk,
onToolCall: hasTools ? onToolCall : undefined,
onApprovalRequest: hasTools ? onApprovalRequest : undefined,
@@ -339,6 +378,18 @@ export class ChatView extends ItemView {
"",
this,
);
+
+ // Wire up internal [[wiki-links]] so they navigate on click
+ bubble.querySelectorAll("a.internal-link").forEach((link) => {
+ link.addEventListener("click", (evt) => {
+ evt.preventDefault();
+ const href = link.getAttribute("href");
+ if (href !== null) {
+ void this.plugin.app.workspace.openLinkText(href, "", false);
+ }
+ });
+ });
+
this.scrollToBottom();
}
@@ -435,10 +486,7 @@ 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 : "";
-
+ if (event.toolName === "edit_file" || event.toolName === "create_file") {
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", {
@@ -450,24 +498,38 @@ export class ChatView extends ItemView {
const titleEl = collapse.createEl("label", {
cls: "ai-organizer-collapse-title",
attr: { for: collapseId },
- text: "Review changes",
+ text: event.toolName === "create_file" ? "Review content" : "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",
- });
+ 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 : "";
+
+ 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",
+ });
+ } else {
+ // create_file
+ const content = typeof event.args.content === "string" ? event.args.content : "";
+
+ contentInner.createEl("div", { text: "Content:", cls: "ai-organizer-tool-call-label" });
+ contentInner.createEl("pre", {
+ text: content === "" ? "(empty file)" : content,
+ cls: "ai-organizer-tool-call-result",
+ });
+ }
}
const buttonRow = container.createDiv({ cls: "ai-organizer-approval-buttons" });
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index 3bbb35b..f1288e7 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -67,6 +67,7 @@ interface AgentLoopOptions {
messages: ChatMessage[];
tools?: OllamaToolDefinition[];
app?: App;
+ userSystemPrompt?: string;
onToolCall?: (event: ToolCallEvent) => void;
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>;
sendRequest: ChatRequestStrategy;
@@ -80,6 +81,12 @@ const TOOL_SYSTEM_PROMPT =
"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 — always use the paths returned by search_files or get_current_note verbatim.\n\n" +
+ "LINKING TO NOTES:\n" +
+ "When you mention a note that exists in the vault, link to it using Obsidian's wiki-link syntax: [[Note Name]]. " +
+ "Use the file's basename (without the .md extension and without folder prefixes) for simple links, e.g. [[My Note]]. " +
+ "If you need to show different display text, use [[Note Name|display text]]. " +
+ "Feel free to link to notes whenever it is helpful — for example when listing search results, suggesting related notes, or referencing files you have read or edited. " +
+ "Links make your responses more useful because the user can click them to navigate directly to that note.\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" +
@@ -93,7 +100,15 @@ const TOOL_SYSTEM_PROMPT =
"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. " +
+ "CREATING FILES:\n" +
+ "Use create_file to make new notes. It will fail if the file already exists — use edit_file for existing files. " +
+ "Parent folders are created automatically.\n\n" +
+ "MOVING/RENAMING FILES:\n" +
+ "Use move_file to move or rename a file. All [[wiki-links]] across the vault are automatically updated.\n\n" +
+ "SEARCHING FILE CONTENTS:\n" +
+ "Use grep_search to find text inside file contents (like grep). " +
+ "Use search_files to find files by name/path. Use grep_search to find files containing specific text.\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.";
/**
@@ -102,15 +117,25 @@ const TOOL_SYSTEM_PROMPT =
* text response or the iteration cap is reached.
*/
async function chatAgentLoop(opts: AgentLoopOptions): Promise<string> {
- const { messages, tools, app, onToolCall, onApprovalRequest, sendRequest } = opts;
+ const { messages, tools, app, userSystemPrompt, onToolCall, onApprovalRequest, sendRequest } = opts;
const maxIterations = 10;
let iterations = 0;
const workingMessages = messages.map((m) => ({ ...m }));
- // Inject system prompt when tools are available
- if (tools !== undefined && tools.length > 0) {
- workingMessages.unshift({ role: "system", content: TOOL_SYSTEM_PROMPT });
+ // Build combined system prompt from tool instructions + user custom prompt
+ const hasTools = tools !== undefined && tools.length > 0;
+ const hasUserPrompt = userSystemPrompt !== undefined && userSystemPrompt.trim() !== "";
+
+ if (hasTools || hasUserPrompt) {
+ const parts: string[] = [];
+ if (hasTools) {
+ parts.push(TOOL_SYSTEM_PROMPT);
+ }
+ if (hasUserPrompt) {
+ parts.push("USER INSTRUCTIONS:\n" + userSystemPrompt.trim());
+ }
+ workingMessages.unshift({ role: "system", content: parts.join("\n\n") });
}
while (iterations < maxIterations) {
@@ -315,6 +340,7 @@ export async function sendChatMessage(
app?: App,
onToolCall?: (event: ToolCallEvent) => void,
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>,
+ userSystemPrompt?: string,
): Promise<string> {
const sendRequest: ChatRequestStrategy = async (workingMessages) => {
const body: Record<string, unknown> = {
@@ -357,6 +383,7 @@ export async function sendChatMessage(
messages,
tools,
app,
+ userSystemPrompt,
onToolCall,
onApprovalRequest,
sendRequest,
@@ -377,6 +404,7 @@ export interface StreamingChatOptions {
tools?: OllamaToolDefinition[];
app?: App;
options?: ModelOptions;
+ userSystemPrompt?: string;
onChunk: (text: string) => void;
onToolCall?: (event: ToolCallEvent) => void;
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>;
@@ -429,7 +457,7 @@ async function* readNdjsonStream(
export async function sendChatMessageStreaming(
opts: StreamingChatOptions,
): Promise<string> {
- const { ollamaUrl, model, tools, app, options, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts;
+ const { ollamaUrl, model, tools, app, options, userSystemPrompt, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts;
const sendRequest: ChatRequestStrategy = Platform.isMobile
? buildMobileStrategy(ollamaUrl, model, tools, options, onChunk, onCreateBubble)
@@ -439,6 +467,7 @@ export async function sendChatMessageStreaming(
messages: opts.messages,
tools,
app,
+ userSystemPrompt,
onToolCall,
onApprovalRequest,
sendRequest,
diff --git a/src/settings-modal.ts b/src/settings-modal.ts
index 5a90649..e475c3f 100644
--- a/src/settings-modal.ts
+++ b/src/settings-modal.ts
@@ -73,6 +73,56 @@ export class SettingsModal extends Modal {
// Move connect above model in the DOM
contentEl.insertBefore(connectSetting.settingEl, modelSetting.settingEl);
+ // --- System Prompt ---
+
+ const promptHeader = contentEl.createEl("h4", { text: "System Prompt" });
+ promptHeader.style.marginTop = "16px";
+ promptHeader.style.marginBottom = "4px";
+
+ // File path setting (disabled/enabled based on toggle)
+ let fileInputEl: HTMLInputElement | null = null;
+ const fileSetting = new Setting(contentEl)
+ .setName("Prompt File")
+ .setDesc("Vault path to a note whose content will be used as the system prompt.")
+ .addText((text) => {
+ text
+ .setPlaceholder("agent.md")
+ .setValue(this.plugin.settings.systemPromptFile)
+ .onChange(async (value) => {
+ this.plugin.settings.systemPromptFile = value;
+ await this.plugin.saveSettings();
+ });
+ text.inputEl.style.width = "200px";
+ fileInputEl = text.inputEl;
+ });
+
+ const updateFileSettingState = (enabled: boolean): void => {
+ fileSetting.settingEl.toggleClass("ai-organizer-setting-disabled", !enabled);
+ if (fileInputEl !== null) {
+ fileInputEl.disabled = !enabled;
+ }
+ };
+
+ // Toggle to enable/disable
+ const toggleSetting = new Setting(contentEl)
+ .setName("Use Custom Instructions")
+ .setDesc("Read a vault note as persistent AI instructions (e.g. formatting rules, writing style).")
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.useSystemPromptFile)
+ .onChange(async (value) => {
+ this.plugin.settings.useSystemPromptFile = value;
+ await this.plugin.saveSettings();
+ updateFileSettingState(value);
+ });
+ });
+
+ // Move toggle above file path in the DOM
+ contentEl.insertBefore(toggleSetting.settingEl, fileSetting.settingEl);
+
+ // Set initial state
+ updateFileSettingState(this.plugin.settings.useSystemPromptFile);
+
// --- Generation Parameters ---
const paramHeader = contentEl.createEl("h4", { text: "Generation Parameters" });
diff --git a/src/settings.ts b/src/settings.ts
index ff61c89..8e73770 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -7,6 +7,8 @@ export interface AIOrganizerSettings {
temperature: number;
numCtx: number;
numPredict: number;
+ useSystemPromptFile: boolean;
+ systemPromptFile: string;
}
export const DEFAULT_SETTINGS: AIOrganizerSettings = {
@@ -16,4 +18,6 @@ export const DEFAULT_SETTINGS: AIOrganizerSettings = {
temperature: 0.7,
numCtx: 4096,
numPredict: -1,
+ useSystemPromptFile: false,
+ systemPromptFile: "agent.md",
};
diff --git a/src/tools.ts b/src/tools.ts
index 94a136d..7e13d26 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -115,6 +115,142 @@ async function executeDeleteFile(app: App, args: Record<string, unknown>): Promi
}
/**
+ * Execute the "grep_search" tool.
+ * Searches file contents for a text query, returning matching lines with context.
+ */
+async function executeGrepSearch(app: App, args: Record<string, unknown>): Promise<string> {
+ const query = typeof args.query === "string" ? args.query : "";
+ if (query === "") {
+ return "Error: query parameter is required.";
+ }
+
+ const filePattern = typeof args.file_pattern === "string" ? args.file_pattern.toLowerCase() : "";
+ const queryLower = query.toLowerCase();
+
+ const files = app.vault.getMarkdownFiles();
+ const results: string[] = [];
+ const maxResults = 50;
+ let totalMatches = 0;
+
+ for (const file of files) {
+ if (totalMatches >= maxResults) break;
+
+ // Optional file pattern filter
+ if (filePattern !== "" && !file.path.toLowerCase().includes(filePattern)) {
+ continue;
+ }
+
+ try {
+ const content = await app.vault.cachedRead(file);
+ const lines = content.split("\n");
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (line !== undefined && line.toLowerCase().includes(queryLower)) {
+ results.push(`${file.path}:${i + 1}: ${line.trim()}`);
+ totalMatches++;
+ if (totalMatches >= maxResults) break;
+ }
+ }
+ } catch {
+ // Skip files that can't be read
+ }
+ }
+
+ if (results.length === 0) {
+ return "No matches found.";
+ }
+
+ const suffix = totalMatches >= maxResults
+ ? `\n... results capped at ${maxResults}. Narrow your query for more specific results.`
+ : "";
+
+ return results.join("\n") + suffix;
+}
+
+/**
+ * Execute the "create_file" tool.
+ * Creates a new file at the given vault path with the provided content.
+ */
+async function executeCreateFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ if (filePath === "") {
+ return "Error: file_path parameter is required.";
+ }
+
+ const content = typeof args.content === "string" ? args.content : "";
+
+ // Check if file already exists
+ const existing = app.vault.getAbstractFileByPath(filePath);
+ if (existing !== null) {
+ return `Error: A file already exists at "${filePath}". Use edit_file to modify it.`;
+ }
+
+ try {
+ // Ensure parent folder exists
+ const lastSlash = filePath.lastIndexOf("/");
+ if (lastSlash > 0) {
+ const folderPath = filePath.substring(0, lastSlash);
+ const folder = app.vault.getFolderByPath(folderPath);
+ if (folder === null) {
+ await app.vault.createFolder(folderPath);
+ }
+ }
+
+ await app.vault.create(filePath, content);
+ return `File created at "${filePath}".`;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ return `Error creating file: ${msg}`;
+ }
+}
+
+/**
+ * Execute the "move_file" tool.
+ * Moves or renames a file, auto-updating all links.
+ */
+async function executeMoveFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ if (filePath === "") {
+ return "Error: file_path parameter is required.";
+ }
+
+ const newPath = typeof args.new_path === "string" ? args.new_path : "";
+ if (newPath === "") {
+ return "Error: new_path parameter is required.";
+ }
+
+ const file = app.vault.getAbstractFileByPath(filePath);
+ if (file === null || !(file instanceof TFile)) {
+ return `Error: File not found at path "${filePath}".`;
+ }
+
+ // Check if destination already exists
+ const destExists = app.vault.getAbstractFileByPath(newPath);
+ if (destExists !== null) {
+ return `Error: A file or folder already exists at "${newPath}".`;
+ }
+
+ try {
+ // Ensure target folder exists
+ const lastSlash = newPath.lastIndexOf("/");
+ if (lastSlash > 0) {
+ const folderPath = newPath.substring(0, lastSlash);
+ const folder = app.vault.getFolderByPath(folderPath);
+ if (folder === null) {
+ await app.vault.createFolder(folderPath);
+ }
+ }
+
+ await app.fileManager.renameFile(file, newPath);
+ return `File moved from "${filePath}" to "${newPath}". All links have been updated.`;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ return `Error moving file: ${msg}`;
+ }
+}
+
+/**
* Execute the "get_current_note" tool.
* Returns the vault-relative path of the currently active note.
*/
@@ -389,6 +525,144 @@ export const TOOL_REGISTRY: ToolEntry[] = [
},
execute: executeEditFile,
},
+ {
+ id: "grep_search",
+ label: "Search File Contents",
+ description: "Search for text across all markdown files in the vault.",
+ friendlyName: "Search Contents",
+ requiresApproval: false,
+ summarize: (args) => {
+ const query = typeof args.query === "string" ? args.query : "";
+ const filePattern = typeof args.file_pattern === "string" ? args.file_pattern : "";
+ const suffix = filePattern !== "" ? ` in "${filePattern}"` : "";
+ return `"${query}"${suffix}`;
+ },
+ summarizeResult: (result) => {
+ if (result === "No matches found.") {
+ return "No results found";
+ }
+ const lines = result.split("\n").filter((l) => l.length > 0 && !l.startsWith("..."));
+ const cappedMatch = result.match(/results capped at (\d+)/);
+ const count = cappedMatch !== null ? `${cappedMatch[1]}+` : `${lines.length}`;
+ return `${count} match${lines.length === 1 ? "" : "es"} found`;
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "grep_search",
+ description: "Search for a text string across all markdown file contents in the vault. Returns matching lines with file paths and line numbers (e.g. 'folder/note.md:12: matching line'). Case-insensitive. Optionally filter by file path pattern.",
+ parameters: {
+ type: "object",
+ required: ["query"],
+ properties: {
+ query: {
+ type: "string",
+ description: "The text to search for in file contents. Case-insensitive.",
+ },
+ file_pattern: {
+ type: "string",
+ description: "Optional filter: only search files whose path contains this string (e.g. 'journal/' or 'project').",
+ },
+ },
+ },
+ },
+ },
+ execute: executeGrepSearch,
+ },
+ {
+ id: "create_file",
+ label: "Create File",
+ description: "Create a new file in the vault (requires approval).",
+ friendlyName: "Create File",
+ requiresApproval: true,
+ approvalMessage: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
+ return `Create "${filePath}"?`;
+ },
+ summarize: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ return `"/${filePath}"`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ if (result.includes("declined")) {
+ return "Declined by user";
+ }
+ return "File created";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "create_file",
+ description: "Create a new file in the Obsidian vault. Parent folders are created automatically if they don't exist. Fails if a file already exists at the path — use edit_file to modify existing files. This action requires user approval.",
+ parameters: {
+ type: "object",
+ required: ["file_path"],
+ properties: {
+ file_path: {
+ type: "string",
+ description: "The vault-relative path for the new file (e.g. 'folder/new-note.md').",
+ },
+ content: {
+ type: "string",
+ description: "The text content to write to the new file. Defaults to empty string if not provided.",
+ },
+ },
+ },
+ },
+ },
+ execute: executeCreateFile,
+ },
+ {
+ id: "move_file",
+ label: "Move/Rename File",
+ description: "Move or rename a file and auto-update all links (requires approval).",
+ friendlyName: "Move File",
+ requiresApproval: true,
+ approvalMessage: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
+ const newPath = typeof args.new_path === "string" ? args.new_path : "unknown";
+ return `Move "${filePath}" to "${newPath}"?`;
+ },
+ summarize: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ const newPath = typeof args.new_path === "string" ? args.new_path : "";
+ return `"/${filePath}" → "/${newPath}"`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ if (result.includes("declined")) {
+ return "Declined by user";
+ }
+ return "File moved";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "move_file",
+ description: "Move or rename a file in the Obsidian vault. All internal links throughout the vault are automatically updated to reflect the new path. Target folders are created automatically if they don't exist. The file_path must be an exact path as returned by search_files. This action requires user approval.",
+ parameters: {
+ type: "object",
+ required: ["file_path", "new_path"],
+ properties: {
+ file_path: {
+ type: "string",
+ description: "The current vault-relative path of the file (e.g. 'folder/note.md').",
+ },
+ new_path: {
+ type: "string",
+ description: "The new vault-relative path for the file (e.g. 'new-folder/renamed-note.md').",
+ },
+ },
+ },
+ },
+ },
+ execute: executeMoveFile,
+ },
];
/**