Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
"author": "Allen Hutchison",
"authorUrl": "https://allen.hutchison.org",
"isDesktopOnly": false
}
}
20 changes: 20 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 28 additions & 23 deletions src/tools/execution-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
160 changes: 160 additions & 0 deletions src/tools/vault-tools-extended.ts
Original file line number Diff line number Diff line change
@@ -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<ToolResult> {
const plugin = context.plugin as InstanceType<typeof ObsidianGemini>;
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<ToolResult> {
const plugin = context.plugin as InstanceType<typeof ObsidianGemini>;
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}`,
};
}
}
}
8 changes: 8 additions & 0 deletions src/tools/web-fetch-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
10 changes: 10 additions & 0 deletions src/ui/agent-view/agent-view-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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');
}
});

Expand Down Expand Up @@ -262,20 +268,23 @@ 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());

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());

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());
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 28 additions & 0 deletions src/ui/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading