diff options
| author | Adam Malczewski <[email protected]> | 2026-03-24 19:57:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-24 19:57:16 +0900 |
| commit | 0d7e2758d28bb37c9d724f79008239a5e29e6ce4 (patch) | |
| tree | cdd294a7a45e712d86c39e15340cadf2a9a1bc91 /src | |
| parent | a5f54269f6b7ace71c4509fb8105993a7f064e63 (diff) | |
| download | ai-pulse-obsidian-plugin-0d7e2758d28bb37c9d724f79008239a5e29e6ce4.tar.gz ai-pulse-obsidian-plugin-0d7e2758d28bb37c9d724f79008239a5e29e6ce4.zip | |
Add vault context injection, frontmatter tool, vision idea
Diffstat (limited to 'src')
| -rw-r--r-- | src/chat-view.ts | 29 | ||||
| -rw-r--r-- | src/ollama-client.ts | 23 | ||||
| -rw-r--r-- | src/settings-modal.ts | 54 | ||||
| -rw-r--r-- | src/settings.ts | 4 | ||||
| -rw-r--r-- | src/tools.ts | 151 | ||||
| -rw-r--r-- | src/vault-context.ts | 168 |
6 files changed, 421 insertions, 8 deletions
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 + ); +} |
