diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..38ff5339 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2024-05-23 - RAG Indexing Performance +**Learning:** The `RagIndexingService` uses `FileUploader` from `gemini-utils`, which scans all files. `FileUploader` relies on the provided `FileSystemAdapter` to compute hashes. To optimize indexing of unchanged files, we cannot modify `FileUploader` logic directly. Instead, we must optimize the `FileSystemAdapter.computeHash` implementation to use a cache. +**Action:** When optimizing file operations controlled by external libraries (like `gemini-utils`), look for adapter interfaces (like `FileSystemAdapter`) where you can inject caching or optimized logic. diff --git a/README.md b/README.md index c8fd35c9..4e6cbcea 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/src/main.ts b/src/main.ts index df6a356e..033ce3df 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,29 @@ 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(this).setName('fetch_url')); + } } // Register memory tools @@ -655,7 +677,7 @@ export default class ObsidianGemini extends Plugin { // Clean up partial initialization if (this.ragIndexing) { - await this.ragIndexing.destroy().catch(() => {}); + await this.ragIndexing.destroy().catch(() => { }); this.ragIndexing = null; } } diff --git a/src/services/obsidian-file-adapter.ts b/src/services/obsidian-file-adapter.ts index 1f700dfe..1d9e88f2 100644 --- a/src/services/obsidian-file-adapter.ts +++ b/src/services/obsidian-file-adapter.ts @@ -18,6 +18,7 @@ export class ObsidianVaultAdapter implements FileSystemAdapter { private historyFolder: string; private includeAttachments: boolean; private logError?: (message: string, ...args: unknown[]) => void; + private hashCacheProvider?: (path: string, mtime: number) => string | null; constructor(options: { vault: Vault; @@ -26,6 +27,7 @@ export class ObsidianVaultAdapter implements FileSystemAdapter { historyFolder?: string; includeAttachments?: boolean; logError?: (message: string, ...args: unknown[]) => void; + hashCacheProvider?: (path: string, mtime: number) => string | null; }) { this.vault = options.vault; this.metadataCache = options.metadataCache; @@ -33,6 +35,7 @@ export class ObsidianVaultAdapter implements FileSystemAdapter { this.historyFolder = options.historyFolder || ''; this.includeAttachments = options.includeAttachments || false; this.logError = options.logError; + this.hashCacheProvider = options.hashCacheProvider; } /** @@ -176,6 +179,14 @@ export class ObsidianVaultAdapter implements FileSystemAdapter { return ''; } + // Check cache first + if (this.hashCacheProvider) { + const cachedHash = this.hashCacheProvider(filePath, file.stat.mtime); + if (cachedHash) { + return cachedHash; + } + } + try { const content = await this.vault.readBinary(file); const hashBuffer = await crypto.subtle.digest('SHA-256', content); diff --git a/src/services/rag-indexing.ts b/src/services/rag-indexing.ts index 0fc49900..deba406f 100644 --- a/src/services/rag-indexing.ts +++ b/src/services/rag-indexing.ts @@ -11,6 +11,7 @@ export interface IndexedFileEntry { resourceName: string; // Gemini file resource name contentHash: string; // SHA-256 hash for reliable change detection lastIndexed: number; // Timestamp + mtime?: number; // Last modification time of the file } /** @@ -183,6 +184,13 @@ export class RagIndexingService { historyFolder: this.plugin.settings.historyFolder, includeAttachments: this.plugin.settings.ragIndexing.includeAttachments, logError: (msg, ...args) => this.plugin.logger.error(msg, ...args), + hashCacheProvider: (path, mtime) => { + const entry = this.cache?.files[path]; + if (entry && entry.mtime === mtime) { + return entry.contentHash; + } + return null; + }, }); // Create file uploader with logger @@ -1180,10 +1188,12 @@ export class RagIndexingService { // Update cache for newly indexed file if (this.cache && event.currentFile && this.vaultAdapter) { const contentHash = await this.vaultAdapter.computeHash(event.currentFile); + const file = this.plugin.app.vault.getAbstractFileByPath(event.currentFile); this.cache.files[event.currentFile] = { resourceName: storeName, // Store name as reference (individual doc names not available) contentHash, lastIndexed: Date.now(), + mtime: file instanceof TFile ? file.stat.mtime : undefined, }; // Track last indexed file for resume capability this.cache.lastIndexedFile = event.currentFile; @@ -1209,10 +1219,12 @@ export class RagIndexingService { // Skipped files are already in cache (unchanged), ensure they're tracked if (this.cache && event.currentFile && !this.cache.files[event.currentFile] && this.vaultAdapter) { const contentHash = await this.vaultAdapter.computeHash(event.currentFile); + const file = this.plugin.app.vault.getAbstractFileByPath(event.currentFile); this.cache.files[event.currentFile] = { resourceName: storeName, contentHash, lastIndexed: Date.now(), + mtime: file instanceof TFile ? file.stat.mtime : undefined, }; } // Incremental cache save for durability (count skipped files too) @@ -1498,6 +1510,7 @@ export class RagIndexingService { resourceName: storeName, contentHash: content.hash, lastIndexed: Date.now(), + mtime: file.stat.mtime, }; } // Incremental cache save for durability @@ -1519,6 +1532,7 @@ export class RagIndexingService { resourceName: storeName, contentHash: content.hash, lastIndexed: Date.now(), + mtime: file.stat.mtime, }; } // Incremental cache save for durability diff --git a/src/tools/execution-engine.ts b/src/tools/execution-engine.ts index 4e7d9254..ae448b48 100644 --- a/src/tools/execution-engine.ts +++ b/src/tools/execution-engine.ts @@ -81,37 +81,46 @@ export class ToolExecutionEngine { // Check if confirmation is required 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); + if (requiresConfirmation) { + // 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); + } } } } // Show execution notification (disabled - now shown in chat UI) // const executionNotice = new Notice(`Executing ${tool.name}...`, 0); - const executionNotice = { hide: () => {} }; // Dummy object for compatibility + const executionNotice = { hide: () => { } }; // Dummy object for compatibility try { // Record the execution attempt diff --git a/src/tools/vault-tools-extended.ts b/src/tools/vault-tools-extended.ts new file mode 100644 index 00000000..a4da74c4 --- /dev/null +++ b/src/tools/vault-tools-extended.ts @@ -0,0 +1,148 @@ +import { Tool, ToolResult, ToolExecutionContext } from './types'; +import { ToolCategory } from '../types/agent'; +import { Notice, 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; + + 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; + + 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..f75eb49e 100644 --- a/src/tools/web-fetch-tool.ts +++ b/src/tools/web-fetch-tool.ts @@ -18,6 +18,11 @@ 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.name = name; + return this; + } + parameters = { type: 'object' as const, properties: { diff --git a/src/ui/settings.ts b/src/ui/settings.ts index 09f02fee..5e6502e7 100644 --- a/src/ui/settings.ts +++ b/src/ui/settings.ts @@ -448,6 +448,20 @@ 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) => { + this.plugin.settings.alwaysAllowReadWrite = value; + await this.plugin.saveSettings(); + }) + ); + // Tool Loop Detection Settings new Setting(containerEl).setName('Tool Loop Detection').setHeading(); diff --git a/test/services/obsidian-file-adapter.test.ts b/test/services/obsidian-file-adapter.test.ts new file mode 100644 index 00000000..bd9d890a --- /dev/null +++ b/test/services/obsidian-file-adapter.test.ts @@ -0,0 +1,139 @@ +// Mock obsidian module +jest.mock('obsidian', () => { + const MockTFile = class { + path: string; + stat: { mtime: number; size: number; mtimeMs: number }; + constructor(path: string) { + this.path = path; + this.stat = { mtime: 1000, size: 10, mtimeMs: 1000 }; + } + }; + return { + TFile: MockTFile, + MetadataCache: jest.fn(), + Vault: jest.fn(), + }; +}); + +// Mock gemini-utils +jest.mock('@allenhutchison/gemini-utils', () => ({ + getMimeTypeWithFallback: jest.fn().mockReturnValue({ mimeType: 'text/markdown' }), + isExtensionSupportedWithFallback: jest.fn().mockReturnValue(true), +})); + +import { ObsidianVaultAdapter } from '../../src/services/obsidian-file-adapter'; +import { TFile } from 'obsidian'; + +describe('ObsidianVaultAdapter', () => { + let adapter: ObsidianVaultAdapter; + let mockVault: any; + let mockMetadataCache: any; + let mockHashCacheProvider: jest.Mock; + + beforeEach(() => { + mockVault = { + getAbstractFileByPath: jest.fn(), + readBinary: jest.fn(), + getFiles: jest.fn().mockReturnValue([]), + getMarkdownFiles: jest.fn().mockReturnValue([]), + }; + + mockMetadataCache = { + getFileCache: jest.fn(), + }; + + mockHashCacheProvider = jest.fn(); + + adapter = new ObsidianVaultAdapter({ + vault: mockVault, + metadataCache: mockMetadataCache, + hashCacheProvider: mockHashCacheProvider, + }); + }); + + describe('computeHash', () => { + it('should return cached hash if provider returns one', async () => { + const filePath = 'test.md'; + const cachedHash = 'cached-hash-123'; + const mockFile = new (TFile as any)(filePath); + mockFile.stat.mtime = 2000; + + mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + mockHashCacheProvider.mockReturnValue(cachedHash); + + const hash = await adapter.computeHash(filePath); + + expect(hash).toBe(cachedHash); + expect(mockHashCacheProvider).toHaveBeenCalledWith(filePath, 2000); + expect(mockVault.readBinary).not.toHaveBeenCalled(); + }); + + it('should compute hash if provider returns null', async () => { + const filePath = 'test.md'; + const fileContent = new Uint8Array([1, 2, 3]); + const mockFile = new (TFile as any)(filePath); + mockFile.stat.mtime = 2000; + + mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + mockHashCacheProvider.mockReturnValue(null); + mockVault.readBinary.mockResolvedValue(fileContent); + + // Mock crypto.subtle.digest + const mockDigest = jest.fn().mockResolvedValue(new Uint8Array([0xaa, 0xbb]).buffer); + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + digest: mockDigest, + }, + }, + writable: true, + }); + + const hash = await adapter.computeHash(filePath); + + expect(hash).toBe('aabb'); // Hex representation + expect(mockHashCacheProvider).toHaveBeenCalledWith(filePath, 2000); + expect(mockVault.readBinary).toHaveBeenCalledWith(mockFile); + }); + + it('should compute hash if provider is not defined', async () => { + adapter = new ObsidianVaultAdapter({ + vault: mockVault, + metadataCache: mockMetadataCache, + // No hashCacheProvider + }); + + const filePath = 'test.md'; + const fileContent = new Uint8Array([1, 2, 3]); + const mockFile = new (TFile as any)(filePath); + + mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + mockVault.readBinary.mockResolvedValue(fileContent); + + // Mock crypto.subtle.digest + const mockDigest = jest.fn().mockResolvedValue(new Uint8Array([0xcc, 0xdd]).buffer); + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + digest: mockDigest, + }, + }, + writable: true, + }); + + const hash = await adapter.computeHash(filePath); + + expect(hash).toBe('ccdd'); + expect(mockVault.readBinary).toHaveBeenCalledWith(mockFile); + }); + + it('should return empty string if file does not exist', async () => { + mockVault.getAbstractFileByPath.mockReturnValue(null); + + const hash = await adapter.computeHash('nonexistent.md'); + + expect(hash).toBe(''); + expect(mockHashCacheProvider).not.toHaveBeenCalled(); + }); + }); +});