From a573d33d0a7cfc4d66af8a81ba9fea567fbe0d92 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 26 May 2026 09:50:23 +0200 Subject: [PATCH] Add undo manager and CoW semantics for tool execution Replace the pending-actions preview flow with immediate execution and undo support. ToolExecutor now accepts an UndoManager and records create, modify, rename, and trash operations so users can roll back batches. Other fixes included: - Deep-merge nested config objects on settings load to preserve new default fields - Increase retry backoff from 10ms to 1000ms and widen the "invalid response format" check to handle prefixed messages - Fix semantic cache clear to null out the collection reference - Tighten memory regex to require "please always/never" - Increase vault indexing batch size from 1 to 5 - Remove unused modeRequiresPreview helper --- main.js | 567 +++++++++++++++++++++++------------- src/agent-modes.ts | 7 +- src/chat-view.ts | 145 +++++---- src/main.ts | 17 +- src/ollama-client.ts | 6 +- src/semantic-cache.ts | 1 + src/structured-memory.ts | 2 +- src/tool-executor.ts | 115 ++++++-- src/undo-manager.ts | 102 +++++++ tests/chat-view.test.ts | 90 +++--- tests/ollama-client.test.ts | 6 +- tests/tool-executor.test.ts | 6 +- 12 files changed, 720 insertions(+), 344 deletions(-) create mode 100644 src/undo-manager.ts diff --git a/main.js b/main.js index 53748c9..9b57440 100644 --- a/main.js +++ b/main.js @@ -3064,10 +3064,10 @@ __export(main_exports, { default: () => OllamaPlugin }); module.exports = __toCommonJS(main_exports); -var import_obsidian7 = require("obsidian"); +var import_obsidian8 = require("obsidian"); // src/chat-view.ts -var import_obsidian5 = require("obsidian"); +var import_obsidian6 = require("obsidian"); // src/types.ts var OllamaError = class _OllamaError extends Error { @@ -3154,12 +3154,7 @@ var EDIT_TOOLS = /* @__PURE__ */ new Set([ "delete_note", "insert_link" ]); -var RESEARCH_TOOLS = /* @__PURE__ */ new Set([ - "read_vault_file", - "search_vault_files", - "list_vault_tags", - "get_vault_stats" -]); +var RESEARCH_TOOLS = READ_TOOLS; var AGENT_MODE_CONFIGS = { ask: { label: "Ask", @@ -3252,9 +3247,6 @@ You do not have direct tool access in this mode \u2014 workflows handle tool use 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; } @@ -3266,6 +3258,9 @@ function filterToolsForMode(tools, mode) { return config.toolFilter(tools); } +// src/undo-manager.ts +var import_obsidian = require("obsidian"); + // src/utils.ts var SEVERITY_ORDER = { debug: 0 /* DEBUG */, @@ -3400,6 +3395,88 @@ function safeParseJson(jsonString) { return parsed; } +// src/undo-manager.ts +var UndoManager = class { + constructor() { + this.batches = []; + this.maxBatches = 10; + } + startBatch() { + const id = crypto.randomUUID?.() ?? `undo-${Date.now()}-${Math.random()}`; + this.batches.push({ id, timestamp: Date.now(), operations: [] }); + if (this.batches.length > this.maxBatches) { + this.batches = this.batches.slice(-this.maxBatches); + } + return id; + } + recordOperation(batchId, op) { + const batch = this.batches.find((b) => b.id === batchId); + batch?.operations.push(op); + } + getBatch(batchId) { + return this.batches.find((b) => b.id === batchId); + } + hasBatch(batchId) { + const batch = this.getBatch(batchId); + return !!(batch && batch.operations.length > 0); + } + async undo(batchId, vault) { + const batch = this.getBatch(batchId); + if (!batch) return { restored: 0, failed: 0 }; + let restored = 0; + let failed = 0; + for (const op of [...batch.operations].reverse()) { + try { + switch (op.type) { + case "create": { + const file = vault.getAbstractFileByPath(op.path); + if (file instanceof import_obsidian.TFile) { + await vault.trash(file, true); + } + break; + } + case "modify": { + const file = vault.getAbstractFileByPath(op.path); + if (file instanceof import_obsidian.TFile) { + await vault.modify(file, op.originalContent); + } + break; + } + case "rename": { + const file = vault.getAbstractFileByPath(op.newPath); + if (file instanceof import_obsidian.TFile) { + await vault.rename(file, op.originalPath); + } + break; + } + case "trash": { + const existing = vault.getAbstractFileByPath(op.path); + if (existing instanceof import_obsidian.TFile) { + await vault.modify(existing, op.originalContent); + } else { + await vault.create(op.path, op.originalContent); + } + break; + } + } + restored++; + } catch (error) { + const path = "path" in op ? op.path : "originalPath" in op ? op.originalPath : "?"; + Logger.warn( + `Undo failed for ${op.type} on ${path}: ${error instanceof Error ? error.message : String(error)}`, + "undo-manager" + ); + failed++; + } + } + this.batches = this.batches.filter((b) => b.id !== batchId); + return { restored, failed }; + } + clear() { + this.batches = []; + } +}; + // node_modules/chromadb/dist/chromadb.mjs var import_isomorphic_fetch = __toESM(require_fetch_npm_node(), 1); var __defProp2 = Object.defineProperty; @@ -8195,6 +8272,7 @@ var SemanticCacheService = class _SemanticCacheService { if (!this.config.enabled || !this.client) return; try { await this.client.deleteCollection({ name: this.config.collectionName }); + this.collection = null; Logger.info("Semantic cache cleared", "semantic-cache"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -8400,7 +8478,7 @@ var OllamaClient = class { `Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, "ollama-client" ); - await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount))); + await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retryCount))); yield* this.streamChatWithRetry(messages, tools, retryCount + 1); } else { throw error; @@ -8450,7 +8528,7 @@ var OllamaClient = class { `Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, "ollama-client" ); - await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount))); + await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retryCount))); return this.chatWithRetry(messages, tools, retryCount + 1); } else { throw error; @@ -8504,7 +8582,7 @@ var OllamaClient = class { if (error.name === "AbortError") { return false; } - if (error.message.startsWith("Ollama error:") || error.message.includes("Too many malformed chunks") || error.message === "No response body" || error.message === "Invalid response format") { + if (error.message.startsWith("Ollama error:") || error.message.includes("Too many malformed chunks") || error.message === "No response body" || error.message.startsWith("Invalid response format")) { return false; } } @@ -8911,16 +8989,17 @@ var VaultIndexer = class { }; // src/tool-executor.ts -var import_obsidian = require("obsidian"); +var import_obsidian2 = require("obsidian"); var INVALID_PATH_CHARS = /[<>:"|?*~]/; var MAX_PATH_LENGTH = 200; var FORBIDDEN_DIRS = [".obsidian", ".git"]; var ToolExecutor = class { - constructor(vault, app, telemetryManager, vaultIndexer) { + constructor(vault, app, telemetryManager, vaultIndexer, undoManager) { this.vault = vault; this.app = app; this.telemetryManager = telemetryManager; this.vaultIndexer = vaultIndexer; + this.undoManager = undoManager; } isSafePath(path) { if (!path || path.trim().length === 0) { @@ -8955,7 +9034,7 @@ var ToolExecutor = class { } getFile(path) { const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof import_obsidian.TFile)) { + if (!(file instanceof import_obsidian2.TFile)) { throw new Error(`File not found: ${path}`); } return file; @@ -8984,7 +9063,7 @@ var ToolExecutor = class { currentPath = currentPath ? `${currentPath}/${part}` : part; const existing = this.vault.getAbstractFileByPath(currentPath); if (existing) { - if (!(existing instanceof import_obsidian.TFolder)) { + if (!(existing instanceof import_obsidian2.TFolder)) { throw new Error(`Cannot create folder ${currentPath}: a file already exists at that path`); } continue; @@ -8992,7 +9071,7 @@ var ToolExecutor = class { await this.vault.createFolder(currentPath); } } - async handleToolCall(toolCall) { + async handleToolCall(toolCall, undoBatchId) { const startTime = Date.now(); const toolName = toolCall.function?.name ?? "unknown"; let parsedArgs = {}; @@ -9014,6 +9093,9 @@ var ToolExecutor = class { } else { throw new Error("Arguments must be an object or JSON string"); } + if (undoBatchId) { + await this.snapshotForUndo(toolName, parsedArgs, undoBatchId); + } switch (toolName) { case "create_file": case "create_note": @@ -9085,14 +9167,9 @@ var ToolExecutor = class { if (!this.isSafePath(path)) { throw new Error("Invalid file path detected"); } - try { - await this.ensureFolderExists(this.getParentFolderPath(path)); - await this.vault.create(path, content); - return { success: true, message: "Note created successfully" }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(errorMessage); - } + await this.ensureFolderExists(this.getParentFolderPath(path)); + await this.vault.create(path, content); + return { success: true, message: "Note created successfully" }; } async executeTool(name, args) { return this.handleToolCall({ @@ -9100,7 +9177,7 @@ var ToolExecutor = class { type: "function", function: { name, - arguments: args + arguments: typeof args === "string" ? args : JSON.stringify(args) } }); } @@ -9346,8 +9423,8 @@ ${lines.join("\n")} 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` }; + await this.vault.trash(file, true); + return { success: true, message: `Note ${path} moved to trash` }; } async handleListVaultTags(args) { const sortBy = args.sortBy === "count" ? "count" : "name"; @@ -9393,10 +9470,10 @@ ${lines.join("\n")} data: entries }; } - async handleGetVaultStats(args) { + handleGetVaultStats(args) { const files = this.vault.getMarkdownFiles(); const folderSet = /* @__PURE__ */ new Set(); - let totalLength = 0; + let totalSize = 0; let taggedCount = 0; let untaggedCount = 0; const tagMap = /* @__PURE__ */ new Map(); @@ -9405,8 +9482,7 @@ ${lines.join("\n")} try { const folder = file.path.split("/").slice(0, -1).join("/") || "(root)"; folderSet.add(folder); - const content = await this.vault.cachedRead(file); - totalLength += content.length; + if (file.stat?.size) totalSize += file.stat.size; const cache = this.app.metadataCache.getFileCache(file); const rawTags = cache?.frontmatter?.tags; let hasTags = false; @@ -9443,11 +9519,80 @@ ${lines.join("\n")} taggedNotes: taggedCount, untaggedNotes: untaggedCount, topTags: Array.from(tagMap.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([tag, count]) => ({ tag, count })), - avgNoteLength: files.length > 0 ? Math.round(totalLength / files.length) : 0, + avgNoteSize: files.length > 0 ? Math.round(totalSize / files.length) : 0, recentFiles: recentFiles.slice(0, 10).map((f) => f.path) } }; } + async snapshotForUndo(toolName, parsedArgs, batchId) { + if (!this.undoManager) return; + try { + switch (toolName) { + case "create_file": + case "create_note": + this.undoManager.recordOperation(batchId, { + type: "create", + path: parsedArgs.path + }); + break; + case "append_to_note": + case "replace_note_section": + case "update_frontmatter": { + const path = parsedArgs.path; + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof import_obsidian2.TFile) { + const originalContent = await this.vault.cachedRead(file); + this.undoManager.recordOperation(batchId, { type: "modify", path, originalContent }); + } + break; + } + case "insert_link": { + const path = parsedArgs.sourcePath; + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof import_obsidian2.TFile) { + const originalContent = await this.vault.cachedRead(file); + this.undoManager.recordOperation(batchId, { type: "modify", path, originalContent }); + } + break; + } + case "rename_note": + this.undoManager.recordOperation(batchId, { + type: "rename", + originalPath: parsedArgs.oldPath, + newPath: parsedArgs.newPath + }); + break; + case "move_note": { + const path = parsedArgs.path; + const folder = (parsedArgs.folder ?? "").replace(/\/$/, "").trim(); + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof import_obsidian2.TFile) { + const newPath = folder ? `${folder}/${file.name}` : file.name; + this.undoManager.recordOperation(batchId, { + type: "rename", + originalPath: path, + newPath + }); + } + break; + } + case "delete_note": { + const path = parsedArgs.path; + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof import_obsidian2.TFile) { + const originalContent = await this.vault.cachedRead(file); + this.undoManager.recordOperation(batchId, { type: "trash", path, originalContent }); + } + break; + } + } + } catch (error) { + Logger.warn( + `Failed to snapshot for undo (${toolName}): ${error instanceof Error ? error.message : String(error)}`, + "tool-executor" + ); + } + } async handleInsertLink(args) { const sourcePath = args.sourcePath; const targetPath = args.targetPath; @@ -9471,7 +9616,7 @@ ${lines.join("\n")} }; // src/action-preview-builder.ts -var import_obsidian2 = require("obsidian"); +var import_obsidian3 = require("obsidian"); var WRITE_TOOLS = /* @__PURE__ */ new Set([ "create_file", "create_note", @@ -9728,7 +9873,7 @@ var ActionPreviewBuilder = class { getFileSafe(path) { try { const file = this.vault.getAbstractFileByPath(path); - if (file instanceof import_obsidian2.TFile) { + if (file instanceof import_obsidian3.TFile) { return file; } } catch { @@ -10563,7 +10708,7 @@ Please provide: }; // src/note-context-builder.ts -var import_obsidian3 = require("obsidian"); +var import_obsidian4 = require("obsidian"); var NoteContextBuilder = class { constructor(vault, app, vaultIndexer) { this.vault = vault; @@ -10613,7 +10758,7 @@ var NoteContextBuilder = class { * Gets selected text from the active markdown editor. */ getSelectedText() { - const activeView = this.app.workspace.getActiveViewOfType(import_obsidian3.MarkdownView); + const activeView = this.app.workspace.getActiveViewOfType(import_obsidian4.MarkdownView); if (!activeView) { return void 0; } @@ -10835,11 +10980,11 @@ ${context.selectedText}`); }; // src/error-handler.ts -var import_obsidian4 = require("obsidian"); +var import_obsidian5 = require("obsidian"); var ErrorHandler = class { static handleError(error, context) { const message = this.getUserFriendlyMessage(error); - new import_obsidian4.Notice(message); + new import_obsidian5.Notice(message); if (error instanceof Error) { const ctx = context ? ` [${context}]` : ""; console.error(`Ollama Plugin Error${ctx}: ${error.message}`); @@ -10935,7 +11080,9 @@ var ErrorHandler = class { }; // src/chat-view.ts -var ChatView = class extends import_obsidian5.ItemView { +var MAX_TOOL_CALLS = 5; +var MAX_TOOL_CALL_DEPTH = 5; +var ChatView = class extends import_obsidian6.ItemView { constructor(leaf, settings, vectorStore, structuredMemoryManager, telemetryManager, chatHistoryManager, onModelChange, onPersist) { super(leaf); // State @@ -10949,15 +11096,13 @@ var ChatView = class extends import_obsidian5.ItemView { this.sendButtonClickHandler = null; this.inputKeyDownHandler = null; this.newChatButtonClickHandler = null; - this.sendButtonClickWrapper = null; - this.inputKeyDownWrapper = null; - this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.isCancelled = false; this.modeSelectorEl = null; this.modelSelectorEl = null; this.historySelectEl = null; this.historyDeleteButton = null; + this.undoManager = new UndoManager(); this.pendingActions = []; this.pendingReadResults = []; this.pendingFollowUpContext = null; @@ -10980,9 +11125,6 @@ var ChatView = class extends import_obsidian5.ItemView { this.sendButtonClickHandler = null; this.inputKeyDownHandler = null; this.newChatButtonClickHandler = null; - this.sendButtonClickWrapper = null; - this.inputKeyDownWrapper = null; - this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.settings = settings; this.currentAgentMode = settings.agentMode ?? "ask"; @@ -10994,7 +11136,8 @@ var ChatView = class extends import_obsidian5.ItemView { this.app.vault, this.app, telemetryManager, - this.vaultIndexer + this.vaultIndexer, + this.undoManager ); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); @@ -11054,7 +11197,7 @@ var ChatView = class extends import_obsidian5.ItemView { ); this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(this.currentAgentMode)); void this.initializeClientCaches().catch(() => { - new import_obsidian5.Notice( + new import_obsidian6.Notice( "Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings." ); }); @@ -11082,7 +11225,7 @@ var ChatView = class extends import_obsidian5.ItemView { try { await this.initializeClientCaches(); } catch { - new import_obsidian5.Notice( + new import_obsidian6.Notice( "Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings." ); } @@ -11245,7 +11388,7 @@ var ChatView = class extends import_obsidian5.ItemView { if (selectedId && selectedId !== "__new__") { const deleted = this.chatHistoryManager?.deleteSession(selectedId); if (deleted) { - new import_obsidian5.Notice("Chat deleted."); + new import_obsidian6.Notice("Chat deleted."); this.clearConversation(); } } @@ -11474,7 +11617,7 @@ var ChatView = class extends import_obsidian5.ItemView { if (!this.historySelectEl) return; const previousValue = this.historySelectEl.value; this.historySelectEl.innerHTML = ""; - const newOption = this.historySelectEl.createEl("option", { + this.historySelectEl.createEl("option", { text: "New Chat", attr: { value: "__new__" } }); @@ -11549,6 +11692,19 @@ var ChatView = class extends import_obsidian5.ItemView { } } } + ensureAssistantMessageFinalized(assistantMessageId) { + const message = this.messages.find((msg) => msg.id === assistantMessageId); + if (!message) { + return; + } + if (message.isStreaming || message.isThinking || message.content.trim().length === 0) { + this.updateMessageById(assistantMessageId, { + content: message.content.trim().length > 0 ? message.content : "No response was returned.", + isStreaming: false, + isThinking: false + }); + } + } getTools() { const allTools = [ { @@ -11723,7 +11879,7 @@ var ChatView = class extends import_obsidian5.ItemView { type: "function", function: { name: "delete_note", - description: "Deletes a note from the vault", + description: "Moves a note to the system trash (recoverable)", parameters: { type: "object", properties: { @@ -11825,48 +11981,26 @@ var ChatView = class extends import_obsidian5.ItemView { } }) )).filter((result) => result !== null); - 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"); + const writeResults = []; + if (writeToolCalls.length > 0) { + const undoBatchId = this.undoManager.startBatch(); + for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) { + try { + const toolResult = await this.toolExecutor.handleToolCall(toolCall, undoBatchId); + writeResults.push({ ...toolResult, id: toolCall.id }); + } catch (error) { + ErrorHandler.handleError(error, "ChatView.processToolCalls"); + writeResults.push({ + success: false, + message: error instanceof Error ? error.message : String(error), + id: toolCall.id + }); + } + } + if (this.undoManager.hasBatch(undoBatchId)) { + const count = this.undoManager.getBatch(undoBatchId).operations.length; + this.renderUndoButton(assistantMessageId, undoBatchId, count); } - } - if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) { - this.pendingActions = writePreviews; - this.pendingReadResults = readResults; - this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse }; - 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 { - success: false, - message: error instanceof Error ? error.message : String(error), - id: action.toolCall.id - }; - } - }) - ); } const allResults = [...readResults, ...writeResults]; const followUpMessages = allResults.map((result) => ({ @@ -12070,6 +12204,33 @@ ${action.preview.after.slice(0, 500)}` this.pendingFollowUpContext = null; this.chatContainer?.querySelectorAll(".ollama-proposed-actions").forEach((el) => el.remove()); } + renderUndoButton(assistantMessageId, batchId, count) { + const messageEl = this.chatContainer?.querySelector( + `.ollama-message[data-msg-id="${assistantMessageId}"]` + ); + if (!messageEl) return; + messageEl.querySelector(".ollama-undo-container")?.remove(); + const container = messageEl.createEl("div", { cls: "ollama-undo-container" }); + container.createEl("span", { + cls: "ollama-undo-label", + text: `${count} file operation${count !== 1 ? "s" : ""} applied.` + }); + const undoBtn = container.createEl("button", { + cls: "ollama-undo-button", + text: "Undo" + }); + undoBtn.addEventListener("click", async () => { + undoBtn.disabled = true; + undoBtn.textContent = "Undoing\u2026"; + const result = await this.undoManager.undo(batchId, this.app.vault); + container.remove(); + if (result.restored > 0 || result.failed > 0) { + new import_obsidian6.Notice( + result.failed > 0 ? `Undo: ${result.restored} restored, ${result.failed} failed` : `Undo: ${result.restored} operation${result.restored !== 1 ? "s" : ""} reverted` + ); + } + }); + } formatWorkflowResult(result) { const lines = []; lines.push(`## ${result.workflowName}`); @@ -12251,14 +12412,12 @@ ${actualMessage}` : actualMessage; assistantMessageId ); } - let shouldFallbackToReadTools = false; const toolCapableModes = ["edit", "organize", "research"]; const isToolCapable = toolCapableModes.includes(this.currentAgentMode); const userWantsVaultOps = this.userMessageImpliesToolUse(actualMessage); const modelMentionedActions = isToolCapable && this.shouldAutoRunReadTools(fullResponse); if (isToolCapable && toolCalls.length === 0) { if (modelMentionedActions || userWantsVaultOps) { - shouldFallbackToReadTools = true; const priorResponse = fullResponse; fullResponse = ""; this.updateMessageById(assistantMessageId, { @@ -12375,6 +12534,7 @@ ${actualMessage}` : actualMessage; } } finally { this.isCancelled = false; + this.ensureAssistantMessageFinalized(assistantMessageId); this.hideActivityIndicator(); this.cleanupStreamingResources(); } @@ -12522,7 +12682,7 @@ ${actualMessage}` : actualMessage; this.isCancelled = true; this.ollamaClient.cancelStream(); this.agentOllamaClient.cancelStream(); - new import_obsidian5.Notice("Stopping\u2026"); + new import_obsidian6.Notice("Stopping\u2026"); } createOllamaClient(model, settings) { return new OllamaClient(settings.ollamaUrl, model, void 0, settings.cacheConfig); @@ -12568,8 +12728,6 @@ ${actualMessage}` : actualMessage; } } }; -var MAX_TOOL_CALLS = 5; -var MAX_TOOL_CALL_DEPTH = 5; // src/constants.ts var DEFAULT_SETTINGS = { @@ -13124,7 +13282,7 @@ var VaultVectorStore = class { }; // src/auto-organizer.ts -var import_obsidian6 = require("obsidian"); +var import_obsidian7 = require("obsidian"); function normalizeTag(raw) { return raw.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, ""); } @@ -13312,16 +13470,16 @@ ${content}`; */ async run() { if (!this.config.enabled) { - new import_obsidian6.Notice("Auto-tagging is disabled in settings."); + new import_obsidian7.Notice("Auto-tagging is disabled in settings."); return { tagged: 0, skipped: 0 }; } const untagged = this.getUntaggedNotes(); if (untagged.length === 0) { - new import_obsidian6.Notice("No untagged notes found."); + new import_obsidian7.Notice("No untagged notes found."); return { tagged: 0, skipped: 0 }; } if (this.config.dryRun) { - new import_obsidian6.Notice(`Dry-run: evaluating ${untagged.length} notes...`); + new import_obsidian7.Notice(`Dry-run: evaluating ${untagged.length} notes...`); const proposals = []; let skipped2 = 0; for (const file of untagged) { @@ -13333,10 +13491,10 @@ ${content}`; } await new Promise((resolve) => setTimeout(resolve, 300)); } - new import_obsidian6.Notice(`Dry-run complete: ${proposals.length} proposed tag changes, ${skipped2} skipped.`); + new import_obsidian7.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...`); + new import_obsidian7.Notice(`Auto-tagging ${untagged.length} notes...`); let tagged = 0; let skipped = 0; for (const file of untagged) { @@ -13349,7 +13507,7 @@ ${content}`; } await new Promise((resolve) => setTimeout(resolve, 300)); } - new import_obsidian6.Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`); + new import_obsidian7.Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`); return { tagged, skipped }; } parseTagResponse(response) { @@ -13432,12 +13590,12 @@ ${links} */ async run(dryRun = false) { if (!this.config.enabled) { - new import_obsidian6.Notice("Auto-linking is disabled in settings."); + new import_obsidian7.Notice("Auto-linking is disabled in settings."); return { linked: 0, skipped: 0 }; } const files = this.vault.getMarkdownFiles().filter((f) => this.isInTargetFolder(f)); if (dryRun) { - new import_obsidian6.Notice(`Dry-run: evaluating ${files.length} notes for links...`); + new import_obsidian7.Notice(`Dry-run: evaluating ${files.length} notes for links...`); const proposals = []; let skipped2 = 0; for (const file of files) { @@ -13449,10 +13607,10 @@ ${links} } await new Promise((resolve) => setTimeout(resolve, 200)); } - new import_obsidian6.Notice(`Dry-run complete: ${proposals.length} proposed link changes.`); + new import_obsidian7.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...`); + new import_obsidian7.Notice(`Auto-linking ${files.length} notes...`); let linked = 0; let skipped = 0; for (const file of files) { @@ -13465,7 +13623,7 @@ ${links} } await new Promise((resolve) => setTimeout(resolve, 200)); } - new import_obsidian6.Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`); + new import_obsidian7.Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`); return { linked, skipped }; } }; @@ -13622,7 +13780,7 @@ var StructuredMemoryManager = class { { 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: /please\s+(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: "preference" }, { regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i, keyPrefix: "favorite" @@ -13886,7 +14044,7 @@ var ChatHistoryManager = class { }; // src/main.ts -var OllamaPlugin = class extends import_obsidian7.Plugin { +var OllamaPlugin = class extends import_obsidian8.Plugin { constructor() { super(...arguments); this.settings = DEFAULT_SETTINGS; @@ -13930,7 +14088,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { name: "Clear Semantic Cache", callback: async () => { await this.clearSemanticCache(); - new import_obsidian7.Notice("Semantic cache cleared."); + new import_obsidian8.Notice("Semantic cache cleared."); } }); this.addCommand({ @@ -13938,16 +14096,16 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { name: "Clear Vault Index", callback: async () => { await this.clearVaultIndex(); - new import_obsidian7.Notice("Vault index cleared."); + new import_obsidian8.Notice("Vault index cleared."); } }); this.addCommand({ id: "rebuild-vault-index", name: "Rebuild Vault Index", callback: async () => { - new import_obsidian7.Notice("Rebuilding vault index..."); + new import_obsidian8.Notice("Rebuilding vault index..."); await this.rebuildVaultIndex(); - new import_obsidian7.Notice("Vault index rebuilt."); + new import_obsidian8.Notice("Vault index rebuilt."); } }); this.addCommand({ @@ -13956,7 +14114,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { callback: () => { this.initializeAutoOrganizer(); if (this.autoTagger) { - new import_obsidian7.Notice("Auto-tagging untagged notes..."); + new import_obsidian8.Notice("Auto-tagging untagged notes..."); void this.autoTagger.run(); } } @@ -13967,7 +14125,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { callback: () => { this.initializeAutoOrganizer(); if (this.autoLinker) { - new import_obsidian7.Notice("Auto-linking related notes..."); + new import_obsidian8.Notice("Auto-linking related notes..."); void this.autoLinker.run(this.settings.autoLinkConfig.dryRun); } } @@ -13978,7 +14136,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { callback: async () => { this.structuredMemoryManager?.clearAll(); await this.saveSettings(); - new import_obsidian7.Notice("Structured memory cleared."); + new import_obsidian8.Notice("Structured memory cleared."); } }); this.addCommand({ @@ -13987,7 +14145,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { callback: async () => { this.telemetryManager?.clear(); await this.saveSettings(); - new import_obsidian7.Notice("Tool telemetry cleared."); + new import_obsidian8.Notice("Tool telemetry cleared."); } }); this.addCommand({ @@ -13996,7 +14154,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { callback: async () => { this.chatHistoryManager?.clearAll(); await this.saveSettings(); - new import_obsidian7.Notice("Chat history cleared."); + new import_obsidian8.Notice("Chat history cleared."); this.notifyChatViews(); } }); @@ -14009,7 +14167,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { try { await this.semanticCache.initialize(); } catch { - new import_obsidian7.Notice("Semantic cache initialization failed. Check console for details."); + new import_obsidian8.Notice("Semantic cache initialization failed. Check console for details."); } } this.registerVaultEventListeners(); @@ -14026,7 +14184,16 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { async loadSettings() { const data = await this.loadData() ?? {}; const loadedSettings = data.settings ?? data; - this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings); + this.settings = { + ...DEFAULT_SETTINGS, + ...loadedSettings, + cacheConfig: { ...DEFAULT_SETTINGS.cacheConfig, ...loadedSettings.cacheConfig ?? {} }, + vaultIndexConfig: { ...DEFAULT_SETTINGS.vaultIndexConfig, ...loadedSettings.vaultIndexConfig ?? {} }, + autoTagConfig: { ...DEFAULT_SETTINGS.autoTagConfig, ...loadedSettings.autoTagConfig ?? {} }, + autoLinkConfig: { ...DEFAULT_SETTINGS.autoLinkConfig, ...loadedSettings.autoLinkConfig ?? {} }, + structuredMemoryConfig: { ...DEFAULT_SETTINGS.structuredMemoryConfig, ...loadedSettings.structuredMemoryConfig ?? {} }, + toolTelemetryConfig: { ...DEFAULT_SETTINGS.toolTelemetryConfig, ...loadedSettings.toolTelemetryConfig ?? {} } + }; const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model; this.settings.chatModel = loadedSettings.chatModel ?? legacyModel; this.settings.agentModel = loadedSettings.agentModel ?? legacyModel; @@ -14098,7 +14265,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { this.indexingAbortController = void 0; }); } catch { - new import_obsidian7.Notice("Vault vector store initialization failed. Check console for details."); + new import_obsidian8.Notice("Vault vector store initialization failed. Check console for details."); } } cancelBackgroundIndexing() { @@ -14121,7 +14288,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { const files = this.app.vault.getMarkdownFiles(); Logger.info(`Starting background vault indexing for ${files.length} files...`, "main"); let indexed = 0; - const BATCH_SIZE = 1; + const BATCH_SIZE = 5; const DELAY_MS = 500; for (let i = 0; i < files.length; i += BATCH_SIZE) { if (signal.aborted) { @@ -14150,7 +14317,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { this.settings.lastIndexTime = Date.now(); await this.saveSettings(); Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, "main"); - new import_obsidian7.Notice(`Vault index updated: ${indexed} files indexed.`); + new import_obsidian8.Notice(`Vault index updated: ${indexed} files indexed.`); } } async rebuildVaultIndex() { @@ -14178,7 +14345,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { await this.awaitBackgroundIndexing(); if (!this.vaultVectorStore) return; const currentFile = this.app.vault.getAbstractFileByPath(file.path); - if (!(currentFile instanceof import_obsidian7.TFile) || currentFile.extension !== "md") return; + if (!(currentFile instanceof import_obsidian8.TFile) || currentFile.extension !== "md") return; const content = await this.app.vault.read(currentFile); const cache = this.app.metadataCache.getFileCache(currentFile); await this.vaultVectorStore.indexFile(currentFile, content, cache ?? void 0); @@ -14190,28 +14357,28 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { registerVaultEventListeners() { this.registerEvent( this.app.vault.on("create", (file) => { - if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian8.TFile && file.extension === "md" && this.vaultVectorStore) { this.indexVaultFileWhenReady(file); } }) ); this.registerEvent( this.app.vault.on("modify", (file) => { - if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian8.TFile && file.extension === "md" && this.vaultVectorStore) { this.indexVaultFileWhenReady(file); } }) ); this.registerEvent( this.app.vault.on("delete", (file) => { - if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian8.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_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { + if (file instanceof import_obsidian8.TFile && file.extension === "md" && this.vaultVectorStore) { void this.vaultVectorStore.deleteFile(oldPath); this.indexVaultFileWhenReady(file); } @@ -14247,7 +14414,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin { }); } }; -var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { +var OllamaSettingTab = class extends import_obsidian8.PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; @@ -14256,13 +14423,13 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Ollama Settings" }); - new import_obsidian7.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText( + new import_obsidian8.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_obsidian7.Setting(containerEl).setName("Chat Model").setDesc("Model for normal chat, Ask mode, and Research mode (default: deepseek-v4-flash)").addText( + new import_obsidian8.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; @@ -14270,43 +14437,43 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { this.plugin.notifyChatViews(); }) ); - new import_obsidian7.Setting(containerEl).setName("Agent Model").setDesc("Model for Edit, Organize, Workflow, and auto-organizer tasks (default: glm-5.1)").addText( + new import_obsidian8.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( + new import_obsidian8.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_obsidian7.Notice("Vault search limit must be a positive integer."); + new import_obsidian8.Notice("Vault search limit must be a positive integer."); } }) ); - 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( + new import_obsidian8.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_obsidian7.Notice("Max context length must be a positive integer."); + new import_obsidian8.Notice("Max context length must be a positive integer."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText( + new import_obsidian8.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_obsidian7.Notice("Max message history must be a positive integer."); + new import_obsidian8.Notice("Max message history must be a positive integer."); } }) ); @@ -14314,7 +14481,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { 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) => { + new import_obsidian8.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)); } @@ -14326,7 +14493,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { }); }); containerEl.createEl("h3", { text: "Vault Semantic Index" }); - new import_obsidian7.Setting(containerEl).setName("Enable Vault Semantic Index").setDesc( + new import_obsidian8.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) => { @@ -14334,7 +14501,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.saveSettings(); this.plugin.notifyChatViews(); if (value) { - new import_obsidian7.Notice("Vault semantic index enabled. Rebuilding index..."); + new import_obsidian8.Notice("Vault semantic index enabled. Rebuilding index..."); await this.plugin.initializeVaultVectorStore(); await this.plugin.rebuildVaultIndex(); } else { @@ -14344,7 +14511,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { } }) ); - new import_obsidian7.Setting(containerEl).setName("Vault Index ChromaDB URL").setDesc( + new import_obsidian8.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) => { @@ -14353,7 +14520,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian7.Setting(containerEl).setName("Vault Index Embedding Model").setDesc( + new import_obsidian8.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) => { @@ -14361,7 +14528,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian7.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc( + new import_obsidian8.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) => { @@ -14370,54 +14537,54 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { this.plugin.settings.vaultIndexConfig.similarityThreshold = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian7.Notice("Similarity threshold must be a number between 0 and 1."); + new import_obsidian8.Notice("Similarity threshold must be a number between 0 and 1."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Rebuild Vault Index").setDesc("Delete and rebuild the entire vault semantic index").addButton( + new import_obsidian8.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_obsidian7.Notice("Rebuilding vault index..."); + new import_obsidian8.Notice("Rebuilding vault index..."); await this.plugin.rebuildVaultIndex(); - new import_obsidian7.Notice("Vault index rebuilt."); + new import_obsidian8.Notice("Vault index rebuilt."); } catch { - new import_obsidian7.Notice("Failed to rebuild vault index. Is ChromaDB running?"); + new import_obsidian8.Notice("Failed to rebuild vault index. Is ChromaDB running?"); } }) ); - new import_obsidian7.Setting(containerEl).setName("Clear Vault Index").setDesc("Delete all indexed vault notes from ChromaDB").addButton( + new import_obsidian8.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_obsidian7.Notice("Vault index cleared."); + new import_obsidian8.Notice("Vault index cleared."); } catch { - new import_obsidian7.Notice("Failed to clear vault index. Is ChromaDB running?"); + new import_obsidian8.Notice("Failed to clear vault index. Is ChromaDB running?"); } }) ); containerEl.createEl("h3", { text: "Semantic Cache" }); - new import_obsidian7.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle( + new import_obsidian8.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_obsidian7.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText( + new import_obsidian8.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_obsidian7.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText( + new import_obsidian8.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_obsidian7.Setting(containerEl).setName("Cache Similarity Threshold").setDesc( + new import_obsidian8.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) => { @@ -14426,62 +14593,62 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { this.plugin.settings.cacheConfig.similarityThreshold = parsed; await this.plugin.saveSettings(); } else { - new import_obsidian7.Notice("Similarity threshold must be a number between 0 and 1."); + new import_obsidian8.Notice("Similarity threshold must be a number between 0 and 1."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton( + new import_obsidian8.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_obsidian7.Notice("Semantic cache cleared."); + new import_obsidian8.Notice("Semantic cache cleared."); } catch { - new import_obsidian7.Notice("Failed to clear semantic cache. Is ChromaDB running?"); + new import_obsidian8.Notice("Failed to clear semantic cache. Is ChromaDB running?"); } }) ); containerEl.createEl("h3", { text: "Auto-Organize" }); containerEl.createEl("h4", { text: "Auto-Tagging" }); - new import_obsidian7.Setting(containerEl).setName("Enable Auto-Tagging").setDesc("Use AI to automatically suggest and apply tags to untagged notes").addToggle( + new import_obsidian8.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_obsidian7.Setting(containerEl).setName("Max Tags Per Note").setDesc("Maximum number of tags to generate for each note (default: 5)").addText( + new import_obsidian8.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_obsidian7.Notice("Max tags must be between 1 and 20."); + new import_obsidian8.Notice("Max tags must be between 1 and 20."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Min Note Length").setDesc("Minimum character length for a note to be tagged (default: 50)").addText( + new import_obsidian8.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_obsidian7.Notice("Min note length must be a non-negative integer."); + new import_obsidian8.Notice("Min note length must be a non-negative integer."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Max Note Length").setDesc("Maximum characters of content sent to the model for tagging (default: 8000)").addText( + new import_obsidian8.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_obsidian7.Notice("Max note length must be a positive integer."); + new import_obsidian8.Notice("Max note length must be a positive integer."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Normalize Tags").setDesc( + new import_obsidian8.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) => { @@ -14489,13 +14656,13 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { 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( + new import_obsidian8.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( + new import_obsidian8.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) => { @@ -14503,7 +14670,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian7.Setting(containerEl).setName("Run Auto-Tagging Now").setDesc("Process all untagged notes and generate tags").addButton( + new import_obsidian8.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 { this.plugin.initializeAutoOrganizer(); @@ -14511,29 +14678,29 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.autoTagger.run(); } } catch { - new import_obsidian7.Notice("Auto-tagging failed. Check console for details."); + new import_obsidian8.Notice("Auto-tagging failed. Check console for details."); } }) ); containerEl.createEl("h4", { text: "Auto-Linking" }); - new import_obsidian7.Setting(containerEl).setName("Enable Auto-Linking").setDesc('Add "Related Notes" sections to notes based on semantic similarity').addToggle( + new import_obsidian8.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_obsidian7.Setting(containerEl).setName("Max Links Per Note").setDesc("Maximum number of related notes to link (default: 3)").addText( + new import_obsidian8.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_obsidian7.Notice("Max links must be between 1 and 10."); + new import_obsidian8.Notice("Max links must be between 1 and 10."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Target Folder (Auto-Link)").setDesc( + new import_obsidian8.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) => { @@ -14541,24 +14708,24 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian7.Setting(containerEl).setName("Dry Run Mode").setDesc("Preview proposed link changes without applying them").addToggle( + new import_obsidian8.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( + new import_obsidian8.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_obsidian7.Notice("Similarity threshold must be between 0 and 1."); + new import_obsidian8.Notice("Similarity threshold must be between 0 and 1."); } }) ); - new import_obsidian7.Setting(containerEl).setName("Run Auto-Linking Now").setDesc("Process all notes and add related note links").addButton( + new import_obsidian8.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 { this.plugin.initializeAutoOrganizer(); @@ -14566,7 +14733,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { await this.plugin.autoLinker.run(this.plugin.settings.autoLinkConfig.dryRun); } } catch { - new import_obsidian7.Notice("Auto-linking failed. Check console for details."); + new import_obsidian8.Notice("Auto-linking failed. Check console for details."); } }) ); @@ -14574,7 +14741,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { 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( + new import_obsidian8.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( @@ -14583,7 +14750,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { 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( + new import_obsidian8.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) { @@ -14593,11 +14760,11 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { ); await this.plugin.saveSettings(); } else { - new import_obsidian7.Notice("Max summaries must be between 0 and 100."); + new import_obsidian8.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( + new import_obsidian8.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) { @@ -14607,11 +14774,11 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { ); await this.plugin.saveSettings(); } else { - new import_obsidian7.Notice("Max preferences must be between 0 and 200."); + new import_obsidian8.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( + new import_obsidian8.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) { @@ -14621,29 +14788,29 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { ); await this.plugin.saveSettings(); } else { - new import_obsidian7.Notice("Max facts must be between 0 and 500."); + new import_obsidian8.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( + new import_obsidian8.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."); + new import_obsidian8.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( + new import_obsidian8.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( + new import_obsidian8.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) { @@ -14651,15 +14818,15 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab { 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_obsidian8.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( + new import_obsidian8.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."); + new import_obsidian8.Notice("Tool telemetry cleared."); }) ); const recentEntries = this.plugin.telemetryManager?.getRecentEntries(10) ?? []; diff --git a/src/agent-modes.ts b/src/agent-modes.ts index 7925e09..7634b00 100644 --- a/src/agent-modes.ts +++ b/src/agent-modes.ts @@ -58,12 +58,7 @@ const EDIT_TOOLS = new Set([ 'insert_link', ]); -const RESEARCH_TOOLS = new Set([ - 'read_vault_file', - 'search_vault_files', - 'list_vault_tags', - 'get_vault_stats', -]); +const RESEARCH_TOOLS = READ_TOOLS; export const AGENT_MODE_CONFIGS: Record = { ask: { diff --git a/src/chat-view.ts b/src/chat-view.ts index 8a4e645..b74c463 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -4,8 +4,8 @@ import { getAgentModeLabel, getSystemPromptForMode, filterToolsForMode, - modeRequiresPreview, } from './agent-modes'; +import { UndoManager } from './undo-manager'; import { OllamaClient } from './ollama-client'; import { VaultIndexer } from './vault-indexer'; import { VaultVectorStore } from './vault-vector-store'; @@ -33,6 +33,9 @@ import { Logger, LogEntry } from './utils'; export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view'; +const MAX_TOOL_CALLS = 5; +const MAX_TOOL_CALL_DEPTH = 5; + export class ChatView extends ItemView { // Getters for testing getSendButtonClickHandler() { @@ -71,9 +74,6 @@ export class ChatView extends ItemView { this.sendButtonClickHandler = null; this.inputKeyDownHandler = null; this.newChatButtonClickHandler = null; - this.sendButtonClickWrapper = null; - this.inputKeyDownWrapper = null; - this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.settings = settings; this.currentAgentMode = settings.agentMode ?? 'ask'; @@ -88,7 +88,8 @@ export class ChatView extends ItemView { this.app.vault, this.app, telemetryManager, - this.vaultIndexer + this.vaultIndexer, + this.undoManager ); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); @@ -652,7 +653,7 @@ export class ChatView extends ItemView { this.historySelectEl.innerHTML = ''; // New Chat option - const newOption = this.historySelectEl.createEl('option', { + this.historySelectEl.createEl('option', { text: 'New Chat', attr: { value: '__new__' }, }); @@ -741,6 +742,24 @@ export class ChatView extends ItemView { } } + private ensureAssistantMessageFinalized(assistantMessageId: string): void { + const message = this.messages.find((msg) => msg.id === assistantMessageId); + if (!message) { + return; + } + + if (message.isStreaming || message.isThinking || message.content.trim().length === 0) { + this.updateMessageById(assistantMessageId, { + content: + message.content.trim().length > 0 + ? message.content + : 'No response was returned.', + isStreaming: false, + isThinking: false, + }); + } + } + getTools(): OllamaTool[] { const allTools: OllamaTool[] = [ { @@ -919,7 +938,7 @@ export class ChatView extends ItemView { type: 'function', function: { name: 'delete_note', - description: 'Deletes a note from the vault', + description: 'Moves a note to the system trash (recoverable)', parameters: { type: 'object', properties: { @@ -1039,54 +1058,27 @@ export class ChatView extends ItemView { ) ).filter((result): result is NonNullable => result !== null); - // Build previews for write tools - const writePreviews: ProposedAction[] = []; - 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'); + // Execute write tools sequentially with CoW snapshotting for undo + const writeResults: (ToolResult & { id?: string })[] = []; + if (writeToolCalls.length > 0) { + const undoBatchId = this.undoManager.startBatch(); + for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) { + try { + const toolResult = await this.toolExecutor.handleToolCall(toolCall, undoBatchId); + writeResults.push({ ...toolResult, id: toolCall.id }); + } catch (error) { + ErrorHandler.handleError(error, 'ChatView.processToolCalls'); + writeResults.push({ + success: false, + message: error instanceof Error ? error.message : String(error), + id: toolCall.id, + }); + } + } + if (this.undoManager.hasBatch(undoBatchId)) { + const count = this.undoManager.getBatch(undoBatchId)!.operations.length; + this.renderUndoButton(assistantMessageId, undoBatchId, count); } - } - - if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) { - // Store pending state for apply/cancel - this.pendingActions = writePreviews; - this.pendingReadResults = readResults; - this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse }; - - this.updateMessageById(assistantMessageId, { - content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`, - isStreaming: false, - isThinking: false, - }); - - this.render(); - this.renderActionPreviews(assistantMessageId); - return; - } - - // If mode does not require preview, execute write tools immediately - let writeResults: (ToolResult & { id?: string })[] = []; - 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 { - success: false, - message: error instanceof Error ? error.message : String(error), - id: action.toolCall.id, - }; - } - }) - ) - ); } // No write tools (or they were already executed) — proceed with follow-up @@ -1332,6 +1324,40 @@ export class ChatView extends ItemView { this.chatContainer?.querySelectorAll('.ollama-proposed-actions').forEach((el) => el.remove()); } + private renderUndoButton(assistantMessageId: string, batchId: string, count: number): void { + const messageEl = this.chatContainer?.querySelector( + `.ollama-message[data-msg-id="${assistantMessageId}"]` + ); + if (!messageEl) return; + + messageEl.querySelector('.ollama-undo-container')?.remove(); + + const container = messageEl.createEl('div', { cls: 'ollama-undo-container' }); + container.createEl('span', { + cls: 'ollama-undo-label', + text: `${count} file operation${count !== 1 ? 's' : ''} applied.`, + }); + + const undoBtn = container.createEl('button', { + cls: 'ollama-undo-button', + text: 'Undo', + }); + + undoBtn.addEventListener('click', async () => { + undoBtn.disabled = true; + undoBtn.textContent = 'Undoing…'; + const result = await this.undoManager.undo(batchId, this.app.vault); + container.remove(); + if (result.restored > 0 || result.failed > 0) { + new Notice( + result.failed > 0 + ? `Undo: ${result.restored} restored, ${result.failed} failed` + : `Undo: ${result.restored} operation${result.restored !== 1 ? 's' : ''} reverted` + ); + } + }); + } + private formatWorkflowResult(result: { workflowName: string; success: boolean; @@ -1562,7 +1588,6 @@ export class ChatView extends ItemView { } // Auto-nudge for tool-capable modes if assistant didn't emit tools but seems to intend to - let shouldFallbackToReadTools = false; const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research']; const isToolCapable = toolCapableModes.includes(this.currentAgentMode); @@ -1572,8 +1597,6 @@ export class ChatView extends ItemView { if (isToolCapable && toolCalls.length === 0) { if (modelMentionedActions || userWantsVaultOps) { - shouldFallbackToReadTools = true; - // Suppress the model's "Let me..." text — clear it from the DOM immediately const priorResponse = fullResponse; fullResponse = ''; @@ -1719,6 +1742,7 @@ export class ChatView extends ItemView { } finally { // Clean up streaming resources regardless of outcome this.isCancelled = false; + this.ensureAssistantMessageFinalized(assistantMessageId); this.hideActivityIndicator(); this.cleanupStreamingResources(); } @@ -1871,9 +1895,6 @@ export class ChatView extends ItemView { private sendButtonClickHandler: (() => void) | null = null; private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null; private newChatButtonClickHandler: (() => void) | null = null; - private sendButtonClickWrapper: (() => void) | null = null; - private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null; - private newChatButtonClickWrapper: (() => void) | null = null; private listenersAttached: boolean = false; private isCancelled: boolean = false; private settings: PluginSettings; @@ -1897,6 +1918,7 @@ export class ChatView extends ItemView { private historySelectEl: HTMLSelectElement | null = null; private historyDeleteButton: HTMLElement | null = null; private currentAgentMode: AgentMode; + private undoManager: UndoManager = new UndoManager(); private pendingActions: ProposedAction[] = []; private pendingReadResults: (ToolResult & { id?: string })[] = []; private pendingFollowUpContext: { @@ -2015,6 +2037,3 @@ export class ChatView extends ItemView { } } } - -const MAX_TOOL_CALLS = 5; -const MAX_TOOL_CALL_DEPTH = 5; diff --git a/src/main.ts b/src/main.ts index 3d31139..255187b 100755 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,9 @@ import { SemanticCacheService } from './semantic-cache'; import { VaultVectorStore } from './vault-vector-store'; import { VaultIndexer } from './vault-indexer'; import { AutoTagger, AutoLinker } from './auto-organizer'; -import { PluginSettings, StructuredMemoryData, ToolTelemetryData, ChatHistoryData } from './types'; +import { PluginSettings, StructuredMemoryData, ToolTelemetryData, ChatHistoryData, AgentMode } from './types'; import { Logger } from './utils'; import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes'; -import { AgentMode } from './types'; import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory'; import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry'; import { ChatHistoryManager, createDefaultChatHistoryData } from './chat-history'; @@ -196,7 +195,17 @@ export default class OllamaPlugin extends Plugin { const data = ((await this.loadData()) ?? {}) as Record; // Backward compatibility: old flat format vs new nested format const loadedSettings = (data.settings ?? data) as Partial; - this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings); + // Deep merge nested config objects so new default fields are preserved + this.settings = { + ...DEFAULT_SETTINGS, + ...loadedSettings, + cacheConfig: { ...DEFAULT_SETTINGS.cacheConfig, ...(loadedSettings.cacheConfig ?? {}) }, + vaultIndexConfig: { ...DEFAULT_SETTINGS.vaultIndexConfig, ...(loadedSettings.vaultIndexConfig ?? {}) }, + autoTagConfig: { ...DEFAULT_SETTINGS.autoTagConfig, ...(loadedSettings.autoTagConfig ?? {}) }, + autoLinkConfig: { ...DEFAULT_SETTINGS.autoLinkConfig, ...(loadedSettings.autoLinkConfig ?? {}) }, + structuredMemoryConfig: { ...DEFAULT_SETTINGS.structuredMemoryConfig, ...(loadedSettings.structuredMemoryConfig ?? {}) }, + toolTelemetryConfig: { ...DEFAULT_SETTINGS.toolTelemetryConfig, ...(loadedSettings.toolTelemetryConfig ?? {}) }, + }; const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model; this.settings.chatModel = loadedSettings.chatModel ?? legacyModel; this.settings.agentModel = loadedSettings.agentModel ?? legacyModel; @@ -316,7 +325,7 @@ export default class OllamaPlugin extends Plugin { Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main'); let indexed = 0; - const BATCH_SIZE = 1; + const BATCH_SIZE = 5; const DELAY_MS = 500; for (let i = 0; i < files.length; i += BATCH_SIZE) { diff --git a/src/ollama-client.ts b/src/ollama-client.ts index d9c552f..d9e968b 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -244,7 +244,7 @@ export class OllamaClient { `Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client' ); - await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount))); + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount))); yield* this.streamChatWithRetry(messages, tools, retryCount + 1); } else { throw error; @@ -302,7 +302,7 @@ export class OllamaClient { `Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client' ); - await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount))); + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount))); return this.chatWithRetry(messages, tools, retryCount + 1); } else { throw error; @@ -393,7 +393,7 @@ export class OllamaClient { error.message.startsWith('Ollama error:') || error.message.includes('Too many malformed chunks') || error.message === 'No response body' || - error.message === 'Invalid response format' + error.message.startsWith('Invalid response format') ) { return false; } diff --git a/src/semantic-cache.ts b/src/semantic-cache.ts index 8fe9bbe..58065d9 100644 --- a/src/semantic-cache.ts +++ b/src/semantic-cache.ts @@ -91,6 +91,7 @@ export class SemanticCacheService { try { await this.client.deleteCollection({ name: this.config.collectionName }); + this.collection = null; Logger.info('Semantic cache cleared', 'semantic-cache'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/structured-memory.ts b/src/structured-memory.ts index a2106f9..e6dde68 100644 --- a/src/structured-memory.ts +++ b/src/structured-memory.ts @@ -193,7 +193,7 @@ export class StructuredMemoryManager { { 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: /please\s+(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' }, { regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i, keyPrefix: 'favorite', diff --git a/src/tool-executor.ts b/src/tool-executor.ts index 1b49bf1..52daec3 100644 --- a/src/tool-executor.ts +++ b/src/tool-executor.ts @@ -2,9 +2,10 @@ import { Vault, App, TFile, TFolder } from 'obsidian'; import type { ToolCall, ToolResult, VaultIndexEntry } from './types'; -import { safeParseJson } from './utils'; +import { safeParseJson, Logger } from './utils'; import { TelemetryManager } from './tool-telemetry'; import { VaultIndexer } from './vault-indexer'; +import { UndoManager } from './undo-manager'; // Disallow characters that are invalid in file paths const INVALID_PATH_CHARS = /[<>:"|?*~]/; @@ -16,17 +17,20 @@ export class ToolExecutor { private app: App; private telemetryManager?: TelemetryManager; private vaultIndexer?: VaultIndexer; + private undoManager?: UndoManager; constructor( vault: Vault, app: App, telemetryManager?: TelemetryManager, - vaultIndexer?: VaultIndexer + vaultIndexer?: VaultIndexer, + undoManager?: UndoManager ) { this.vault = vault; this.app = app; this.telemetryManager = telemetryManager; this.vaultIndexer = vaultIndexer; + this.undoManager = undoManager; } private isSafePath(path: string): boolean { @@ -122,7 +126,7 @@ export class ToolExecutor { } } - async handleToolCall(toolCall: ToolCall): Promise { + async handleToolCall(toolCall: ToolCall, undoBatchId?: string): Promise { const startTime = Date.now(); const toolName = toolCall.function?.name ?? 'unknown'; let parsedArgs: Record = {}; @@ -149,6 +153,11 @@ export class ToolExecutor { throw new Error('Arguments must be an object or JSON string'); } + // Snapshot state before write operations for undo + if (undoBatchId) { + await this.snapshotForUndo(toolName, parsedArgs, undoBatchId); + } + // Process the tool call based on its type switch (toolName) { case 'create_file': @@ -227,14 +236,9 @@ export class ToolExecutor { throw new Error('Invalid file path detected'); } - try { - await this.ensureFolderExists(this.getParentFolderPath(path)); - await this.vault.create(path, content); - return { success: true, message: 'Note created successfully' }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(errorMessage); - } + await this.ensureFolderExists(this.getParentFolderPath(path)); + await this.vault.create(path, content); + return { success: true, message: 'Note created successfully' }; } async executeTool(name: string, args: string | Record): Promise { @@ -243,7 +247,7 @@ export class ToolExecutor { type: 'function', function: { name, - arguments: args as string, + arguments: typeof args === 'string' ? args : JSON.stringify(args), }, }); } @@ -565,8 +569,8 @@ export class ToolExecutor { } const file = this.getFile(path); - await this.vault.delete(file); - return { success: true, message: `Note ${path} deleted successfully` }; + await this.vault.trash(file, true); + return { success: true, message: `Note ${path} moved to trash` }; } private async handleListVaultTags(args: Record): Promise { @@ -622,10 +626,10 @@ export class ToolExecutor { }; } - private async handleGetVaultStats(args: Record): Promise { + private handleGetVaultStats(args: Record): ToolResult { const files = this.vault.getMarkdownFiles(); const folderSet = new Set(); - let totalLength = 0; + let totalSize = 0; let taggedCount = 0; let untaggedCount = 0; const tagMap = new Map(); @@ -636,8 +640,7 @@ export class ToolExecutor { const folder = file.path.split('/').slice(0, -1).join('/') || '(root)'; folderSet.add(folder); - const content = await this.vault.cachedRead(file); - totalLength += content.length; + if (file.stat?.size) totalSize += file.stat.size; const cache = this.app.metadataCache.getFileCache(file); const rawTags: unknown = cache?.frontmatter?.tags; @@ -690,12 +693,86 @@ export class ToolExecutor { .sort((a, b) => b[1] - a[1]) .slice(0, 20) .map(([tag, count]) => ({ tag, count })), - avgNoteLength: files.length > 0 ? Math.round(totalLength / files.length) : 0, + avgNoteSize: files.length > 0 ? Math.round(totalSize / files.length) : 0, recentFiles: recentFiles.slice(0, 10).map((f) => f.path), }, }; } + private async snapshotForUndo( + toolName: string, + parsedArgs: Record, + batchId: string + ): Promise { + if (!this.undoManager) return; + try { + switch (toolName) { + case 'create_file': + case 'create_note': + this.undoManager.recordOperation(batchId, { + type: 'create', + path: parsedArgs.path as string, + }); + break; + case 'append_to_note': + case 'replace_note_section': + case 'update_frontmatter': { + const path = parsedArgs.path as string; + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const originalContent = await this.vault.cachedRead(file); + this.undoManager.recordOperation(batchId, { type: 'modify', path, originalContent }); + } + break; + } + case 'insert_link': { + const path = parsedArgs.sourcePath as string; + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const originalContent = await this.vault.cachedRead(file); + this.undoManager.recordOperation(batchId, { type: 'modify', path, originalContent }); + } + break; + } + case 'rename_note': + this.undoManager.recordOperation(batchId, { + type: 'rename', + originalPath: parsedArgs.oldPath as string, + newPath: parsedArgs.newPath as string, + }); + break; + case 'move_note': { + const path = parsedArgs.path as string; + const folder = ((parsedArgs.folder as string) ?? '').replace(/\/$/, '').trim(); + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const newPath = folder ? `${folder}/${file.name}` : file.name; + this.undoManager.recordOperation(batchId, { + type: 'rename', + originalPath: path, + newPath, + }); + } + break; + } + case 'delete_note': { + const path = parsedArgs.path as string; + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const originalContent = await this.vault.cachedRead(file); + this.undoManager.recordOperation(batchId, { type: 'trash', path, originalContent }); + } + break; + } + } + } catch (error) { + Logger.warn( + `Failed to snapshot for undo (${toolName}): ${error instanceof Error ? error.message : String(error)}`, + 'tool-executor' + ); + } + } + private async handleInsertLink(args: Record): Promise { const sourcePath = args.sourcePath; const targetPath = args.targetPath; diff --git a/src/undo-manager.ts b/src/undo-manager.ts new file mode 100644 index 0000000..4cb81de --- /dev/null +++ b/src/undo-manager.ts @@ -0,0 +1,102 @@ +import { Vault, TFile } from 'obsidian'; +import { Logger } from './utils'; + +export type UndoOperation = + | { type: 'create'; path: string } + | { type: 'modify'; path: string; originalContent: string } + | { type: 'rename'; originalPath: string; newPath: string } + | { type: 'trash'; path: string; originalContent: string }; + +export interface UndoBatch { + id: string; + timestamp: number; + operations: UndoOperation[]; +} + +export class UndoManager { + private batches: UndoBatch[] = []; + private readonly maxBatches = 10; + + startBatch(): string { + const id = crypto.randomUUID?.() ?? `undo-${Date.now()}-${Math.random()}`; + this.batches.push({ id, timestamp: Date.now(), operations: [] }); + if (this.batches.length > this.maxBatches) { + this.batches = this.batches.slice(-this.maxBatches); + } + return id; + } + + recordOperation(batchId: string, op: UndoOperation): void { + const batch = this.batches.find((b) => b.id === batchId); + batch?.operations.push(op); + } + + getBatch(batchId: string): UndoBatch | undefined { + return this.batches.find((b) => b.id === batchId); + } + + hasBatch(batchId: string): boolean { + const batch = this.getBatch(batchId); + return !!(batch && batch.operations.length > 0); + } + + async undo(batchId: string, vault: Vault): Promise<{ restored: number; failed: number }> { + const batch = this.getBatch(batchId); + if (!batch) return { restored: 0, failed: 0 }; + + let restored = 0; + let failed = 0; + + for (const op of [...batch.operations].reverse()) { + try { + switch (op.type) { + case 'create': { + const file = vault.getAbstractFileByPath(op.path); + if (file instanceof TFile) { + await vault.trash(file, true); + } + break; + } + case 'modify': { + const file = vault.getAbstractFileByPath(op.path); + if (file instanceof TFile) { + await vault.modify(file, op.originalContent); + } + break; + } + case 'rename': { + const file = vault.getAbstractFileByPath(op.newPath); + if (file instanceof TFile) { + await vault.rename(file, op.originalPath); + } + break; + } + case 'trash': { + const existing = vault.getAbstractFileByPath(op.path); + if (existing instanceof TFile) { + await vault.modify(existing, op.originalContent); + } else { + await vault.create(op.path, op.originalContent); + } + break; + } + } + restored++; + } catch (error) { + const path = 'path' in op ? op.path : 'originalPath' in op ? op.originalPath : '?'; + Logger.warn( + `Undo failed for ${op.type} on ${path}: ${error instanceof Error ? error.message : String(error)}`, + 'undo-manager' + ); + failed++; + } + } + + this.batches = this.batches.filter((b) => b.id !== batchId); + return { restored, failed }; + } + + clear(): void { + this.batches = []; + } +} diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index c3d22fe..f99cfc6 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -292,6 +292,41 @@ describe('ChatView', () => { expect(lastMessage.isStreaming).toBe(false); }); + it('should finalize with a visible fallback if tool processing returns no output', async () => { + view.setAgentMode('research'); + view['sendButton'] = document.createElement('button'); + view['inputEl'] = document.createElement('textarea'); + (view['inputEl'] as HTMLTextAreaElement).value = 'find project notes'; + + jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( + (async function* () { + yield { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'search_vault_files', + arguments: '{"query":"project notes"}', + }, + }, + ], + }; + })() + ); + jest.spyOn(view as any, 'processToolCalls').mockResolvedValue(undefined); + + await (view as any).handleUserInput('find project notes'); + + const messages = (view as any).messages; + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.isStreaming).toBe(false); + expect(lastMessage.isThinking).toBe(false); + expect(lastMessage.content).toBe('No response was returned.'); + }); + it('should handle streaming re-attach when existing streaming element is found', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); @@ -515,7 +550,7 @@ describe('ChatView', () => { expect(lastMessage.isStreaming).toBe(false); }); - it('should show preview for write tool calls and defer follow-up', async () => { + it('should execute write tools immediately with CoW undo', async () => { view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); @@ -540,31 +575,20 @@ describe('ChatView', () => { ); const followUpSpy = jest .spyOn(view['ollamaClient'], 'chat') - .mockResolvedValue({ role: 'assistant', content: ' follow-up' }); + .mockResolvedValue({ role: 'assistant', content: 'follow-up' }); - // Mock preview builder - jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({ - id: 'tool_1', - toolCall: { - id: 'tool_1', - type: 'function', - function: { - name: 'create_file', - arguments: '{"path":"test/file.md","content":"Test content"}', - }, - } as unknown as any, - operation: 'create', - path: 'test/file.md', - description: 'Create note: test/file.md', - preview: { after: 'Test content' }, - status: 'pending', + // Mock handleToolCall so the write executes without needing real vault + jest.spyOn(view['toolExecutor'], 'handleToolCall').mockResolvedValue({ + success: true, + message: 'Note created successfully', }); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); - // With write tools, follow-up should be deferred until apply - expect(followUpSpy).not.toHaveBeenCalled(); - expect((view as any).pendingActions.length).toBe(1); + // Write tools execute immediately — follow-up is called right away + expect(followUpSpy).toHaveBeenCalled(); + // No pending actions queue in CoW mode + expect((view as any).pendingActions.length).toBe(0); expect((view as any).messages.length).toBeGreaterThan(1); }); @@ -708,25 +732,7 @@ describe('ChatView', () => { tool_calls: [], }); - // Mock preview builder for write tool - jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({ - id: 'call_1', - toolCall: { - id: 'call_1', - type: 'function', - function: { - name: 'create_file', - arguments: '{"path":"test.md","content":"test"}', - }, - } as unknown as any, - operation: 'create', - path: 'test.md', - description: 'Create note: test.md', - preview: { after: 'test' }, - status: 'pending', - }); - - // Mock tool executor — read tool fails + // Mock tool executor: read tool fails, write tool succeeds const toolExecutor = view['toolExecutor']; jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => { if (call.function.name === 'nonexistent_tool') { @@ -741,8 +747,8 @@ describe('ChatView', () => { await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); - // Write tools trigger preview, not immediate follow-up - expect(followUpSpy).not.toHaveBeenCalled(); + // Write tools execute immediately with CoW; follow-up is called once results are ready + expect(followUpSpy).toHaveBeenCalled(); expect(errorHandlerSpy).toHaveBeenCalledWith(expect.any(Error), 'ChatView.handleUserInput'); expect((view as any).messages.length).toBeGreaterThan(1); consoleSpy.mockRestore(); diff --git a/tests/ollama-client.test.ts b/tests/ollama-client.test.ts index 3f891fd..7300009 100755 --- a/tests/ollama-client.test.ts +++ b/tests/ollama-client.test.ts @@ -80,7 +80,7 @@ describe('OllamaClient', () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); await expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500'); - }); + }, 15000); it('should handle missing message content gracefully', async () => { mockFetch.mockResolvedValue({ @@ -391,7 +391,7 @@ describe('OllamaClient', () => { expect(callCount).toBe(3); expect(chunks.length).toBe(0); - }); + }, 15000); it('should give up after maxRetries attempts', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); @@ -405,7 +405,7 @@ describe('OllamaClient', () => { } })() ).rejects.toThrow('Ollama API error: 500'); - }); + }, 15000); it('should not retry on 4xx errors', async () => { mockFetch.mockResolvedValue({ ok: false, status: 404 }); diff --git a/tests/tool-executor.test.ts b/tests/tool-executor.test.ts index ffb83d7..325696b 100755 --- a/tests/tool-executor.test.ts +++ b/tests/tool-executor.test.ts @@ -13,7 +13,7 @@ interface MockVault { getMarkdownFiles: () => any[]; modify: (file: any, content: string) => Promise; rename: (file: any, newPath: string) => Promise; - delete: (file: any) => Promise; + trash: (file: any, system: boolean) => Promise; } interface MockApp { metadataCache: { @@ -58,7 +58,7 @@ describe('ToolExecutor', () => { getMarkdownFiles: jest.fn().mockReturnValue([]), modify: jest.fn().mockResolvedValue(undefined), rename: jest.fn().mockResolvedValue(undefined), - delete: jest.fn().mockResolvedValue(undefined), + trash: jest.fn().mockResolvedValue(undefined), }; mockApp = { metadataCache: { @@ -1294,7 +1294,7 @@ describe('ToolExecutor', () => { }; const result = await executor.handleToolCall(call); expect(result.success).toBe(true); - expect(mockVault.delete).toHaveBeenCalledWith(file); + expect(mockVault.trash).toHaveBeenCalledWith(file, true); }); });