summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rules/changelog/2026-03/24/16.md36
-rw-r--r--.rules/research/plugin-feature-ideas.md (renamed from .notes/research/plugin-feature-ideas.md)24
-rw-r--r--README.md74
-rw-r--r--src/chat-view.ts29
-rw-r--r--src/ollama-client.ts23
-rw-r--r--src/settings-modal.ts54
-rw-r--r--src/settings.ts4
-rw-r--r--src/tools.ts151
-rw-r--r--src/vault-context.ts168
9 files changed, 542 insertions, 21 deletions
diff --git a/.rules/changelog/2026-03/24/16.md b/.rules/changelog/2026-03/24/16.md
new file mode 100644
index 0000000..d01842b
--- /dev/null
+++ b/.rules/changelog/2026-03/24/16.md
@@ -0,0 +1,36 @@
+# Changelog — 2026-03-24 #16
+
+## Vault Context Injection
+
+- Created `src/vault-context.ts` with `collectVaultContext()` and `formatVaultContext()`
+ - Builds folder tree, tag taxonomy (sorted by count, capped at 100), and recent files list
+ - All data sourced from `metadataCache` and vault indexes — no file reads
+- Added `injectVaultContext` (default: off) and `vaultContextRecentFiles` (default: 20) to settings
+- Added "Vault Context" section in settings modal with toggle and recent files count input
+- System prompt now includes vault context block between tool instructions and user custom prompt
+- Plumbed `vaultContext` through `AgentLoopOptions`, `StreamingChatOptions`, `sendChatMessage`, and `sendChatMessageStreaming`
+
+## Frontmatter Management Tool (set_frontmatter)
+
+- Added `set_frontmatter` tool: atomically sets/updates/removes YAML frontmatter via `processFrontMatter()`
+ - Requires user approval; approval dialog shows "Review properties" with JSON preview
+ - Handles both object and JSON-string inputs from the LLM
+ - Set a value to `null` to remove a property
+- Enhanced `read_file` tool to include parsed frontmatter as a labeled JSON block when present
+- Added frontmatter management instructions to the tool system prompt
+- Updated `chat-view.ts` approval dialog to handle `set_frontmatter` display
+
+## Feature Ideas Document
+
+- Marked features #2 (Frontmatter Management) and #4 (Vault Context Injection) as implemented
+- Added idea #13: Vision Preprocessing (Image-to-Text) — standalone vision model describes images, summary injected as text context
+
+## Files Changed
+
+- `src/vault-context.ts` (new)
+- `src/settings.ts`
+- `src/settings-modal.ts`
+- `src/ollama-client.ts`
+- `src/chat-view.ts`
+- `src/tools.ts`
+- `.rules/research/plugin-feature-ideas.md`
diff --git a/.notes/research/plugin-feature-ideas.md b/.rules/research/plugin-feature-ideas.md
index fa76f62..755b1f0 100644
--- a/.notes/research/plugin-feature-ideas.md
+++ b/.rules/research/plugin-feature-ideas.md
@@ -13,11 +13,11 @@ Use Ollama's `/api/embed` endpoint to generate vector embeddings for vault notes
**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
+### 2. Frontmatter Management Tool ✅ IMPLEMENTED
-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.
+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. The `read_file` tool also automatically includes parsed frontmatter as JSON.
-**APIs**: `FileManager.processFrontMatter(file, fn)`
+**APIs**: `FileManager.processFrontMatter(file, fn)`, `metadataCache.getFileCache()`
**Why**: Much safer than `edit_file` for metadata operations. No risk of breaking YAML formatting.
### 3. Auto-Process on File Creation
@@ -27,9 +27,9 @@ When a new note is created, automatically queue it for AI processing (tagging, l
**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
+### 4. Vault Context Injection ✅ IMPLEMENTED
-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.
+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. Togglable in settings with configurable recent files count.
**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.
@@ -97,3 +97,17 @@ Let users configure different models for different tasks (e.g. a small fast mode
**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.
+
+### 13. Vision Preprocessing (Image-to-Text)
+
+When a user attaches an image to a chat message, send it to a vision model (e.g. `moondream`, `llava`, `llama3.2-vision`) in a standalone request asking it to describe everything visible — objects, text, numbers, layout. The text summary is then injected into the main conversation as context, replacing the raw image.
+
+**Flow**:
+1. User attaches an image (from vault or clipboard)
+2. Plugin reads the image binary, base64-encodes it
+3. Standalone `/api/chat` request to the vision model with `images` field: "Describe everything you see in this image, including all text and numbers."
+4. Vision model response (~100 tokens) is injected into the conversation as `[Image description: ...]`
+5. Main chat model processes the text description as normal
+
+**APIs**: Ollama `/api/chat` with `images` field, `vault.readBinary()`, base64 encoding
+**Why**: Raw base64 images consume massive context (~1.3MB for a 1MB image). Preprocessing shrinks this to a small paragraph while preserving all useful information. Also enables non-vision chat models to reason about images. Pairs naturally with multi-model support (idea #12) — configure a dedicated small/fast vision model separately from the main chat model.
diff --git a/README.md b/README.md
index 0f2e19f..6079304 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,67 @@
The Obsidian AI Note Management Plugin — powered by [Ollama](https://ollama.com).
+Chat with a local AI that has full access to your vault. Search files, read and edit notes, manage frontmatter, create and move files — all through natural conversation with tool-calling support.
+
+## Features
+
+### AI Chat Sidebar
+
+- Streaming responses with real-time token-by-token display (desktop) or full-response delivery (mobile)
+- Markdown rendering with clickable `[[wiki-links]]` in AI responses
+- Multi-turn conversation with full message history
+- Abort generation mid-stream with a Stop button
+- FAB speed dial for quick access to Settings, Tools, and Clear Chat
+
+### Vault Tools
+
+Enable tools to give the AI direct access to your vault. Each tool can be individually toggled on/off. Destructive actions require user approval before executing.
+
+| Tool | Description | Approval |
+|------|-------------|----------|
+| **Search Files** | Find files by name or path | No |
+| **Read File** | Read file content (includes parsed frontmatter as JSON) | No |
+| **Search Contents** | Grep-style text search across all markdown files | No |
+| **Get Current Note** | Get the path of the currently open note | No |
+| **Edit File** | Find-and-replace text in a file | Yes |
+| **Create File** | Create a new note (auto-creates parent folders) | Yes |
+| **Delete File** | Move a file to system trash | Yes |
+| **Move/Rename File** | Move or rename a file (auto-updates all links) | Yes |
+| **Set Frontmatter** | Add, update, or remove YAML frontmatter properties | Yes |
+
+The AI follows a mandatory read-before-edit workflow to prevent data loss. Approval dialogs show full diffs for review before any changes are applied.
+
+### Vault Context Injection
+
+Optionally inject a summary of your vault structure into every conversation so the AI understands your vault without searching first:
+
+- **Folder tree** — indented ASCII tree of all vault folders
+- **Tag taxonomy** — all tags sorted by usage count
+- **Recent files** — most recently modified notes (configurable count)
+- **Stats** — vault name, total notes, total folders
+
+All data comes from the metadata cache — no file reads, instant computation.
+
+### Custom System Prompt
+
+Point the plugin at any vault note to use as persistent AI instructions (writing style, formatting rules, etc.). The note content is injected into the system prompt alongside tool instructions and vault context.
+
+### Settings
+
+- **Ollama URL** — connect to any Ollama instance (localhost, LAN IP, remote)
+- **Model selection** — auto-populated from connected Ollama server
+- **Temperature** — control response randomness (0–2)
+- **Context window** — set `num_ctx` with model max display and one-click apply
+- **Max output tokens** — set `num_predict` (-1 for unlimited)
+
+### Mobile Support
+
+Fully functional on Obsidian Mobile. Uses Obsidian's `requestUrl()` for network requests (non-streaming fallback) to reach Ollama over LAN. Set the Ollama URL to your computer's LAN IP instead of localhost.
+
## Prerequisites
- [Obsidian](https://obsidian.md) v0.15.0 or later
-- [Ollama](https://ollama.com) installed and running locally (default: `http://localhost:11434`)
+- [Ollama](https://ollama.com) installed and running (default: `http://localhost:11434`)
- [Node.js](https://nodejs.org) v16 or later (for building from source)
## Building from Source
@@ -45,16 +102,17 @@ ln -s /path/to/ai-pulse ai-pulse
Then run `npm run dev` and reload Obsidian to pick up changes.
-## Usage
+## Quick Start
-1. Click the **message icon** in the left ribbon or run the **"Open AI Chat"** command from the command palette.
-2. The chat sidebar opens in the right panel.
-3. In the **Settings** section at the bottom of the sidebar:
- - Set the **Ollama URL** (defaults to `http://localhost:11434`).
- - Click **Test** to verify the connection.
+1. Click the **message icon** in the left ribbon or run **"Open AI Chat"** from the command palette.
+2. Click the **gear icon** (FAB) → **AI Settings**:
+ - Set the **Ollama URL** if not using the default.
+ - Click **Connect** to verify the connection and load models.
- Select a **Model** from the dropdown.
-4. Type a message and press **Enter** to chat with the AI.
+3. Click **Tools** to enable vault tools (optional but recommended).
+4. Type a message and press **Enter** to chat.
- **Shift+Enter** inserts a newline.
+ - Click **Stop** to abort a streaming response.
## License
diff --git a/src/chat-view.ts b/src/chat-view.ts
index 7975cd2..b76fb8e 100644
--- a/src/chat-view.ts
+++ b/src/chat-view.ts
@@ -6,6 +6,7 @@ import { SettingsModal } from "./settings-modal";
import { ToolModal } from "./tool-modal";
import { TOOL_REGISTRY } from "./tools";
import type { OllamaToolDefinition } from "./tools";
+import { collectVaultContext, formatVaultContext } from "./vault-context";
export const VIEW_TYPE_CHAT = "ai-pulse-chat";
@@ -236,6 +237,16 @@ export class ChatView extends ItemView {
}
}
+ // Build vault context if enabled
+ let vaultContext: string | undefined;
+ if (this.plugin.settings.injectVaultContext) {
+ const ctx = collectVaultContext(
+ this.plugin.app,
+ this.plugin.settings.vaultContextRecentFiles,
+ );
+ vaultContext = formatVaultContext(ctx);
+ }
+
try {
const enabledTools = this.getEnabledTools();
const hasTools = enabledTools.length > 0;
@@ -291,6 +302,7 @@ export class ChatView extends ItemView {
num_predict: this.plugin.settings.numPredict,
},
userSystemPrompt,
+ vaultContext,
onChunk,
onToolCall: hasTools ? onToolCall : undefined,
onApprovalRequest: hasTools ? onApprovalRequest : undefined,
@@ -486,7 +498,7 @@ export class ChatView extends ItemView {
container.createDiv({ text: event.message, cls: "ai-pulse-approval-message" });
// Show details for edit_file so the user can review the change
- if (event.toolName === "edit_file" || event.toolName === "create_file") {
+ if (event.toolName === "edit_file" || event.toolName === "create_file" || event.toolName === "set_frontmatter") {
const collapse = container.createDiv({ cls: "ai-pulse-collapse ai-pulse-collapse-arrow" });
const collapseId = `approval-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const checkbox = collapse.createEl("input", {
@@ -498,7 +510,9 @@ export class ChatView extends ItemView {
const titleEl = collapse.createEl("label", {
cls: "ai-pulse-collapse-title",
attr: { for: collapseId },
- text: event.toolName === "create_file" ? "Review content" : "Review changes",
+ text: event.toolName === "create_file" ? "Review content"
+ : event.toolName === "set_frontmatter" ? "Review properties"
+ : "Review changes",
});
void titleEl;
@@ -520,6 +534,17 @@ export class ChatView extends ItemView {
text: newText,
cls: "ai-pulse-tool-call-result",
});
+ } else if (event.toolName === "set_frontmatter") {
+ const props = event.args.properties;
+ const propsStr = typeof props === "object" && props !== null
+ ? JSON.stringify(props, null, 2)
+ : typeof props === "string" ? props : "{}";
+
+ contentInner.createEl("div", { text: "Properties to set:", cls: "ai-pulse-tool-call-label" });
+ contentInner.createEl("pre", {
+ text: propsStr,
+ cls: "ai-pulse-tool-call-result",
+ });
} else {
// create_file
const content = typeof event.args.content === "string" ? event.args.content : "";
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index f1288e7..c01b5bc 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -68,6 +68,7 @@ interface AgentLoopOptions {
tools?: OllamaToolDefinition[];
app?: App;
userSystemPrompt?: string;
+ vaultContext?: string;
onToolCall?: (event: ToolCallEvent) => void;
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>;
sendRequest: ChatRequestStrategy;
@@ -108,6 +109,12 @@ const TOOL_SYSTEM_PROMPT =
"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" +
+ "FRONTMATTER MANAGEMENT:\n" +
+ "When you read a file with read_file, its YAML frontmatter is automatically included as a parsed JSON block at the top of the output. " +
+ "Use set_frontmatter to add, update, or remove frontmatter properties (tags, aliases, categories, etc.). " +
+ "set_frontmatter is MUCH safer than edit_file for metadata changes \u2014 it preserves YAML formatting. " +
+ "ALWAYS prefer set_frontmatter over edit_file when modifying tags, aliases, or other frontmatter fields. " +
+ "RECOMMENDED: Read the file first to see existing frontmatter before calling set_frontmatter.\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.";
@@ -117,21 +124,25 @@ const TOOL_SYSTEM_PROMPT =
* text response or the iteration cap is reached.
*/
async function chatAgentLoop(opts: AgentLoopOptions): Promise<string> {
- const { messages, tools, app, userSystemPrompt, onToolCall, onApprovalRequest, sendRequest } = opts;
+ const { messages, tools, app, userSystemPrompt, vaultContext, onToolCall, onApprovalRequest, sendRequest } = opts;
const maxIterations = 10;
let iterations = 0;
const workingMessages = messages.map((m) => ({ ...m }));
- // Build combined system prompt from tool instructions + user custom prompt
+ // Build combined system prompt from tool instructions + vault context + user custom prompt
const hasTools = tools !== undefined && tools.length > 0;
const hasUserPrompt = userSystemPrompt !== undefined && userSystemPrompt.trim() !== "";
+ const hasVaultContext = vaultContext !== undefined && vaultContext.trim() !== "";
- if (hasTools || hasUserPrompt) {
+ if (hasTools || hasUserPrompt || hasVaultContext) {
const parts: string[] = [];
if (hasTools) {
parts.push(TOOL_SYSTEM_PROMPT);
}
+ if (hasVaultContext) {
+ parts.push(vaultContext);
+ }
if (hasUserPrompt) {
parts.push("USER INSTRUCTIONS:\n" + userSystemPrompt.trim());
}
@@ -341,6 +352,7 @@ export async function sendChatMessage(
onToolCall?: (event: ToolCallEvent) => void,
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>,
userSystemPrompt?: string,
+ vaultContext?: string,
): Promise<string> {
const sendRequest: ChatRequestStrategy = async (workingMessages) => {
const body: Record<string, unknown> = {
@@ -384,6 +396,7 @@ export async function sendChatMessage(
tools,
app,
userSystemPrompt,
+ vaultContext,
onToolCall,
onApprovalRequest,
sendRequest,
@@ -405,6 +418,7 @@ export interface StreamingChatOptions {
app?: App;
options?: ModelOptions;
userSystemPrompt?: string;
+ vaultContext?: string;
onChunk: (text: string) => void;
onToolCall?: (event: ToolCallEvent) => void;
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>;
@@ -457,7 +471,7 @@ async function* readNdjsonStream(
export async function sendChatMessageStreaming(
opts: StreamingChatOptions,
): Promise<string> {
- const { ollamaUrl, model, tools, app, options, userSystemPrompt, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts;
+ const { ollamaUrl, model, tools, app, options, userSystemPrompt, vaultContext, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts;
const sendRequest: ChatRequestStrategy = Platform.isMobile
? buildMobileStrategy(ollamaUrl, model, tools, options, onChunk, onCreateBubble)
@@ -468,6 +482,7 @@ export async function sendChatMessageStreaming(
tools,
app,
userSystemPrompt,
+ vaultContext,
onToolCall,
onApprovalRequest,
sendRequest,
diff --git a/src/settings-modal.ts b/src/settings-modal.ts
index 9a4218c..8ffc3e7 100644
--- a/src/settings-modal.ts
+++ b/src/settings-modal.ts
@@ -123,6 +123,60 @@ export class SettingsModal extends Modal {
// Set initial state
updateFileSettingState(this.plugin.settings.useSystemPromptFile);
+ // --- Vault Context ---
+
+ const ctxHeader = contentEl.createEl("h4", { text: "Vault Context" });
+ ctxHeader.style.marginTop = "16px";
+ ctxHeader.style.marginBottom = "4px";
+
+ // Recent files count (disabled/enabled based on toggle)
+ let recentFilesInputEl: HTMLInputElement | null = null;
+ const recentFilesSetting = new Setting(contentEl)
+ .setName("Recent Files Count")
+ .setDesc("Number of recently modified files to include in the context.")
+ .addText((text) => {
+ text.inputEl.type = "number";
+ text.inputEl.min = "0";
+ text.inputEl.max = "100";
+ text.inputEl.step = "5";
+ text.setValue(String(this.plugin.settings.vaultContextRecentFiles));
+ text.onChange(async (value) => {
+ const num = parseInt(value, 10);
+ if (!isNaN(num) && num >= 0) {
+ this.plugin.settings.vaultContextRecentFiles = num;
+ await this.plugin.saveSettings();
+ }
+ });
+ text.inputEl.style.width = "60px";
+ recentFilesInputEl = text.inputEl;
+ });
+
+ const updateVaultContextState = (enabled: boolean): void => {
+ recentFilesSetting.settingEl.toggleClass("ai-pulse-setting-disabled", !enabled);
+ if (recentFilesInputEl !== null) {
+ recentFilesInputEl.disabled = !enabled;
+ }
+ };
+
+ const vaultContextToggle = new Setting(contentEl)
+ .setName("Inject Vault Context")
+ .setDesc("Automatically inject a summary of the vault (folders, tags, recent files) into each conversation.")
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.injectVaultContext)
+ .onChange(async (value) => {
+ this.plugin.settings.injectVaultContext = value;
+ await this.plugin.saveSettings();
+ updateVaultContextState(value);
+ });
+ });
+
+ // Move toggle above count in the DOM
+ contentEl.insertBefore(vaultContextToggle.settingEl, recentFilesSetting.settingEl);
+
+ // Set initial state
+ updateVaultContextState(this.plugin.settings.injectVaultContext);
+
// --- Generation Parameters ---
const paramHeader = contentEl.createEl("h4", { text: "Generation Parameters" });
diff --git a/src/settings.ts b/src/settings.ts
index eb42c3f..c61af9d 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -9,6 +9,8 @@ export interface AIPulseSettings {
numPredict: number;
useSystemPromptFile: boolean;
systemPromptFile: string;
+ injectVaultContext: boolean;
+ vaultContextRecentFiles: number;
}
export const DEFAULT_SETTINGS: AIPulseSettings = {
@@ -20,4 +22,6 @@ export const DEFAULT_SETTINGS: AIPulseSettings = {
numPredict: -1,
useSystemPromptFile: false,
systemPromptFile: "agent.md",
+ injectVaultContext: false,
+ vaultContextRecentFiles: 20,
};
diff --git a/src/tools.ts b/src/tools.ts
index 7e13d26..70deb0e 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -68,7 +68,8 @@ async function executeSearchFiles(app: App, args: Record<string, unknown>): Prom
/**
* Execute the "read_file" tool.
- * Returns the full text content of a file by its vault path.
+ * Returns the full text content of a file by its vault path,
+ * plus parsed frontmatter as a JSON block if present.
*/
async function executeReadFile(app: App, args: Record<string, unknown>): Promise<string> {
const filePath = typeof args.file_path === "string" ? args.file_path : "";
@@ -83,6 +84,20 @@ async function executeReadFile(app: App, args: Record<string, unknown>): Promise
try {
const content = await app.vault.cachedRead(file);
+
+ // Include parsed frontmatter as JSON if available
+ const cache = app.metadataCache.getFileCache(file);
+ if (cache?.frontmatter !== undefined) {
+ const fm: Record<string, unknown> = {};
+ for (const [key, value] of Object.entries(cache.frontmatter)) {
+ if (key !== "position") {
+ fm[key] = value;
+ }
+ }
+ const fmJson = JSON.stringify(fm, null, 2);
+ return `--- FRONTMATTER (parsed) ---\n${fmJson}\n--- END FRONTMATTER ---\n\n--- FILE CONTENT ---\n${content}\n--- END FILE CONTENT ---`;
+ }
+
return content;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error";
@@ -320,6 +335,77 @@ async function executeEditFile(app: App, args: Record<string, unknown>): Promise
}
/**
+ * Execute the "set_frontmatter" tool.
+ * Atomically sets or updates frontmatter properties using processFrontMatter().
+ * The `properties` argument is a JSON object whose keys are set/overwritten in the YAML block.
+ * To remove a property, set its value to null.
+ */
+async function executeSetFrontmatter(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.";
+ }
+
+ let properties = args.properties;
+
+ // The model may pass properties as a JSON string — parse it
+ if (typeof properties === "string") {
+ try {
+ properties = JSON.parse(properties) as unknown;
+ } catch {
+ return "Error: properties must be a valid JSON object. Failed to parse the string.";
+ }
+ }
+
+ if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
+ return "Error: properties must be a JSON object with key-value pairs.";
+ }
+
+ const propsObj = properties as Record<string, unknown>;
+ if (Object.keys(propsObj).length === 0) {
+ return "Error: properties object is empty. Provide at least one key to set.";
+ }
+
+ const file = app.vault.getAbstractFileByPath(filePath);
+ if (file === null || !(file instanceof TFile)) {
+ return `Error: File not found at path "${filePath}".`;
+ }
+
+ try {
+ const keysSet: string[] = [];
+ const keysRemoved: string[] = [];
+
+ await app.fileManager.processFrontMatter(file, (fm) => {
+ for (const [key, value] of Object.entries(propsObj)) {
+ if (value === null) {
+ // Remove the property
+ if (key in fm) {
+ delete fm[key];
+ keysRemoved.push(key);
+ }
+ } else {
+ fm[key] = value;
+ keysSet.push(key);
+ }
+ }
+ });
+
+ const parts: string[] = [];
+ if (keysSet.length > 0) {
+ parts.push(`Set: ${keysSet.join(", ")}`);
+ }
+ if (keysRemoved.length > 0) {
+ parts.push(`Removed: ${keysRemoved.join(", ")}`);
+ }
+
+ return `Frontmatter updated for "${filePath}". ${parts.join(". ")}.`;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ return `Error updating frontmatter: ${msg}`;
+ }
+}
+
+/**
* All available tools for the plugin.
*/
export const TOOL_REGISTRY: ToolEntry[] = [
@@ -383,7 +469,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [
type: "function",
function: {
name: "read_file",
- description: "Read the full text content of a file in the Obsidian vault. The file_path must be an exact path as returned by search_files.",
+ description: "Read the full text content of a file in the Obsidian vault. If the file has YAML frontmatter, it is also returned as a parsed JSON block at the top of the output. The file_path must be an exact path as returned by search_files.",
parameters: {
type: "object",
required: ["file_path"],
@@ -663,6 +749,67 @@ export const TOOL_REGISTRY: ToolEntry[] = [
},
execute: executeMoveFile,
},
+ {
+ id: "set_frontmatter",
+ label: "Set Frontmatter",
+ description: "Add or update YAML frontmatter properties (requires approval).",
+ friendlyName: "Set Frontmatter",
+ requiresApproval: true,
+ approvalMessage: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
+ const props = typeof args.properties === "object" && args.properties !== null
+ ? Object.keys(args.properties as Record<string, unknown>)
+ : [];
+ return `Update frontmatter in "${filePath}"? Properties: ${props.join(", ")}`;
+ },
+ summarize: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ const props = typeof args.properties === "object" && args.properties !== null
+ ? Object.keys(args.properties as Record<string, unknown>)
+ : [];
+ return `"/${filePath}" — ${props.join(", ")}`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ if (result.includes("declined")) {
+ return "Declined by user";
+ }
+ return "Frontmatter updated";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "set_frontmatter",
+ description: "Add or update YAML frontmatter properties on a note. " +
+ "Pass a JSON object of key-value pairs to set. " +
+ "Existing properties not mentioned are left unchanged. " +
+ "Set a value to null to remove that property. " +
+ "Use this for tags, aliases, categories, dates, or any custom metadata. " +
+ "For tags, use an array of strings (e.g. [\"ai\", \"research\"]). " +
+ "This is safer than edit_file for metadata changes because it preserves YAML formatting. " +
+ "RECOMMENDED: Call read_file first to see existing frontmatter before updating. " +
+ "The file_path must be an exact path from search_files or get_current_note. " +
+ "This action requires user approval.",
+ parameters: {
+ type: "object",
+ required: ["file_path", "properties"],
+ properties: {
+ file_path: {
+ type: "string",
+ description: "The vault-relative path to the file (e.g. 'folder/note.md').",
+ },
+ properties: {
+ type: "string",
+ description: 'A JSON object of frontmatter key-value pairs to set. Example: {"tags": ["ai", "research"], "category": "notes", "status": "draft"}. Set a value to null to remove that property.',
+ },
+ },
+ },
+ },
+ },
+ execute: executeSetFrontmatter,
+ },
];
/**
diff --git a/src/vault-context.ts b/src/vault-context.ts
new file mode 100644
index 0000000..80afa03
--- /dev/null
+++ b/src/vault-context.ts
@@ -0,0 +1,168 @@
+import type { App } from "obsidian";
+
+/**
+ * Collected vault context summary injected into the AI system prompt.
+ */
+export interface VaultContext {
+ vaultName: string;
+ totalNotes: number;
+ totalFolders: number;
+ folderTree: string;
+ tagTaxonomy: string;
+ recentFiles: string;
+}
+
+/**
+ * Build a folder tree string from the vault.
+ * Produces an indented tree like:
+ * /
+ * ├── folder-a/
+ * │ ├── subfolder/
+ * ├── folder-b/
+ */
+function buildFolderTree(app: App): string {
+ const folders = app.vault.getAllFolders(true);
+ // Build a map of parent → children folder names
+ const tree = new Map<string, string[]>();
+
+ for (const folder of folders) {
+ if (folder.isRoot()) continue;
+ const parentPath = folder.parent?.path ?? "/";
+ const key = parentPath === "/" || parentPath === "" ? "/" : parentPath;
+ if (!tree.has(key)) {
+ tree.set(key, []);
+ }
+ tree.get(key)!.push(folder.path);
+ }
+
+ const lines: string[] = [];
+
+ function walk(path: string, prefix: string): void {
+ const children = tree.get(path) ?? [];
+ children.sort();
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const isLast = i === children.length - 1;
+ const connector = isLast ? "└── " : "├── ";
+ const childPrefix = isLast ? " " : "│ ";
+ // Show just the folder name, not the full path
+ const name = child.split("/").pop() ?? child;
+ lines.push(`${prefix}${connector}${name}/`);
+ walk(child, prefix + childPrefix);
+ }
+ }
+
+ lines.push("/");
+ walk("/", "");
+
+ return lines.join("\n");
+}
+
+/**
+ * Collect all tags in the vault with their usage counts.
+ * Returns a formatted string like: #tag1 (12), #tag2 (8), ...
+ */
+function buildTagTaxonomy(app: App): string {
+ const tagCounts = new Map<string, number>();
+ const files = app.vault.getMarkdownFiles();
+
+ for (const file of files) {
+ const cache = app.metadataCache.getFileCache(file);
+ if (cache === null) continue;
+
+ // Inline tags
+ if (cache.tags !== undefined) {
+ for (const tagEntry of cache.tags) {
+ const tag = tagEntry.tag.toLowerCase();
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
+ }
+ }
+
+ // Frontmatter tags
+ if (cache.frontmatter?.tags !== undefined) {
+ const fmTags = cache.frontmatter.tags;
+ if (Array.isArray(fmTags)) {
+ for (const raw of fmTags) {
+ const tag = typeof raw === "string"
+ ? (raw.startsWith("#") ? raw.toLowerCase() : `#${raw.toLowerCase()}`)
+ : "";
+ if (tag !== "") {
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
+ }
+ }
+ }
+ }
+ }
+
+ if (tagCounts.size === 0) {
+ return "No tags in vault.";
+ }
+
+ // Sort by count descending
+ const sorted = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
+
+ // Cap at 100 tags to avoid overwhelming context
+ const maxTags = 100;
+ const limited = sorted.slice(0, maxTags);
+ const lines = limited.map(([tag, count]) => `${tag} (${count})`);
+ const suffix = sorted.length > maxTags
+ ? `\n...and ${sorted.length - maxTags} more tags.`
+ : "";
+
+ return lines.join(", ") + suffix;
+}
+
+/**
+ * Get the most recently modified files.
+ */
+function buildRecentFiles(app: App, maxFiles: number): string {
+ const files = app.vault.getMarkdownFiles();
+
+ // Sort by modification time descending
+ const sorted = [...files].sort((a, b) => b.stat.mtime - a.stat.mtime);
+ const limited = sorted.slice(0, maxFiles);
+
+ if (limited.length === 0) {
+ return "No notes in vault.";
+ }
+
+ return limited.map((f) => f.path).join("\n");
+}
+
+/**
+ * Collect the full vault context summary.
+ * This is cheap — all data comes from the metadata cache and vault indexes.
+ */
+export function collectVaultContext(app: App, maxRecentFiles: number): VaultContext {
+ const markdownFiles = app.vault.getMarkdownFiles();
+ const allFolders = app.vault.getAllFolders(false);
+
+ return {
+ vaultName: app.vault.getName(),
+ totalNotes: markdownFiles.length,
+ totalFolders: allFolders.length,
+ folderTree: buildFolderTree(app),
+ tagTaxonomy: buildTagTaxonomy(app),
+ recentFiles: buildRecentFiles(app, maxRecentFiles),
+ };
+}
+
+/**
+ * Format the vault context into a system prompt block.
+ */
+export function formatVaultContext(ctx: VaultContext): string {
+ return (
+ "VAULT CONTEXT (auto-injected summary of the user's Obsidian vault):\n\n" +
+ `Vault name: ${ctx.vaultName}\n` +
+ `Total notes: ${ctx.totalNotes}\n` +
+ `Total folders: ${ctx.totalFolders}\n\n` +
+ "Folder structure:\n" +
+ "```\n" +
+ ctx.folderTree + "\n" +
+ "```\n\n" +
+ "Tags in use:\n" +
+ ctx.tagTaxonomy + "\n\n" +
+ "Recently modified notes:\n" +
+ ctx.recentFiles
+ );
+}