summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.notes/research/plugin-feature-ideas.md99
-rw-r--r--.rules/changelog/2026-03/24/15.md43
-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
-rw-r--r--styles.css38
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,
+ },
];
/**
diff --git a/styles.css b/styles.css
index 7ab52d2..9503bca 100644
--- a/styles.css
+++ b/styles.css
@@ -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;
+}