diff --git a/README.md b/README.md index c8fd35c9..832e5dc2 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,16 @@ Let the AI actively work with your vault through tool calling capabilities. - **Context Files:** Add specific notes as persistent context - **Session Configuration:** Override model, temperature, and prompt per session - **Safety Features:** System folders are protected from modifications +- **Tool Permission System**: + - Granular control over which tools the agent can use + - **Trusted Mode**: Optional setting to allow file modifications without constant confirmation prompts (Use with caution!) +- **New Power Tools**: + - `update_frontmatter`: Safely modify note properties (status, tags, dates) without rewriting content. Critical for "Bases" and "Projects" workflows. + - `append_content`: Efficiently add text to the end of notes (great for logs and journals). + - `fetch_url`: Alias for Web Fetch, ensuring compatibility with standard agent prompts. +- **RAG (Retrieval Augmented Generation)**: + - Index your vault for semantic search + - Retrieve relevant context based on user queries **Example Commands:** diff --git a/manifest.json b/manifest.json index f7249c67..4c802716 100644 --- a/manifest.json +++ b/manifest.json @@ -7,4 +7,4 @@ "author": "Allen Hutchison", "authorUrl": "https://allen.hutchison.org", "isDesktopOnly": false -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index df6a356e..c25c9fb0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -70,6 +70,8 @@ export interface ObsidianGeminiSettings { loopDetectionEnabled: boolean; loopDetectionThreshold: number; loopDetectionTimeWindowSeconds: number; + // Trusted Mode + alwaysAllowReadWrite: boolean; // V4 upgrade tracking hasSeenV4Welcome: boolean; // Version tracking for update notifications @@ -106,6 +108,8 @@ const DEFAULT_SETTINGS: ObsidianGeminiSettings = { loopDetectionEnabled: true, loopDetectionThreshold: 3, loopDetectionTimeWindowSeconds: 30, + // Trusted Mode + alwaysAllowReadWrite: false, // V4 upgrade tracking hasSeenV4Welcome: false, // Version tracking for update notifications @@ -532,11 +536,27 @@ export default class ObsidianGemini extends Plugin { this.toolRegistry.registerTool(tool); } + // Register extended vault tools (Frontmatter & Append) + // Dynamically import to avoid circular dependencies if any + const { UpdateFrontmatterTool, AppendContentTool } = await import('./tools/vault-tools-extended'); + this.toolRegistry.registerTool(new UpdateFrontmatterTool()); + this.toolRegistry.registerTool(new AppendContentTool()); + // Register web tools (Google Search and Web Fetch) const { getWebTools } = await import('./tools/web-tools'); const webTools = getWebTools(); for (const tool of webTools) { this.toolRegistry.registerTool(tool); + // Register fetch_url alias for WebFetchTool + // This is required for compatibility with AGENTS.md which expects 'fetch_url' + if (tool.name === 'web_fetch') { + // We need to cast to any or instantiate a new class to set the name + // Since setToolName is not on the interface, we'll try to clone it or instantiate new + // Easier approach: Use the WebFetchTool class directly if possible, or just re-register + // However, getWebTools returns instances. + const { WebFetchTool } = await import('./tools/web-fetch-tool'); + this.toolRegistry.registerTool(new WebFetchTool().setName('fetch_url')); + } } // Register memory tools diff --git a/src/tools/execution-engine.ts b/src/tools/execution-engine.ts index 4e7d9254..ef0f0603 100644 --- a/src/tools/execution-engine.ts +++ b/src/tools/execution-engine.ts @@ -82,29 +82,34 @@ export class ToolExecutionEngine { const requiresConfirmation = this.registry.requiresConfirmation(toolCall.name, context); if (requiresConfirmation) { - // Check if this tool is allowed without confirmation for this session - const isAllowedWithoutConfirmation = view?.isToolAllowedWithoutConfirmation?.(toolCall.name) || false; - - if (!isAllowedWithoutConfirmation) { - // Update progress to show waiting for confirmation - const toolDisplay = tool.displayName || tool.name; - const confirmationMessage = `Waiting for confirmation: ${toolDisplay}`; - view?.updateProgress?.(confirmationMessage, 'waiting'); - - const result = await this.requestUserConfirmation(tool, toolCall.arguments, view); - - // Update progress back to tool execution - view?.updateProgress?.(`Executing: ${toolDisplay}`, 'tool'); - - if (!result.confirmed) { - return { - success: false, - error: 'User declined tool execution', - }; - } - // If user allowed this action without future confirmation - if (result.allowWithoutConfirmation && view) { - view.allowToolWithoutConfirmation(toolCall.name); + // Check if Trusted Mode is enabled + if (this.plugin.settings.alwaysAllowReadWrite) { + context.plugin.logger.log(`[Trusted Mode] Bypassing confirmation for ${toolCall.name}`); + } else { + // Check if this tool is allowed without confirmation for this session + const isAllowedWithoutConfirmation = view?.isToolAllowedWithoutConfirmation?.(toolCall.name) || false; + + if (!isAllowedWithoutConfirmation) { + // Update progress to show waiting for confirmation + const toolDisplay = tool.displayName || tool.name; + const confirmationMessage = `Waiting for confirmation: ${toolDisplay}`; + view?.updateProgress?.(confirmationMessage, 'waiting'); + + const result = await this.requestUserConfirmation(tool, toolCall.arguments, view); + + // Update progress back to tool execution + view?.updateProgress?.(`Executing: ${toolDisplay}`, 'tool'); + + if (!result.confirmed) { + return { + success: false, + error: 'User declined tool execution', + }; + } + // If user allowed this action without future confirmation + if (result.allowWithoutConfirmation && view) { + view.allowToolWithoutConfirmation(toolCall.name); + } } } } diff --git a/src/tools/vault-tools-extended.ts b/src/tools/vault-tools-extended.ts new file mode 100644 index 00000000..13dab902 --- /dev/null +++ b/src/tools/vault-tools-extended.ts @@ -0,0 +1,160 @@ +import { Tool, ToolResult, ToolExecutionContext } from './types'; +import { ToolCategory } from '../types/agent'; +import { TFile } from 'obsidian'; +import type ObsidianGemini from '../main'; + +/** + * Tool to safely update YAML frontmatter without touching content + * Critical for integration with Obsidian Bases and other metadata-driven plugins + */ +export class UpdateFrontmatterTool implements Tool { + name = 'update_frontmatter'; + displayName = 'Update Frontmatter'; + category = ToolCategory.VAULT_OPERATIONS; + requiresConfirmation = true; + description = + 'Update a specific YAML frontmatter property in a file. ' + + 'This tool is safe to use as it only modifies metadata and preserves the note content. ' + + 'Use it to update status, tags, dates, or any other property.'; + + parameters = { + type: 'object' as const, + properties: { + path: { + type: 'string' as const, + description: 'Absolute path to the file to update', + }, + key: { + type: 'string' as const, + description: 'The property key to update', + }, + value: { + type: ['string', 'number', 'boolean', 'array'] as const, + description: 'The new value for the property', + }, + }, + required: ['path', 'key', 'value'], + }; + + async execute(params: { path: string; key: string; value: any }, context: ToolExecutionContext): Promise { + const plugin = context.plugin as InstanceType; + const { path, key, value } = params; + + // Check for system folder protection + const historyFolder = plugin.settings.historyFolder; + if (path.startsWith(historyFolder + '/') || path.startsWith('.obsidian/')) { + return { + success: false, + error: `Cannot modify files in protected system folder: ${path}`, + }; + } + + try { + const file = plugin.app.vault.getAbstractFileByPath(path); + + if (!file || !(file instanceof TFile)) { + return { + success: false, + error: `File not found or is not a markdown file: ${path}`, + }; + } + + // Use Obsidian's native API for safe frontmatter updates + await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + frontmatter[key] = value; + }); + + plugin.logger.log(`Updated frontmatter for ${path}: ${key} = ${value}`); + + return { + success: true, + output: `Successfully updated property "${key}" to "${value}" in ${path}`, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + plugin.logger.error(`Failed to update frontmatter for ${path}: ${msg}`); + return { + success: false, + error: `Failed to update frontmatter: ${msg}`, + }; + } + } +} + +/** + * Tool to append content to the end of a file + * Useful for logging, journaling, or adding items to lists without rewriting the whole file + */ +export class AppendContentTool implements Tool { + name = 'append_content'; + displayName = 'Append Content'; + category = ToolCategory.VAULT_OPERATIONS; + requiresConfirmation = true; + description = + 'Append text to the end of a file. ' + + 'Useful for adding log entries, diary updates, or new sections without rewriting the entire file. ' + + 'If the file does not exist, an error is returned (use write_file to create new files).'; + + parameters = { + type: 'object' as const, + properties: { + path: { + type: 'string' as const, + description: 'Absolute path to the file', + }, + content: { + type: 'string' as const, + description: 'The text content to append (automatically adds newline if needed)', + }, + }, + required: ['path', 'content'], + }; + + async execute(params: { path: string; content: string }, context: ToolExecutionContext): Promise { + const plugin = context.plugin as InstanceType; + const { path, content } = params; + + // Check for system folder protection + const historyFolder = plugin.settings.historyFolder; + if (path.startsWith(historyFolder + '/') || path.startsWith('.obsidian/')) { + return { + success: false, + error: `Cannot modify files in protected system folder: ${path}`, + }; + } + + try { + const file = plugin.app.vault.getAbstractFileByPath(path); + + if (!file || !(file instanceof TFile)) { + return { + success: false, + error: `File not found: ${path}`, + }; + } + + // Ensure content starts with newline if file is not empty + let contentToAppend = content; + const fileContent = await plugin.app.vault.read(file); + if (fileContent.length > 0 && !fileContent.endsWith('\n') && !content.startsWith('\n')) { + contentToAppend = '\n' + content; + } + + await plugin.app.vault.append(file, contentToAppend); + + plugin.logger.log(`Appended ${contentToAppend.length} chars to ${path}`); + + return { + success: true, + output: `Successfully appended content to ${path}`, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + plugin.logger.error(`Failed to append content to ${path}: ${msg}`); + return { + success: false, + error: `Failed to append content: ${msg}`, + }; + } + } +} diff --git a/src/tools/web-fetch-tool.ts b/src/tools/web-fetch-tool.ts index 84dddd24..b3f329a9 100644 --- a/src/tools/web-fetch-tool.ts +++ b/src/tools/web-fetch-tool.ts @@ -18,6 +18,14 @@ export class WebFetchTool implements Tool { description = "Fetch and analyze content from a specific URL using Google's URL Context feature and AI. Provide a URL and a query describing what information to extract or questions to answer about the page content. The AI will read the page and provide a targeted analysis based on your query. Returns the analyzed content, URL metadata, and fetch timestamp. Falls back to direct HTTP fetch if URL Context fails. Use this to extract specific information from web pages, documentation, articles, or any publicly accessible URL."; + setName(name: string): this { + if (!name || name.trim().length === 0) { + throw new Error('Tool name cannot be empty'); + } + this.name = name; + return this; + } + parameters = { type: 'object' as const, properties: { diff --git a/src/ui/agent-view/agent-view-ui.ts b/src/ui/agent-view/agent-view-ui.ts index f57779e1..5c82cbe4 100644 --- a/src/ui/agent-view/agent-view-ui.ts +++ b/src/ui/agent-view/agent-view-ui.ts @@ -123,6 +123,10 @@ export class AgentViewUI { const toggleBtn = leftSection.createEl('button', { cls: 'gemini-agent-toggle-btn', title: 'Toggle context panel', + attr: { + 'aria-label': 'Toggle context panel', + 'aria-expanded': 'false', + }, }); setIcon(toggleBtn, 'chevron-down'); @@ -131,9 +135,11 @@ export class AgentViewUI { if (isCollapsed) { contextPanel.removeClass('gemini-agent-context-panel-collapsed'); setIcon(toggleBtn, 'chevron-up'); + toggleBtn.setAttribute('aria-expanded', 'true'); } else { contextPanel.addClass('gemini-agent-context-panel-collapsed'); setIcon(toggleBtn, 'chevron-down'); + toggleBtn.setAttribute('aria-expanded', 'false'); } }); @@ -262,6 +268,7 @@ export class AgentViewUI { const settingsBtn = rightSection.createEl('button', { cls: 'gemini-agent-btn gemini-agent-btn-icon', title: 'Session Settings', + attr: { 'aria-label': 'Session Settings' }, }); setIcon(settingsBtn, 'settings'); settingsBtn.addEventListener('click', () => callbacks.showSessionSettings()); @@ -269,6 +276,7 @@ export class AgentViewUI { const newSessionBtn = rightSection.createEl('button', { cls: 'gemini-agent-btn gemini-agent-btn-icon', title: 'New Session', + attr: { 'aria-label': 'New Session' }, }); setIcon(newSessionBtn, 'plus'); newSessionBtn.addEventListener('click', () => callbacks.createNewSession()); @@ -276,6 +284,7 @@ export class AgentViewUI { const listSessionsBtn = rightSection.createEl('button', { cls: 'gemini-agent-btn gemini-agent-btn-icon', title: 'Browse Sessions', + attr: { 'aria-label': 'Browse Sessions' }, }); setIcon(listSessionsBtn, 'list'); listSessionsBtn.addEventListener('click', () => callbacks.showSessionList()); @@ -640,6 +649,7 @@ export class AgentViewUI { text: '×', cls: 'gemini-agent-remove-btn', title: 'Remove file', + attr: { 'aria-label': `Remove ${file.basename}` }, }); removeBtn.addEventListener('click', () => { diff --git a/src/ui/settings.ts b/src/ui/settings.ts index 09f02fee..6f9db406 100644 --- a/src/ui/settings.ts +++ b/src/ui/settings.ts @@ -448,6 +448,34 @@ export default class ObsidianGeminiSettingTab extends PluginSettingTab { }) ); + // Trusted Mode Setting + const trustedModeSetting = new Setting(containerEl) + .setName('Trusted Mode (Always Allow Read/Write)') + .setDesc('DANGEROUS: Allow the agent to create/edit/delete files without asking for confirmation.'); + + trustedModeSetting.descEl.style.color = 'var(--text-warning)'; + + trustedModeSetting.addToggle((toggle) => + toggle.setValue(this.plugin.settings.alwaysAllowReadWrite ?? false).onChange(async (value) => { + if (value) { + // Revert toggle until user confirms + toggle.setValue(false); + const { TrustedModeConfirmationModal } = await import('./trusted-mode-modal'); + const modal = new TrustedModeConfirmationModal(this.app, async (confirmed) => { + if (confirmed) { + toggle.setValue(true); + this.plugin.settings.alwaysAllowReadWrite = true; + await this.plugin.saveSettings(); + } + }); + modal.open(); + } else { + this.plugin.settings.alwaysAllowReadWrite = value; + await this.plugin.saveSettings(); + } + }) + ); + // Tool Loop Detection Settings new Setting(containerEl).setName('Tool Loop Detection').setHeading(); diff --git a/src/ui/trusted-mode-modal.ts b/src/ui/trusted-mode-modal.ts new file mode 100644 index 00000000..b645d8bc --- /dev/null +++ b/src/ui/trusted-mode-modal.ts @@ -0,0 +1,53 @@ +import { App, Modal, Setting } from 'obsidian'; + +/** + * Modal shown when user enables Trusted Mode to confirm they understand the risks + */ +export class TrustedModeConfirmationModal extends Modal { + private onConfirm: (confirmed: boolean) => void; + + constructor(app: App, onConfirm: (confirmed: boolean) => void) { + super(app); + this.onConfirm = onConfirm; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl('h2', { text: 'Enable Trusted Mode?' }); + + const container = contentEl.createEl('div'); + + container.createEl('p', { + text: 'Trusted Mode allows the AI agent to create, edit, and delete files in your vault without asking for confirmation.', + }); + + const warningEl = container.createEl('p', { + text: '⚠️ This grants the AI full write access to your vault. While convenient, it carries risks if the model hallucinates or makes mistakes.', + }); + warningEl.style.color = 'var(--text-warning)'; + + new Setting(contentEl) + .addButton((btn) => + btn.setButtonText('Cancel').onClick(() => { + this.close(); + this.onConfirm(false); + }) + ) + .addButton((btn) => + btn + .setButtonText('Enable Trusted Mode') + .setWarning() + .onClick(() => { + this.close(); + this.onConfirm(true); + }) + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/versions.json b/versions.json index 28a884dd..6317b322 100644 --- a/versions.json +++ b/versions.json @@ -42,4 +42,4 @@ "4.2.1": "1.7.5", "4.3.0": "1.7.5", "4.3.1": "1.7.5" -} \ No newline at end of file +}