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