diff options
| -rw-r--r-- | .notes/research/plugin-feature-ideas.md | 99 | ||||
| -rw-r--r-- | .rules/changelog/2026-03/24/15.md | 43 | ||||
| -rw-r--r-- | src/chat-view.ts | 98 | ||||
| -rw-r--r-- | src/ollama-client.ts | 41 | ||||
| -rw-r--r-- | src/settings-modal.ts | 50 | ||||
| -rw-r--r-- | src/settings.ts | 4 | ||||
| -rw-r--r-- | src/tools.ts | 274 | ||||
| -rw-r--r-- | styles.css | 38 |
8 files changed, 623 insertions, 24 deletions
diff --git a/.notes/research/plugin-feature-ideas.md b/.notes/research/plugin-feature-ideas.md new file mode 100644 index 0000000..ffd6752 --- /dev/null +++ b/.notes/research/plugin-feature-ideas.md @@ -0,0 +1,99 @@ +# Plugin Feature Ideas + +Ideas for the AI Note Organizer plugin, drawn from the Obsidian and Ollama APIs. + +--- + +## High Impact + +### 1. Embedding-Based Semantic Search Tool + +Use Ollama's `/api/embed` endpoint to generate vector embeddings for vault notes. Store them in a local index (Dexie/IndexedDB). Add a `semantic_search` tool that finds notes by meaning rather than exact text match. + +**APIs**: Ollama `/api/embed`, Dexie (IndexedDB), cosine similarity +**Why**: Massive upgrade over `grep_search` — the AI can find conceptually related notes even when wording differs. + +### 2. Frontmatter Management Tool + +A `set_frontmatter` tool using `app.fileManager.processFrontMatter()` to let the AI add/update tags, aliases, categories, dates, etc. Atomic read-modify-save on the YAML block. + +**APIs**: `FileManager.processFrontMatter(file, fn)` +**Why**: Much safer than `edit_file` for metadata operations. No risk of breaking YAML formatting. + +### 3. Auto-Process on File Creation + +When a new note is created, automatically queue it for AI processing (tagging, linking suggestions, folder placement). Uses vault `create` events. + +**APIs**: `vault.on('create')`, `workspace.onLayoutReady()` (to skip initial load events) +**Why**: This is the core "organizer" part of the plugin. Makes the AI proactive rather than reactive. + +### 4. Vault Context Injection + +Before each message, automatically inject a summary of the vault structure (folder tree, tag taxonomy, recent files) so the AI understands the vault without needing to search first. + +**APIs**: `metadataCache` (tags, links, headings, frontmatter), `vault.getAllFolders()`, `vault.getMarkdownFiles()` +**Why**: Gives the AI immediate awareness of the vault. Cheap to compute from the metadata cache. + +--- + +## Medium Impact + +### 5. Backlinks / Related Notes Tool + +A `get_related_notes` tool that uses `metadataCache.resolvedLinks` to find backlinks and forward links for a given note. + +**APIs**: `metadataCache.resolvedLinks`, `metadataCache.unresolvedLinks` +**Why**: Helps the AI understand note relationships and make better suggestions. + +### 6. Batch Operations + +A `batch_move` or `batch_tag` command that lets the AI propose bulk changes (move 20 notes into folders, add tags to untagged notes) with a single approval step instead of 20 individual approvals. + +**APIs**: `FileManager.renameFile()`, `FileManager.processFrontMatter()`, custom approval UI +**Why**: Current per-file approval is tedious for bulk operations. A summary-and-confirm flow would be much smoother. + +### 7. Conversation Persistence + +Save chat history to a vault note (or `data.json`) so conversations survive plugin reloads. Allow users to resume previous conversations. + +**APIs**: `Plugin.loadData()` / `Plugin.saveData()`, or `vault.create()` for markdown export +**Why**: Conversations are currently lost on reload. Persistence enables long-running workflows. + +### 8. Streaming Thinking / Reasoning Display + +If using thinking models (Qwen 3, DeepSeek R1), display the `<think>` reasoning trace in a collapsible block, separate from the main response. + +**APIs**: Ollama `think` parameter, streaming two-phase output (thinking chunks then content chunks) +**Why**: Transparency into the AI's reasoning. Useful for debugging prompts and understanding decisions. + +--- + +## Lower Effort / Polish + +### 9. Template-Based File Creation + +Let the AI use vault templates when creating notes. Read a template file, fill in variables, create the note. + +**APIs**: `vault.cachedRead()` for template files, `vault.create()` for output +**Why**: Consistent note formatting without repeating instructions in every prompt. + +### 10. Status Bar Indicator + +Show connection status and current model in Obsidian's status bar. + +**APIs**: `Plugin.addStatusBarItem()` +**Why**: At-a-glance awareness without opening the chat panel. + +### 11. Command Palette Integration + +Add commands like "AI: Organize current note", "AI: Suggest tags", "AI: Summarize note" that pre-fill the chat with specific prompts. + +**APIs**: `Plugin.addCommand()`, editor commands with `editorCallback` +**Why**: Quick access to common workflows without typing prompts manually. + +### 12. Multi-Model Support + +Let users configure different models for different tasks (e.g. a small fast model for auto-tagging, a large model for chat, an embedding model for semantic search). + +**APIs**: Ollama `/api/tags` (list models), settings UI +**Why**: Optimizes speed and quality per task. Embedding models are tiny and fast; chat models can be large. diff --git a/.rules/changelog/2026-03/24/15.md b/.rules/changelog/2026-03/24/15.md new file mode 100644 index 0000000..8652a7f --- /dev/null +++ b/.rules/changelog/2026-03/24/15.md @@ -0,0 +1,43 @@ +# Changelog — 2026-03-24 (15) + +## New Tools + +- **grep_search**: Case-insensitive text search across markdown file contents with optional file path filter. Returns `path:line: text` format, capped at 50 results. +- **create_file**: Create new files with content. Auto-creates parent folders. Requires approval. Shows content preview in approval dialog. +- **move_file**: Move/rename files using `fileManager.renameFile()` which auto-updates all `[[wiki-links]]`. Auto-creates target folders. Requires approval. + +## Wiki-Link Support in AI Responses + +- System prompt now instructs the AI to use `[[Note Name]]` wiki-link syntax when referencing vault notes. +- Added click handlers on rendered `a.internal-link` elements in chat bubbles so links navigate to the target note via `workspace.openLinkText()`. + +## Custom System Prompt from Vault File + +- New settings: "Use Custom Instructions" toggle and "Prompt File" path input (default `agent.md`). +- When enabled, reads the vault note content at send time and injects it as a system prompt alongside the built-in tool instructions. +- Works even when no tools are enabled. +- File path input appears greyed out and disabled when the toggle is off. + +## Model Badge + +- Added a pill-shaped model indicator in the top left of the chat area. +- Shows the currently selected model name, or "No model selected" in muted italic when none is configured. +- Updates automatically when the settings modal closes. + +## System Prompt Updates + +- Added LINKING TO NOTES, CREATING FILES, MOVING/RENAMING FILES, and SEARCHING FILE CONTENTS sections to the tool system prompt. +- Updated approval tool list to include `create_file` and `move_file`. + +## Research Notes + +- Created `.notes/research/plugin-feature-ideas.md` with 12 potential feature ideas organized by impact level. + +## Files Changed + +- `src/tools.ts` — three new tool execute functions and TOOL_REGISTRY entries +- `src/ollama-client.ts` — system prompt updates, `userSystemPrompt` plumbing through agent loop and both chat functions +- `src/chat-view.ts` — wiki-link click handlers, system prompt file reading, model badge, create_file approval preview +- `src/settings.ts` — `useSystemPromptFile` and `systemPromptFile` settings +- `src/settings-modal.ts` — toggle + file path input for custom instructions +- `styles.css` — disabled setting style, model badge styles 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, + }, ]; /** @@ -606,3 +606,41 @@ .ai-organizer-approval-declined { border-left-color: var(--text-error); } + +/* ===== Disabled Setting ===== */ + +.ai-organizer-setting-disabled { + opacity: 0.5; + pointer-events: none; +} + +/* ===== Model Badge ===== */ + +.ai-organizer-model-badge { + position: absolute; + top: 8px; + left: 12px; + z-index: 10; + height: 40px; + padding: 0 14px; + border-radius: 20px; + background-color: var(--background-primary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + font-size: 0.8em; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); + max-width: calc(100% - 80px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; +} + +.ai-organizer-model-badge-empty { + color: var(--text-muted); + font-style: italic; +} |
