diff --git a/README.md b/README.md index 132a217..800ddfd 100755 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ Open **Settings → Ollama Settings** to configure the plugin. | Setting | Default | Description | |---------|---------|-------------| | Ollama URL | `http://localhost:11434` | Base URL of your Ollama instance | -| Model | `llama3` | Model used for chat responses | +| Chat Model | `deepseek-v4-flash` | Model used for normal chat, Ask mode, and Research mode | +| Agent Model | `glm-5.1` | Model used for Edit, Organize, Workflow, and auto-organizer tasks | | **Default Agent Mode** | `Ask` | Default chat mode (Ask, Edit, Organize, Research, Workflow) | | Vault Search Limit | `5` | Maximum number of vault entries to include in context | | Max Context Length | `8000` | Maximum characters of vault content sent to the AI per message | diff --git a/main.js b/main.js index 395bfa5..7b31134 100644 --- a/main.js +++ b/main.js @@ -3064,10 +3064,10 @@ __export(main_exports, { default: () => OllamaPlugin }); module.exports = __toCommonJS(main_exports); -var import_obsidian5 = require("obsidian"); +var import_obsidian7 = require("obsidian"); // src/chat-view.ts -var import_obsidian3 = require("obsidian"); +var import_obsidian5 = require("obsidian"); // src/types.ts var OllamaError = class _OllamaError extends Error { @@ -3119,6 +3119,122 @@ var PathValidationError = class _PathValidationError extends OllamaError { } }; +// src/agent-modes.ts +var ALL_AGENT_MODES = ["ask", "edit", "organize", "research", "workflow"]; +function filterToolsByName(tools, allowed) { + return tools.filter((t) => allowed.has(t.function.name)); +} +var READ_TOOLS = /* @__PURE__ */ new Set([ + "read_vault_file", + "search_vault_files" +]); +var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([ + "read_vault_file", + "search_vault_files", + "update_frontmatter", + "rename_note", + "move_note", + "insert_link" +]); +var EDIT_TOOLS = /* @__PURE__ */ new Set([ + "read_vault_file", + "search_vault_files", + "create_note", + "append_to_note", + "replace_note_section", + "update_frontmatter", + "rename_note", + "move_note", + "delete_note", + "insert_link" +]); +var RESEARCH_TOOLS = /* @__PURE__ */ new Set([ + "read_vault_file", + "search_vault_files" +]); +var AGENT_MODE_CONFIGS = { + ask: { + label: "Ask", + description: "Answer questions using vault context. Read-only mode.", + systemPrompt: `You are a helpful assistant that answers questions using the contents of the user's Obsidian vault. +You have access to search and read tools to find relevant information. +Always base your answers on vault content when possible. +If you cannot find relevant information, say so clearly. +Do not make up facts.`, + toolFilter: (tools) => filterToolsByName(tools, READ_TOOLS), + requiresPreview: false, + showModeIndicator: true + }, + edit: { + label: "Edit", + description: "Create, modify, and organize notes with full editing tools.", + systemPrompt: `You are an assistant that helps edit and manage notes in the user's Obsidian vault. +You have full access to reading, searching, creating, appending, renaming, moving, and deleting notes. +When editing notes: +- Prefer modifying existing content over creating duplicates. +- Use the replace_note_section tool to update specific sections. +- Use update_frontmatter to manage metadata. +- Always confirm destructive actions (deletes, moves) with the user when possible. +- Preview changes when the system supports it.`, + toolFilter: (tools) => filterToolsByName(tools, EDIT_TOOLS), + requiresPreview: true, + showModeIndicator: true + }, + organize: { + label: "Organize", + description: "Tag, rename, move, and link notes to keep the vault tidy.", + systemPrompt: `You are an assistant that helps organize the user's Obsidian vault. +You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links. +When organizing: +- Suggest consistent tag vocabularies. +- Group related notes by linking them. +- Propose folder structures that match the user's existing patterns. +- Avoid destructive changes unless explicitly requested.`, + toolFilter: (tools) => filterToolsByName(tools, ORGANIZE_TOOLS), + requiresPreview: true, + showModeIndicator: true + }, + research: { + label: "Research", + description: "Deep vault search and synthesis across multiple notes.", + systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault. +Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries. +Search broadly, read key sources, and cross-reference information. +Cite specific notes and quotes where possible. +If information is incomplete or contradictory, note it explicitly.`, + toolFilter: (tools) => filterToolsByName(tools, RESEARCH_TOOLS), + requiresPreview: false, + showModeIndicator: true + }, + workflow: { + label: "Workflow", + description: "Execute multi-step workflows via the /workflow command.", + systemPrompt: `You are a workflow orchestrator. Users can trigger workflows with the /workflow command. +When a user describes a multi-step task, you can suggest using /workflow. +Workflows can chain vault searches, LLM calls, tool executions, and formatting steps together. +You do not have direct tool access in this mode \u2014 workflows handle tool use.`, + toolFilter: () => [], + requiresPreview: false, + showModeIndicator: true + } +}; +function getAgentModeLabel(mode) { + return AGENT_MODE_CONFIGS[mode]?.label ?? mode; +} +function modeRequiresPreview(mode) { + return AGENT_MODE_CONFIGS[mode]?.requiresPreview ?? false; +} +function getSystemPromptForMode(mode) { + return AGENT_MODE_CONFIGS[mode]?.systemPrompt ?? AGENT_MODE_CONFIGS.ask.systemPrompt; +} +function filterToolsForMode(tools, mode) { + const config = AGENT_MODE_CONFIGS[mode]; + if (!config) { + return tools; + } + return config.toolFilter(tools); +} + // src/utils.ts var SEVERITY_ORDER = { debug: 0 /* DEBUG */, @@ -8143,8 +8259,8 @@ var OllamaClient = class { throw new Error("No response body"); } const contentType = response.headers?.get?.("content-type"); - if (contentType && !contentType.includes("application/x-ndjson")) { - throw new Error("Invalid response format"); + if (contentType && contentType.includes("text/html")) { + throw new Error("Invalid response format: server returned HTML instead of JSON"); } reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -8181,7 +8297,7 @@ var OllamaClient = class { const errorMsg = typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed.error); throw new Error(`Ollama error: ${errorMsg}`); } - yield this.normalizeMessage(parsed.message); + yield this.normalizeMessage(parsed.message, parsed.prompt_eval_count, parsed.eval_count); } } if (buffer.trim() !== "") { @@ -8200,7 +8316,7 @@ var OllamaClient = class { throw new Error(`Ollama error: ${errorMsg}`); } if (parsed?.message) { - yield this.normalizeMessage(parsed.message); + yield this.normalizeMessage(parsed.message, parsed.prompt_eval_count, parsed.eval_count); } } } catch (error) { @@ -8252,7 +8368,7 @@ var OllamaClient = class { if (!this.isChatResponse(data)) { return this.normalizeMessage(); } - return this.normalizeMessage(data.message); + return this.normalizeMessage(data.message, data.prompt_eval_count, data.eval_count); } catch (error) { if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -8271,12 +8387,14 @@ var OllamaClient = class { } } } - normalizeMessage(message) { + normalizeMessage(message, promptEvalCount, evalCount) { return { role: message?.role ?? "assistant", content: message?.content ?? "", tool_calls: message?.tool_calls ?? [], - tool_call_id: message?.tool_call_id + tool_call_id: message?.tool_call_id, + prompt_eval_count: promptEvalCount, + eval_count: evalCount }; } parseChatResponse(raw) { @@ -8381,6 +8499,8 @@ var STOP_WORDS = /* @__PURE__ */ new Set([ "they" ]); var CONTENT_PREVIEW_LENGTH = 500; +var DAYS_TO_MS = 864e5; +var DEFAULT_RECENCY_HALF_LIFE = 30; var VaultIndexer = class { constructor(vault, cache, vectorStore) { this.SCORING_WEIGHTS = { @@ -8388,7 +8508,12 @@ var VaultIndexer = class { FRONTMATTER_TITLE: 4, FRONTMATTER_TAGS: 3, HEADINGS: 2, - CONTENT: 1 + CONTENT: 1, + FILENAME: 3, + EXACT_PHRASE: 8, + LINKED: 2, + RECENT: 0.5 + // multiplier, not additive }; this.vault = vault; this.cache = cache; @@ -8414,14 +8539,23 @@ var VaultIndexer = class { basename: file.basename }; } - calculateWeightedScore(tokenized, queryTokens) { + calculateWeightedScore(tokenized, queryTokens, exactPhrases) { let score = 0; + const fullText = [ + tokenized.title, + tokenized.headings.join(" "), + tokenized.frontmatter.title ?? "", + tokenized.frontmatter.tags ?? "", + tokenized.content, + tokenized.firstParagraph, + tokenized.basename + ].join(" ").toLowerCase(); for (const token of queryTokens) { if (tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, token)) { score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; } if (tokenized.basename && this.exactMatch(tokenized.basename, token)) { - score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; + score += this.SCORING_WEIGHTS.FILENAME; } if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, token)) { score += this.SCORING_WEIGHTS.FRONTMATTER_TAGS; @@ -8435,8 +8569,16 @@ var VaultIndexer = class { if (tokenized.title && this.exactMatch(tokenized.title, token)) { score += this.SCORING_WEIGHTS.TITLE; } + if (tokenized.firstParagraph.toLowerCase().includes(token.toLowerCase())) { + score += this.SCORING_WEIGHTS.CONTENT; + } } - return { score }; + for (const phrase of exactPhrases) { + if (fullText.includes(phrase.toLowerCase())) { + score += this.SCORING_WEIGHTS.EXACT_PHRASE; + } + } + return score; } async getVaultEntries() { const files = this.vault.getMarkdownFiles(); @@ -8461,26 +8603,23 @@ var VaultIndexer = class { } return entries; } - async searchVault(query, limit = 3) { + async searchVault(query, limit = 3, options) { if (!query || !query.trim()) { return []; } + const exactPhrases = options?.includeExactPhrase !== false ? this.extractExactPhrases(query) : []; + const queryTokens = this.tokenize(query); + let semanticResults = []; if (this.vectorStore) { try { - const semanticResults = await this.vectorStore.search(query, limit); - if (semanticResults.length > 0) { - return semanticResults; - } + semanticResults = await this.vectorStore.search(query, limit * 3); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - Logger.warn( - `Semantic search failed, falling back to keyword search: ${errorMessage}`, - "vault-indexer" - ); + Logger.warn(`Semantic search failed: ${errorMessage}`, "vault-indexer"); } } - const cacheKey = `query:${query.trim()}:limit:${limit}`; - if (this.cache) { + const cacheKey = this.buildCacheKey(query, limit, options); + if (this.cache && semanticResults.length === 0) { let cachedResults = null; try { cachedResults = await this.cache.get(cacheKey); @@ -8495,13 +8634,16 @@ var VaultIndexer = class { } } } - const queryTokens = this.tokenize(query); - if (queryTokens.length === 0) { + if (queryTokens.length === 0 && exactPhrases.length === 0) { + if (semanticResults.length > 0) return semanticResults.slice(0, limit); return []; } const entries = await this.getVaultEntries(); + const now = Date.now(); + const halfLife = (options?.recencyHalfLifeDays ?? DEFAULT_RECENCY_HALF_LIFE) * DAYS_TO_MS; + const lowerExactPhrases = exactPhrases.map((p) => p.toLowerCase()); const scored = entries.map((entry) => { - const { score } = this.calculateWeightedScore( + const keywordScore = this.calculateWeightedScore( { title: entry.title, headings: entry.headings, @@ -8510,19 +8652,43 @@ var VaultIndexer = class { content: entry.content, basename: entry.basename }, - queryTokens + queryTokens, + lowerExactPhrases ); + const semanticEntry = semanticResults.find((s) => s.path === entry.file.path); + const semanticScore = semanticEntry ? (semanticEntry.score || 0) * 0.3 : 0; + let score = keywordScore + semanticScore; + if (options?.folder) { + const folderLower = options.folder.toLowerCase().replace(/\/$/, ""); + const entryFolder = entry.file.path.toLowerCase().split("/").slice(0, -1).join("/"); + if (!entryFolder.startsWith(folderLower) && entryFolder !== folderLower) { + score *= 0.1; + } + } + if (options?.tag) { + const tagLower = options.tag.toLowerCase(); + const entryTags = (entry.frontmatter.tags ?? "").toLowerCase(); + if (!entryTags.includes(tagLower)) { + score *= 0.1; + } + } + if (options?.recencyBoost !== false && entry.file.stat?.mtime) { + const age = now - entry.file.stat.mtime; + const recencyMultiplier = 1 + this.SCORING_WEIGHTS.RECENT * Math.exp(-age / halfLife); + score *= recencyMultiplier; + } return { ...entry, score }; - }).filter((e) => e.score > 0); + }).filter((e) => e.score > 0.01); scored.sort((a, b) => b.score - a.score); const results = scored.slice(0, limit).map((e) => ({ path: e.file.path, title: e.title, content: e.content, score: e.score, - tags: e.frontmatter?.tags + tags: e.frontmatter?.tags, + mtime: e.file.stat?.mtime })); - if (this.cache) { + if (this.cache && semanticResults.length === 0) { try { await this.cache.put(cacheKey, JSON.stringify(results)); } catch (error) { @@ -8535,6 +8701,26 @@ var VaultIndexer = class { } return results; } + /** + * Extracts quoted exact phrases from a query. + */ + extractExactPhrases(query) { + const phrases = []; + const quoteRegex = /"([^"]+)"/g; + let match; + while ((match = quoteRegex.exec(query)) !== null) { + phrases.push(match[1]); + } + return phrases; + } + buildCacheKey(query, limit, options) { + const parts = [`query:${query.trim()}:limit:${limit}`]; + if (options?.folder) parts.push(`folder:${options.folder}`); + if (options?.tag) parts.push(`tag:${options.tag}`); + if (options?.recencyBoost === false) parts.push("norecency"); + if (options?.includeExactPhrase === false) parts.push("noexact"); + return parts.join(":"); + } stemToken(token) { if (token.endsWith("ing") && token.length > 4) return token.slice(0, -3); if (token.endsWith("ed") && token.length > 3) return token.slice(0, -2); @@ -8586,9 +8772,10 @@ var INVALID_PATH_CHARS = /[<>:"|?*~]/; var MAX_PATH_LENGTH = 200; var FORBIDDEN_DIRS = [".obsidian", ".git"]; var ToolExecutor = class { - constructor(vault, app) { + constructor(vault, app, telemetryManager) { this.vault = vault; this.app = app; + this.telemetryManager = telemetryManager; } isSafePath(path) { if (!path || path.trim().length === 0) { @@ -8623,14 +8810,32 @@ var ToolExecutor = class { } return true; } + getFile(path) { + const file = this.vault.getAbstractFileByPath(path); + if (!(file instanceof import_obsidian.TFile)) { + throw new Error(`File not found: ${path}`); + } + return file; + } + async readFileContent(path) { + const file = this.getFile(path); + return await this.vault.cachedRead(file); + } + async writeFileContent(path, content) { + const file = this.getFile(path); + await this.vault.modify(file, content); + } async handleToolCall(toolCall) { + const startTime = Date.now(); + const toolName = toolCall.function?.name ?? "unknown"; + let parsedArgs = {}; + let result = { success: false, message: "No result" }; + let success = false; try { - const toolName = toolCall.function?.name; const rawArgs = toolCall.function?.arguments; if (!toolName) { throw new Error("Tool name is required"); } - let parsedArgs; if (typeof rawArgs === "string") { try { parsedArgs = safeParseJson(rawArgs); @@ -8644,20 +8849,58 @@ var ToolExecutor = class { } switch (toolName) { case "create_file": - return await this.handleCreateFile(parsedArgs); + case "create_note": + result = await this.handleCreateNote(parsedArgs); + break; case "read_vault_file": - return await this.handleReadVaultFile(parsedArgs); + result = await this.handleReadVaultFile(parsedArgs); + break; case "search_vault_files": - return this.handleSearchVaultFiles(parsedArgs); + result = this.handleSearchVaultFiles(parsedArgs); + break; + case "append_to_note": + result = await this.handleAppendToNote(parsedArgs); + break; + case "replace_note_section": + result = await this.handleReplaceNoteSection(parsedArgs); + break; + case "update_frontmatter": + result = await this.handleUpdateFrontmatter(parsedArgs); + break; + case "rename_note": + result = await this.handleRenameNote(parsedArgs); + break; + case "move_note": + result = await this.handleMoveNote(parsedArgs); + break; + case "delete_note": + result = await this.handleDeleteNote(parsedArgs); + break; + case "insert_link": + result = await this.handleInsertLink(parsedArgs); + break; default: - return { success: false, message: `Unknown tool: ${toolName}` }; + result = { success: false, message: `Unknown tool: ${toolName}` }; } + success = result.success; + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + success = false; + result = { success: false, message: errorMessage }; throw new Error(errorMessage); + } finally { + const durationMs = Date.now() - startTime; + this.telemetryManager?.recordToolCall({ + toolName, + args: parsedArgs, + success, + resultSummary: result?.message ?? "No result", + durationMs + }); } } - async handleCreateFile(args) { + async handleCreateNote(args) { const path = args.path; const content = args.content; if (typeof path !== "string") { @@ -8671,7 +8914,7 @@ var ToolExecutor = class { } try { await this.vault.create(path, content); - return { success: true, message: "File created successfully" }; + return { success: true, message: "Note created successfully" }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(errorMessage); @@ -8695,11 +8938,7 @@ var ToolExecutor = class { if (!this.isSafePath(path)) { throw new Error("Invalid file path detected"); } - const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof import_obsidian.TFile)) { - throw new Error(`File not found: ${path}`); - } - const content = await this.vault.cachedRead(file); + const content = await this.readFileContent(path); return { success: true, message: "File read successfully", @@ -8721,6 +8960,548 @@ var ToolExecutor = class { data: files }; } + async handleAppendToNote(args) { + const path = args.path; + const content = args.content; + if (typeof path !== "string") { + throw new Error("Path must be a string"); + } + if (typeof content !== "string") { + throw new Error("Content must be a string"); + } + if (!this.isSafePath(path)) { + throw new Error("Invalid file path detected"); + } + const currentContent = await this.readFileContent(path); + const separator = currentContent.endsWith("\n") ? "" : "\n"; + const newContent = currentContent + separator + content; + await this.writeFileContent(path, newContent); + return { success: true, message: "Content appended successfully" }; + } + async handleReplaceNoteSection(args) { + const path = args.path; + const heading = args.heading; + const content = args.content; + if (typeof path !== "string") { + throw new Error("Path must be a string"); + } + if (typeof heading !== "string") { + throw new Error("Heading must be a string"); + } + if (typeof content !== "string") { + throw new Error("Content must be a string"); + } + if (!this.isSafePath(path)) { + throw new Error("Invalid file path detected"); + } + const fileContent = await this.readFileContent(path); + const file = this.getFile(path); + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.headings) { + const targetHeading = cache.headings.find((h) => h.heading === heading); + if (targetHeading) { + const startOffset = targetHeading.position.start.offset; + const headingLevel2 = targetHeading.level; + const nextHeading = cache.headings.find( + (h) => h.position.start.offset > startOffset && h.level <= headingLevel2 + ); + const sectionEnd2 = nextHeading ? nextHeading.position.start.offset : fileContent.length; + const newFileContent2 = fileContent.slice(0, startOffset) + "#".repeat(headingLevel2) + " " + heading + "\n" + content + "\n" + fileContent.slice(sectionEnd2); + await this.writeFileContent(path, newFileContent2); + return { success: true, message: `Section "${heading}" replaced successfully` }; + } + } + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headingRegex = new RegExp(`^(#{1,6})\\s+${escapedHeading}\\s*$`, "m"); + const match = fileContent.match(headingRegex); + if (!match) { + throw new Error(`Heading "${heading}" not found in ${path}`); + } + const headingLevel = match[1].length; + const headingIndex = match.index; + const afterHeading = headingIndex + match[0].length; + const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}})\\s`, "m"); + const nextMatch = nextHeadingRegex.exec(fileContent.slice(afterHeading)); + const sectionStart = headingIndex; + const sectionEnd = nextMatch ? afterHeading + nextMatch.index : fileContent.length; + const newFileContent = fileContent.slice(0, sectionStart) + match[0] + "\n" + content + "\n" + fileContent.slice(sectionEnd); + await this.writeFileContent(path, newFileContent); + return { success: true, message: `Section "${heading}" replaced successfully` }; + } + parseFrontmatter(file, content) { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter) { + return { exists: true, fields: { ...cache.frontmatter } }; + } + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; + const match = content.match(frontmatterRegex); + if (!match) { + return { exists: false, fields: {} }; + } + const raw = match[1]; + const fields = {}; + for (const line of raw.split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) { + fields[key] = value; + } + } + } + return { exists: true, fields }; + } + serializeFrontmatter(fields) { + const lines = []; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === void 0) { + continue; + } + if (Array.isArray(value)) { + lines.push(`${key}: [${value.join(", ")}]`); + } else if (typeof value === "string") { + lines.push(`${key}: ${value}`); + } else if (typeof value === "number" || typeof value === "boolean") { + lines.push(`${key}: ${value}`); + } else { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } + return `--- +${lines.join("\n")} +--- +`; + } + async handleUpdateFrontmatter(args) { + const path = args.path; + const fields = args.fields; + if (typeof path !== "string") { + throw new Error("Path must be a string"); + } + if (!this.isSafePath(path)) { + throw new Error("Invalid file path detected"); + } + if (!fields || typeof fields !== "object" || Array.isArray(fields)) { + throw new Error("Fields must be an object"); + } + const content = await this.readFileContent(path); + const file = this.getFile(path); + const parsed = this.parseFrontmatter(file, content); + const newFields = { ...parsed.fields }; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === void 0) { + delete newFields[key]; + } else if (typeof value === "string") { + newFields[key] = value; + } else if (Array.isArray(value)) { + newFields[key] = value; + } else if (typeof value === "number" || typeof value === "boolean") { + newFields[key] = value; + } else { + newFields[key] = JSON.stringify(value); + } + } + const newFrontmatter = this.serializeFrontmatter(newFields); + const body = parsed.exists ? content.replace(/^---\n[\s\S]*?\n---\n/, "") : content; + const newContent = newFrontmatter + body; + await this.writeFileContent(path, newContent); + return { success: true, message: "Frontmatter updated successfully" }; + } + async handleRenameNote(args) { + const oldPath = args.oldPath; + const newPath = args.newPath; + if (typeof oldPath !== "string") { + throw new Error("oldPath must be a string"); + } + if (typeof newPath !== "string") { + throw new Error("newPath must be a string"); + } + if (!this.isSafePath(oldPath) || !this.isSafePath(newPath)) { + throw new Error("Invalid file path detected"); + } + const file = this.getFile(oldPath); + await this.vault.rename(file, newPath); + return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` }; + } + async handleMoveNote(args) { + const path = args.path; + const folder = args.folder; + if (typeof path !== "string") { + throw new Error("Path must be a string"); + } + if (typeof folder !== "string") { + throw new Error("Folder must be a string"); + } + if (!this.isSafePath(path)) { + throw new Error("Invalid file path detected"); + } + const normalizedFolder = folder.replace(/\/$/, "").trim(); + if (normalizedFolder && !this.isSafePath(normalizedFolder)) { + throw new Error("Invalid folder path detected"); + } + const file = this.getFile(path); + const fileName = file.name; + const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName; + await this.vault.rename(file, newPath); + return { success: true, message: `Note moved to ${newPath}` }; + } + async handleDeleteNote(args) { + const path = args.path; + if (typeof path !== "string") { + throw new Error("Path must be a string"); + } + if (!this.isSafePath(path)) { + throw new Error("Invalid file path detected"); + } + const file = this.getFile(path); + await this.vault.delete(file); + return { success: true, message: `Note ${path} deleted successfully` }; + } + async handleInsertLink(args) { + const sourcePath = args.sourcePath; + const targetPath = args.targetPath; + const anchorText = args.anchorText; + if (typeof sourcePath !== "string") { + throw new Error("sourcePath must be a string"); + } + if (typeof targetPath !== "string") { + throw new Error("targetPath must be a string"); + } + if (!this.isSafePath(sourcePath) || !this.isSafePath(targetPath)) { + throw new Error("Invalid file path detected"); + } + const currentContent = await this.readFileContent(sourcePath); + const linkText = typeof anchorText === "string" && anchorText.trim() ? `[[${targetPath}|${anchorText}]]` : `[[${targetPath}]]`; + const separator = currentContent.endsWith("\n") ? "" : "\n"; + const newContent = currentContent + separator + linkText + "\n"; + await this.writeFileContent(sourcePath, newContent); + return { success: true, message: `Link to ${targetPath} inserted successfully` }; + } +}; + +// src/action-preview-builder.ts +var import_obsidian2 = require("obsidian"); +var WRITE_TOOLS = /* @__PURE__ */ new Set([ + "create_file", + "create_note", + "append_to_note", + "replace_note_section", + "update_frontmatter", + "rename_note", + "move_note", + "delete_note", + "insert_link" +]); +function isWriteTool(name) { + return WRITE_TOOLS.has(name); +} +var ActionPreviewBuilder = class { + constructor(vault, app) { + this.vault = vault; + this.app = app; + } + parseArgs(toolCall) { + const rawArgs = toolCall.function?.arguments; + if (typeof rawArgs === "string") { + try { + return safeParseJson(rawArgs); + } catch { + return {}; + } + } else if (rawArgs && typeof rawArgs === "object") { + return rawArgs; + } + return {}; + } + async buildPreview(toolCall) { + const name = toolCall.function?.name ?? ""; + const args = this.parseArgs(toolCall); + switch (name) { + case "create_file": + case "create_note": + return this.buildCreatePreview(toolCall, args); + case "append_to_note": + return this.buildAppendPreview(toolCall, args); + case "replace_note_section": + return this.buildReplaceSectionPreview(toolCall, args); + case "update_frontmatter": + return this.buildUpdateFrontmatterPreview(toolCall, args); + case "rename_note": + return this.buildRenamePreview(toolCall, args); + case "move_note": + return this.buildMovePreview(toolCall, args); + case "delete_note": + return this.buildDeletePreview(toolCall, args); + case "insert_link": + return this.buildInsertLinkPreview(toolCall, args); + default: + return { + id: toolCall.id, + toolCall, + operation: "read", + path: "", + description: `Unknown operation: ${name}`, + status: "pending" + }; + } + } + strArg(value) { + return typeof value === "string" ? value : ""; + } + buildCreatePreview(toolCall, args) { + const path = this.strArg(args.path); + const content = this.strArg(args.content); + return { + id: toolCall.id, + toolCall, + operation: "create", + path, + description: `Create note: ${path}`, + preview: { + before: void 0, + after: content + }, + status: "pending" + }; + } + async buildAppendPreview(toolCall, args) { + const path = this.strArg(args.path); + const content = this.strArg(args.content); + const before = await this.readFileSafe(path); + const separator = before && before.endsWith("\n") ? "" : "\n"; + return { + id: toolCall.id, + toolCall, + operation: "append", + path, + description: `Append to note: ${path}`, + preview: { + before, + after: before ? before + separator + content : content + }, + status: "pending" + }; + } + async buildReplaceSectionPreview(toolCall, args) { + const path = this.strArg(args.path); + const heading = this.strArg(args.heading); + const content = this.strArg(args.content); + const before = await this.readFileSafe(path); + let after = before ?? ""; + if (before) { + const file = this.getFileSafe(path); + const cache = file ? this.app.metadataCache.getFileCache(file) : null; + if (cache?.headings) { + const targetHeading = cache.headings.find((h) => h.heading === heading); + if (targetHeading) { + const startOffset = targetHeading.position.start.offset; + const headingLevel = targetHeading.level; + const nextHeading = cache.headings.find( + (h) => h.position.start.offset > startOffset && h.level <= headingLevel + ); + const sectionEnd = nextHeading ? nextHeading.position.start.offset : before.length; + after = before.slice(0, startOffset) + "#".repeat(headingLevel) + " " + heading + "\n" + content + "\n" + before.slice(sectionEnd); + } + } else { + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headingRegex = new RegExp(`^(#{1,6}\\s+)${escapedHeading}\\s*$`, "m"); + const match = before.match(headingRegex); + if (match) { + const headingLevel = match[1].length; + const headingIndex = match.index; + const afterHeading = headingIndex + match[0].length; + const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}}\\s)`, "m"); + const nextMatch = nextHeadingRegex.exec(before.slice(afterHeading)); + const sectionEnd = nextMatch ? afterHeading + nextMatch.index : before.length; + after = before.slice(0, headingIndex) + match[0] + "\n" + content + "\n" + before.slice(sectionEnd); + } + } + } + return { + id: toolCall.id, + toolCall, + operation: "replace_section", + path, + description: `Replace section "${heading}" in ${path}`, + preview: { + before, + after + }, + status: "pending" + }; + } + async buildUpdateFrontmatterPreview(toolCall, args) { + const path = this.strArg(args.path); + const fields = args.fields; + const before = await this.readFileSafe(path); + let after = before ?? ""; + if (fields && typeof fields === "object" && !Array.isArray(fields)) { + const file = this.getFileSafe(path); + const parsed = this.parseFrontmatter(file, before ?? ""); + const newFields = { ...parsed.fields }; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === void 0) { + delete newFields[key]; + } else { + newFields[key] = value; + } + } + const newFrontmatter = this.serializeFrontmatter(newFields); + const body = parsed.exists ? (before ?? "").replace(/^---\n[\s\S]*?\n---\n/, "") : before ?? ""; + after = newFrontmatter + body; + } + return { + id: toolCall.id, + toolCall, + operation: "update_frontmatter", + path, + description: `Update frontmatter in ${path}`, + preview: { + before, + after + }, + status: "pending" + }; + } + buildRenamePreview(toolCall, args) { + const oldPath = this.strArg(args.oldPath); + const newPath = this.strArg(args.newPath); + return { + id: toolCall.id, + toolCall, + operation: "rename", + path: oldPath, + description: `Rename ${oldPath} to ${newPath}`, + preview: { + before: oldPath, + after: newPath + }, + status: "pending" + }; + } + buildMovePreview(toolCall, args) { + const path = this.strArg(args.path); + const folder = this.strArg(args.folder); + const fileName = path.split("/").pop() ?? path; + const newPath = folder ? `${folder}/${fileName}` : fileName; + return { + id: toolCall.id, + toolCall, + operation: "move", + path, + description: `Move ${path} to ${newPath}`, + preview: { + before: path, + after: newPath + }, + status: "pending" + }; + } + async buildDeletePreview(toolCall, args) { + const path = this.strArg(args.path); + const before = await this.readFileSafe(path); + return { + id: toolCall.id, + toolCall, + operation: "delete", + path, + description: `Delete note: ${path}`, + preview: { + before, + after: void 0 + }, + status: "pending" + }; + } + async buildInsertLinkPreview(toolCall, args) { + const sourcePath = this.strArg(args.sourcePath); + const targetPath = this.strArg(args.targetPath); + const anchorText = args.anchorText; + const before = await this.readFileSafe(sourcePath); + const linkText = typeof anchorText === "string" && anchorText.trim() ? `[[${targetPath}|${anchorText}]]` : `[[${targetPath}]]`; + const separator = before && before.endsWith("\n") ? "" : "\n"; + const after = before ? before + separator + linkText + "\n" : linkText + "\n"; + return { + id: toolCall.id, + toolCall, + operation: "insert_link", + path: sourcePath, + description: `Insert link to ${targetPath} in ${sourcePath}`, + preview: { + before, + after + }, + status: "pending" + }; + } + getFileSafe(path) { + try { + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof import_obsidian2.TFile) { + return file; + } + } catch { + } + return null; + } + async readFileSafe(path) { + const file = this.getFileSafe(path); + if (file) { + try { + return await this.vault.cachedRead(file); + } catch { + } + } + return void 0; + } + parseFrontmatter(file, content) { + if (file) { + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter) { + return { exists: true, fields: { ...cache.frontmatter } }; + } + } + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; + const match = content.match(frontmatterRegex); + if (!match) { + return { exists: false, fields: {} }; + } + const raw = match[1]; + const fields = {}; + for (const line of raw.split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) { + fields[key] = value; + } + } + } + return { exists: true, fields }; + } + serializeFrontmatter(fields) { + const lines = []; + for (const [key, value] of Object.entries(fields)) { + if (value === null || value === void 0) { + continue; + } + if (Array.isArray(value)) { + lines.push(`${key}: [${value.join(", ")}]`); + } else if (typeof value === "string") { + lines.push(`${key}: ${value}`); + } else if (typeof value === "number" || typeof value === "boolean") { + lines.push(`${key}: ${value}`); + } else { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } + return `--- +${lines.join("\n")} +--- +`; + } }; // src/conversation-state.ts @@ -8855,12 +9636,917 @@ ${queryResult}` } }; +// src/workflow-engine/workflow-engine.ts +var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g; +var WorkflowEngine = class _WorkflowEngine { + constructor(vault, app, ollamaUrl, model, options) { + this.vaultIndexer = new VaultIndexer(vault); + this.toolExecutor = new ToolExecutor(vault, app); + this.ollamaClient = new OllamaClient(ollamaUrl, model, void 0, options?.cacheConfig); + this.conversationStateManager = new ConversationStateManager(); + this.maxSteps = options?.maxSteps ?? 20; + this.maxWorkflowDuration = options?.maxWorkflowDuration ?? 3e5; + } + /** + * Execute a workflow definition. + * @param definition The workflow to execute + * @param initialVariables Optional initial variables to seed the context + * @returns The execution result with all step outputs + */ + async executeWorkflow(definition, initialVariables) { + Logger.info(`Starting workflow: ${definition.name} (${definition.id})`, "workflow-engine"); + const startTime = Date.now(); + const context = this.createExecutionContext(initialVariables); + const validationError = this.validateWorkflow(definition); + if (validationError) { + Logger.error(`Workflow validation failed: ${validationError}`, "workflow-engine"); + return { + workflowId: definition.id, + workflowName: definition.name, + success: false, + stepResults: [], + finalOutput: null, + error: validationError + }; + } + try { + const orderedSteps = this.topologicalSort(definition.steps); + let stepCount = 0; + for (const step of orderedSteps) { + const elapsed = Date.now() - startTime; + if (elapsed > this.maxWorkflowDuration) { + throw new Error(`Workflow exceeded maximum duration of ${this.maxWorkflowDuration}ms`); + } + stepCount++; + if (stepCount > this.maxSteps) { + throw new Error(`Workflow exceeded maximum step count of ${this.maxSteps}`); + } + if (step.dependsOn) { + const depResult = context.stepResults.find((r) => r.stepId === step.dependsOn); + if (!depResult) { + throw new Error(`Step ${step.id}: dependency '${step.dependsOn}' not found`); + } + if (!depResult.success) { + Logger.warn( + `Step ${step.id}: dependency '${step.dependsOn}' failed, skipping`, + "workflow-engine" + ); + context.stepResults.push({ + stepId: step.id, + stepName: step.name, + success: false, + data: null, + error: `Dependency '${step.dependsOn}' failed: ${depResult.error}`, + timestamp: Date.now() + }); + continue; + } + } + Logger.debug(`Executing step: ${step.name} (${step.id})`, "workflow-engine"); + const result = await this.executeStep(step, context); + context.stepResults.push(result); + context.variables.set(step.id, result.data); + if (step.type === "llm" && result.success) { + this.conversationStateManager.updateShortTermContext({ + role: "assistant", + content: String(result.data) + }); + } + } + const lastSuccessfulResult = [...context.stepResults].reverse().find((r) => r.success); + const finalOutput = lastSuccessfulResult?.data ?? null; + const success = context.stepResults.every((r) => r.success); + Logger.info( + `Workflow completed: ${definition.name} (${success ? "success" : "partial"})`, + "workflow-engine" + ); + return { + workflowId: definition.id, + workflowName: definition.name, + success, + stepResults: context.stepResults, + finalOutput + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Workflow failed: ${errorMessage}`, "workflow-engine"); + return { + workflowId: definition.id, + workflowName: definition.name, + success: false, + stepResults: context.stepResults, + finalOutput: null, + error: errorMessage + }; + } + } + /** + * Execute a workflow from a natural language description. + * The LLM will generate the workflow steps, then we execute them. + * + * @param userQuery The user's natural language request + * @param availableTools Optional list of available tools to inform the LLM + * @returns The execution result + */ + async executeWorkflowFromQuery(userQuery, availableTools) { + Logger.info(`Generating workflow from query: ${userQuery}`, "workflow-engine"); + const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools); + if (!workflow) { + return { + workflowId: "auto-generated", + workflowName: "Auto-generated workflow", + success: false, + stepResults: [], + finalOutput: null, + error: "Failed to generate workflow from query" + }; + } + const initialVariables = { + original_query: userQuery + }; + return this.executeWorkflow(workflow, initialVariables); + } + /** + * Generate a workflow definition from a natural language query using the LLM. + */ + async generateWorkflowFromQuery(query, availableTools) { + const toolDescriptions = availableTools?.map((t) => `- ${t.function.name}: ${t.function.description}`).join("\n") ?? ""; + const systemPrompt = `You are a workflow planner. Given a user query, break it down into a sequence of workflow steps. + +Available step types: +- vault_search: Search the vault for notes. Config: { type: 'vault_search', query: string, limit?: number, tagFilter?: string } +- llm: Call an LLM. Config: { type: 'llm', systemPrompt?: string, userPrompt: string } +- tool: Execute a tool. Config: { type: 'tool', toolName: string, args: object } +- format: Format output. Config: { type: 'format', template: string, outputFormat?: 'markdown' | 'text' | 'json' } + +Available tools: +${toolDescriptions} + +Variable interpolation syntax: +- Use {{stepId.output}} to reference a previous step's output +- Use {{stepId.output.property}} to reference a property of a step's output +- Use {{original_query}} to reference the original user query + +Return a JSON object with this structure: +{ + "id": "workflow-uuid", + "name": "workflow name", + "description": "description", + "steps": [ + { + "id": "step_1", + "type": "vault_search" | "llm" | "tool" | "format", + "name": "step name", + "description": "optional description", + "config": { /* step-specific config */ }, + "dependsOn": "optional_step_id" + } + ] +} + +Rules: +1. Number steps sequentially (step_1, step_2, etc.) +2. Use dependsOn to specify ordering when needed +3. Use variable interpolation to pass data between steps +4. Keep the workflow minimal but effective +5. End with a format step if the user wants structured output`; + const userPrompt = `User query: ${query}`; + try { + const messages = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt } + ]; + const response = await this.ollamaClient.chat(messages, []); + const content = response.content?.trim(); + if (!content) { + Logger.error("Empty response from LLM when generating workflow", "workflow-engine"); + return null; + } + const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) ?? content.match(/\{[\s\S]*\}/); + const jsonString = jsonMatch ? jsonMatch[1] : content; + const parsed = safeParseJson(jsonString); + if (!parsed || typeof parsed !== "object") { + Logger.error("Invalid workflow JSON from LLM", "workflow-engine"); + return null; + } + return parsed; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Failed to generate workflow: ${errorMessage}`, "workflow-engine"); + return null; + } + } + /** + * Create a fresh execution context. + */ + createExecutionContext(initialVariables) { + const variables = /* @__PURE__ */ new Map(); + if (initialVariables) { + for (const [key, value] of Object.entries(initialVariables)) { + variables.set(key, value); + } + } + return { + variables, + stepResults: [], + conversationHistory: [] + }; + } + /** + * Execute a single workflow step. + */ + async executeStep(step, context) { + const timestamp = Date.now(); + try { + const interpolatedConfig = this.interpolateVariables(step.config, context.variables); + let data; + switch (step.type) { + case "llm": + data = await this.executeLlmStep(interpolatedConfig, context); + break; + case "vault_search": + data = await this.executeVaultSearchStep(interpolatedConfig); + break; + case "tool": + data = await this.executeToolStep(interpolatedConfig); + break; + case "format": + data = this.executeFormatStep(interpolatedConfig, context); + break; + default: + throw new Error(`Unknown step type: ${String(step.type)}`); + } + return { + stepId: step.id, + stepName: step.name, + success: true, + data, + timestamp + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Step ${step.id} failed: ${errorMessage}`, "workflow-engine"); + return { + stepId: step.id, + stepName: step.name, + success: false, + data: null, + error: errorMessage, + timestamp + }; + } + } + /** + * Execute an LLM step. + */ + async executeLlmStep(config, context) { + const messages = []; + if (config.systemPrompt) { + messages.push({ + role: "system", + content: config.systemPrompt + }); + } + messages.push(...context.conversationHistory); + messages.push({ + role: "user", + content: config.userPrompt + }); + const tools = config.includeToolCalls ? [] : []; + const response = await this.ollamaClient.chat(messages, tools); + return response.content ?? ""; + } + /** + * Execute a vault search step. + */ + async executeVaultSearchStep(config) { + const limit = config.limit ?? 5; + const entries = await this.vaultIndexer.searchVault(config.query, limit); + const filtered = config.tagFilter ? entries.filter((entry) => { + const tags = entry.tags ?? ""; + return tags.toLowerCase().includes(config.tagFilter.toLowerCase()); + }) : entries; + return filtered.map((entry) => ({ + path: entry.path, + title: entry.title, + content: entry.content, + score: entry.score, + tags: entry.tags + })); + } + /** + * Execute a tool step. + */ + async executeToolStep(config) { + const args = config.args ?? {}; + const result = await this.toolExecutor.executeTool(config.toolName, args); + return { + success: result.success, + message: result.message, + data: result.data + }; + } + /** + * Execute a format step (template rendering). + */ + executeFormatStep(config, context) { + let output = config.template; + output = String(this.interpolateVariables(output, context.variables)); + if (config.outputFormat === "json") { + try { + const parsed = safeParseJson(output); + return JSON.stringify(parsed, null, 2); + } catch { + return output; + } + } + return output; + } + /** + * Interpolate {{variables}} in a string or object. + * Supports: + * - {{variableName}} -> value from context + * - {{stepId.output}} -> data from a step result + * - {{stepId.output.property}} -> nested property access + */ + interpolateVariables(input, variables) { + if (typeof input === "string") { + return this.interpolateString(input, variables); + } + if (Array.isArray(input)) { + return input.map((item) => this.interpolateVariables(item, variables)); + } + if (input !== null && typeof input === "object") { + const result = {}; + for (const [key, value] of Object.entries(input)) { + result[key] = this.interpolateVariables(value, variables); + } + return result; + } + return input; + } + /** + * Interpolate variables in a string. + */ + interpolateString(input, variables) { + return input.replace(VARIABLE_PATTERN, (_match, variablePath) => { + const value = this.resolveVariable(variablePath, variables); + if (value === void 0) { + Logger.warn(`Variable '${variablePath}' not found during interpolation`, "workflow-engine"); + return _match; + } + if (typeof value === "object" && value !== null) { + return JSON.stringify(value); + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null || value === void 0) { + return String(value); + } + return String(value); + }); + } + /** + * Resolve a variable path like "step_1.output" or "step_1.output.property". + */ + resolveVariable(path, variables) { + const parts = path.split("."); + if (parts.length >= 2 && parts[1] === "output") { + const stepId = parts[0]; + const stepData = variables.get(stepId); + if (parts.length === 2) { + return stepData; + } + let current = stepData; + for (let i = 2; i < parts.length; i++) { + if (current === null || current === void 0 || typeof current !== "object") { + return void 0; + } + current = current[parts[i]]; + } + return current; + } + return variables.get(path); + } + /** + * Perform topological sort on steps to determine execution order. + * This respects the dependsOn field and ensures steps run in the correct order. + */ + topologicalSort(steps) { + const stepMap = /* @__PURE__ */ new Map(); + for (const step of steps) { + stepMap.set(step.id, step); + } + const result = []; + const visited = /* @__PURE__ */ new Set(); + const visiting = /* @__PURE__ */ new Set(); + const visit = (stepId) => { + if (visited.has(stepId)) return; + if (visiting.has(stepId)) { + throw new Error(`Circular dependency detected involving step '${stepId}'`); + } + const step = stepMap.get(stepId); + if (!step) { + throw new Error(`Step '${stepId}' not found`); + } + visiting.add(stepId); + if (step.dependsOn) { + visit(step.dependsOn); + } + visiting.delete(stepId); + visited.add(stepId); + result.push(step); + }; + for (const step of steps) { + visit(step.id); + } + return result; + } + /** + * Validate a workflow definition before execution. + * Returns null if valid, or an error message string if invalid. + */ + validateWorkflow(definition) { + if (!definition.id) { + return "Workflow must have an id"; + } + if (!definition.name) { + return "Workflow must have a name"; + } + if (!definition.steps || !Array.isArray(definition.steps)) { + return "Workflow must have a steps array"; + } + if (definition.steps.length === 0) { + return "Workflow must have at least one step"; + } + const stepIds = /* @__PURE__ */ new Set(); + for (const step of definition.steps) { + if (!step.id) { + return "Each step must have an id"; + } + if (!step.type) { + return `Step '${step.id}' must have a type`; + } + if (!step.name) { + return `Step '${step.id}' must have a name`; + } + if (!step.config) { + return `Step '${step.id}' must have a config`; + } + if (stepIds.has(step.id)) { + return `Duplicate step id: '${step.id}'`; + } + stepIds.add(step.id); + const validTypes = ["llm", "vault_search", "tool", "format"]; + if (!validTypes.includes(step.type)) { + return `Step '${step.id}' has invalid type: '${step.type}'`; + } + if (step.dependsOn && !definition.steps.some((s) => s.id === step.dependsOn)) { + return `Step '${step.id}' depends on unknown step: '${step.dependsOn}'`; + } + const configError = this.validateStepConfig(step); + if (configError) { + return configError; + } + } + return null; + } + /** + * Validate a step's configuration based on its type. + */ + validateStepConfig(step) { + switch (step.type) { + case "llm": { + const config = step.config; + if (!config.userPrompt) { + return `LLM step '${step.id}' requires a userPrompt`; + } + break; + } + case "vault_search": { + const config = step.config; + if (!config.query) { + return `Vault search step '${step.id}' requires a query`; + } + break; + } + case "tool": { + const config = step.config; + if (!config.toolName) { + return `Tool step '${step.id}' requires a toolName`; + } + break; + } + case "format": { + const config = step.config; + if (!config.template) { + return `Format step '${step.id}' requires a template`; + } + break; + } + } + return null; + } + /** + * Get all built-in workflow definitions (presets). + */ + static getBuiltInWorkflows() { + return [ + _WorkflowEngine.createMeetingSummaryWorkflow(), + _WorkflowEngine.createNoteAnalyzerWorkflow() + ]; + } + /** + * Create a workflow that summarizes meeting notes from the last week. + */ + static createMeetingSummaryWorkflow() { + return { + id: "meeting-summary", + name: "Meeting Notes Summary", + description: "Analyzes meeting notes from the last week, extracts decisions, and creates a summary.", + steps: [ + { + id: "step_1", + type: "vault_search", + name: "Find Meeting Notes", + description: "Search for notes tagged with #meeting", + config: { + type: "vault_search", + query: "meeting", + limit: 10, + tagFilter: "meeting" + } + }, + { + id: "step_2", + type: "llm", + name: "Extract Decisions", + description: "Extract key decisions and assigned owners from meeting notes", + config: { + type: "llm", + systemPrompt: "You are a meeting analyst. Extract key decisions, action items, and assigned owners from meeting notes.", + userPrompt: `Here are meeting notes from recent meetings: + +{{step_1.output}} + +Please extract: +1. Key decisions made +2. Action items with assigned owners +3. Deadlines if mentioned + +Format as a structured list.` + }, + dependsOn: "step_1" + }, + { + id: "step_3", + type: "format", + name: "Format Summary", + description: "Format the results into a markdown table", + config: { + type: "format", + template: "# Meeting Summary\n\n## Decisions and Action Items\n\n{{step_2.output}}\n\n---\n*Generated by Workflow Engine*", + outputFormat: "markdown" + }, + dependsOn: "step_2" + } + ] + }; + } + /** + * Create a workflow that analyzes notes and generates insights. + */ + static createNoteAnalyzerWorkflow() { + return { + id: "note-analyzer", + name: "Note Analyzer", + description: "Analyzes notes and generates insights, summaries, and suggestions.", + steps: [ + { + id: "step_1", + type: "vault_search", + name: "Search Notes", + description: "Search for relevant notes based on query", + config: { + type: "vault_search", + query: "{{original_query}}", + limit: 5 + } + }, + { + id: "step_2", + type: "llm", + name: "Analyze Content", + description: "Analyze the found notes for insights", + config: { + type: "llm", + systemPrompt: "You are an analytical assistant. Analyze the provided notes and identify key themes, insights, and connections.", + userPrompt: `Original query: {{original_query}} + +Found notes: +{{step_1.output}} + +Please provide: +1. Key themes identified +2. Important insights +3. Potential connections between notes +4. Suggestions for further exploration` + }, + dependsOn: "step_1" + }, + { + id: "step_3", + type: "format", + name: "Format Results", + description: "Format the analysis into a readable report", + config: { + type: "format", + template: "# Analysis Report\n\n## Query: {{original_query}}\n\n## Findings\n\n{{step_2.output}}\n\n---\n*Generated by Workflow Engine*", + outputFormat: "markdown" + }, + dependsOn: "step_2" + } + ] + }; + } +}; + +// src/note-context-builder.ts +var import_obsidian3 = require("obsidian"); +var NoteContextBuilder = class { + constructor(vault, app, vaultIndexer) { + this.vault = vault; + this.app = app; + this.vaultIndexer = vaultIndexer; + } + /** + * Extracts wikilink mentions like [[Note Title]] from a message. + */ + extractExplicitMentions(message) { + const mentions = []; + const wikiLinkRegex = /\[\[(.+?)\]\]/g; + let match; + while ((match = wikiLinkRegex.exec(message)) !== null) { + const title = match[1].split("|")[0].trim(); + mentions.push(title); + } + return [...new Set(mentions)]; + } + /** + * Detects scope commands in the user message. + * Returns 'explicit' if user says "use only this note" or similar. + * Returns 'related' if user says "include related notes" or similar. + * Returns 'default' otherwise. + */ + detectScopeIntent(message) { + const lower = message.toLowerCase(); + if (lower.includes("use only this note") || lower.includes("only this note") || lower.includes("just this note") || lower.includes("use only the current note")) { + return "explicit"; + } + if (lower.includes("include related notes") || lower.includes("include related") || lower.includes("neighboring notes") || lower.includes("linked notes") || lower.includes("context around")) { + return "related"; + } + return "default"; + } + /** + * Gets the currently active note entry. + */ + async getOpenNote() { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + return void 0; + } + return this.fileToIndexEntry(activeFile); + } + /** + * Gets selected text from the active markdown editor. + */ + getSelectedText() { + const activeView = this.app.workspace.getActiveViewOfType(import_obsidian3.MarkdownView); + if (!activeView) { + return void 0; + } + const editor = activeView.editor; + if (!editor) { + return void 0; + } + const selection = editor.getSelection().trim(); + return selection.length > 0 ? selection : void 0; + } + /** + * Resolves a note title or path to a TFile. + */ + resolveNote(titleOrPath) { + const isFile = (f) => !!f && typeof f === "object" && "path" in f && "basename" in f; + const byPath = this.vault.getAbstractFileByPath(titleOrPath); + if (isFile(byPath)) { + return byPath; + } + const withExtension = titleOrPath.endsWith(".md") ? titleOrPath : `${titleOrPath}.md`; + const byPathExt = this.vault.getAbstractFileByPath(withExtension); + if (isFile(byPathExt)) { + return byPathExt; + } + const files = this.vault.getMarkdownFiles(); + return files.find((f) => f.basename === titleOrPath) ?? null; + } + /** + * Reads file content and builds a VaultIndexEntry using metadataCache. + */ + async fileToIndexEntry(file) { + try { + const content = await this.vault.cachedRead(file); + const cache = this.app.metadataCache.getFileCache(file); + let title = file.basename; + const frontmatter = cache?.frontmatter ? cache.frontmatter : void 0; + if (frontmatter?.title && typeof frontmatter.title === "string") { + title = frontmatter.title; + } else if (cache?.headings && cache.headings.length > 0) { + title = cache.headings[0].heading; + } + let tags; + const frontmatterTags = frontmatter?.tags; + if (Array.isArray(frontmatterTags)) { + tags = frontmatterTags.join(", "); + } else if (typeof frontmatterTags === "string") { + tags = frontmatterTags; + } + const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").slice(0, 500); + return { + path: file.path, + title, + content: body, + score: 1, + tags + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.warn(`Failed to read ${file.path}: ${errorMessage}`, "note-context"); + return { + path: file.path, + title: file.basename, + content: "", + score: 1 + }; + } + } + /** + * Gets backlinks for a file using Obsidian's metadataCache. + */ + getBacklinks(file) { + const metadataCache = this.app.metadataCache; + const resolvedLinks = metadataCache.resolvedLinks ?? {}; + const backlinks = []; + for (const sourcePath of Object.keys(resolvedLinks)) { + const targets = resolvedLinks[sourcePath]; + if (targets && targets[file.path]) { + const sourceFile = this.vault.getAbstractFileByPath(sourcePath); + if (sourceFile && typeof sourceFile === "object" && "path" in sourceFile) { + backlinks.push(sourceFile); + } + } + } + return backlinks; + } + /** + * Gets outlinks (forward links) for a file using Obsidian's metadataCache. + */ + getOutlinks(file) { + const cache = this.app.metadataCache.getCache(file.path); + if (!cache?.links) { + return []; + } + const outlinks = []; + for (const link of cache.links) { + const targetPath = link.link; + const resolved = this.resolveNote(targetPath); + if (resolved) { + outlinks.push(resolved); + } + } + return [...new Set(outlinks.map((f) => f.path))].map((p) => this.vault.getAbstractFileByPath(p)).filter((f) => !!f && typeof f === "object" && "path" in f); + } + /** + * Builds the full note context for a user message. + */ + async buildContext(message, searchLimit, options = {}) { + const scope = this.detectScopeIntent(message); + const explicitTitles = this.extractExplicitMentions(message); + const explicitNotes = []; + for (const title of explicitTitles) { + const file = this.resolveNote(title); + if (file) { + explicitNotes.push(await this.fileToIndexEntry(file)); + } + } + let openNote; + let selectedText; + const backlinks = []; + const outlinks = []; + const relatedNotes = []; + let searchResults = []; + if (scope !== "explicit" || explicitNotes.length === 0) { + openNote = await this.getOpenNote(); + if (options.includeSelectedText !== false) { + selectedText = this.getSelectedText(); + } + } + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && (scope === "related" || options.includeBacklinks || options.includeOutlinks)) { + if (options.includeBacklinks !== false) { + const backFiles = this.getBacklinks(activeFile); + for (const f of backFiles.slice(0, options.maxRelatedNotes ?? 10)) { + backlinks.push(await this.fileToIndexEntry(f)); + } + } + if (options.includeOutlinks !== false) { + const outFiles = this.getOutlinks(activeFile); + for (const f of outFiles.slice(0, options.maxRelatedNotes ?? 10)) { + outlinks.push(await this.fileToIndexEntry(f)); + } + } + } + if (scope === "related" || options.includeRelated) { + const relatedPaths = /* @__PURE__ */ new Set(); + for (const n of [...backlinks, ...outlinks]) { + if (!relatedPaths.has(n.path)) { + relatedPaths.add(n.path); + relatedNotes.push(n); + } + } + } + if (scope !== "explicit") { + const searchQuery = this.sanitizeSearchQuery(message); + searchResults = await this.vaultIndexer.searchVault(searchQuery, searchLimit); + } else if (explicitNotes.length > 0) { + searchResults = explicitNotes; + } + return { + explicitMentions: explicitNotes, + openNote, + selectedText, + backlinks, + outlinks, + relatedNotes, + searchResults + }; + } + /** + * Formats a NoteContext into a string for the LLM prompt. + */ + formatContext(context, maxLength) { + const parts = []; + if (context.selectedText) { + parts.push(`Selected text from current note: +${context.selectedText}`); + } + if (context.openNote) { + parts.push(`Current open note: ${context.openNote.title} (${context.openNote.path})`); + if (context.openNote.tags) { + parts.push(`Tags: ${context.openNote.tags}`); + } + parts.push(context.openNote.content); + } + if (context.explicitMentions.length > 0) { + parts.push("Explicitly mentioned notes:"); + for (const note of context.explicitMentions) { + parts.push(`- ${note.title} (${note.path})`); + if (note.tags) parts.push(` Tags: ${note.tags}`); + parts.push(note.content.slice(0, 300)); + } + } + if (context.relatedNotes.length > 0) { + parts.push("Related notes (backlinks + outlinks):"); + for (const note of context.relatedNotes) { + parts.push(`- ${note.title} (${note.path})`); + } + } + if (context.searchResults.length > 0) { + parts.push("Vault search results:"); + for (const note of context.searchResults) { + parts.push(`- ${note.title} (${note.path})`); + if (note.tags) parts.push(` Tags: ${note.tags}`); + parts.push(note.content.slice(0, 300)); + } + } + let result = parts.join("\n\n"); + if (result.length > maxLength) { + result = result.slice(0, maxLength) + "\n... [truncated]"; + } + return result; + } + /** + * Removes wikilinks and command phrases to get a clean search query. + */ + sanitizeSearchQuery(message) { + return message.replace(/\[\[.+?\]\]/g, "").replace(/use only this note/gi, "").replace(/include related notes/gi, "").replace(/include related/gi, "").replace(/neighboring notes/gi, "").replace(/linked notes/gi, "").replace(/context around/gi, "").trim(); + } +}; + // src/error-handler.ts -var import_obsidian2 = require("obsidian"); +var import_obsidian4 = require("obsidian"); var ErrorHandler = class { static handleError(error, context) { const message = this.getUserFriendlyMessage(error); - new import_obsidian2.Notice(message); + new import_obsidian4.Notice(message); if (error instanceof Error) { const ctx = context ? ` [${context}]` : ""; console.error(`Ollama Plugin Error${ctx}: ${error.message}`); @@ -8956,8 +10642,8 @@ var ErrorHandler = class { }; // src/chat-view.ts -var ChatView = class extends import_obsidian3.ItemView { - constructor(leaf, settings, vectorStore) { +var ChatView = class extends import_obsidian5.ItemView { + constructor(leaf, settings, vectorStore, structuredMemoryManager, telemetryManager) { super(leaf); // State this.messages = []; @@ -8973,6 +10659,11 @@ var ChatView = class extends import_obsidian3.ItemView { this.inputKeyDownWrapper = null; this.newChatButtonClickWrapper = null; this.listenersAttached = false; + this.modeSelectorEl = null; + // Pending action state + this.pendingActions = []; + this.pendingReadResults = []; + this.pendingFollowUpContext = null; this.messages = []; this.lastMessageEl = null; this.newChatButton = null; @@ -8987,15 +10678,23 @@ var ChatView = class extends import_obsidian3.ItemView { this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.settings = settings; - this.ollamaClient = new OllamaClient( - settings.ollamaUrl, - settings.model, - void 0, - settings.cacheConfig - ); + this.currentAgentMode = settings.agentMode ?? "ask"; + this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings); + this.agentOllamaClient = (settings.agentModel ?? settings.model) === (settings.chatModel ?? settings.model) ? this.ollamaClient : this.createOllamaClient(settings.agentModel ?? settings.model, settings); this.vaultIndexer = new VaultIndexer(this.app.vault, void 0, vectorStore); - this.toolExecutor = new ToolExecutor(this.app.vault, this.app); + this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager); + this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); + this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); this.conversationStateManager = new ConversationStateManager(); + this.structuredMemoryManager = structuredMemoryManager; + this.telemetryManager = telemetryManager; + this.workflowEngine = new WorkflowEngine( + this.app.vault, + this.app, + settings.ollamaUrl, + settings.agentModel ?? settings.model, + { cacheConfig: settings.cacheConfig } + ); } // Getters for testing getSendButtonClickHandler() { @@ -9009,14 +10708,24 @@ var ChatView = class extends import_obsidian3.ItemView { } updateSettings(newSettings) { this.settings = newSettings; - this.ollamaClient = new OllamaClient( - newSettings.ollamaUrl, - newSettings.model, - void 0, - newSettings.cacheConfig + this.currentAgentMode = newSettings.agentMode ?? "ask"; + if (this.modeSelectorEl) { + this.modeSelectorEl.value = this.currentAgentMode; + } + this.ollamaClient = this.createOllamaClient( + newSettings.chatModel ?? newSettings.model, + newSettings ); - void this.ollamaClient.initializeCache().catch(() => { - new import_obsidian3.Notice( + this.agentOllamaClient = (newSettings.agentModel ?? newSettings.model) === (newSettings.chatModel ?? newSettings.model) ? this.ollamaClient : this.createOllamaClient(newSettings.agentModel ?? newSettings.model, newSettings); + this.workflowEngine = new WorkflowEngine( + this.app.vault, + this.app, + newSettings.ollamaUrl, + newSettings.agentModel ?? newSettings.model, + { cacheConfig: newSettings.cacheConfig } + ); + void this.initializeClientCaches().catch(() => { + new import_obsidian5.Notice( "Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings." ); }); @@ -9027,6 +10736,9 @@ var ChatView = class extends import_obsidian3.ItemView { } async clearCache() { await this.ollamaClient.clearCache(); + if (this.agentOllamaClient !== this.ollamaClient) { + await this.agentOllamaClient.clearCache(); + } } getViewType() { return "ollama-chat-view"; @@ -9039,9 +10751,9 @@ var ChatView = class extends import_obsidian3.ItemView { } async onOpen() { try { - await this.ollamaClient.initializeCache(); + await this.initializeClientCaches(); } catch { - new import_obsidian3.Notice( + new import_obsidian5.Notice( "Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings." ); } @@ -9118,6 +10830,25 @@ var ChatView = class extends import_obsidian3.ItemView { container.appendChild(this.lastMessageEl); } } + if (!this.modeSelectorEl) { + this.modeSelectorEl = newChatContainer.createEl("select", { + cls: "ollama-mode-selector" + }); + for (const mode of ALL_AGENT_MODES) { + const option = this.modeSelectorEl.createEl("option", { + text: getAgentModeLabel(mode), + attr: { value: mode } + }); + if (mode === this.currentAgentMode) { + option.setAttribute("selected", "selected"); + } + } + this.modeSelectorEl.addEventListener("change", () => { + this.currentAgentMode = this.modeSelectorEl.value; + }); + } else { + newChatContainer.appendChild(this.modeSelectorEl); + } if (!this.newChatButton) { this.newChatButton = newChatContainer.createEl("button", { cls: "ollama-new-chat-button", @@ -9189,6 +10920,15 @@ var ChatView = class extends import_obsidian3.ItemView { } this.listenersAttached = false; } + getAgentMode() { + return this.currentAgentMode; + } + setAgentMode(mode) { + this.currentAgentMode = mode; + if (this.modeSelectorEl) { + this.modeSelectorEl.value = mode; + } + } clearConversation() { this.messages = []; this.conversationStateManager.clear(); @@ -9212,7 +10952,28 @@ var ChatView = class extends import_obsidian3.ItemView { } } getTools() { - return [ + const allTools = [ + { + type: "function", + function: { + name: "create_note", + description: "Creates a new note in the vault at the specified path with the given content", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: 'The path to the new note (e.g., "Projects/My Note.md")' + }, + content: { + type: "string", + description: "The markdown content for the new note" + } + }, + required: ["path", "content"] + } + } + }, { type: "function", function: { @@ -9224,10 +10985,6 @@ var ChatView = class extends import_obsidian3.ItemView { path: { type: "string", description: "The path to the file to read" - }, - content: { - type: "string", - description: "The content of the file to read" } }, required: ["path"] @@ -9254,25 +11011,193 @@ var ChatView = class extends import_obsidian3.ItemView { required: ["query"] } } + }, + { + type: "function", + function: { + name: "append_to_note", + description: "Appends content to the end of an existing note", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The path to the note" + }, + content: { + type: "string", + description: "The content to append" + } + }, + required: ["path", "content"] + } + } + }, + { + type: "function", + function: { + name: "replace_note_section", + description: "Replaces the body of a section under the specified heading in a note", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The path to the note" + }, + heading: { + type: "string", + description: "The heading text of the section to replace" + }, + content: { + type: "string", + description: "The new content for the section (heading will be preserved)" + } + }, + required: ["path", "heading", "content"] + } + } + }, + { + type: "function", + function: { + name: "update_frontmatter", + description: "Updates YAML frontmatter fields in a note. Adds, updates, or removes fields.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The path to the note" + }, + fields: { + type: "object", + description: "An object of frontmatter key-value pairs to set. Use null to remove a field." + } + }, + required: ["path", "fields"] + } + } + }, + { + type: "function", + function: { + name: "rename_note", + description: "Renames a note to a new path within the vault", + parameters: { + type: "object", + properties: { + oldPath: { + type: "string", + description: "The current path to the note" + }, + newPath: { + type: "string", + description: "The new path for the note" + } + }, + required: ["oldPath", "newPath"] + } + } + }, + { + type: "function", + function: { + name: "move_note", + description: "Moves a note into a different folder", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The current path to the note" + }, + folder: { + type: "string", + description: 'The target folder path (e.g., "Projects"). Use "" for vault root.' + } + }, + required: ["path", "folder"] + } + } + }, + { + type: "function", + function: { + name: "delete_note", + description: "Deletes a note from the vault", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The path to the note to delete" + } + }, + required: ["path"] + } + } + }, + { + type: "function", + function: { + name: "insert_link", + description: "Inserts a wikilink to another note at the end of a source note", + parameters: { + type: "object", + properties: { + sourcePath: { + type: "string", + description: "The path to the note that will contain the link" + }, + targetPath: { + type: "string", + description: "The path to the note being linked to" + }, + anchorText: { + type: "string", + description: "Optional display text for the link" + } + }, + required: ["sourcePath", "targetPath"] + } + } } ]; + return filterToolsForMode(allTools, this.currentAgentMode); + } + /** + * Build messages for the LLM, injecting structured memory context if available. + * This is the canonical message builder used for all LLM calls in this view. + */ + buildMessagesWithMemory(baseMessages) { + const memoryContext = this.structuredMemoryManager?.buildMemoryContext(); + if (memoryContext) { + return [{ role: "system", content: memoryContext }, ...baseMessages]; + } + return baseMessages; } buildMessages(userMessageContent, tools) { - const systemContent = `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - Vault context includes note titles, content, and any tags (shown as "Tags: ..." at the top of a note entry). - When organizing or categorizing notes, pay attention to tags as they reflect the note's topics and categories. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.`; - const systemMessage = { + const systemContent = getSystemPromptForMode(this.currentAgentMode); + const messages = []; + if (this.structuredMemoryManager) { + const memoryContext = this.structuredMemoryManager.buildMemoryContext(); + if (memoryContext) { + messages.push({ + role: "system", + content: memoryContext + }); + } + } + messages.push({ role: "system", content: systemContent - }; + }); const userMessage = { role: "user", content: userMessageContent }; - const messages = [systemMessage, userMessage]; + messages.push(userMessage); if (tools && tools.length > 0) { messages.push({ role: "assistant", @@ -9282,8 +11207,10 @@ var ChatView = class extends import_obsidian3.ItemView { return messages; } async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) { - const toolResults = (await Promise.all( - toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => { + const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? "")); + const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? "")); + const readResults = (await Promise.all( + readToolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => { try { const toolResult = await this.toolExecutor.handleToolCall(toolCall); return { ...toolResult, id: toolCall.id }; @@ -9293,13 +11220,51 @@ var ChatView = class extends import_obsidian3.ItemView { } }) )).filter((result) => result !== null); - const followUpMessages = toolResults.map((result) => { - return { - role: "tool", - content: JSON.stringify(result), - tool_call_id: result.id ?? "" - }; - }); + const writePreviews = []; + for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) { + try { + const preview = await this.actionPreviewBuilder.buildPreview(toolCall); + writePreviews.push(preview); + } catch (error) { + ErrorHandler.handleError(error, "ChatView.handleUserInput"); + } + } + if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) { + this.pendingActions = writePreviews; + this.pendingReadResults = readResults; + this.pendingFollowUpContext = { messages, tools, assistantMessageId }; + this.updateMessageById(assistantMessageId, { + content: `${fullResponse} + +*Proposed actions:* +${writePreviews.map((a) => `- ${a.description}`).join("\n")}`, + isStreaming: false, + isThinking: false + }); + this.render(); + this.renderActionPreviews(assistantMessageId); + return; + } + let writeResults = []; + if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) { + writeResults = (await Promise.all( + writePreviews.map(async (action) => { + try { + const toolResult = await this.toolExecutor.handleToolCall(action.toolCall); + return { ...toolResult, id: action.toolCall.id }; + } catch (error) { + ErrorHandler.handleError(error, "ChatView.processToolCalls"); + return null; + } + }) + )).filter((result) => result !== null); + } + const allResults = [...readResults, ...writeResults]; + const followUpMessages = allResults.map((result) => ({ + role: "tool", + content: JSON.stringify(result), + tool_call_id: result.id ?? "" + })); const followUp = { role: "assistant", content: "I have processed your request using the following tools. Here are the results:", @@ -9307,10 +11272,223 @@ var ChatView = class extends import_obsidian3.ItemView { }; if (followUpMessages.length > 0) { const finalMessages = [...messages, followUp, ...followUpMessages]; - const response = await this.ollamaClient.chat(finalMessages, tools); + const followUpStartTime = Date.now(); + const response = await this.getActiveOllamaClient().chat(finalMessages, tools); + const followUpDurationMs = Date.now() - followUpStartTime; const finalResponse = response.content || fullResponse; this.updateMessageById(assistantMessageId, { content: finalResponse, + isStreaming: false, + isThinking: false + }); + this.telemetryManager?.recordLlmCall({ + model: this.getActiveModel(), + promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4), + completionTokens: Math.round(finalResponse.length / 4), + totalTokens: Math.round( + (finalMessages.reduce((sum, m) => sum + m.content.length, 0) + finalResponse.length) / 4 + ), + durationMs: followUpDurationMs + }); + } else { + this.updateMessageById(assistantMessageId, { + content: fullResponse || "No tool results to report.", + isStreaming: false, + isThinking: false + }); + } + } + async applyPendingActions() { + if (this.pendingActions.length === 0 || !this.pendingFollowUpContext) { + return; + } + const { messages, tools, assistantMessageId } = this.pendingFollowUpContext; + const writeResults = (await Promise.all( + this.pendingActions.map(async (action) => { + try { + const toolResult = await this.toolExecutor.handleToolCall(action.toolCall); + return { ...toolResult, id: action.toolCall.id }; + } catch (error) { + ErrorHandler.handleError(error, "ChatView.applyPendingActions"); + return null; + } + }) + )).filter((result) => result !== null); + const allResults = [...this.pendingReadResults, ...writeResults]; + const followUpMessages = allResults.map((result) => ({ + role: "tool", + content: JSON.stringify(result), + tool_call_id: result.id ?? "" + })); + const followUp = { + role: "assistant", + content: "I have processed your request using the following tools. Here are the results:", + tool_calls: this.pendingActions.map((a) => a.toolCall) + }; + if (followUpMessages.length > 0) { + const finalMessages = [...messages, followUp, ...followUpMessages]; + const followUpStartTime = Date.now(); + const response = await this.getActiveOllamaClient().chat(finalMessages, tools); + const followUpDurationMs = Date.now() - followUpStartTime; + const finalResponse = response.content || "Actions applied successfully."; + this.updateMessageById(assistantMessageId, { + content: finalResponse, + isStreaming: false, + isThinking: false + }); + this.telemetryManager?.recordLlmCall({ + model: this.getActiveModel(), + promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4), + completionTokens: Math.round(finalResponse.length / 4), + totalTokens: Math.round( + (finalMessages.reduce((sum, m) => sum + m.content.length, 0) + finalResponse.length) / 4 + ), + durationMs: followUpDurationMs + }); + } else { + this.updateMessageById(assistantMessageId, { + content: "Actions applied successfully.", + isStreaming: false, + isThinking: false + }); + } + this.clearPendingActions(); + this.render(); + } + cancelPendingActions() { + if (this.pendingActions.length === 0) { + return; + } + const context = this.pendingFollowUpContext; + if (context) { + this.updateMessageById(context.assistantMessageId, { + content: "Actions cancelled. No changes were made.", + isStreaming: false, + isThinking: false + }); + } + this.clearPendingActions(); + this.render(); + } + renderActionPreviews(assistantMessageId) { + const messageEl = this.chatContainer?.querySelector( + `.ollama-message[data-msg-id="${assistantMessageId}"]` + ); + if (!messageEl) { + return; + } + const existing = messageEl.querySelector(".ollama-proposed-actions"); + existing?.remove(); + const container = messageEl.createEl("div", { cls: "ollama-proposed-actions" }); + container.createEl("div", { + cls: "ollama-proposed-actions-header", + text: "Proposed Actions" + }); + for (const action of this.pendingActions) { + const card = container.createEl("div", { cls: "ollama-proposed-action" }); + card.createEl("div", { cls: "ollama-action-description", text: action.description }); + if (action.preview) { + const diffEl = card.createEl("div", { cls: "ollama-action-diff" }); + if (action.preview.before !== void 0) { + diffEl.createEl("pre", { + cls: "ollama-diff-before", + text: `Before: +${action.preview.before.slice(0, 500)}` + }); + } + if (action.preview.after !== void 0) { + diffEl.createEl("pre", { + cls: "ollama-diff-after", + text: `After: +${action.preview.after.slice(0, 500)}` + }); + } + } + } + const buttonContainer = container.createEl("div", { cls: "ollama-action-buttons" }); + const applyBtn = buttonContainer.createEl("button", { + cls: "ollama-apply-button", + text: "Apply All" + }); + const cancelBtn = buttonContainer.createEl("button", { + cls: "ollama-cancel-button", + text: "Cancel" + }); + applyBtn.addEventListener("click", () => { + void this.applyPendingActions(); + }); + cancelBtn.addEventListener("click", () => { + this.cancelPendingActions(); + }); + } + clearPendingActions() { + this.pendingActions = []; + this.pendingReadResults = []; + this.pendingFollowUpContext = null; + this.chatContainer?.querySelectorAll(".ollama-proposed-actions").forEach((el) => el.remove()); + } + formatWorkflowResult(result) { + const lines = []; + lines.push(`## ${result.workflowName}`); + lines.push(""); + if (result.stepResults.length > 0) { + lines.push("**Steps:**"); + for (const step of result.stepResults) { + const status = step.success ? "\u2705" : "\u274C"; + lines.push(`${status} **${step.stepName}**`); + if (!step.success && step.error) { + lines.push(` Error: ${step.error}`); + } + } + lines.push(""); + } + if (result.error) { + lines.push(`**Workflow Error:** ${result.error}`); + lines.push(""); + } + if (result.finalOutput) { + lines.push("**Result:**"); + if (typeof result.finalOutput === "string") { + lines.push(result.finalOutput); + } else { + lines.push(JSON.stringify(result.finalOutput, null, 2)); + } + } + return lines.join("\n"); + } + async handleWorkflowRequest(query, assistantMessageId) { + try { + this.updateMessageById(assistantMessageId, { + content: "\u{1F504} Generating workflow plan...", + isStreaming: false, + isThinking: false + }); + const result = await this.workflowEngine.executeWorkflowFromQuery(query, this.getTools()); + const formatted = this.formatWorkflowResult({ + workflowName: result.workflowName, + success: result.success, + stepResults: result.stepResults.map((sr) => ({ + stepName: sr.stepName, + success: sr.success, + data: sr.data, + error: sr.error + })), + finalOutput: result.finalOutput, + error: result.error + }); + this.updateMessageById(assistantMessageId, { + content: formatted, + isStreaming: false, + isThinking: false + }); + this.conversationStateManager.updateShortTermContext({ + role: "assistant", + content: formatted + }); + } catch (error) { + ErrorHandler.handleError(error, "ChatView.handleWorkflowRequest"); + this.updateMessageById(assistantMessageId, { + content: "An error occurred while executing the workflow.", isStreaming: false }); } @@ -9320,6 +11498,8 @@ var ChatView = class extends import_obsidian3.ItemView { if (!userMessage) { return; } + const isWorkflowCommand = userMessage.toLowerCase().startsWith("/workflow"); + const actualMessage = isWorkflowCommand ? userMessage.slice("/workflow".length).trim() : userMessage; const maxContextLength = this.settings.maxContextLength; const tools = this.getTools(); const messageId = crypto.randomUUID(); @@ -9328,7 +11508,7 @@ var ChatView = class extends import_obsidian3.ItemView { const userChatMessage = { id: userMessageId, role: "user", - content: userMessage, + content: actualMessage, timestamp: Date.now() }; const assistantMessage = { @@ -9352,29 +11532,40 @@ var ChatView = class extends import_obsidian3.ItemView { this.contentEl.appendChild(previousStreamingEl); this.lastMessageEl = previousStreamingEl; } + if (isWorkflowCommand) { + await this.handleWorkflowRequest(actualMessage, assistantMessageId); + this.cleanupStreamingResources(); + return; + } try { - const entries = await this.vaultIndexer.searchVault( - userMessage, - this.settings.vaultSearchLimit - ); - const context = entries.map((entry) => { - const parts = []; - if (entry.tags) { - parts.push(`Tags: ${entry.tags}`); + const noteContext = await this.noteContextBuilder.buildContext( + actualMessage, + this.settings.vaultSearchLimit, + { + includeOpenNote: true, + includeSelectedText: true, + includeBacklinks: true, + includeOutlinks: true, + includeRelated: true, + maxRelatedNotes: 10 } - parts.push(entry.title); - parts.push(entry.content); - return parts.join("\n"); - }).join("\n\n").slice(0, maxContextLength); + ); + const context = this.noteContextBuilder.formatContext(noteContext, maxContextLength); const userMessageWithContext = context ? `Relevant vault context: ${context} User question: -${userMessage}` : userMessage; +${actualMessage}` : actualMessage; const completeMessages = this.conversationStateManager.getCompleteMessages(userMessageWithContext); - const stream = this.ollamaClient.streamChat(completeMessages, tools); + const messagesWithMemory = this.buildMessagesWithMemory(completeMessages); + const activeClient = this.getActiveOllamaClient(); + const activeModel = this.getActiveModel(); + const stream = activeClient.streamChat(messagesWithMemory, tools); let fullResponse = ""; let toolCalls = []; + let promptTokens = 0; + let completionTokens = 0; + const llmStartTime = Date.now(); for await (const chunk of stream) { if (chunk.content) { fullResponse += chunk.content; @@ -9387,11 +11578,27 @@ ${userMessage}` : userMessage; if (chunk.tool_calls) { toolCalls = [...toolCalls, ...chunk.tool_calls]; } + if (typeof chunk.prompt_eval_count === "number") { + promptTokens = chunk.prompt_eval_count; + } + if (typeof chunk.eval_count === "number") { + completionTokens = chunk.eval_count; + } } + const llmDurationMs = Date.now() - llmStartTime; + const estimatedPromptTokens = promptTokens > 0 ? promptTokens : completeMessages.reduce((sum, m) => sum + m.content.length, 0) / 4; + const estimatedCompletionTokens = completionTokens > 0 ? completionTokens : fullResponse.length / 4; + this.telemetryManager?.recordLlmCall({ + model: activeModel, + promptTokens: Math.round(estimatedPromptTokens), + completionTokens: Math.round(estimatedCompletionTokens), + totalTokens: Math.round(estimatedPromptTokens + estimatedCompletionTokens), + durationMs: llmDurationMs + }); if (toolCalls.length > 0) { await this.processToolCalls( toolCalls, - completeMessages, + messagesWithMemory, tools, fullResponse, assistantMessageId @@ -9400,7 +11607,8 @@ ${userMessage}` : userMessage; if (toolCalls.length === 0) { this.updateMessageById(assistantMessageId, { content: fullResponse, - isStreaming: false + isStreaming: false, + isThinking: false }); } this.conversationStateManager.updateShortTermContext({ role: "user", content: userMessage }); @@ -9408,6 +11616,32 @@ ${userMessage}` : userMessage; role: "assistant", content: fullResponse }); + if (this.structuredMemoryManager) { + const prefs = this.structuredMemoryManager.extractPreferencesFromMessage(userMessage); + for (const pref of prefs) { + this.structuredMemoryManager.addUserPreference(pref); + } + const facts = this.structuredMemoryManager.extractFactsFromMessage(userMessage); + for (const fact of facts) { + this.structuredMemoryManager.addLearnedFact(fact); + } + const assistantFacts = this.structuredMemoryManager.extractFactsFromMessage(fullResponse); + for (const fact of assistantFacts) { + this.structuredMemoryManager.addLearnedFact(fact); + } + const { topic, keyPoints } = this.structuredMemoryManager.summarizeConversation( + this.conversationStateManager.getShortTermContext() + ); + if (keyPoints.length > 0) { + this.structuredMemoryManager.addConversationSummary({ + id: crypto.randomUUID?.() ?? `summary-${Date.now()}-${Math.random()}`, + timestamp: Date.now(), + topic, + summary: keyPoints.join("; ").slice(0, 300), + keyPoints + }); + } + } if (this.messages.length > this.settings.maxMessageHistory) { this.messages = this.messages.slice(-this.settings.maxMessageHistory); } @@ -9423,17 +11657,38 @@ ${userMessage}` : userMessage; this.cleanupStreamingResources(); } } + createOllamaClient(model, settings) { + return new OllamaClient(settings.ollamaUrl, model, void 0, settings.cacheConfig); + } + async initializeClientCaches() { + await this.ollamaClient.initializeCache(); + if (this.agentOllamaClient !== this.ollamaClient) { + await this.agentOllamaClient.initializeCache(); + } + } + getActiveModel() { + return this.isAgenticMode(this.currentAgentMode) ? this.settings.agentModel ?? this.settings.model : this.settings.chatModel ?? this.settings.model; + } + getActiveOllamaClient() { + return this.isAgenticMode(this.currentAgentMode) ? this.agentOllamaClient : this.ollamaClient; + } + isAgenticMode(mode) { + return mode === "edit" || mode === "organize" || mode === "workflow"; + } }; var MAX_TOOL_CALLS = 5; // src/constants.ts var DEFAULT_SETTINGS = { ollamaUrl: "http://localhost:11434", - model: "llama3", + chatModel: "deepseek-v4-flash", + agentModel: "glm-5.1", + model: "deepseek-v4-flash", vaultSearchLimit: 5, maxMessageHistory: 50, maxContextLength: 8e3, lastIndexTime: 0, + agentMode: "ask", cacheConfig: { enabled: false, similarityThreshold: 0.85, @@ -9453,12 +11708,27 @@ var DEFAULT_SETTINGS = { maxTagsPerNote: 5, minNoteLength: 50, maxNoteLength: 8e3, - tagPromptTemplate: "Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}" + tagPromptTemplate: "Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}", + dryRun: false, + targetFolder: "", + normalizeTags: true }, autoLinkConfig: { enabled: false, maxLinksPerNote: 3, - similarityThreshold: 0.6 + similarityThreshold: 0.6, + targetFolder: "", + dryRun: false + }, + structuredMemoryConfig: { + enabled: true, + maxSummaries: 10, + maxPreferences: 20, + maxFacts: 50 + }, + toolTelemetryConfig: { + enabled: true, + maxEntries: 100 } }; @@ -9540,38 +11810,59 @@ var ContentVectorizer = class { // src/indexing-pipeline/extraction.ts var ContentExtractor = class { - extractFromFile(file, content) { + extractFromFile(file, content, cache) { const frontmatter = {}; const headings = []; const embeddedCodeBlocks = []; let firstParagraph; - const frontmatterMatch = content.match(/^---(.*?)---/s); - if (frontmatterMatch) { - try { - const frontmatterContent = frontmatterMatch[1]; - const lines = frontmatterContent.trim().split("\n"); - for (const line of lines) { - const [key, ...valueParts] = line.split(":"); - if (!key) continue; - const value = valueParts.join(":").trim(); - if (key.trim() === "title") { - if (value) { - frontmatter.title = value; - } - } else if (key.trim() === "tags") { - if (value) { - frontmatter.tags = value; - } - } else { - frontmatter[key.trim()] = value; + if (cache?.frontmatter) { + const fm = cache.frontmatter; + for (const [key, value] of Object.entries(fm)) { + if (key === "title" && typeof value === "string") { + frontmatter.title = value; + } else if (key === "tags") { + if (Array.isArray(value)) { + frontmatter.tags = value.join(", "); + } else if (typeof value === "string") { + frontmatter.tags = value; } + } else { + frontmatter[key] = value; + } + } + } else { + const frontmatterMatch = content.match(/^---(.*?)---/s); + if (frontmatterMatch) { + try { + const frontmatterContent = frontmatterMatch[1]; + const lines = frontmatterContent.trim().split("\n"); + for (const line of lines) { + const [key, ...valueParts] = line.split(":"); + if (!key) continue; + const value = valueParts.join(":").trim(); + if (key.trim() === "title") { + if (value) { + frontmatter.title = value; + } + } else if (key.trim() === "tags") { + if (value) { + frontmatter.tags = value; + } + } else { + frontmatter[key.trim()] = value; + } + } + } catch { } - } catch { } } - const headingMatches = content.match(/^#{1,6} (.*?)$/gm); - if (headingMatches) { - headings.push(...headingMatches.map((h) => h.replace(/^#{1,6} /, ""))); + if (cache?.headings) { + headings.push(...cache.headings.map((h) => h.heading)); + } else { + const headingMatches = content.match(/^#{1,6} (.*?)$/gm); + if (headingMatches) { + headings.push(...headingMatches.map((h) => h.replace(/^#{1,6} /, ""))); + } } const codeBlockMatches = content.match(/```([\s\S]*?)```/g); if (codeBlockMatches) { @@ -9780,8 +12071,9 @@ var VaultVectorStore = class { } /** * Index a single vault file by generating an embedding and storing it in ChromaDB. + * Optionally accepts cached metadata from Obsidian's metadataCache. */ - async indexFile(file, content) { + async indexFile(file, content, cache) { if (!this.collection || !this.config.enabled) return; if (!content.trim()) { await this.deleteFile(file.path); @@ -9790,7 +12082,8 @@ var VaultVectorStore = class { try { const extracted = this.extractor.extractFromFile( { basename: file.basename, path: file.path }, - content + content, + cache ); const normalized = this.normalizer.normalize(extracted); const chunk = { @@ -9938,10 +12231,53 @@ var VaultVectorStore = class { }; // src/auto-organizer.ts -var import_obsidian4 = require("obsidian"); +var import_obsidian6 = require("obsidian"); +function normalizeTag(raw) { + return raw.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} +function buildTagVocabulary(vault, app) { + const vocab = /* @__PURE__ */ new Map(); + const files = vault.getMarkdownFiles(); + for (const file of files) { + try { + const cache = app.metadataCache.getFileCache(file); + const frontmatter = cache?.frontmatter; + const rawTags = frontmatter?.tags; + const tagList = []; + if (Array.isArray(rawTags)) { + tagList.push(...rawTags.map(String)); + } else if (typeof rawTags === "string") { + tagList.push( + ...rawTags.split(/[,\n]+/).map((t) => t.trim()).filter((t) => t.length > 0) + ); + } + for (const tag of tagList) { + const norm = normalizeTag(tag); + if (norm.length > 0 && !vocab.has(norm)) { + vocab.set(norm, tag); + } + } + } catch { + } + } + return vocab; +} +function normalizeTagsAgainstVocabulary(tags, vocab) { + const result = []; + const seen = /* @__PURE__ */ new Set(); + for (const tag of tags) { + const norm = normalizeTag(tag); + if (seen.has(norm)) continue; + seen.add(norm); + const canonical = vocab.get(norm); + result.push(canonical ?? norm); + } + return result; +} var AutoTagger = class { - constructor(vault, ollamaUrl, model, config) { + constructor(vault, app, ollamaUrl, model, config) { this.vault = vault; + this.app = app; this.config = config; this.ollamaClient = new OllamaClient(ollamaUrl, model); } @@ -9949,27 +12285,47 @@ var AutoTagger = class { this.config = config; } /** - * Find all markdown files that lack a `tags` frontmatter field. + * Check if a file is inside the target folder. */ - async getUntaggedNotes() { + isInTargetFolder(file) { + if (!this.config.targetFolder || this.config.targetFolder.trim().length === 0) { + return true; + } + const target = this.config.targetFolder.replace(/\/$/, "").trim(); + const fileFolder = file.path.split("/").slice(0, -1).join("/"); + return fileFolder === target || fileFolder.startsWith(`${target}/`); + } + /** + * Check if a note has meaningful tags using metadataCache. + */ + hasTags(file) { + const cache = this.app.metadataCache.getFileCache(file); + if (!cache?.frontmatter) { + return false; + } + const tags = cache.frontmatter["tags"]; + if (tags === void 0 || tags === null) { + return false; + } + if (Array.isArray(tags)) { + return tags.length > 0; + } + if (typeof tags === "string") { + const trimmed = tags.trim(); + return trimmed.length > 0 && trimmed !== "[]" && trimmed !== "null"; + } + return false; + } + /** + * Find all markdown files that lack a `tags` frontmatter field. + * Respects targetFolder config. + */ + getUntaggedNotes() { const files = this.vault.getMarkdownFiles(); const untagged = []; for (const file of files) { try { - const content = await this.vault.cachedRead(file); - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); - if (!frontmatterMatch) { - untagged.push(file); - continue; - } - const frontmatterText = frontmatterMatch[1]; - const tagsMatch = frontmatterText.match(/^tags:\s*(.+)$/m); - if (!tagsMatch) { - untagged.push(file); - continue; - } - const tagsValue = tagsMatch[1].trim(); - if (tagsValue === "" || tagsValue === "[]" || tagsValue === "null") { + if (!this.hasTags(file) && this.isInTargetFolder(file)) { untagged.push(file); } } catch { @@ -9991,7 +12347,11 @@ var AutoTagger = class { const truncated = content.substring(0, this.config.maxNoteLength); const prompt = this.config.tagPromptTemplate.replace(/\{\{maxTags\}\}/g, String(this.config.maxTagsPerNote)).replace(/\{\{title\}\}/g, file.basename).replace(/\{\{content\}\}/g, truncated); const response = await this.ollamaClient.chat([{ role: "user", content: prompt }]); - const tags = this.parseTagResponse(response.content); + let tags = this.parseTagResponse(response.content); + if (this.config.normalizeTags) { + const vocab = buildTagVocabulary(this.vault, this.app); + tags = normalizeTagsAgainstVocabulary(tags, vocab); + } Logger.info(`Generated tags for ${file.path}: ${tags.join(", ")}`, "auto-tagger"); return tags; } catch (error) { @@ -10007,27 +12367,37 @@ var AutoTagger = class { if (tags.length === 0) return; try { const content = await this.vault.read(file); - const existingFrontmatter = content.match(/^---\n([\s\S]*?)\n---\n/); + const cache = this.app.metadataCache.getFileCache(file); + const hasFrontmatter = !!cache?.frontmatter; let newContent; - if (existingFrontmatter) { - const frontmatterText = existingFrontmatter[1]; - const hasTagsLine = /^tags:/m.test(frontmatterText); - if (hasTagsLine) { - const updatedFrontmatter = frontmatterText.replace( - /^tags:.*$/m, - `tags: ${tags.join(", ")}` - ); - newContent = content.replace(existingFrontmatter[0], `--- + if (hasFrontmatter) { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (frontmatterMatch) { + const frontmatterText = frontmatterMatch[1]; + const hasTagsLine = /^tags:/m.test(frontmatterText); + if (hasTagsLine) { + const updatedFrontmatter = frontmatterText.replace( + /^tags:.*$/m, + `tags: ${tags.join(", ")}` + ); + newContent = content.replace(frontmatterMatch[0], `--- ${updatedFrontmatter} --- `); - } else { - const updatedFrontmatter = `tags: ${tags.join(", ")} + } else { + const updatedFrontmatter = `tags: ${tags.join(", ")} ${frontmatterText}`; - newContent = content.replace(existingFrontmatter[0], `--- + newContent = content.replace(frontmatterMatch[0], `--- ${updatedFrontmatter} --- `); + } + } else { + newContent = `--- +tags: ${tags.join(", ")} +--- + +${content}`; } } else { newContent = `--- @@ -10045,18 +12415,35 @@ ${content}`; } /** * Run auto-tagging on all untagged notes. + * If dryRun is enabled, returns proposed changes without applying. */ async run() { if (!this.config.enabled) { - new import_obsidian4.Notice("Auto-tagging is disabled in settings."); + new import_obsidian6.Notice("Auto-tagging is disabled in settings."); return { tagged: 0, skipped: 0 }; } - const untagged = await this.getUntaggedNotes(); + const untagged = this.getUntaggedNotes(); if (untagged.length === 0) { - new import_obsidian4.Notice("No untagged notes found."); + new import_obsidian6.Notice("No untagged notes found."); return { tagged: 0, skipped: 0 }; } - new import_obsidian4.Notice(`Auto-tagging ${untagged.length} notes...`); + if (this.config.dryRun) { + new import_obsidian6.Notice(`Dry-run: evaluating ${untagged.length} notes...`); + const proposals = []; + let skipped2 = 0; + for (const file of untagged) { + const tags = await this.generateTags(file); + if (tags.length > 0) { + proposals.push({ file, proposedTags: tags }); + } else { + skipped2++; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + new import_obsidian6.Notice(`Dry-run complete: ${proposals.length} proposed tag changes, ${skipped2} skipped.`); + return { tagged: proposals.length, skipped: skipped2, dryRun: proposals }; + } + new import_obsidian6.Notice(`Auto-tagging ${untagged.length} notes...`); let tagged = 0; let skipped = 0; for (const file of untagged) { @@ -10069,7 +12456,7 @@ ${content}`; } await new Promise((resolve) => setTimeout(resolve, 300)); } - new import_obsidian4.Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`); + new import_obsidian6.Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`); return { tagged, skipped }; } parseTagResponse(response) { @@ -10077,14 +12464,29 @@ ${content}`; } }; var AutoLinker = class { - constructor(vault, vaultIndexer, config) { + constructor(vault, vaultIndexer, config, targetFolder = "") { this.vault = vault; this.vaultIndexer = vaultIndexer; this.config = config; + this.targetFolder = targetFolder; } updateConfig(config) { this.config = config; } + setTargetFolder(folder) { + this.targetFolder = folder; + } + /** + * Check if a file is inside the target folder. + */ + isInTargetFolder(file) { + if (!this.targetFolder || this.targetFolder.trim().length === 0) { + return true; + } + const target = this.targetFolder.replace(/\/$/, "").trim(); + const fileFolder = file.path.split("/").slice(0, -1).join("/"); + return fileFolder === target || fileFolder.startsWith(`${target}/`); + } /** * Find related notes for a given file using semantic search. */ @@ -10130,14 +12532,31 @@ ${links} } /** * Run auto-linking on all notes. + * If dryRun is enabled, returns proposed changes without applying. */ - async run() { + async run(dryRun = false) { if (!this.config.enabled) { - new import_obsidian4.Notice("Auto-linking is disabled in settings."); + new import_obsidian6.Notice("Auto-linking is disabled in settings."); return { linked: 0, skipped: 0 }; } - const files = this.vault.getMarkdownFiles(); - new import_obsidian4.Notice(`Auto-linking ${files.length} notes...`); + const files = this.vault.getMarkdownFiles().filter((f) => this.isInTargetFolder(f)); + if (dryRun) { + new import_obsidian6.Notice(`Dry-run: evaluating ${files.length} notes for links...`); + const proposals = []; + let skipped2 = 0; + for (const file of files) { + const related = await this.findRelatedNotes(file); + if (related.length > 0) { + proposals.push({ file, relatedNotes: related }); + } else { + skipped2++; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + new import_obsidian6.Notice(`Dry-run complete: ${proposals.length} proposed link changes.`); + return { linked: proposals.length, skipped: skipped2, dryRun: proposals }; + } + new import_obsidian6.Notice(`Auto-linking ${files.length} notes...`); let linked = 0; let skipped = 0; for (const file of files) { @@ -10150,13 +12569,341 @@ ${links} } await new Promise((resolve) => setTimeout(resolve, 200)); } - new import_obsidian4.Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`); + new import_obsidian6.Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`); return { linked, skipped }; } }; +// src/structured-memory.ts +function createDefaultStructuredMemoryData() { + return { + conversationSummaries: [], + userPreferences: [], + learnedFacts: [] + }; +} +var StructuredMemoryManager = class { + constructor(config, initialData) { + this.config = config; + this.data = initialData ?? createDefaultStructuredMemoryData(); + } + /** + * Replace the in-memory data (e.g., after loading from disk). + */ + loadData(data) { + this.data = { + conversationSummaries: data.conversationSummaries ?? [], + userPreferences: data.userPreferences ?? [], + learnedFacts: data.learnedFacts ?? [] + }; + } + /** + * Get a serializable copy of the current memory data. + */ + getData() { + return { + conversationSummaries: [...this.data.conversationSummaries], + userPreferences: [...this.data.userPreferences], + learnedFacts: [...this.data.learnedFacts] + }; + } + /** + * Update the config (e.g., when settings change). + */ + updateConfig(config) { + this.config = config; + this.enforceLimits(); + } + /** + * Add a conversation summary, keeping the newest within maxSummaries. + */ + addConversationSummary(summary) { + if (!this.config.enabled) return; + this.data.conversationSummaries.push(summary); + this.enforceLimits(); + } + getConversationSummaries() { + return [...this.data.conversationSummaries]; + } + clearConversationSummaries() { + this.data.conversationSummaries = []; + } + /** + * Add or update a user preference. If the key already exists, update it. + */ + addUserPreference(preference) { + if (!this.config.enabled) return; + const existingIndex = this.data.userPreferences.findIndex((p) => p.key === preference.key); + if (existingIndex >= 0) { + this.data.userPreferences[existingIndex] = preference; + } else { + this.data.userPreferences.push(preference); + } + this.enforceLimits(); + } + getUserPreference(key) { + return this.data.userPreferences.find((p) => p.key === key); + } + getUserPreferences() { + return [...this.data.userPreferences]; + } + removeUserPreference(key) { + this.data.userPreferences = this.data.userPreferences.filter((p) => p.key !== key); + } + clearUserPreferences() { + this.data.userPreferences = []; + } + /** + * Add a learned fact, deduplicating by content (case-insensitive). + */ + addLearnedFact(fact) { + if (!this.config.enabled) return; + const normalizedContent = fact.content.trim().toLowerCase(); + const existingIndex = this.data.learnedFacts.findIndex( + (f) => f.content.trim().toLowerCase() === normalizedContent + ); + if (existingIndex >= 0) { + this.data.learnedFacts[existingIndex] = { + ...fact, + timestamp: Date.now(), + confidence: Math.max(fact.confidence, this.data.learnedFacts[existingIndex].confidence) + }; + } else { + this.data.learnedFacts.push(fact); + } + this.enforceLimits(); + } + getLearnedFacts() { + return [...this.data.learnedFacts]; + } + getLearnedFactsByCategory(category) { + return this.data.learnedFacts.filter((f) => f.category === category); + } + clearLearnedFacts() { + this.data.learnedFacts = []; + } + clearAll() { + this.data = createDefaultStructuredMemoryData(); + } + /** + * Build a context string from stored memory for injection into the system prompt. + * Returns an empty string if memory is disabled or empty. + */ + buildMemoryContext() { + if (!this.config.enabled) return ""; + const parts = []; + const summaries = this.data.conversationSummaries; + if (summaries.length > 0) { + parts.push("## Past Conversations"); + for (const s of summaries.slice(-3)) { + parts.push(`- ${s.topic}: ${s.summary}`); + } + } + const preferences = this.data.userPreferences; + if (preferences.length > 0) { + parts.push("## User Preferences"); + for (const p of preferences) { + parts.push(`- ${p.key}: ${p.value}`); + } + } + const facts = this.data.learnedFacts; + if (facts.length > 0) { + parts.push("## Learned Facts"); + for (const f of facts.filter((fact) => fact.confidence >= 0.5).slice(-10)) { + parts.push(`- ${f.content}`); + } + } + if (parts.length === 0) return ""; + return "The following is remembered context from past sessions:\n" + parts.join("\n"); + } + /** + * Extract likely user preferences from a message using lightweight regex heuristics. + */ + extractPreferencesFromMessage(message) { + if (!this.config.enabled) return []; + const preferences = []; + const patterns = [ + { regex: /i(?:'d| would)?\s+prefer\s+(?:that\s+)?(.+?)(?:\.|$)/i, keyPrefix: "preference" }, + { regex: /i\s+(?:like|love|enjoy)\s+(.+?)(?:\.|$)/i, keyPrefix: "preference" }, + { regex: /i\s+(?:dislike|hate|avoid)\s+(.+?)(?:\.|$)/i, keyPrefix: "preference" }, + { regex: /(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: "preference" }, + { + regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i, + keyPrefix: "favorite" + } + ]; + for (const { regex, keyPrefix } of patterns) { + const match = regex.exec(message); + if (match) { + const value = match[match.length - 1].trim(); + const key = value.length > 30 ? `${keyPrefix}-${Date.now()}` : `${keyPrefix}-${value.toLowerCase().replace(/\s+/g, "-")}`; + preferences.push({ + key, + value, + timestamp: Date.now(), + source: "inferred" + }); + } + } + return preferences; + } + /** + * Extract likely facts from a message using lightweight regex heuristics. + */ + extractFactsFromMessage(message) { + if (!this.config.enabled) return []; + const facts = []; + const folderPattern = /(\/[^\s]+\/(?:[^\s/]+\/)*)/g; + const folderMatches = message.matchAll(folderPattern); + for (const match of folderMatches) { + facts.push({ + id: crypto.randomUUID?.() ?? `fact-${Date.now()}-${Math.random()}`, + timestamp: Date.now(), + content: `The vault contains a folder at ${match[1]}.`, + category: "vault_structure", + confidence: 0.6 + }); + } + const topicPattern = /(\w+(?:\s+\w+){0,5})\s+is\s+(?:a|an|the)\s+(.+?)(?:\.|$)/gi; + const topicMatches = message.matchAll(topicPattern); + for (const match of topicMatches) { + const subject = match[1].trim(); + const predicate = match[2].trim(); + if (subject.length > 2 && predicate.length > 2) { + facts.push({ + id: crypto.randomUUID?.() ?? `fact-${Date.now()}-${Math.random()}`, + timestamp: Date.now(), + content: `${subject} is ${predicate}.`, + category: "topic", + confidence: 0.5 + }); + } + } + return facts; + } + /** + * Generate a simple topic string from a conversation by looking at the first user message. + */ + summarizeConversation(messages) { + const firstUser = messages.find((m) => m.role === "user"); + const topic = firstUser ? firstUser.content.slice(0, 60).replace(/\n/g, " ") : "Untitled conversation"; + const keyPoints = []; + for (const msg of messages) { + if (msg.role === "assistant" && msg.content) { + const sentences = msg.content.split(/[.!?]+/).map((s) => s.trim()).filter((s) => s.length > 10 && s.length < 120); + keyPoints.push(...sentences.slice(0, 2)); + } + if (keyPoints.length >= 3) break; + } + return { topic, keyPoints }; + } + enforceLimits() { + if (this.data.conversationSummaries.length > this.config.maxSummaries) { + this.data.conversationSummaries = this.data.conversationSummaries.slice( + -this.config.maxSummaries + ); + } + if (this.data.userPreferences.length > this.config.maxPreferences) { + const sorted = [...this.data.userPreferences].sort((a, b) => b.timestamp - a.timestamp); + this.data.userPreferences = sorted.slice(0, this.config.maxPreferences); + } + if (this.data.learnedFacts.length > this.config.maxFacts) { + const sorted = [...this.data.learnedFacts].sort((a, b) => b.confidence - a.confidence); + this.data.learnedFacts = sorted.slice(0, this.config.maxFacts); + } + } +}; + +// src/tool-telemetry.ts +function createDefaultToolTelemetryData() { + return { + entries: [] + }; +} +function generateId() { + return crypto.randomUUID?.() ?? `id-${Date.now()}-${Math.random()}`; +} +var TelemetryManager = class { + constructor(config, initialData) { + this.config = config; + this.data = initialData ? { entries: [...initialData.entries] } : createDefaultToolTelemetryData(); + } + loadData(data) { + this.data = { + entries: [...data.entries] + }; + this.enforceLimits(); + } + getData() { + return { + entries: [...this.data.entries] + }; + } + updateConfig(config) { + this.config = config; + this.enforceLimits(); + } + recordToolCall(entry) { + if (!this.config.enabled) { + return; + } + const fullEntry = { + ...entry, + id: generateId(), + timestamp: Date.now(), + type: "tool_call" + }; + this.data.entries.push(fullEntry); + this.enforceLimits(); + } + recordLlmCall(entry) { + if (!this.config.enabled) { + return; + } + const fullEntry = { + ...entry, + id: generateId(), + timestamp: Date.now(), + type: "llm_call" + }; + this.data.entries.push(fullEntry); + this.enforceLimits(); + } + recordSearch(entry) { + if (!this.config.enabled) { + return; + } + const fullEntry = { + ...entry, + id: generateId(), + timestamp: Date.now(), + type: "vault_search" + }; + this.data.entries.push(fullEntry); + this.enforceLimits(); + } + getRecentEntries(limit) { + const sorted = [...this.data.entries].sort((a, b) => b.timestamp - a.timestamp); + if (limit !== void 0) { + return sorted.slice(0, limit); + } + return sorted; + } + getEntriesByType(type) { + return [...this.data.entries.filter((entry) => entry.type === type)]; + } + clear() { + this.data.entries = []; + } + enforceLimits() { + if (this.data.entries.length > this.config.maxEntries) { + this.data.entries = this.data.entries.slice(-this.config.maxEntries); + } + } +}; + // src/main.ts -var OllamaPlugin = class extends import_obsidian5.Plugin { +var OllamaPlugin = class extends import_obsidian7.Plugin { constructor() { super(...arguments); this.settings = DEFAULT_SETTINGS; @@ -10168,7 +12915,13 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { } this.registerView( "ollama-chat-view", - (leaf) => new ChatView(leaf, this.settings, this.vaultVectorStore) + (leaf) => new ChatView( + leaf, + this.settings, + this.vaultVectorStore, + this.structuredMemoryManager, + this.telemetryManager + ) ); this.addRibbonIcon("bot", "Open Ollama Chat", async () => { await this.activateChatView(); @@ -10185,7 +12938,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { name: "Clear Semantic Cache", callback: async () => { await this.clearSemanticCache(); - new import_obsidian5.Notice("Semantic cache cleared."); + new import_obsidian7.Notice("Semantic cache cleared."); } }); this.addCommand({ @@ -10193,40 +12946,58 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { name: "Clear Vault Index", callback: async () => { await this.clearVaultIndex(); - new import_obsidian5.Notice("Vault index cleared."); + new import_obsidian7.Notice("Vault index cleared."); } }); this.addCommand({ id: "rebuild-vault-index", name: "Rebuild Vault Index", callback: async () => { - new import_obsidian5.Notice("Rebuilding vault index..."); + new import_obsidian7.Notice("Rebuilding vault index..."); await this.rebuildVaultIndex(); - new import_obsidian5.Notice("Vault index rebuilt."); + new import_obsidian7.Notice("Vault index rebuilt."); } }); this.addCommand({ id: "auto-tag-notes", name: "Auto-Tag Untagged Notes", - callback: async () => { - await this.initializeAutoOrganizer(); + callback: () => { + this.initializeAutoOrganizer(); if (this.autoTagger) { - new import_obsidian5.Notice("Auto-tagging untagged notes..."); - await this.autoTagger.run(); + new import_obsidian7.Notice("Auto-tagging untagged notes..."); + void this.autoTagger.run(); } } }); this.addCommand({ id: "auto-link-notes", name: "Auto-Link Related Notes", - callback: async () => { - await this.initializeAutoOrganizer(); + callback: () => { + this.initializeAutoOrganizer(); if (this.autoLinker) { - new import_obsidian5.Notice("Auto-linking related notes..."); - await this.autoLinker.run(); + new import_obsidian7.Notice("Auto-linking related notes..."); + void this.autoLinker.run(); } } }); + this.addCommand({ + id: "clear-structured-memory", + name: "Clear Structured Memory", + callback: async () => { + this.structuredMemoryManager?.clearAll(); + await this.saveSettings(); + new import_obsidian7.Notice("Structured memory cleared."); + } + }); + this.addCommand({ + id: "clear-tool-telemetry", + name: "Clear Tool Telemetry", + callback: async () => { + this.telemetryManager?.clear(); + await this.saveSettings(); + new import_obsidian7.Notice("Tool telemetry cleared."); + } + }); this.addSettingTab(new OllamaSettingTab(this.app, this)); if (this.settings.cacheConfig) { this.semanticCache = new SemanticCacheService( @@ -10236,7 +13007,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { try { await this.semanticCache.initialize(); } catch { - new import_obsidian5.Notice("Semantic cache initialization failed. Check console for details."); + new import_obsidian7.Notice("Semantic cache initialization failed. Check console for details."); } } this.registerVaultEventListeners(); @@ -10249,26 +13020,44 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { } } async loadSettings() { - const loadedSettings = await this.loadData() ?? {}; + const data = await this.loadData() ?? {}; + const loadedSettings = data.settings ?? data; this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings); + const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model; + this.settings.chatModel = loadedSettings.chatModel ?? legacyModel; + this.settings.agentModel = loadedSettings.agentModel ?? legacyModel; + this.settings.model = this.settings.chatModel; + const memoryData = data.structuredMemory ?? createDefaultStructuredMemoryData(); + this.structuredMemoryManager = new StructuredMemoryManager( + this.settings.structuredMemoryConfig, + memoryData + ); + const telemetryData = data.toolTelemetry ?? createDefaultToolTelemetryData(); + this.telemetryManager = new TelemetryManager(this.settings.toolTelemetryConfig, telemetryData); } async saveSettings() { - await this.saveData(this.settings); + await this.saveData({ + settings: this.settings, + structuredMemory: this.structuredMemoryManager?.getData() ?? createDefaultStructuredMemoryData(), + toolTelemetry: this.telemetryManager?.getData() ?? createDefaultToolTelemetryData() + }); } - async initializeAutoOrganizer() { - if (!this.autoTagger) { - this.autoTagger = new AutoTagger( - this.app.vault, - this.settings.ollamaUrl, - this.settings.model, - this.settings.autoTagConfig - ); - } else { - this.autoTagger.updateConfig(this.settings.autoTagConfig); - } + initializeAutoOrganizer() { + this.autoTagger = new AutoTagger( + this.app.vault, + this.app, + this.settings.ollamaUrl, + this.settings.agentModel, + this.settings.autoTagConfig + ); if (!this.autoLinker) { const vaultIndexer = new VaultIndexer(this.app.vault, void 0, this.vaultVectorStore); - this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig); + this.autoLinker = new AutoLinker( + this.app.vault, + vaultIndexer, + this.settings.autoLinkConfig, + this.settings.autoLinkConfig.targetFolder + ); } else { this.autoLinker.updateConfig(this.settings.autoLinkConfig); } @@ -10290,7 +13079,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { this.indexingAbortController = void 0; }); } catch { - new import_obsidian5.Notice("Vault vector store initialization failed. Check console for details."); + new import_obsidian7.Notice("Vault vector store initialization failed. Check console for details."); } } cancelBackgroundIndexing() { @@ -10326,7 +13115,8 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { try { const content = await this.app.vault.read(file); if (signal.aborted) break; - await this.vaultVectorStore.indexFile(file, content); + const cache = this.app.metadataCache.getFileCache(file); + await this.vaultVectorStore.indexFile(file, content, cache ?? void 0); indexed++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -10341,7 +13131,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { this.settings.lastIndexTime = Date.now(); await this.saveSettings(); Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, "main"); - new import_obsidian5.Notice(`Vault index updated: ${indexed} files indexed.`); + new import_obsidian7.Notice(`Vault index updated: ${indexed} files indexed.`); } } async rebuildVaultIndex() { @@ -10367,7 +13157,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { registerVaultEventListeners() { this.registerEvent( this.app.vault.on("create", (file) => { - if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { void this.app.vault.read(file).then((content) => { if (!this.currentIndexingPromise) { void this.vaultVectorStore?.indexFile(file, content); @@ -10378,7 +13168,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { ); this.registerEvent( this.app.vault.on("modify", (file) => { - if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { void this.app.vault.read(file).then((content) => { if (!this.currentIndexingPromise) { void this.vaultVectorStore?.indexFile(file, content); @@ -10389,14 +13179,14 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { ); this.registerEvent( this.app.vault.on("delete", (file) => { - if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { void this.vaultVectorStore.deleteFile(file.path); } }) ); this.registerEvent( this.app.vault.on("rename", (file, oldPath) => { - if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { void this.vaultVectorStore.deleteFile(oldPath); void this.app.vault.read(file).then((content) => { if (!this.currentIndexingPromise) { @@ -10436,7 +13226,7 @@ var OllamaPlugin = class extends import_obsidian5.Plugin { }); } }; -var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { +var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; @@ -10445,53 +13235,77 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Ollama Settings" }); - new import_obsidian5.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText( + new import_obsidian7.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText( (text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => { this.plugin.settings.ollamaUrl = value; await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Model").setDesc("Ollama model to use (default: llama3)").addText( - (text) => text.setValue(this.plugin.settings.model).onChange(async (value) => { - this.plugin.settings.model = value; + new import_obsidian7.Setting(containerEl).setName("Chat Model").setDesc("Model for normal chat, Ask mode, and Research mode (default: deepseek-v4-flash)").addText( + (text) => text.setValue(this.plugin.settings.chatModel).onChange(async (value) => { + this.plugin.settings.chatModel = value.trim(); + this.plugin.settings.model = this.plugin.settings.chatModel; await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); }) ); - new import_obsidian5.Setting(containerEl).setName("Vault Search Limit").setDesc("Maximum number of vault entries to include in context (default: 5)").addText( + new import_obsidian7.Setting(containerEl).setName("Agent Model").setDesc("Model for Edit, Organize, Workflow, and auto-organizer tasks (default: glm-5.1)").addText( + (text) => text.setValue(this.plugin.settings.agentModel).onChange(async (value) => { + this.plugin.settings.agentModel = value.trim(); + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Vault Search Limit").setDesc("Maximum number of vault entries to include in context (default: 5)").addText( (text) => text.setValue(String(this.plugin.settings.vaultSearchLimit)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0) { this.plugin.settings.vaultSearchLimit = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Vault search limit must be a positive integer."); + new import_obsidian7.Notice("Vault search limit must be a positive integer."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Max Context Length").setDesc("Maximum characters of vault content to send to the AI per message (default: 8000)").addText( + new import_obsidian7.Setting(containerEl).setName("Max Context Length").setDesc("Maximum characters of vault content to send to the AI per message (default: 8000)").addText( (text) => text.setValue(String(this.plugin.settings.maxContextLength)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0) { this.plugin.settings.maxContextLength = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Max context length must be a positive integer."); + new import_obsidian7.Notice("Max context length must be a positive integer."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText( + new import_obsidian7.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText( (text) => text.setValue(String(this.plugin.settings.maxMessageHistory)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0) { this.plugin.settings.maxMessageHistory = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Max message history must be a positive integer."); + new import_obsidian7.Notice("Max message history must be a positive integer."); } }) ); + containerEl.createEl("h3", { text: "Agent Mode" }); + containerEl.createEl("p", { + text: "Default chat mode that controls available tools and system behavior." + }); + new import_obsidian7.Setting(containerEl).setName("Default Agent Mode").setDesc("Select the default mode for new chat sessions.").addDropdown((dropdown) => { + for (const mode of ALL_AGENT_MODES) { + dropdown.addOption(mode, getAgentModeLabel(mode)); + } + dropdown.setValue(this.plugin.settings.agentMode ?? "ask"); + dropdown.onChange(async (value) => { + this.plugin.settings.agentMode = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + }); + }); containerEl.createEl("h3", { text: "Vault Semantic Index" }); - new import_obsidian5.Setting(containerEl).setName("Enable Vault Semantic Index").setDesc( + new import_obsidian7.Setting(containerEl).setName("Enable Vault Semantic Index").setDesc( "Automatically index vault notes into a vector database for semantic/RAG search. Requires ChromaDB and an embedding model." ).addToggle( (toggle) => toggle.setValue(this.plugin.settings.vaultIndexConfig.enabled).onChange(async (value) => { @@ -10499,7 +13313,7 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { await this.plugin.saveSettings(); this.plugin.notifyChatViews(); if (value) { - new import_obsidian5.Notice("Vault semantic index enabled. Rebuilding index..."); + new import_obsidian7.Notice("Vault semantic index enabled. Rebuilding index..."); await this.plugin.initializeVaultVectorStore(); await this.plugin.rebuildVaultIndex(); } else { @@ -10509,7 +13323,7 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { } }) ); - new import_obsidian5.Setting(containerEl).setName("Vault Index ChromaDB URL").setDesc( + new import_obsidian7.Setting(containerEl).setName("Vault Index ChromaDB URL").setDesc( "URL for your ChromaDB instance used for the vault index (default: http://localhost:8000)" ).addText( (text) => text.setValue(this.plugin.settings.vaultIndexConfig.chromaURL || "http://localhost:8000").onChange(async (value) => { @@ -10518,7 +13332,7 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Vault Index Embedding Model").setDesc( + new import_obsidian7.Setting(containerEl).setName("Vault Index Embedding Model").setDesc( "Ollama model used to generate embeddings for vault notes (default: nomic-embed-text)" ).addText( (text) => text.setValue(this.plugin.settings.vaultIndexConfig.embeddingModel).onChange(async (value) => { @@ -10526,7 +13340,7 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc( + new import_obsidian7.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc( "Minimum cosine similarity (0\u20131) for a vault search hit. Higher values require closer matches (default: 0.75)." ).addText( (text) => text.setValue(String(this.plugin.settings.vaultIndexConfig.similarityThreshold)).onChange(async (value) => { @@ -10535,54 +13349,54 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { this.plugin.settings.vaultIndexConfig.similarityThreshold = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Similarity threshold must be a number between 0 and 1."); + new import_obsidian7.Notice("Similarity threshold must be a number between 0 and 1."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Rebuild Vault Index").setDesc("Delete and rebuild the entire vault semantic index").addButton( + new import_obsidian7.Setting(containerEl).setName("Rebuild Vault Index").setDesc("Delete and rebuild the entire vault semantic index").addButton( (button) => button.setButtonText("Rebuild Index").onClick(async () => { try { - new import_obsidian5.Notice("Rebuilding vault index..."); + new import_obsidian7.Notice("Rebuilding vault index..."); await this.plugin.rebuildVaultIndex(); - new import_obsidian5.Notice("Vault index rebuilt."); + new import_obsidian7.Notice("Vault index rebuilt."); } catch { - new import_obsidian5.Notice("Failed to rebuild vault index. Is ChromaDB running?"); + new import_obsidian7.Notice("Failed to rebuild vault index. Is ChromaDB running?"); } }) ); - new import_obsidian5.Setting(containerEl).setName("Clear Vault Index").setDesc("Delete all indexed vault notes from ChromaDB").addButton( + new import_obsidian7.Setting(containerEl).setName("Clear Vault Index").setDesc("Delete all indexed vault notes from ChromaDB").addButton( (button) => button.setButtonText("Clear Index").onClick(async () => { try { await this.plugin.clearVaultIndex(); - new import_obsidian5.Notice("Vault index cleared."); + new import_obsidian7.Notice("Vault index cleared."); } catch { - new import_obsidian5.Notice("Failed to clear vault index. Is ChromaDB running?"); + new import_obsidian7.Notice("Failed to clear vault index. Is ChromaDB running?"); } }) ); containerEl.createEl("h3", { text: "Semantic Cache" }); - new import_obsidian5.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle( + new import_obsidian7.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle( (toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => { this.plugin.settings.cacheConfig.enabled = value; await this.plugin.saveSettings(); this.plugin.notifyChatViews(); }) ); - new import_obsidian5.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText( + new import_obsidian7.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText( (text) => text.setValue(this.plugin.settings.cacheConfig.chromaURL || "http://localhost:8000").onChange(async (value) => { const trimmed = value.trim(); this.plugin.settings.cacheConfig.chromaURL = trimmed && trimmed.includes("://") ? trimmed : "http://localhost:8000"; await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText( + new import_obsidian7.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText( (text) => text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => { this.plugin.settings.cacheConfig.embeddingModel = value; await this.plugin.saveSettings(); this.plugin.notifyChatViews(); }) ); - new import_obsidian5.Setting(containerEl).setName("Cache Similarity Threshold").setDesc( + new import_obsidian7.Setting(containerEl).setName("Cache Similarity Threshold").setDesc( "Minimum cosine similarity (0\u20131) for a cache hit. Higher values require closer matches." ).addText( (text) => text.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold)).onChange(async (value) => { @@ -10591,62 +13405,76 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { this.plugin.settings.cacheConfig.similarityThreshold = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Similarity threshold must be a number between 0 and 1."); + new import_obsidian7.Notice("Similarity threshold must be a number between 0 and 1."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton( + new import_obsidian7.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton( (button) => button.setButtonText("Clear Cache").onClick(async () => { try { await this.plugin.clearSemanticCache(); - new import_obsidian5.Notice("Semantic cache cleared."); + new import_obsidian7.Notice("Semantic cache cleared."); } catch { - new import_obsidian5.Notice("Failed to clear semantic cache. Is ChromaDB running?"); + new import_obsidian7.Notice("Failed to clear semantic cache. Is ChromaDB running?"); } }) ); containerEl.createEl("h3", { text: "Auto-Organize" }); containerEl.createEl("h4", { text: "Auto-Tagging" }); - new import_obsidian5.Setting(containerEl).setName("Enable Auto-Tagging").setDesc("Use AI to automatically suggest and apply tags to untagged notes").addToggle( + new import_obsidian7.Setting(containerEl).setName("Enable Auto-Tagging").setDesc("Use AI to automatically suggest and apply tags to untagged notes").addToggle( (toggle) => toggle.setValue(this.plugin.settings.autoTagConfig.enabled).onChange(async (value) => { this.plugin.settings.autoTagConfig.enabled = value; await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Max Tags Per Note").setDesc("Maximum number of tags to generate for each note (default: 5)").addText( + new import_obsidian7.Setting(containerEl).setName("Max Tags Per Note").setDesc("Maximum number of tags to generate for each note (default: 5)").addText( (text) => text.setValue(String(this.plugin.settings.autoTagConfig.maxTagsPerNote)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0 && parsed <= 20) { this.plugin.settings.autoTagConfig.maxTagsPerNote = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Max tags must be between 1 and 20."); + new import_obsidian7.Notice("Max tags must be between 1 and 20."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Min Note Length").setDesc("Minimum character length for a note to be tagged (default: 50)").addText( + new import_obsidian7.Setting(containerEl).setName("Min Note Length").setDesc("Minimum character length for a note to be tagged (default: 50)").addText( (text) => text.setValue(String(this.plugin.settings.autoTagConfig.minNoteLength)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed >= 0) { this.plugin.settings.autoTagConfig.minNoteLength = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Min note length must be a non-negative integer."); + new import_obsidian7.Notice("Min note length must be a non-negative integer."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Max Note Length").setDesc("Maximum characters of content sent to the model for tagging (default: 8000)").addText( + new import_obsidian7.Setting(containerEl).setName("Max Note Length").setDesc("Maximum characters of content sent to the model for tagging (default: 8000)").addText( (text) => text.setValue(String(this.plugin.settings.autoTagConfig.maxNoteLength)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0) { this.plugin.settings.autoTagConfig.maxNoteLength = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Max note length must be a positive integer."); + new import_obsidian7.Notice("Max note length must be a positive integer."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Tag Prompt Template").setDesc( + new import_obsidian7.Setting(containerEl).setName("Normalize Tags").setDesc( + 'Normalize generated tags against existing vault tag vocabulary (e.g., prefer "machine-learning" over "machine learning")' + ).addToggle( + (toggle) => toggle.setValue(this.plugin.settings.autoTagConfig.normalizeTags).onChange(async (value) => { + this.plugin.settings.autoTagConfig.normalizeTags = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Target Folder (Auto-Tag)").setDesc("Only auto-tag notes inside this folder path. Leave empty for all notes.").addText( + (text) => text.setValue(this.plugin.settings.autoTagConfig.targetFolder).onChange(async (value) => { + this.plugin.settings.autoTagConfig.targetFolder = value.trim(); + await this.plugin.saveSettings(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Tag Prompt Template").setDesc( "Prompt template for tag generation. Use {{maxTags}}, {{title}}, {{content}} as placeholders." ).addTextArea( (text) => text.setValue(this.plugin.settings.autoTagConfig.tagPromptTemplate).onChange(async (value) => { @@ -10654,59 +13482,186 @@ var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Run Auto-Tagging Now").setDesc("Process all untagged notes and generate tags").addButton( + new import_obsidian7.Setting(containerEl).setName("Run Auto-Tagging Now").setDesc("Process all untagged notes and generate tags").addButton( (button) => button.setButtonText("Auto-Tag Notes").onClick(async () => { try { - await this.plugin.initializeAutoOrganizer(); + this.plugin.initializeAutoOrganizer(); if (this.plugin.autoTagger) { await this.plugin.autoTagger.run(); } } catch { - new import_obsidian5.Notice("Auto-tagging failed. Check console for details."); + new import_obsidian7.Notice("Auto-tagging failed. Check console for details."); } }) ); containerEl.createEl("h4", { text: "Auto-Linking" }); - new import_obsidian5.Setting(containerEl).setName("Enable Auto-Linking").setDesc('Add "Related Notes" sections to notes based on semantic similarity').addToggle( + new import_obsidian7.Setting(containerEl).setName("Enable Auto-Linking").setDesc('Add "Related Notes" sections to notes based on semantic similarity').addToggle( (toggle) => toggle.setValue(this.plugin.settings.autoLinkConfig.enabled).onChange(async (value) => { this.plugin.settings.autoLinkConfig.enabled = value; await this.plugin.saveSettings(); }) ); - new import_obsidian5.Setting(containerEl).setName("Max Links Per Note").setDesc("Maximum number of related notes to link (default: 3)").addText( + new import_obsidian7.Setting(containerEl).setName("Max Links Per Note").setDesc("Maximum number of related notes to link (default: 3)").addText( (text) => text.setValue(String(this.plugin.settings.autoLinkConfig.maxLinksPerNote)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0 && parsed <= 10) { this.plugin.settings.autoLinkConfig.maxLinksPerNote = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Max links must be between 1 and 10."); + new import_obsidian7.Notice("Max links must be between 1 and 10."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Auto-Link Similarity Threshold").setDesc("Minimum similarity score for notes to be considered related (default: 0.6)").addText( + new import_obsidian7.Setting(containerEl).setName("Target Folder (Auto-Link)").setDesc( + "Only add related links to notes inside this folder path. Leave empty for all notes." + ).addText( + (text) => text.setValue(this.plugin.settings.autoLinkConfig.targetFolder).onChange(async (value) => { + this.plugin.settings.autoLinkConfig.targetFolder = value.trim(); + await this.plugin.saveSettings(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Dry Run Mode").setDesc("Preview proposed link changes without applying them").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.autoLinkConfig.dryRun).onChange(async (value) => { + this.plugin.settings.autoLinkConfig.dryRun = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Auto-Link Similarity Threshold").setDesc("Minimum similarity score for notes to be considered related (default: 0.6)").addText( (text) => text.setValue(String(this.plugin.settings.autoLinkConfig.similarityThreshold)).onChange(async (value) => { const parsed = parseFloat(value); if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { this.plugin.settings.autoLinkConfig.similarityThreshold = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian5.Notice("Similarity threshold must be between 0 and 1."); + new import_obsidian7.Notice("Similarity threshold must be between 0 and 1."); } }) ); - new import_obsidian5.Setting(containerEl).setName("Run Auto-Linking Now").setDesc("Process all notes and add related note links").addButton( + new import_obsidian7.Setting(containerEl).setName("Run Auto-Linking Now").setDesc("Process all notes and add related note links").addButton( (button) => button.setButtonText("Auto-Link Notes").onClick(async () => { try { - await this.plugin.initializeAutoOrganizer(); + this.plugin.initializeAutoOrganizer(); if (this.plugin.autoLinker) { await this.plugin.autoLinker.run(); } } catch { - new import_obsidian5.Notice("Auto-linking failed. Check console for details."); + new import_obsidian7.Notice("Auto-linking failed. Check console for details."); } }) ); + containerEl.createEl("h3", { text: "Structured Memory" }); + containerEl.createEl("p", { + text: "Persist conversation summaries, user preferences, and learned facts across sessions." + }); + new import_obsidian7.Setting(containerEl).setName("Enable Structured Memory").setDesc("Inject remembered context from past sessions into the system prompt.").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.structuredMemoryConfig.enabled).onChange(async (value) => { + this.plugin.settings.structuredMemoryConfig.enabled = value; + this.plugin.structuredMemoryManager.updateConfig( + this.plugin.settings.structuredMemoryConfig + ); + await this.plugin.saveSettings(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Max Conversation Summaries").setDesc("Maximum number of past conversation summaries to retain (default: 10).").addText( + (text) => text.setValue(String(this.plugin.settings.structuredMemoryConfig.maxSummaries)).onChange(async (value) => { + const parsed = parseInt(value); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) { + this.plugin.settings.structuredMemoryConfig.maxSummaries = parsed; + this.plugin.structuredMemoryManager.updateConfig( + this.plugin.settings.structuredMemoryConfig + ); + await this.plugin.saveSettings(); + } else { + new import_obsidian7.Notice("Max summaries must be between 0 and 100."); + } + }) + ); + new import_obsidian7.Setting(containerEl).setName("Max User Preferences").setDesc("Maximum number of user preferences to retain (default: 20).").addText( + (text) => text.setValue(String(this.plugin.settings.structuredMemoryConfig.maxPreferences)).onChange(async (value) => { + const parsed = parseInt(value); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 200) { + this.plugin.settings.structuredMemoryConfig.maxPreferences = parsed; + this.plugin.structuredMemoryManager.updateConfig( + this.plugin.settings.structuredMemoryConfig + ); + await this.plugin.saveSettings(); + } else { + new import_obsidian7.Notice("Max preferences must be between 0 and 200."); + } + }) + ); + new import_obsidian7.Setting(containerEl).setName("Max Learned Facts").setDesc("Maximum number of learned facts to retain (default: 50).").addText( + (text) => text.setValue(String(this.plugin.settings.structuredMemoryConfig.maxFacts)).onChange(async (value) => { + const parsed = parseInt(value); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 500) { + this.plugin.settings.structuredMemoryConfig.maxFacts = parsed; + this.plugin.structuredMemoryManager.updateConfig( + this.plugin.settings.structuredMemoryConfig + ); + await this.plugin.saveSettings(); + } else { + new import_obsidian7.Notice("Max facts must be between 0 and 500."); + } + }) + ); + new import_obsidian7.Setting(containerEl).setName("Clear Structured Memory").setDesc("Delete all stored conversation summaries, preferences, and facts.").addButton( + (button) => button.setButtonText("Clear Memory").onClick(async () => { + this.plugin.structuredMemoryManager.clearAll(); + await this.plugin.saveSettings(); + new import_obsidian7.Notice("Structured memory cleared."); + }) + ); + containerEl.createEl("h3", { text: "Tool Telemetry" }); + containerEl.createEl("p", { + text: "Track which tools were called, which notes were searched, and LLM token usage." + }); + new import_obsidian7.Setting(containerEl).setName("Enable Tool Telemetry").setDesc("Record tool calls, searches, and LLM token counts for analysis.").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.toolTelemetryConfig.enabled).onChange(async (value) => { + this.plugin.settings.toolTelemetryConfig.enabled = value; + this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig); + await this.plugin.saveSettings(); + }) + ); + new import_obsidian7.Setting(containerEl).setName("Max Telemetry Entries").setDesc("Maximum number of telemetry events to retain (default: 100).").addText( + (text) => text.setValue(String(this.plugin.settings.toolTelemetryConfig.maxEntries)).onChange(async (value) => { + const parsed = parseInt(value); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 1e3) { + this.plugin.settings.toolTelemetryConfig.maxEntries = parsed; + this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig); + await this.plugin.saveSettings(); + } else { + new import_obsidian7.Notice("Max entries must be between 0 and 1000."); + } + }) + ); + new import_obsidian7.Setting(containerEl).setName("Clear Tool Telemetry").setDesc("Delete all recorded tool telemetry.").addButton( + (button) => button.setButtonText("Clear Telemetry").onClick(async () => { + this.plugin.telemetryManager?.clear(); + await this.plugin.saveSettings(); + new import_obsidian7.Notice("Tool telemetry cleared."); + }) + ); + const recentEntries = this.plugin.telemetryManager?.getRecentEntries(10) ?? []; + if (recentEntries.length > 0) { + containerEl.createEl("h4", { text: "Recent Activity" }); + const telemetryList = containerEl.createEl("ul"); + for (const entry of recentEntries) { + const li = telemetryList.createEl("li"); + if (entry.type === "tool_call") { + li.setText( + `${new Date(entry.timestamp).toLocaleString()}: Tool "${entry.toolName}" \u2014 ${entry.success ? "success" : "failed"} (${entry.durationMs}ms)` + ); + } else if (entry.type === "llm_call") { + li.setText( + `${new Date(entry.timestamp).toLocaleString()}: LLM call \u2014 ${entry.totalTokens} tokens (${entry.durationMs}ms)` + ); + } else if (entry.type === "vault_search") { + li.setText( + `${new Date(entry.timestamp).toLocaleString()}: Search "${entry.query}" \u2014 ${entry.resultsCount} results` + ); + } + } + } } hide() { this.containerEl.empty(); diff --git a/src/chat-view.ts b/src/chat-view.ts index 1eae351..67c04b1 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -67,12 +67,11 @@ export class ChatView extends ItemView { this.listenersAttached = false; this.settings = settings; this.currentAgentMode = settings.agentMode ?? 'ask'; - this.ollamaClient = new OllamaClient( - settings.ollamaUrl, - settings.model, - undefined, - settings.cacheConfig - ); + this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings); + this.agentOllamaClient = + (settings.agentModel ?? settings.model) === (settings.chatModel ?? settings.model) + ? this.ollamaClient + : this.createOllamaClient(settings.agentModel ?? settings.model, settings); this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore); this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); @@ -84,7 +83,7 @@ export class ChatView extends ItemView { this.app.vault, this.app, settings.ollamaUrl, - settings.model, + settings.agentModel ?? settings.model, { cacheConfig: settings.cacheConfig } ); } @@ -95,20 +94,22 @@ export class ChatView extends ItemView { if (this.modeSelectorEl) { this.modeSelectorEl.value = this.currentAgentMode; } - this.ollamaClient = new OllamaClient( - newSettings.ollamaUrl, - newSettings.model, - undefined, - newSettings.cacheConfig + this.ollamaClient = this.createOllamaClient( + newSettings.chatModel ?? newSettings.model, + newSettings ); + this.agentOllamaClient = + (newSettings.agentModel ?? newSettings.model) === (newSettings.chatModel ?? newSettings.model) + ? this.ollamaClient + : this.createOllamaClient(newSettings.agentModel ?? newSettings.model, newSettings); this.workflowEngine = new WorkflowEngine( this.app.vault, this.app, newSettings.ollamaUrl, - newSettings.model, + newSettings.agentModel ?? newSettings.model, { cacheConfig: newSettings.cacheConfig } ); - void this.ollamaClient.initializeCache().catch(() => { + void this.initializeClientCaches().catch(() => { new Notice( 'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.' ); @@ -122,6 +123,9 @@ export class ChatView extends ItemView { async clearCache(): Promise { await this.ollamaClient.clearCache(); + if (this.agentOllamaClient !== this.ollamaClient) { + await this.agentOllamaClient.clearCache(); + } } getViewType(): string { @@ -138,7 +142,7 @@ export class ChatView extends ItemView { async onOpen(): Promise { try { - await this.ollamaClient.initializeCache(); + await this.initializeClientCaches(); } catch { new Notice( 'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.' @@ -744,7 +748,7 @@ export class ChatView extends ItemView { if (followUpMessages.length > 0) { const finalMessages = [...messages, followUp, ...followUpMessages]; const followUpStartTime = Date.now(); - const response = await this.ollamaClient.chat(finalMessages, tools); + const response = await this.getActiveOllamaClient().chat(finalMessages, tools); const followUpDurationMs = Date.now() - followUpStartTime; const finalResponse = response.content || fullResponse; this.updateMessageById(assistantMessageId, { @@ -755,7 +759,7 @@ export class ChatView extends ItemView { // Record follow-up LLM call telemetry this.telemetryManager?.recordLlmCall({ - model: this.settings.model, + model: this.getActiveModel(), promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4), completionTokens: Math.round(finalResponse.length / 4), totalTokens: Math.round( @@ -812,7 +816,7 @@ export class ChatView extends ItemView { if (followUpMessages.length > 0) { const finalMessages = [...messages, followUp, ...followUpMessages]; const followUpStartTime = Date.now(); - const response = await this.ollamaClient.chat(finalMessages, tools); + const response = await this.getActiveOllamaClient().chat(finalMessages, tools); const followUpDurationMs = Date.now() - followUpStartTime; const finalResponse = response.content || 'Actions applied successfully.'; this.updateMessageById(assistantMessageId, { @@ -823,7 +827,7 @@ export class ChatView extends ItemView { // Record follow-up LLM call telemetry this.telemetryManager?.recordLlmCall({ - model: this.settings.model, + model: this.getActiveModel(), promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4), completionTokens: Math.round(finalResponse.length / 4), totalTokens: Math.round( @@ -1090,7 +1094,9 @@ export class ChatView extends ItemView { // Prepend structured memory as a system message if available const messagesWithMemory = this.buildMessagesWithMemory(completeMessages); - const stream = this.ollamaClient.streamChat(messagesWithMemory, tools); + const activeClient = this.getActiveOllamaClient(); + const activeModel = this.getActiveModel(); + const stream = activeClient.streamChat(messagesWithMemory, tools); let fullResponse = ''; let toolCalls: OllamaToolCall[] = []; @@ -1131,7 +1137,7 @@ export class ChatView extends ItemView { completionTokens > 0 ? completionTokens : fullResponse.length / 4; this.telemetryManager?.recordLlmCall({ - model: this.settings.model, + model: activeModel, promptTokens: Math.round(estimatedPromptTokens), completionTokens: Math.round(estimatedCompletionTokens), totalTokens: Math.round(estimatedPromptTokens + estimatedCompletionTokens), @@ -1232,6 +1238,7 @@ export class ChatView extends ItemView { private listenersAttached: boolean = false; private settings: PluginSettings; private ollamaClient: OllamaClient; + private agentOllamaClient: OllamaClient; private vaultIndexer: VaultIndexer; private toolExecutor: ToolExecutor; private actionPreviewBuilder: ActionPreviewBuilder; @@ -1253,6 +1260,31 @@ export class ChatView extends ItemView { tools: OllamaTool[]; assistantMessageId: string; } | null = null; + + private createOllamaClient(model: string, settings: PluginSettings): OllamaClient { + return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig); + } + + private async initializeClientCaches(): Promise { + await this.ollamaClient.initializeCache(); + if (this.agentOllamaClient !== this.ollamaClient) { + await this.agentOllamaClient.initializeCache(); + } + } + + private getActiveModel(): string { + return this.isAgenticMode(this.currentAgentMode) + ? (this.settings.agentModel ?? this.settings.model) + : (this.settings.chatModel ?? this.settings.model); + } + + private getActiveOllamaClient(): OllamaClient { + return this.isAgenticMode(this.currentAgentMode) ? this.agentOllamaClient : this.ollamaClient; + } + + private isAgenticMode(mode: AgentMode): boolean { + return mode === 'edit' || mode === 'organize' || mode === 'workflow'; + } } const MAX_TOOL_CALLS = 5; diff --git a/src/constants.ts b/src/constants.ts index 091ff30..b64142a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,8 @@ export const DEFAULT_SETTINGS = { ollamaUrl: 'http://localhost:11434', - model: 'llama3', + chatModel: 'deepseek-v4-flash', + agentModel: 'glm-5.1', + model: 'deepseek-v4-flash', vaultSearchLimit: 5, maxMessageHistory: 50, maxContextLength: 8000, diff --git a/src/main.ts b/src/main.ts index a9d5714..39e7a6e 100755 --- a/src/main.ts +++ b/src/main.ts @@ -173,6 +173,10 @@ export default class OllamaPlugin extends Plugin { // Backward compatibility: old flat format vs new nested format const loadedSettings = (data.settings ?? data) as Partial; this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings); + const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model; + this.settings.chatModel = loadedSettings.chatModel ?? legacyModel; + this.settings.agentModel = loadedSettings.agentModel ?? legacyModel; + this.settings.model = this.settings.chatModel; const memoryData: StructuredMemoryData = (data.structuredMemory as StructuredMemoryData | undefined) ?? @@ -197,17 +201,13 @@ export default class OllamaPlugin extends Plugin { } initializeAutoOrganizer(): void { - if (!this.autoTagger) { - this.autoTagger = new AutoTagger( - this.app.vault, - this.app, - this.settings.ollamaUrl, - this.settings.model, - this.settings.autoTagConfig - ); - } else { - this.autoTagger.updateConfig(this.settings.autoTagConfig); - } + this.autoTagger = new AutoTagger( + this.app.vault, + this.app, + this.settings.ollamaUrl, + this.settings.agentModel, + this.settings.autoTagConfig + ); if (!this.autoLinker) { const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore); @@ -448,12 +448,25 @@ class OllamaSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName('Model') - .setDesc('Ollama model to use (default: llama3)') + .setName('Chat Model') + .setDesc('Model for normal chat, Ask mode, and Research mode (default: deepseek-v4-flash)') .addText((text) => - text.setValue(this.plugin.settings.model).onChange(async (value) => { - this.plugin.settings.model = value; + text.setValue(this.plugin.settings.chatModel).onChange(async (value) => { + this.plugin.settings.chatModel = value.trim(); + this.plugin.settings.model = this.plugin.settings.chatModel; await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + }) + ); + + new Setting(containerEl) + .setName('Agent Model') + .setDesc('Model for Edit, Organize, Workflow, and auto-organizer tasks (default: glm-5.1)') + .addText((text) => + text.setValue(this.plugin.settings.agentModel).onChange(async (value) => { + this.plugin.settings.agentModel = value.trim(); + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); }) ); diff --git a/src/types.ts b/src/types.ts index 01c39de..a340adc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -314,6 +314,8 @@ export interface VaultIndexConfig { export interface PluginSettings { ollamaUrl: string; + chatModel: string; + agentModel: string; model: string; vaultSearchLimit: number; maxMessageHistory: number; diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 7568949..33932d5 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -33,6 +33,8 @@ jest.mock('obsidian', () => ({ const mockSettings: PluginSettings = { ollamaUrl: 'http://localhost:11434', + chatModel: 'llama3', + agentModel: 'llama3', model: 'llama3', vaultSearchLimit: 3, maxMessageHistory: 50,