diff --git a/src/chat-view.js b/src/chat-view.js
new file mode 100644
index 0000000..eb8ab24
--- /dev/null
+++ b/src/chat-view.js
@@ -0,0 +1,405 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ChatView = void 0;
+const obsidian_1 = require("obsidian");
+const DEFAULT_VAULT_SEARCH_LIMIT = 3;
+const MAX_MESSAGE_HISTORY = 50;
+const MAX_STREAM_CHUNKS = 1000;
+const ollama_client_1 = require("./ollama-client");
+const vault_indexer_1 = require("./vault-indexer");
+const tool_executor_1 = require("./tool-executor");
+const error_handler_1 = require("./error-handler");
+class ChatView extends obsidian_1.ItemView {
+ // Getters for testing
+ getSendButtonClickHandler() {
+ return this.sendButtonClickHandler;
+ }
+ getInputKeyDownHandler() {
+ return this.inputKeyDownHandler;
+ }
+ getNewChatButtonClickHandler() {
+ return this.newChatButtonClickHandler;
+ }
+ constructor(leaf, settings) {
+ super(leaf);
+ this.messages = [];
+ this.lastMessageEl = null;
+ this.newChatButton = null;
+ this.sendButton = null;
+ this.inputEl = null;
+ this.chatContainer = null;
+ this.sendButtonClickHandler = null;
+ this.inputKeyDownHandler = null;
+ this.newChatButtonClickHandler = null;
+ this.sendButtonEventHandler = null;
+ this.inputKeyDownEventHandler = null;
+ this.newChatButtonEventHandler = null;
+ this.listenersAttached = false;
+ this.settings = settings;
+ this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
+ this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault);
+ this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app);
+ }
+ updateSettings(newSettings) {
+ this.settings = newSettings;
+ this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model);
+ }
+ getViewType() {
+ return 'ollama-chat-view';
+ }
+ getDisplayText() {
+ return 'Ollama Chat';
+ }
+ onOpen() {
+ this.render();
+ this.removeEventListeners(); // Clean up any existing listeners before reattaching
+ this.setupEventListeners();
+ return Promise.resolve();
+ }
+ onSettingsChange(newSettings) {
+ this.updateSettings(newSettings);
+ }
+ onClose() {
+ this.ollamaClient.cancelStream();
+ this.removeEventListeners();
+ this.cleanupStreamingResources();
+ this.lastMessageEl = null;
+ this.sendButton = null;
+ this.inputEl = null;
+ this.chatContainer = null;
+ return Promise.resolve();
+ }
+ cleanupStreamingResources() {
+ // Ensure any ongoing streaming is properly cleaned up
+ if (this.lastMessageEl && this.lastMessageEl.parentElement) {
+ this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
+ this.lastMessageEl = null;
+ }
+ }
+ render() {
+ const container = this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
+ this.chatContainer = container;
+ const inputContainer = this.contentEl.querySelector('.ollama-input-container') ||
+ this.contentEl.createEl('div', { cls: 'ollama-input-container' });
+ if (!this.inputEl) {
+ this.inputEl = inputContainer.createEl('textarea', { cls: 'ollama-input' });
+ }
+ if (!this.sendButton) {
+ this.sendButton = inputContainer.createEl('button', {
+ cls: 'ollama-send-button',
+ });
+ this.sendButton.textContent = 'Send';
+ }
+ if (!this.newChatButton) {
+ const newChatContainer = this.contentEl.querySelector('.ollama-new-chat') ||
+ this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
+ this.newChatButton = newChatContainer.createEl('button', {
+ cls: 'ollama-new-chat-button',
+ });
+ this.newChatButton.textContent = '🔄 New Chat';
+ this.newChatButton.title = 'Start a new conversation';
+ }
+ // Create immutable snapshot for rendering
+ const messagesSnapshot = [...this.messages];
+ // Only render messages that are not currently streaming
+ const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
+ // Differential update: only update messages that have changed
+ const existingMessages = container.querySelectorAll('.ollama-message');
+ for (const msg of nonStreamingMessages) {
+ const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
+ if (existingEl) {
+ existingEl.textContent = msg.content;
+ }
+ else {
+ const messageEl = container.createEl('div', {
+ cls: `ollama-message ${msg.role}`,
+ });
+ messageEl.setAttribute('data-msg-id', msg.id);
+ messageEl.textContent = msg.content;
+ }
+ }
+ // Remove messages that are no longer in the array
+ for (const el of Array.from(existingMessages)) {
+ const id = el.getAttribute('data-msg-id');
+ if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
+ el.remove();
+ }
+ }
+ // Re-attach streaming message if it exists
+ const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
+ if (streamingMessage && this.lastMessageEl) {
+ const existingStreamingEl = container.querySelector(`.ollama-message[data-msg-id="${streamingMessage.id}"]`);
+ if (!existingStreamingEl) {
+ container.appendChild(this.lastMessageEl);
+ }
+ }
+ }
+ setupEventListeners() {
+ if (!this.sendButton || !this.inputEl || this.listenersAttached)
+ return;
+ // Create handlers if they don't exist
+ if (!this.sendButtonClickHandler) {
+ this.sendButtonClickHandler = async () => {
+ if (!this.inputEl)
+ return;
+ await this.handleUserInput(this.inputEl.value);
+ this.inputEl.value = '';
+ };
+ }
+ if (!this.inputKeyDownHandler) {
+ this.inputKeyDownHandler = async (e) => {
+ if (!this.inputEl || e.key !== 'Enter' || e.shiftKey)
+ return;
+ e.preventDefault();
+ await this.handleUserInput(this.inputEl.value);
+ this.inputEl.value = '';
+ };
+ }
+ // Add event listeners
+ this.sendButtonEventHandler = () => {
+ void this.sendButtonClickHandler?.();
+ };
+ this.inputKeyDownEventHandler = (e) => {
+ void this.inputKeyDownHandler?.(e);
+ };
+ this.sendButton.addEventListener('click', this.sendButtonEventHandler);
+ this.inputEl.addEventListener('keydown', this.inputKeyDownEventHandler);
+ if (this.newChatButton) {
+ if (!this.newChatButtonClickHandler) {
+ this.newChatButtonClickHandler = () => this.clearConversation();
+ }
+ this.newChatButtonEventHandler = () => {
+ this.newChatButtonClickHandler?.();
+ };
+ this.newChatButton.addEventListener('click', this.newChatButtonEventHandler);
+ }
+ this.listenersAttached = true;
+ }
+ removeEventListeners() {
+ if (this.sendButton && this.sendButtonEventHandler) {
+ this.sendButton.removeEventListener('click', this.sendButtonEventHandler);
+ }
+ if (this.inputEl && this.inputKeyDownEventHandler) {
+ this.inputEl.removeEventListener('keydown', this.inputKeyDownEventHandler);
+ }
+ if (this.newChatButton && this.newChatButtonEventHandler) {
+ this.newChatButton.removeEventListener('click', this.newChatButtonEventHandler);
+ }
+ this.sendButtonEventHandler = null;
+ this.inputKeyDownEventHandler = null;
+ this.newChatButtonEventHandler = null;
+ this.listenersAttached = false;
+ }
+ clearConversation() {
+ // Create new array to ensure immutability
+ this.messages = [];
+ this.lastMessageEl = null;
+ this.render();
+ new obsidian_1.Notice('Conversation cleared');
+ }
+ updateMessageById(id, partial) {
+ const index = this.messages.findIndex((m) => m.id === id);
+ if (index < 0)
+ return false;
+ this.messages = [
+ ...this.messages.slice(0, index),
+ { ...this.messages[index], ...partial },
+ ...this.messages.slice(index + 1),
+ ];
+ return true;
+ }
+ updateLastMessage(content) {
+ const streamingMessage = this.messages.find((msg) => msg.isStreaming);
+ if (streamingMessage && !this.lastMessageEl) {
+ this.lastMessageEl = this.contentEl.createEl('div', {
+ cls: `ollama-message assistant`,
+ });
+ this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
+ }
+ if (this.lastMessageEl) {
+ this.lastMessageEl.textContent = content;
+ }
+ }
+ async handleUserInput(content) {
+ if (!this.sendButton || !this.inputEl)
+ return;
+ this.sendButton.disabled = true;
+ try {
+ // Guard against empty messages
+ const userMessage = content.trim();
+ if (!userMessage)
+ return;
+ // Search vault using user message as query
+ const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
+ let context = entries.map((entry) => `### ${entry.title}\n${entry.content}`).join('\n\n');
+ // Cap context size to prevent prompt bloat with large vaults
+ const MAX_CONTEXT_LENGTH = 4000;
+ if (context.length > MAX_CONTEXT_LENGTH) {
+ context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)';
+ }
+ const systemMessage = {
+ role: 'system',
+ content: 'You are a helpful assistant.',
+ };
+ const userContent = context ? `${context}\n\n${userMessage}` : userMessage;
+ const userMessageWithContext = {
+ role: 'user',
+ content: userContent,
+ };
+ const messages = [
+ systemMessage,
+ ...this.messages.map((m) => ({
+ role: m.role,
+ content: m.content,
+ tool_calls: m.tool_calls,
+ })),
+ userMessageWithContext,
+ ];
+ const tools = [
+ {
+ type: 'function',
+ function: {
+ name: 'create_file',
+ description: 'Create a new file in the vault',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description: "Relative path within the vault, e.g. 'Notes/todo.md'",
+ },
+ content: { type: 'string', description: 'Content of the file to create' },
+ },
+ required: ['path', 'content'],
+ },
+ },
+ },
+ ];
+ const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const userMessageId = messageId;
+ const assistantMessageId = `${messageId}-assistant`;
+ // Store user message in conversation history
+ const userChatMessage = {
+ id: userMessageId,
+ role: 'user',
+ content: userMessage,
+ timestamp: Date.now(),
+ };
+ const assistantMessage = {
+ id: assistantMessageId,
+ role: 'assistant',
+ content: '',
+ timestamp: Date.now(),
+ isStreaming: true,
+ };
+ // Update messages immutably
+ this.messages = [...this.messages, userChatMessage, assistantMessage];
+ try {
+ this.render();
+ const stream = this.ollamaClient.streamChat(messages, tools);
+ let fullResponse = '';
+ let toolCalls = [];
+ let chunkCount = 0;
+ for await (const chunk of stream) {
+ chunkCount++;
+ if (chunkCount > MAX_STREAM_CHUNKS) {
+ throw new Error('Response too long, stopped streaming');
+ }
+ if (chunk.content) {
+ fullResponse += chunk.content;
+ }
+ if (chunk.tool_calls) {
+ toolCalls = toolCalls.concat(chunk.tool_calls);
+ }
+ this.updateLastMessage(fullResponse);
+ }
+ // Update the assistant message with the full response immutably
+ this.updateMessageById(assistantMessageId, {
+ content: fullResponse,
+ tool_calls: toolCalls,
+ });
+ // Process tool calls with proper follow-up context
+ if (toolCalls.length > 0) {
+ // Validate tool calls before processing
+ const MAX_TOOL_CALLS = 10;
+ if (toolCalls.length > MAX_TOOL_CALLS) {
+ throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
+ }
+ // Collect all tool results using allSettled to support partial results
+ const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call)));
+ const toolResults = [];
+ for (const result of settledResults) {
+ if (result.status === 'fulfilled') {
+ toolResults.push(result.value);
+ }
+ else {
+ // Use centralized error handler for tool errors
+ error_handler_1.ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
+ }
+ }
+ // Only create follow-up when we have tool results
+ if (toolResults.length > 0) {
+ // Create follow-up messages including the assistant's tool calls and results
+ const followUpMessages = [
+ ...messages,
+ { role: 'assistant', content: fullResponse, tool_calls: toolCalls },
+ ...toolResults.map((result) => ({
+ role: 'tool',
+ content: JSON.stringify(result),
+ })),
+ ];
+ const followUp = await this.ollamaClient.chat(followUpMessages, tools);
+ fullResponse += followUp.content;
+ this.updateLastMessage(fullResponse);
+ // Update the assistant message with the final response immutably
+ this.updateMessageById(assistantMessageId, {
+ content: fullResponse,
+ isStreaming: false,
+ });
+ }
+ else {
+ // Even if no tool results were successful, mark streaming as complete
+ // to prevent the assistant message from disappearing
+ this.updateMessageById(assistantMessageId, {
+ content: fullResponse,
+ isStreaming: false,
+ });
+ }
+ }
+ // Update last message immutably — only if no tool calls were processed
+ if (toolCalls.length === 0) {
+ const lastMessageIndex = this.messages.length - 1;
+ if (lastMessageIndex >= 0) {
+ const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
+ this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
+ }
+ }
+ // Limit conversation history to prevent memory issues
+ if (this.messages.length > MAX_MESSAGE_HISTORY) {
+ this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
+ }
+ this.render();
+ }
+ finally {
+ // Clean up streaming resources regardless of outcome
+ this.cleanupStreamingResources();
+ }
+ }
+ catch (error) {
+ // Use centralized error handler
+ error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput');
+ // Update any streaming messages to non-streaming state to prevent stale messages
+ // This ensures that if an error occurs during streaming, the assistant message
+ // is still visible (with any partial content received) but won't cause issues
+ // in subsequent requests due to stale isStreaming: true flag
+ this.messages = this.messages.map((msg) => msg.isStreaming ? { ...msg, isStreaming: false } : msg);
+ this.cleanupStreamingResources();
+ }
+ finally {
+ if (this.sendButton) {
+ this.sendButton.disabled = false;
+ }
+ }
+ }
+}
+exports.ChatView = ChatView;
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000..b9c1b73
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,13 @@
+"use strict";
+// Default plugin settings
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MODEL_NAME_REGEX = exports.DEFAULT_SETTINGS = void 0;
+exports.DEFAULT_SETTINGS = {
+ ollamaUrl: 'http://localhost:11434',
+ model: 'llama3',
+ vaultSearchLimit: 3,
+ maxMessageHistory: 50,
+ lastIndexTime: 0,
+};
+// Model validation regex - lowercase letters, numbers, dashes, underscores only
+exports.MODEL_NAME_REGEX = /^[a-z0-9-_]+$/;
diff --git a/src/error-handler.js b/src/error-handler.js
new file mode 100644
index 0000000..c281335
--- /dev/null
+++ b/src/error-handler.js
@@ -0,0 +1,107 @@
+"use strict";
+// src/error-handler.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ErrorHandler = void 0;
+const obsidian_1 = require("obsidian");
+const types_1 = require("./types");
+class ErrorHandler {
+ static handleError(error, context) {
+ const message = this.getUserFriendlyMessage(error);
+ new obsidian_1.Notice(message);
+ if (error instanceof Error) {
+ const ctx = context ? ` [${context}]` : '';
+ // Use console.error instead of ErrorHandler.error for fatal errors
+ console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
+ if (error.stack) {
+ console.error(error.stack);
+ }
+ }
+ else {
+ const ctx = context ? ` [${context}]` : '';
+ console.error(`Ollama Plugin Error${ctx}:`, error);
+ }
+ }
+ static getUserFriendlyMessage(error) {
+ if (error instanceof types_1.OllamaError) {
+ return this.getUserFriendlyMessageFromOllamaError(error);
+ }
+ if (error instanceof Error) {
+ return this.getUserFriendlyMessageFromError(error);
+ }
+ return 'An unexpected error occurred';
+ }
+ static getUserFriendlyMessageFromOllamaError(error) {
+ switch (error.type) {
+ case types_1.ErrorType.NETWORK_ERROR:
+ return 'Connection error. Please check if Ollama is running.';
+ case types_1.ErrorType.API_ERROR:
+ return `API error: ${error.message}`;
+ case types_1.ErrorType.VALIDATION_ERROR:
+ return this.getUserFriendlyValidationMessage(error);
+ case types_1.ErrorType.STREAMING_ERROR:
+ return 'Response too long. Please try a shorter request.';
+ case types_1.ErrorType.TOOL_EXECUTION_ERROR:
+ return `Tool error for ${error.toolName}. ${error.message}`;
+ case types_1.ErrorType.PATH_VALIDATION_ERROR:
+ return `Invalid file path: ${error.path}`;
+ case types_1.ErrorType.UNKNOWN_ERROR:
+ return 'An unexpected error occurred';
+ default:
+ return 'An unexpected error occurred';
+ }
+ }
+ static getUserFriendlyValidationMessage(error) {
+ if (error instanceof types_1.ValidationError && error.details?.field) {
+ const fieldMsg = error.details.field.charAt(0).toUpperCase() + error.details.field.slice(1);
+ return `Invalid ${fieldMsg.toLowerCase()}. ${error.details.message ?? error.message}`;
+ }
+ return 'Input validation error. Please correct your input.';
+ }
+ static getUserFriendlyMessageFromError(error) {
+ const msg = error.message.toLowerCase();
+ // Check timeout BEFORE network (more specific matches first)
+ if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('time out')) {
+ return 'Request timed out. Please check your Ollama connection.';
+ }
+ if (msg.includes('network') || msg.includes('connection') || msg.includes('fetch')) {
+ return 'Connection error. Please check if Ollama is running.';
+ }
+ if (msg.includes('validation') || msg.includes('invalid')) {
+ return 'Invalid input. Please correct your input.';
+ }
+ if (msg.includes('stream') || msg.includes('chunk')) {
+ return 'Response too long. Please try a shorter request.';
+ }
+ if (msg.includes('tool') || msg.includes('function')) {
+ return 'Tool error. Please try again.';
+ }
+ if (msg.includes('path') || msg.includes('file')) {
+ return 'Invalid file path. Please check the path and try again.';
+ }
+ return 'An unexpected error occurred';
+ }
+ // -- Factory methods --
+ static createNetworkError(message, statusCode) {
+ return new types_1.NetworkError(message, statusCode);
+ }
+ static createApiError(message, statusCode) {
+ return new types_1.ApiError(message, statusCode);
+ }
+ static createValidationError(message, field) {
+ const details = field ? { field, message } : undefined;
+ return new types_1.ValidationError(message, details);
+ }
+ static createStreamingError(message) {
+ return new types_1.StreamingError(message);
+ }
+ static createToolExecutionError(message, toolName) {
+ return new types_1.ToolExecutionError(message, toolName ?? 'unknown');
+ }
+ static createPathValidationError(message, path) {
+ return new types_1.PathValidationError(message, path ?? '');
+ }
+ static createUnknownError(message) {
+ return new types_1.OllamaError(message, types_1.ErrorType.UNKNOWN_ERROR);
+ }
+}
+exports.ErrorHandler = ErrorHandler;
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..b013a42
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,134 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const obsidian_1 = require("obsidian");
+const chat_view_1 = require("./chat-view");
+const utils_1 = require("./utils");
+const constants_1 = require("./constants");
+const error_handler_1 = require("./error-handler");
+class OllamaPlugin extends obsidian_1.Plugin {
+ constructor() {
+ super(...arguments);
+ this.settings = constants_1.DEFAULT_SETTINGS;
+ }
+ async onload() {
+ // Initialize logging
+ utils_1.Logger.info('Ollama Plugin loading...', 'plugin');
+ await this.loadSettings();
+ utils_1.Logger.info('Plugin loaded successfully', 'plugin');
+ try {
+ this.registerView('ollama-chat-view', (leaf) => new chat_view_1.ChatView(leaf, this.settings));
+ }
+ catch (error) {
+ utils_1.Logger.error('Failed to register view: ' + error.message, 'plugin');
+ new obsidian_1.Notice('Failed to register Ollama chat view');
+ // Don't throw - let the plugin continue loading other features
+ }
+ try {
+ this.addRibbonIcon('message-square', 'Ollama Chat', async () => {
+ const leaf = this.app.workspace.getLeaf();
+ await leaf.setViewState({
+ type: 'ollama-chat-view',
+ active: true,
+ });
+ await this.app.workspace.revealLeaf(leaf);
+ });
+ }
+ catch (error) {
+ utils_1.Logger.error('Failed to add ribbon icon: ' + error.message, 'plugin');
+ new obsidian_1.Notice('Failed to add Ollama ribbon icon');
+ // Don't throw - let the plugin continue loading other features
+ }
+ this.addSettingTab(new OllamaSettingTab(this.app, this));
+ }
+ async loadSettings() {
+ try {
+ const data = (await this.loadData());
+ if (data) {
+ utils_1.Logger.debug('Loading saved settings', 'settings');
+ this.settings = Object.assign({}, this.settings, data);
+ }
+ }
+ catch (error) {
+ error_handler_1.ErrorHandler.handleError(error, 'settings load');
+ }
+ }
+ async saveSettings() {
+ try {
+ // Validate settings before saving
+ const validationErrors = (0, utils_1.validatePluginSettings)(this.settings);
+ if (validationErrors.length > 0) {
+ utils_1.Logger.error('Validation errors prevented saving settings: ' + validationErrors.join('; '), 'settings');
+ new obsidian_1.Notice(`Cannot save settings: ${validationErrors[0]}`);
+ return false;
+ }
+ utils_1.Logger.debug('Saving settings: ' + JSON.stringify(this.settings), 'settings');
+ await this.saveData(this.settings);
+ utils_1.Logger.info('Settings saved successfully', 'settings');
+ return true;
+ }
+ catch (error) {
+ error_handler_1.ErrorHandler.handleError(error, 'settings save');
+ return false;
+ }
+ }
+ // Notify all open ChatView instances when settings change
+ notifyChatViews() {
+ const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
+ leaves.forEach((leaf) => {
+ const view = leaf.view;
+ if (view instanceof chat_view_1.ChatView) {
+ view.onSettingsChange(this.settings);
+ }
+ });
+ }
+}
+exports.default = OllamaPlugin;
+class OllamaSettingTab extends obsidian_1.PluginSettingTab {
+ constructor(app, plugin) {
+ super(app, plugin);
+ this.plugin = plugin;
+ }
+ display() {
+ // Clear any existing content first to prevent duplicates
+ this.containerEl.empty();
+ // Create container for settings
+ const container = this.containerEl.createDiv();
+ container.empty();
+ new obsidian_1.Setting(container)
+ .setName('Ollama URL')
+ .setDesc('URL of your Ollama instance')
+ .addText((text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
+ const urlValidation = (0, utils_1.validateOllamaUrl)(value);
+ if (urlValidation.valid) {
+ utils_1.Logger.debug('URL changed to: ' + value, 'settings');
+ this.plugin.settings.ollamaUrl = value;
+ await this.plugin.saveSettings();
+ this.plugin.notifyChatViews();
+ }
+ else {
+ utils_1.Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings');
+ new obsidian_1.Notice(urlValidation.error || 'Invalid Ollama URL format.');
+ }
+ }));
+ new obsidian_1.Setting(container)
+ .setName('Model')
+ .setDesc('Model to use for chat')
+ .addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => {
+ const modelValidation = (0, utils_1.validateModelName)(value);
+ if (modelValidation.valid) {
+ utils_1.Logger.debug('Model changed to: ' + value, 'settings');
+ this.plugin.settings.model = value;
+ await this.plugin.saveSettings();
+ this.plugin.notifyChatViews();
+ }
+ else {
+ utils_1.Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings');
+ new obsidian_1.Notice(modelValidation.error || 'Invalid model name format.');
+ }
+ }));
+ }
+ hide() {
+ // Clear the container to prevent duplicate elements
+ this.containerEl.empty();
+ }
+}
diff --git a/src/ollama-client.js b/src/ollama-client.js
new file mode 100644
index 0000000..c5630c4
--- /dev/null
+++ b/src/ollama-client.js
@@ -0,0 +1,174 @@
+"use strict";
+// src/ollama-client.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.OllamaClient = void 0;
+const types_1 = require("./types");
+const utils_1 = require("./utils");
+class OllamaClient {
+ constructor(baseURL, model, fetchFn) {
+ this.abortController = null;
+ this.maxRetries = 3;
+ this.baseURL = baseURL;
+ this.model = model;
+ this.fetchFn = fetchFn ?? fetch;
+ }
+ cancelStream() {
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = null;
+ }
+ }
+ async *streamChat(messages, tools = []) {
+ yield* this.streamChatWithRetry(messages, tools, 0);
+ }
+ async streamChatAsPromise(messages, tools = []) {
+ const chunks = [];
+ for await (const chunk of this.streamChat(messages, tools)) {
+ chunks.push(chunk);
+ }
+ return chunks;
+ }
+ async *streamChatWithRetry(messages, tools = [], attempt = 0) {
+ this.abortController = new AbortController();
+ try {
+ const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model: this.model,
+ messages,
+ tools,
+ stream: true,
+ }),
+ signal: this.abortController.signal,
+ });
+ if (!response.ok) {
+ if (response.status >= 500 && attempt < this.maxRetries) {
+ const retryDelay = Math.pow(2, attempt) * 100;
+ utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
+ yield* this.streamChatWithRetry(messages, tools, attempt + 1);
+ return;
+ }
+ throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
+ }
+ if (!response.body) {
+ throw new Error('No response body');
+ }
+ const contentType = response.headers.get('content-type');
+ if (!contentType || (!contentType.includes('ndjson') && !contentType.includes('json'))) {
+ throw new Error('Invalid response format');
+ }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let malformedCount = 0;
+ const maxMalformed = 50;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done)
+ break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+ for (const line of lines) {
+ if (!line.trim())
+ continue;
+ try {
+ const parsed = JSON.parse(line);
+ this.throwIfOllamaError(parsed);
+ const message = this.toOllamaMessage(parsed.message);
+ if (!message) {
+ continue;
+ }
+ malformedCount = 0;
+ yield message;
+ }
+ catch (error) {
+ if (error instanceof Error && error.message.startsWith('Ollama error:')) {
+ throw error;
+ }
+ malformedCount++;
+ if (malformedCount > maxMalformed) {
+ throw new Error('Too many malformed chunks in stream');
+ }
+ utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${error.message}`, 'ollama-client');
+ }
+ }
+ }
+ if (buffer.trim()) {
+ try {
+ const parsed = JSON.parse(buffer);
+ this.throwIfOllamaError(parsed);
+ const message = this.toOllamaMessage(parsed.message);
+ if (message) {
+ yield message;
+ }
+ }
+ catch (error) {
+ if (error instanceof Error && error.message.startsWith('Ollama error:')) {
+ throw error;
+ }
+ utils_1.Logger.warn(`Failed to parse final chunk: ${buffer.substring(0, 80)}...`, 'ollama-client');
+ }
+ }
+ }
+ finally {
+ reader.releaseLock();
+ }
+ }
+ finally {
+ this.abortController = null;
+ }
+ }
+ async chat(messages, tools = []) {
+ return this.chatWithRetry(messages, tools, 0);
+ }
+ async chatWithRetry(messages, tools = [], attempt = 0) {
+ const controller = new AbortController();
+ const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model: this.model,
+ messages,
+ tools,
+ stream: false,
+ }),
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ if (response.status >= 500 && attempt < this.maxRetries) {
+ const retryDelay = Math.pow(2, attempt) * 100;
+ utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
+ return this.chatWithRetry(messages, tools, attempt + 1);
+ }
+ throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
+ }
+ const data = (await response.json());
+ return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
+ }
+ throwIfOllamaError(parsed) {
+ if (parsed.error) {
+ throw new Error(`Ollama error: ${String(parsed.error)}`);
+ }
+ }
+ toOllamaMessage(value) {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+ const record = value;
+ return {
+ role: record.role ?? 'assistant',
+ content: typeof record.content === 'string' ? record.content : '',
+ tool_calls: record.tool_calls ?? [],
+ };
+ }
+}
+exports.OllamaClient = OllamaClient;
diff --git a/src/tool-executor.js b/src/tool-executor.js
new file mode 100644
index 0000000..b80f258
--- /dev/null
+++ b/src/tool-executor.js
@@ -0,0 +1,114 @@
+"use strict";
+// src/tool-executor.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ToolExecutor = void 0;
+const utils_1 = require("./utils");
+// Disallow characters that are invalid in file paths
+const INVALID_PATH_CHARS = /[<>:"|?*~]/;
+const MAX_PATH_LENGTH = 200;
+const FORBIDDEN_DIRS = ['.obsidian', '.git'];
+class ToolExecutor {
+ constructor(vault, app) {
+ this.vault = vault;
+ this.app = app;
+ }
+ isSafePath(path) {
+ // Reject empty paths
+ if (!path || path.trim().length === 0) {
+ return false;
+ }
+ // Reject paths that are too long
+ if (path.length > MAX_PATH_LENGTH) {
+ return false;
+ }
+ // Reject paths with invalid characters
+ if (INVALID_PATH_CHARS.test(path)) {
+ return false;
+ }
+ // Reject absolute paths
+ if (path.startsWith('/') || path.startsWith('\\')) {
+ return false;
+ }
+ // Reject Windows drive letters (e.g., C:)
+ if (/^[a-zA-Z]:/.test(path)) {
+ return false;
+ }
+ // Reject paths containing backslashes (Windows-style path separators)
+ if (path.includes('\\')) {
+ return false;
+ }
+ // Reject paths that traverse to parent directories
+ const normalized = path.replace(/^(\.\/)+/, '');
+ if (normalized.split('/').includes('..')) {
+ return false;
+ }
+ // Reject forbidden directories
+ for (const dir of FORBIDDEN_DIRS) {
+ if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
+ return false;
+ }
+ if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ async handleToolCall(toolCall) {
+ try {
+ const toolName = toolCall.function?.name;
+ const rawArgs = toolCall.function?.arguments;
+ if (!toolName) {
+ throw new Error('Tool name is required');
+ }
+ // Parse arguments whether they're a string or object
+ let parsedArgs;
+ if (typeof rawArgs === 'string') {
+ try {
+ parsedArgs = (0, utils_1.safeParseJson)(rawArgs);
+ }
+ catch {
+ throw new Error('Invalid JSON arguments');
+ }
+ }
+ else if (rawArgs && typeof rawArgs === 'object') {
+ parsedArgs = rawArgs;
+ }
+ else {
+ throw new Error('Arguments must be an object or JSON string');
+ }
+ // Process the tool call based on its type
+ switch (toolName) {
+ case 'create_file':
+ return await this.handleCreateFile(parsedArgs);
+ default:
+ return { success: false, message: `Unknown tool: ${toolName}` };
+ }
+ }
+ catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ throw new Error(errorMessage);
+ }
+ }
+ async handleCreateFile(args) {
+ const path = args.path;
+ const content = args.content;
+ if (typeof path !== 'string') {
+ throw new Error('Path must be a string');
+ }
+ if (typeof content !== 'string') {
+ throw new Error('Content must be a string');
+ }
+ if (!this.isSafePath(path)) {
+ throw new Error('Invalid file path detected');
+ }
+ try {
+ await this.vault.create(path, content);
+ return { success: true, message: 'File created successfully' };
+ }
+ catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ throw new Error(errorMessage);
+ }
+ }
+}
+exports.ToolExecutor = ToolExecutor;
diff --git a/src/types.js b/src/types.js
new file mode 100644
index 0000000..a90b553
--- /dev/null
+++ b/src/types.js
@@ -0,0 +1,79 @@
+"use strict";
+// src/types.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.DEFAULT_SETTINGS = exports.PathValidationError = exports.ToolExecutionError = exports.StreamingError = exports.ValidationError = exports.ApiError = exports.NetworkError = exports.OllamaError = exports.ErrorType = void 0;
+// ============================================================
+// Error Type Hierarchy
+// ============================================================
+var ErrorType;
+(function (ErrorType) {
+ ErrorType["NETWORK_ERROR"] = "network_error";
+ ErrorType["API_ERROR"] = "api_error";
+ ErrorType["VALIDATION_ERROR"] = "validation_error";
+ ErrorType["STREAMING_ERROR"] = "streaming_error";
+ ErrorType["TOOL_EXECUTION_ERROR"] = "tool_execution_error";
+ ErrorType["PATH_VALIDATION_ERROR"] = "path_validation_error";
+ ErrorType["UNKNOWN_ERROR"] = "unknown_error";
+})(ErrorType || (exports.ErrorType = ErrorType = {}));
+class OllamaError extends Error {
+ constructor(message, type) {
+ super(message);
+ this.type = type;
+ Object.setPrototypeOf(this, OllamaError.prototype);
+ }
+}
+exports.OllamaError = OllamaError;
+class NetworkError extends OllamaError {
+ constructor(message, statusCode) {
+ super(message, ErrorType.NETWORK_ERROR);
+ this.statusCode = statusCode;
+ Object.setPrototypeOf(this, NetworkError.prototype);
+ }
+}
+exports.NetworkError = NetworkError;
+class ApiError extends OllamaError {
+ constructor(message, statusCode) {
+ super(message, ErrorType.API_ERROR);
+ this.statusCode = statusCode;
+ Object.setPrototypeOf(this, ApiError.prototype);
+ }
+}
+exports.ApiError = ApiError;
+class ValidationError extends OllamaError {
+ constructor(message, details) {
+ super(message, ErrorType.VALIDATION_ERROR);
+ this.details = details;
+ Object.setPrototypeOf(this, ValidationError.prototype);
+ }
+}
+exports.ValidationError = ValidationError;
+class StreamingError extends OllamaError {
+ constructor(message) {
+ super(message, ErrorType.STREAMING_ERROR);
+ Object.setPrototypeOf(this, StreamingError.prototype);
+ }
+}
+exports.StreamingError = StreamingError;
+class ToolExecutionError extends OllamaError {
+ constructor(message, toolName) {
+ super(message, ErrorType.TOOL_EXECUTION_ERROR);
+ this.toolName = toolName;
+ Object.setPrototypeOf(this, ToolExecutionError.prototype);
+ }
+}
+exports.ToolExecutionError = ToolExecutionError;
+class PathValidationError extends OllamaError {
+ constructor(message, path) {
+ super(message, ErrorType.PATH_VALIDATION_ERROR);
+ this.path = path;
+ Object.setPrototypeOf(this, PathValidationError.prototype);
+ }
+}
+exports.PathValidationError = PathValidationError;
+exports.DEFAULT_SETTINGS = {
+ ollamaUrl: 'http://localhost:11434',
+ model: 'llama3',
+ vaultSearchLimit: 3,
+ maxMessageHistory: 50,
+ lastIndexTime: 0,
+};
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..46adb11
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,180 @@
+"use strict";
+// src/utils.ts
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Logger = void 0;
+exports.validateOllamaUrl = validateOllamaUrl;
+exports.validateModelName = validateModelName;
+exports.validatePluginSettings = validatePluginSettings;
+exports.safeParseJson = safeParseJson;
+exports.sanitizeFilePath = sanitizeFilePath;
+exports.isValidHttpUrl = isValidHttpUrl;
+exports.convertMarkdownToHtml = convertMarkdownToHtml;
+// ==================== Logger ====================
+var LogLevel;
+(function (LogLevel) {
+ LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
+ LogLevel[LogLevel["INFO"] = 1] = "INFO";
+ LogLevel[LogLevel["WARN"] = 2] = "WARN";
+ LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
+})(LogLevel || (LogLevel = {}));
+const SEVERITY_ORDER = {
+ debug: LogLevel.DEBUG,
+ info: LogLevel.INFO,
+ warn: LogLevel.WARN,
+ error: LogLevel.ERROR,
+};
+class Logger {
+ static setLevel(level) {
+ if (typeof level === 'string') {
+ const lowerLevel = level.toLowerCase();
+ Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;
+ }
+ else {
+ Logger.minLevel = level;
+ }
+ }
+ static debug(message, category = 'general') {
+ if (LogLevel.DEBUG >= Logger.minLevel) {
+ console.debug(`[${category}] DEBUG: ${message}`);
+ }
+ }
+ static info(message, category = 'general') {
+ if (LogLevel.INFO >= Logger.minLevel) {
+ console.info(`[${category}] INFO: ${message}`);
+ }
+ }
+ static warn(message, category = 'general') {
+ if (LogLevel.WARN >= Logger.minLevel) {
+ console.warn(`[${category}] WARN: ${message}`);
+ }
+ }
+ static error(message, category = 'general') {
+ if (LogLevel.ERROR >= Logger.minLevel) {
+ console.error(`[${category}] ERROR: ${message}`);
+ }
+ }
+}
+exports.Logger = Logger;
+Logger.minLevel = LogLevel.DEBUG;
+// ==================== URL & Model Validation ====================
+function validateOllamaUrl(url) {
+ if (typeof url !== 'string' || !url.trim()) {
+ return { valid: false, error: 'URL cannot be empty' };
+ }
+ const trimmedUrl = url.trim();
+ if (trimmedUrl.endsWith('/')) {
+ return { valid: false, error: 'URL should not end with a slash' };
+ }
+ try {
+ const parsed = new URL(trimmedUrl);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' };
+ }
+ return { valid: true };
+ }
+ catch {
+ return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' };
+ }
+}
+function validateModelName(model) {
+ if (typeof model !== 'string') {
+ return { valid: false, error: 'Model name must be a string' };
+ }
+ const trimmedModel = model.trim();
+ // Explicit check for empty string after trimming
+ if (!trimmedModel || trimmedModel.length === 0) {
+ return { valid: false, error: 'Model name cannot be empty' };
+ }
+ if (trimmedModel.length < 2) {
+ return { valid: false, error: 'Model name must be at least 2 characters long' };
+ }
+ if (trimmedModel.length > 100) {
+ return { valid: false, error: 'Model name must be less than 100 characters long' };
+ }
+ if (!/^[a-zA-Z0-9._:-]+$/.test(trimmedModel)) {
+ return {
+ valid: false,
+ error: 'Model name can only contain letters, numbers, dots, dashes, underscores, and colons',
+ };
+ }
+ return { valid: true };
+}
+function validatePluginSettings(settings) {
+ const errors = [];
+ const urlValidation = validateOllamaUrl(settings.ollamaUrl);
+ if (!urlValidation.valid) {
+ errors.push(`Invalid Ollama URL: ${urlValidation.error}`);
+ }
+ const modelValidation = validateModelName(settings.model);
+ if (!modelValidation.valid) {
+ errors.push(`Invalid Model Name: ${modelValidation.error}`);
+ }
+ return errors;
+}
+// ==================== Safe JSON Parsing ====================
+const MAX_JSON_SIZE = 1000000;
+const MAX_JSON_NESTING = 24;
+function countNestingDepth(value, depth = 0) {
+ if (depth > MAX_JSON_NESTING) {
+ return depth;
+ }
+ if (Array.isArray(value)) {
+ return Math.max(...value.map((item) => countNestingDepth(item, depth + 1)), depth);
+ }
+ if (value !== null && typeof value === 'object') {
+ const entries = Object.values(value);
+ if (entries.length === 0)
+ return depth;
+ return Math.max(...entries.map((item) => countNestingDepth(item, depth + 1)), depth);
+ }
+ return depth;
+}
+function safeParseJson(jsonString) {
+ if (typeof jsonString !== 'string') {
+ throw new Error('Input must be a string');
+ }
+ if (jsonString.length > MAX_JSON_SIZE) {
+ throw new Error('JSON input too large');
+ }
+ let parsed;
+ try {
+ parsed = JSON.parse(jsonString);
+ }
+ catch {
+ throw new Error('Invalid JSON');
+ }
+ // Check for dangerous prototype pollution patterns
+ const reStringified = JSON.stringify(parsed);
+ if (reStringified.includes('constructor') ||
+ reStringified.includes('prototype') ||
+ reStringified.includes('__proto__') ||
+ reStringified.includes('function')) {
+ throw new Error('dangerous code pattern detected');
+ }
+ // Check nesting depth
+ if (countNestingDepth(parsed) > MAX_JSON_NESTING) {
+ throw new Error('JSON nesting too deep');
+ }
+ return parsed;
+}
+// ==================== Path & File Utilities ====================
+function sanitizeFilePath(path) {
+ if (path.includes('..')) {
+ throw new Error('Invalid path - cannot contain .. segments');
+ }
+ return path;
+}
+// ==================== HTTP Helpers ====================
+function isValidHttpUrl(url) {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+ }
+ catch {
+ return false;
+ }
+}
+// ==================== Markdown Utilities ====================
+function convertMarkdownToHtml(markdown) {
+ return markdown.replace(/\n/g, '
').replace(/# (.+)/g, '