diff --git a/package.json b/package.json index ca12eb3..9eb42c8 100755 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ollama-plugin", "version": "1.0.0", "description": "Ollama integration plugin for Obsidian", - "main": "main.ts", + "main": "dist/main.js", "scripts": { "test": "jest", "build": "tsc", diff --git a/src/cache.js b/src/cache.js deleted file mode 100644 index c8ad2e5..0000000 --- a/src/cache.js +++ /dev/null @@ -1,2 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/src/chat-view.js b/src/chat-view.js deleted file mode 100644 index 8ec069f..0000000 --- a/src/chat-view.js +++ /dev/null @@ -1,457 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ChatView = exports.VIEW_TYPE_OLLAMA_CHAT = void 0; -const obsidian_1 = require("obsidian"); -const ollama_client_1 = require("./ollama-client"); -const vault_indexer_1 = require("./vault-indexer"); -const tool_executor_1 = require("./tool-executor"); -const conversation_state_1 = require("./conversation-state"); -const error_handler_1 = require("./error-handler"); -exports.VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view'; -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); - // State - 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.sendButtonClickWrapper = null; - this.inputKeyDownWrapper = null; - this.newChatButtonClickWrapper = null; - this.listenersAttached = false; - 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.sendButtonClickWrapper = null; - this.inputKeyDownWrapper = null; - this.newChatButtonClickWrapper = null; - this.listenersAttached = false; - this.settings = settings; - this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model, undefined, settings.cacheConfig); - this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault); - this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app); - this.conversationStateManager = new conversation_state_1.ConversationStateManager(); - } - updateSettings(newSettings) { - this.settings = newSettings; - this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model, undefined, newSettings.cacheConfig); - void this.ollamaClient.initializeCache().catch(() => { - new obsidian_1.Notice('Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'); - }); - } - async clearCache() { - await this.ollamaClient.clearCache(); - } - getViewType() { - return 'ollama-chat-view'; - } - getDisplayText() { - return 'Ollama Chat'; - } - async onOpen() { - try { - await this.ollamaClient.initializeCache(); - } - catch { - new obsidian_1.Notice('Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'); - } - this.render(); - this.removeEventListeners(); // Clean up any existing listeners before reattaching - this.setupEventListeners(); - } - onSettingsChange(newSettings) { - this.updateSettings(newSettings); - } - async onClose() { - this.ollamaClient.cancelStream(); - this.removeEventListeners(); - this.cleanupStreamingResources(); - this.lastMessageEl = null; - this.sendButton = null; - this.inputEl = null; - this.chatContainer = null; - return Promise.resolve(); - } - cleanupStreamingResources() { - // Only cleanup if there's still an active streaming message - const streamingMessage = this.messages.find((msg) => msg.isStreaming); - if (streamingMessage && 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' }); - const newChatContainer = this.contentEl.querySelector('.ollama-new-chat-container') || - this.contentEl.createEl('div', { cls: 'ollama-new-chat-container' }); - const messagesSnapshot = [...this.messages]; - const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming); - const existingMessages = container.querySelectorAll('.ollama-message'); - // 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(); - } - } - // Render non-streaming messages - for (const msg of nonStreamingMessages) { - const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`); - if (existingEl) { - const contentEl = existingEl.querySelector('.ollama-message-content'); - if (contentEl) { - contentEl.textContent = msg.content; - } - } - else { - const messageEl = container.createEl('div', { cls: 'ollama-message' }); - messageEl.setAttribute('data-msg-id', msg.id); - messageEl.createEl('div', { cls: 'ollama-message-role', text: msg.role }); - const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' }); - contentEl.textContent = msg.content; - } - } - // 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); - } - } - // Setup new chat button - if (!this.newChatButton) { - this.newChatButton = newChatContainer.createEl('button', { - cls: 'ollama-new-chat-button', - text: 'New Chat', - }); - } - else { - newChatContainer.appendChild(this.newChatButton); - } - // Setup input area - if (!this.inputEl) { - this.inputEl = inputContainer.createEl('textarea', { - cls: 'ollama-input', - attr: { placeholder: 'Type your message...' }, - }); - } - else { - inputContainer.appendChild(this.inputEl); - } - // Setup send button - if (!this.sendButton) { - this.sendButton = inputContainer.createEl('button', { - cls: 'ollama-send-button', - text: 'Send', - }); - } - else { - inputContainer.appendChild(this.sendButton); - } - // Append containers to contentEl - this.contentEl.appendChild(newChatContainer); - this.contentEl.appendChild(inputContainer); - this.contentEl.appendChild(container); - // Focus input on open - this.inputEl.focus(); - } - setupEventListeners() { - if (this.listenersAttached) { - return; - } - this.sendButtonClickHandler = () => { - void this.handleUserInput(this.inputEl?.value); - }; - this.inputKeyDownHandler = (event) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - void this.handleUserInput(this.inputEl?.value); - } - }; - this.newChatButtonClickHandler = () => { - this.clearConversation(); - }; - if (this.sendButton && this.sendButtonClickHandler) { - this.sendButton.addEventListener('click', this.sendButtonClickHandler); - } - if (this.inputEl && this.inputKeyDownHandler) { - this.inputEl.addEventListener('keydown', this.inputKeyDownHandler); - } - if (this.newChatButton && this.newChatButtonClickHandler) { - this.newChatButton.addEventListener('click', this.newChatButtonClickHandler); - } - this.listenersAttached = true; - } - removeEventListeners() { - if (!this.listenersAttached) { - return; - } - if (this.sendButton && this.sendButtonClickHandler) { - this.sendButton.removeEventListener('click', this.sendButtonClickHandler); - } - if (this.inputEl && this.inputKeyDownHandler) { - this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler); - } - if (this.newChatButton && this.newChatButtonClickHandler) { - this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler); - } - this.listenersAttached = false; - } - clearConversation() { - this.messages = []; - this.conversationStateManager.clear(); - this.render(); - } - updateMessageById(id, updates) { - const index = this.messages.findIndex((m) => m.id === id); - if (index !== -1) { - this.messages[index] = { ...this.messages[index], ...updates }; - this.render(); - } - } - updateLastMessage(updates) { - const streamingMessage = this.messages.find((msg) => msg.isStreaming); - if (streamingMessage) { - const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id); - if (index !== -1) { - this.messages[index] = { ...this.messages[index], ...updates }; - this.render(); - } - } - } - getTools() { - return [ - { - type: 'function', - function: { - name: 'read_vault_file', - description: 'Reads the content of a file from the vault', - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'The path to the file to read', - }, - content: { - type: 'string', - description: 'The content of the file to read', - }, - }, - required: ['path'], - }, - }, - }, - { - type: 'function', - function: { - name: 'search_vault_files', - description: 'Searches for files in the vault that match a given query', - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to use', - }, - limit: { - type: 'number', - description: 'The maximum number of results to return', - }, - }, - required: ['query'], - }, - }, - }, - ]; - } - buildMessages(userMessageContent, tools) { - const systemContent = `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.`; - const systemMessage = { - role: 'system', - content: systemContent, - }; - const userMessage = { - role: 'user', - content: userMessageContent, - }; - const messages = [systemMessage, userMessage]; - if (tools && tools.length > 0) { - messages.push({ - role: 'assistant', - content: 'I have access to the following tools to help answer your questions:', - }); - } - return messages; - } - async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) { - const toolResults = (await Promise.all(toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => { - try { - const toolResult = await this.toolExecutor.handleToolCall(toolCall); - return { ...toolResult, id: toolCall.id }; - } - catch (error) { - error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput'); - return null; - } - }))).filter((result) => result !== null); - const followUpMessages = toolResults.map((result) => { - return { - role: 'tool', - content: JSON.stringify(result), - tool_call_id: result.id ?? '', - }; - }); - const followUp = { - role: 'assistant', - content: 'I have processed your request using the following tools. Here are the results:', - tool_calls: toolCalls, - }; - if (followUpMessages.length > 0) { - const finalMessages = [...messages, followUp, ...followUpMessages]; - const response = await this.ollamaClient.chat(finalMessages, tools); - const finalResponse = response.content || fullResponse; - this.updateMessageById(assistantMessageId, { - content: finalResponse, - isStreaming: false, - }); - } - } - async handleUserInput(inputValue) { - const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim(); - if (!userMessage) { - return; - } - const MAX_CONTEXT_LENGTH = 2000; - const tools = this.getTools(); - const messageId = crypto.randomUUID(); - const userMessageId = `${messageId}-user`; - const assistantMessageId = `${messageId}-assistant`; - const userChatMessage = { - id: userMessageId, - role: 'user', - content: userMessage, - timestamp: Date.now(), - }; - const assistantMessage = { - id: assistantMessageId, - role: 'assistant', - content: '', - timestamp: Date.now(), - isStreaming: true, - }; - const previousStreamingEl = this.lastMessageEl; - this.messages = [...this.messages, userChatMessage, assistantMessage]; - this.render(); - if (this.inputEl) { - this.inputEl.value = ''; - } - // Add the assistant message to the DOM to enable streaming - this.lastMessageEl = - this.chatContainer?.querySelector(`.ollama-message[data-msg-id="${assistantMessageId}"]`) ?? - null; - if (!this.lastMessageEl && previousStreamingEl) { - previousStreamingEl.classList.add('ollama-message'); - previousStreamingEl.setAttribute('data-msg-id', assistantMessageId); - this.contentEl.appendChild(previousStreamingEl); - this.lastMessageEl = previousStreamingEl; - } - try { - const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit); - const context = entries - .map((entry) => `${entry.title}\n${entry.content}`) - .join('\n\n') - .slice(0, MAX_CONTEXT_LENGTH); - const userMessageWithContext = context - ? `Relevant vault context:\n${context}\n\nUser question:\n${userMessage}` - : userMessage; - // Get the complete messages array for the LLM with all context layers - const completeMessages = this.conversationStateManager.getCompleteMessages(userMessageWithContext); - const stream = this.ollamaClient.streamChat(completeMessages, tools); - let fullResponse = ''; - let toolCalls = []; - let chunkCount = 0; - for await (const chunk of stream) { - if (chunk.content) { - fullResponse += chunk.content; - this.updateLastMessage({ - content: fullResponse, - isStreaming: true, - }); - } - if (chunk.tool_calls) { - toolCalls = [...toolCalls, ...chunk.tool_calls]; - } - chunkCount++; - if (chunkCount > MAX_STREAM_CHUNKS) { - break; - } - } - // Process tool calls if any - if (toolCalls.length > 0) { - await this.processToolCalls(toolCalls, completeMessages, tools, fullResponse, assistantMessageId); - } - // Update assistant message immutably — only if no tool calls were processed - if (toolCalls.length === 0) { - this.updateMessageById(assistantMessageId, { - content: fullResponse, - isStreaming: false, - }); - } - // Update conversation state with the assistant's response - this.conversationStateManager.updateShortTermContext({ role: 'user', content: userMessage }); - this.conversationStateManager.updateShortTermContext({ - role: 'assistant', - content: fullResponse, - }); - // Limit conversation history to prevent memory issues - if (this.messages.length > this.settings.maxMessageHistory) { - this.messages = this.messages.slice(-this.settings.maxMessageHistory); - } - this.render(); - } - catch (error) { - error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput'); - this.updateMessageById(assistantMessageId, { - content: 'An error occurred while processing your request.', - isStreaming: false, - }); - } - finally { - // Clean up streaming resources regardless of outcome - this.cleanupStreamingResources(); - } - } -} -exports.ChatView = ChatView; -const MAX_STREAM_CHUNKS = 1000; -const MAX_TOOL_CALLS = 5; diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index baad4e1..0000000 --- a/src/constants.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.DEFAULT_SETTINGS = void 0; -exports.DEFAULT_SETTINGS = { - ollamaUrl: 'http://localhost:11434', - model: 'llama3', - vaultSearchLimit: 3, - maxMessageHistory: 50, - lastIndexTime: 0, - cacheConfig: { - enabled: false, - similarityThreshold: 0.85, - collectionName: 'ollama_semantic_cache', - embeddingModel: 'nomic-embed-text', - chromaURL: 'http://localhost:8000', - }, -}; diff --git a/src/conversation-state.js b/src/conversation-state.js deleted file mode 100644 index bec90e2..0000000 --- a/src/conversation-state.js +++ /dev/null @@ -1,145 +0,0 @@ -"use strict"; -// src/conversation-state.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ConversationStateManager = void 0; -class ConversationStateManager { - constructor() { - this.shortTermContext = []; - this.mediumTermContext = []; - this.longTermContext = []; - this.maxShortTermTurns = 10; - this.maxMediumTermMessages = 20; - // Initialize with default system context - this.longTermContext = [ - { - role: 'system', - content: `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.`, - }, - ]; - } - /** - * Updates the short-term context with a new message - * @param message The message to add to short-term context - */ - updateShortTermContext(message) { - // Add new message - this.shortTermContext.push(message); - // Limit to max turns - if (this.shortTermContext.length > this.maxShortTermTurns) { - this.shortTermContext = this.shortTermContext.slice(-this.maxShortTermTurns); - } - } - /** - * Updates the medium-term context with a new message - * @param message The message to add to medium-term context - */ - updateMediumTermContext(message) { - // Add new message - this.mediumTermContext.push(message); - // Limit to max messages - if (this.mediumTermContext.length > this.maxMediumTermMessages) { - this.mediumTermContext = this.mediumTermContext.slice(-this.maxMediumTermMessages); - } - } - /** - * Sets the user's persona or core knowledge as long-term context - * @param personaContent The persona or core knowledge content - */ - setPersona(personaContent) { - // Remove any existing persona messages - this.longTermContext = this.longTermContext.filter((msg) => msg.role !== 'system' || - !msg.content.includes('You are an assistant that can help answer questions using the contents of a vault')); - // Add the new persona - this.longTermContext.push({ - role: 'system', - content: personaContent, - }); - } - /** - * Gets the combined conversation context for the current turn - * @param userMessage The user's current message - * @returns Complete conversation context with all three layers - */ - getConversationContext(_userMessage) { - return { - shortTermContext: this.shortTermContext, - mediumTermContext: this.mediumTermContext, - longTermContext: this.longTermContext, - }; - } - /** - * Gets the complete messages array for sending to the LLM - * @param userMessage The user's current message - * @returns Complete message array for the LLM - */ - getCompleteMessages(userMessage) { - const userMessageWithContext = { - role: 'user', - content: userMessage, - }; - // Build messages in the proper order: - // 1. Long-term context (user persona, system instructions) - // 2. Medium-term context (session knowledge base query results) - // 3. Short-term context (last N turns) - // 4. Current user message - return [ - ...this.longTermContext, - ...this.mediumTermContext, - ...this.shortTermContext, - userMessageWithContext, - ]; - } - /** - * Clears all conversation context - */ - clear() { - this.shortTermContext = []; - this.mediumTermContext = []; - this.longTermContext = [ - { - role: 'system', - content: `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.`, - }, - ]; - } - /** - * Sets the medium-term context from a knowledge base query result - * @param queryResult The result from a knowledge base query - */ - setMediumTermContextFromQuery(queryResult) { - // Clear previous medium-term context - this.mediumTermContext = []; - // Add the query result as context - if (queryResult.trim()) { - this.mediumTermContext.push({ - role: 'system', - content: `Knowledge base results for current query:\n${queryResult}`, - }); - } - } - /** - * Gets the current short-term context - */ - getShortTermContext() { - return [...this.shortTermContext]; - } - /** - * Gets the current medium-term context - */ - getMediumTermContext() { - return [...this.mediumTermContext]; - } - /** - * Gets the current long-term context - */ - getLongTermContext() { - return [...this.longTermContext]; - } -} -exports.ConversationStateManager = ConversationStateManager; diff --git a/src/error-handler.js b/src/error-handler.js deleted file mode 100644 index d179f08..0000000 --- a/src/error-handler.js +++ /dev/null @@ -1,107 +0,0 @@ -"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 ?? 500); - } - 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/indexing-pipeline/extraction.js b/src/indexing-pipeline/extraction.js deleted file mode 100644 index 4296abb..0000000 --- a/src/indexing-pipeline/extraction.js +++ /dev/null @@ -1,90 +0,0 @@ -"use strict"; -// src/indexing-pipeline/extraction.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ContentExtractor = void 0; -/** - * Extracts raw content from a vault file including: - * - Markdown content - * - YAML frontmatter - * - Headings - * - Embedded code blocks - * - First paragraph - */ -class ContentExtractor { - extractFromFile(file, content) { - const frontmatter = {}; - const headings = []; - const embeddedCodeBlocks = []; - let firstParagraph; - // Extract frontmatter - const frontmatterMatch = content.match(/^---(.*?)---/s); - if (frontmatterMatch) { - try { - const frontmatterContent = frontmatterMatch[1]; - const lines = frontmatterContent.trim().split('\n'); - for (const line of lines) { - const [key, ...valueParts] = line.split(':'); - if (!key) - continue; - const value = valueParts.join(':').trim(); - if (key.trim() === 'title') { - if (value) { - frontmatter.title = value; - } - } - else if (key.trim() === 'tags') { - if (value) { - frontmatter.tags = value; - } - } - else { - // Store other frontmatter fields as-is - frontmatter[key.trim()] = value; - } - } - } - catch { - // If frontmatter parsing fails, continue with empty frontmatter - } - } - // Extract headings - const headingMatches = content.match(/^#{1,6} (.*?)$/gm); - if (headingMatches) { - headings.push(...headingMatches.map((h) => h.replace(/^#{1,6} /, ''))); - } - // Extract embedded code blocks - const codeBlockMatches = content.match(/```([\s\S]*?)```/g); - if (codeBlockMatches) { - embeddedCodeBlocks.push(...codeBlockMatches); - } - // Extract first paragraph - const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s); - if (paragraphMatch) { - firstParagraph = paragraphMatch[1].trim(); - } - return { - basename: file.basename, - path: file.path, - content, - frontmatter, - headings, - embeddedCodeBlocks, - firstParagraph, - }; - } - /** - * Extracts just the raw text content without headers, frontmatter, etc. - */ - extractRawText(content) { - return content - .replace(/^---.*?---/s, '') - .replace(/^#.*?$/gm, '') - .replace(/```.*?```/gs, '') - .replace(/`.*?`/g, '') - .replace(/\[(.*?)\]\(.*?\)/g, '$1') - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') - .trim(); - } -} -exports.ContentExtractor = ContentExtractor; diff --git a/src/indexing-pipeline/index.js b/src/indexing-pipeline/index.js deleted file mode 100644 index 00eaba3..0000000 --- a/src/indexing-pipeline/index.js +++ /dev/null @@ -1,12 +0,0 @@ -"use strict"; -// src/indexing-pipeline/index.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.IndexingPipeline = exports.ContentVectorizer = exports.ContentNormalizer = exports.ContentExtractor = void 0; -var extraction_1 = require("./extraction"); -Object.defineProperty(exports, "ContentExtractor", { enumerable: true, get: function () { return extraction_1.ContentExtractor; } }); -var normalization_1 = require("./normalization"); -Object.defineProperty(exports, "ContentNormalizer", { enumerable: true, get: function () { return normalization_1.ContentNormalizer; } }); -var vectorization_1 = require("./vectorization"); -Object.defineProperty(exports, "ContentVectorizer", { enumerable: true, get: function () { return vectorization_1.ContentVectorizer; } }); -var pipeline_1 = require("./pipeline"); -Object.defineProperty(exports, "IndexingPipeline", { enumerable: true, get: function () { return pipeline_1.IndexingPipeline; } }); diff --git a/src/indexing-pipeline/normalization.js b/src/indexing-pipeline/normalization.js deleted file mode 100644 index 3fbf923..0000000 --- a/src/indexing-pipeline/normalization.js +++ /dev/null @@ -1,159 +0,0 @@ -"use strict"; -// src/indexing-pipeline/normalization.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ContentNormalizer = void 0; -/** - * Normalizes and enriches extracted content - */ -class ContentNormalizer { - /** - * Normalizes content by: - * - Standardizing dates to ISO 8601 - * - Converting to lowercase for tokenization - * - Extracting tokens - * - Adding metadata - */ - normalize(extractedContent) { - const { basename, path, content, frontmatter, headings, firstParagraph } = extractedContent; - // Standardize title (remove .md extension) - const title = basename.replace(/\.md$/, ''); - // Extract tokens (lowercase, remove stop words, etc.) - const tokens = this.tokenize(content); - // Normalize dates (if present in frontmatter) - const normalizedFrontmatter = this.normalizeFrontmatter(frontmatter); - // Calculate word count - const wordCount = content.split(/\s+/).filter(Boolean).length; - return { - path, - title, - content, - tokens, - headings, - frontmatter: normalizedFrontmatter, - firstParagraph, - wordCount, - // Add timestamps if available in frontmatter - createdAt: this.extractDate(frontmatter, 'created') || this.extractDate(frontmatter, 'date'), - updatedAt: this.extractDate(frontmatter, 'updated'), - }; - } - /** - * Tokenizes text content by splitting on whitespace and removing stop words - */ - tokenize(text) { - const stopWords = new Set([ - 'the', - 'a', - 'an', - 'and', - 'or', - 'but', - 'is', - 'are', - 'was', - 'were', - 'in', - 'on', - 'at', - 'to', - 'of', - 'for', - 'with', - 'as', - 'by', - 'it', - 'its', - 'that', - 'this', - 'these', - 'those', - 'from', - 'up', - 'out', - 'off', - 'over', - 'under', - 'again', - 'further', - 'then', - 'once', - 'here', - 'there', - 'when', - 'where', - 'why', - 'how', - 'all', - 'any', - 'both', - 'each', - 'few', - 'more', - 'most', - 'other', - 'some', - 'such', - 'no', - 'nor', - 'not', - 'only', - 'own', - 'same', - 'so', - 'than', - 'too', - 'very', - 'just', - 'now', - ]); - return text - .toLowerCase() - .split(/\W+/) - .filter((token) => token.length > 1 && !stopWords.has(token)); - } - /** - * Normalizes frontmatter by standardizing data types and formats - */ - normalizeFrontmatter(frontmatter) { - const normalized = {}; - for (const [key, value] of Object.entries(frontmatter)) { - if (key === 'tags' && typeof value === 'string') { - // Convert tag string to array if needed - normalized.tags = value.split(',').map((tag) => tag.trim()); - } - else if (key === 'date' || key === 'created' || key === 'updated') { - // Try to parse and standardize date formats - if (typeof value === 'string') { - const date = new Date(value); - if (!isNaN(date.getTime())) { - normalized[key] = date.toISOString(); - } - else { - normalized[key] = value; // Keep original if invalid date - } - } - else { - normalized[key] = value; - } - } - else { - normalized[key] = value; - } - } - return normalized; - } - /** - * Extracts a date from frontmatter - */ - extractDate(frontmatter, key) { - const value = frontmatter[key]; - if (typeof value === 'string') { - const date = new Date(value); - if (!isNaN(date.getTime())) { - return date.toISOString(); - } - } - return undefined; - } -} -exports.ContentNormalizer = ContentNormalizer; diff --git a/src/indexing-pipeline/pipeline.js b/src/indexing-pipeline/pipeline.js deleted file mode 100644 index 394993e..0000000 --- a/src/indexing-pipeline/pipeline.js +++ /dev/null @@ -1,67 +0,0 @@ -"use strict"; -// src/indexing-pipeline/pipeline.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.IndexingPipeline = void 0; -const extraction_1 = require("./extraction"); -const normalization_1 = require("./normalization"); -const vectorization_1 = require("./vectorization"); -class IndexingPipeline { - constructor(config) { - this.extractor = new extraction_1.ContentExtractor(); - this.normalizer = new normalization_1.ContentNormalizer(); - this.vectorizer = new vectorization_1.ContentVectorizer({ - model: config.embeddingModel, - ollamaUrl: config.ollamaUrl, - }); - } - /** - * Processes a vault file through the entire pipeline - */ - processFile(file, content) { - try { - if (!content.trim()) { - return null; - } - // Extraction step - const extracted = this.extractor.extractFromFile(file, content); - // Normalization/Enrichment step - const normalized = this.normalizer.normalize(extracted); - // Return the normalized content as an index entry - return { - path: normalized.path, - title: normalized.title, - content: this.extractor.extractRawText(content).substring(0, 500), - score: 0, // Score will be calculated during search - }; - } - catch { - return null; - } - } - /** - * Processes multiple files in batches - */ - processFilesInBatches(files, fileContents, batchSize = 10) { - const results = []; - const seenPaths = new Set(); - for (let i = 0; i < files.length; i += batchSize) { - const batch = files.slice(i, i + batchSize); - const batchResults = batch.map((file) => { - const content = fileContents[file.path]; - if (!content) { - return null; - } - const entry = this.processFile(file, content); - if (entry && !seenPaths.has(entry.path)) { - seenPaths.add(entry.path); - return entry; - } - return null; - }); - const validResults = batchResults.filter((result) => result !== null); - results.push(...validResults); - } - return results; - } -} -exports.IndexingPipeline = IndexingPipeline; diff --git a/src/indexing-pipeline/vectorization.js b/src/indexing-pipeline/vectorization.js deleted file mode 100644 index a2cc5f3..0000000 --- a/src/indexing-pipeline/vectorization.js +++ /dev/null @@ -1,65 +0,0 @@ -"use strict"; -// src/indexing-pipeline/vectorization.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ContentVectorizer = void 0; -const utils_1 = require("../utils"); -/** - * Vectorizes content chunks using Ollama embeddings - */ -class ContentVectorizer { - constructor(config, fetchFn) { - this.model = config.model; - this.ollamaUrl = config.ollamaUrl; - this.fetchFn = fetchFn ?? fetch; - } - /** - * Generates embeddings for a content chunk - */ - async vectorize(chunk) { - try { - const prompt = this.createPrompt(chunk); - const response = await this.fetchFn(`${this.ollamaUrl}/api/embeddings`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: this.model, - prompt: prompt, - }), - }); - if (!response.ok) { - throw new Error(`Embedding failed with status ${response.status}`); - } - const data = await response.json(); - if (!this.isEmbeddingResponse(data)) { - throw new Error('Invalid embedding response'); - } - return data.embedding; - } - catch (error) { - // Return empty array on failure to maintain compatibility - utils_1.Logger.warn(`Failed to generate embedding: ${String(error)}`, 'indexing-pipeline'); - return []; - } - } - isEmbeddingResponse(data) { - return (typeof data === 'object' && - data !== null && - Array.isArray(data.embedding) && - data.embedding.every((value) => typeof value === 'number')); - } - /** - * Creates a prompt from content chunk for embedding - */ - createPrompt(chunk) { - // Combine important elements for embedding - const parts = [ - chunk.title, - chunk.firstParagraph, - chunk.content.substring(0, 1000), // Limit content to avoid long prompts - chunk.headings.join(' '), - JSON.stringify(chunk.frontmatter), - ].filter(Boolean); - return parts.join('\n\n'); - } -} -exports.ContentVectorizer = ContentVectorizer; diff --git a/src/main.js b/src/main.js deleted file mode 100644 index 9c00ce7..0000000 --- a/src/main.js +++ /dev/null @@ -1,198 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const obsidian_1 = require("obsidian"); -const chat_view_1 = require("./chat-view"); -const constants_1 = require("./constants"); -const semantic_cache_1 = require("./semantic-cache"); -class OllamaPlugin extends obsidian_1.Plugin { - constructor() { - super(...arguments); - this.settings = constants_1.DEFAULT_SETTINGS; - } - async onload() { - await this.loadSettings(); - // Register the chat view - this.registerView('ollama-chat-view', (leaf) => new chat_view_1.ChatView(leaf, this.settings)); - // Add a command to open the chat view - this.addCommand({ - id: 'open-ollama-chat', - name: 'Open Ollama Chat', - callback: async () => { - await this.activateChatView(); - }, - }); - // Add a command to clear the semantic cache - this.addCommand({ - id: 'clear-semantic-cache', - name: 'Clear Semantic Cache', - callback: async () => { - await this.clearSemanticCache(); - new obsidian_1.Notice('Semantic cache cleared.'); - }, - }); - // Add a settings tab - this.addSettingTab(new OllamaSettingTab(this.app, this)); - // Initialize the semantic cache - if (this.settings.cacheConfig) { - this.semanticCache = new semantic_cache_1.SemanticCacheService(this.settings.ollamaUrl, this.settings.cacheConfig); - try { - await this.semanticCache.initialize(); - } - catch { - new obsidian_1.Notice('Semantic cache initialization failed. Check console for details.'); - } - } - } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - onunload() { - // Clean up any active semantic cache resources on plugin unload - // Using fire-and-forget pattern since onunload cannot be async per Obsidian API - if (this.semanticCache) { - void this.semanticCache.clearCache(); - } - // No explicit unregisterView needed; relying on Obsidian lifecycle management. - } - async loadSettings() { - const loadedSettings = ((await this.loadData()) ?? {}); - this.settings = Object.assign({}, constants_1.DEFAULT_SETTINGS, loadedSettings); - } - async saveSettings() { - await this.saveData(this.settings); - } - async activateChatView() { - const existing = this.app.workspace.getLeavesOfType('ollama-chat-view'); - if (existing.length > 0) { - await this.app.workspace.revealLeaf(existing[0]); - } - else { - const leaf = this.app.workspace.getRightLeaf(false); - if (leaf) { - await leaf.setViewState({ - type: 'ollama-chat-view', - active: true, - }); - } - } - } - async clearSemanticCache() { - if (this.semanticCache) { - await this.semanticCache.clearCache(); - } - } - notifyChatViews() { - const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view'); - leaves.forEach((leaf) => { - if (leaf.view instanceof chat_view_1.ChatView) { - leaf.view.updateSettings(this.settings); - } - }); - } -} -exports.default = OllamaPlugin; -class OllamaSettingTab extends obsidian_1.PluginSettingTab { - constructor(app, plugin) { - super(app, plugin); - this.plugin = plugin; - } - display() { - const { containerEl } = this; - containerEl.empty(); - containerEl.createEl('h2', { text: 'Ollama Settings' }); - new obsidian_1.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 obsidian_1.Setting(containerEl) - .setName('Model') - .setDesc('Ollama model to use (default: llama3)') - .addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => { - this.plugin.settings.model = value; - await this.plugin.saveSettings(); - })); - new obsidian_1.Setting(containerEl) - .setName('Vault Search Limit') - .setDesc('Maximum number of vault entries to include in context (default: 3)') - .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 obsidian_1.Notice('Vault search limit must be a positive integer.'); - } - })); - new obsidian_1.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 obsidian_1.Notice('Max message history must be a positive integer.'); - } - })); - new obsidian_1.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 obsidian_1.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) => { - this.plugin.settings.cacheConfig.chromaURL = value; - await this.plugin.saveSettings(); - })); - new obsidian_1.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 obsidian_1.Setting(containerEl) - .setName('Cache Similarity Threshold') - .setDesc('Minimum cosine similarity (0–1) for a cache hit. Higher values require closer matches.') - .addText((text) => text - .setValue(String(this.plugin.settings.cacheConfig.similarityThreshold)) - .onChange(async (value) => { - const parsed = parseFloat(value); - if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { - this.plugin.settings.cacheConfig.similarityThreshold = parsed; - await this.plugin.saveSettings(); - } - else { - new obsidian_1.Notice('Similarity threshold must be a number between 0 and 1.'); - } - })); - new obsidian_1.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 obsidian_1.Notice('Semantic cache cleared.'); - } - catch { - new obsidian_1.Notice('Failed to clear semantic cache. Is ChromaDB running?'); - } - })); - } - hide() { - // Clear the container to prevent duplicate elements - this.containerEl.empty(); - } -} diff --git a/src/ollama-client.js b/src/ollama-client.js deleted file mode 100644 index 46a6d0c..0000000 --- a/src/ollama-client.js +++ /dev/null @@ -1,294 +0,0 @@ -"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"); -const semantic_cache_1 = require("./semantic-cache"); -class OllamaClient { - constructor(baseURL, model, fetchFn, cacheConfig) { - this.maxRetries = 3; - this.maxMalformedChunks = 50; - this.currentStreamController = null; - this.baseURL = baseURL; - this.model = model; - this.fetchFn = fetchFn ?? fetch; - if (cacheConfig?.enabled) { - this.cacheService = new semantic_cache_1.SemanticCacheService(baseURL, cacheConfig); - void this.cacheService.initialize(); - } - } - async initializeCache() { - if (this.cacheService) { - await this.cacheService.initialize(); - } - } - async clearCache() { - if (this.cacheService) { - await this.cacheService.clearCache(); - } - } - cancelStream() { - if (this.currentStreamController) { - this.currentStreamController.abort(); - this.currentStreamController = null; - } - } - async *streamChat(messages, tools = []) { - // Bypass cache if tools are involved to prevent state corruption - if (tools.length > 0) { - yield* this.streamChatWithRetry(messages, tools, 0); - return; - } - // Find the last user message - const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); - if (lastUserMsg && this.cacheService) { - const cached = await this.cacheService.getCache(lastUserMsg.content); - if (cached) { - yield { role: 'assistant', content: cached, tool_calls: [] }; - return; - } - } - const chunks = []; - for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) { - chunks.push(chunk); - yield chunk; - } - const fullContent = chunks.map((c) => c.content).join(''); - if (this.cacheService && lastUserMsg) { - void this.cacheService.setCache(lastUserMsg.content, fullContent); - } - } - async chat(messages, tools = []) { - // Bypass cache if tools are involved to prevent state corruption - if (tools.length > 0) { - return this.chatWithRetry(messages, tools, 0); - } - // Find the last user message - const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); - if (lastUserMsg && this.cacheService) { - const cached = await this.cacheService.getCache(lastUserMsg.content); - if (cached) { - return { role: 'assistant', content: cached }; - } - } - const response = await this.chatWithRetry(messages, tools, 0); - if (this.cacheService && lastUserMsg) { - void this.cacheService.setCache(lastUserMsg.content, response.content); - } - return response; - } - async streamChatAsPromise(messages, tools = []) { - let content = ''; - let role = 'assistant'; - let toolCalls; - for await (const chunk of this.streamChat(messages, tools)) { - role = chunk.role ?? role; - content += chunk.content ?? ''; - if (chunk.tool_calls) { - toolCalls = [...(toolCalls ?? []), ...chunk.tool_calls]; - } - } - return { role, content, tool_calls: toolCalls }; - } - async *streamChatWithRetry(messages, tools = [], retryCount) { - const controller = new AbortController(); - this.currentStreamController = controller; - let reader = null; - try { - const response = await this.fetchFn(`${this.baseURL}/api/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: this.model, - messages: messages, - tools: tools, - stream: true, - }), - signal: controller.signal, - }); - if (!response.ok) { - 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('application/x-ndjson')) { - throw new Error('Invalid response format'); - } - reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let malformedChunks = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - buffer += decoder.decode(value); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - if (line.trim() === '') { - continue; - } - let parsed; - try { - parsed = this.parseChatResponse(line); - } - catch (error) { - malformedChunks++; - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Skipped malformed chunk: ${line.slice(0, 50)}... - ${errorMessage}`, 'ollama-client'); - if (malformedChunks > this.maxMalformedChunks) { - throw new Error('Too many malformed chunks in Ollama response'); - } - continue; - } - if (parsed.error) { - throw new Error(`Ollama error: ${parsed.error}`); - } - yield this.normalizeMessage(parsed.message); - } - } - if (buffer.trim() !== '') { - let parsed = null; - try { - parsed = this.parseChatResponse(buffer); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Skipped malformed chunk: ${buffer.slice(0, 50)}... - ${errorMessage}`, 'ollama-client'); - } - if (parsed?.error) { - throw new Error(`Ollama error: ${parsed.error}`); - } - if (parsed?.message) { - yield this.normalizeMessage(parsed.message); - } - } - } - catch (error) { - if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client'); - await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount))); - yield* this.streamChatWithRetry(messages, tools, retryCount + 1); - } - else { - throw error; - } - } - finally { - reader?.releaseLock(); - if (this.currentStreamController === controller) { - this.currentStreamController = null; - } - } - } - async chatWithRetry(messages, tools = [], retryCount) { - const controller = new AbortController(); - this.currentStreamController = controller; - try { - const response = await this.fetchFn(`${this.baseURL}/api/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: this.model, - messages: messages, - tools: tools, - stream: false, - }), - signal: controller.signal, - }); - if (!response.ok) { - throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); - } - const data = await response.json(); - if (!this.isChatResponse(data)) { - return this.normalizeMessage(); - } - return this.normalizeMessage(data.message); - } - catch (error) { - if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client'); - await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount))); - return this.chatWithRetry(messages, tools, retryCount + 1); - } - else { - throw error; - } - } - finally { - if (this.currentStreamController === controller) { - this.currentStreamController = null; - } - } - } - normalizeMessage(message) { - return { - role: message?.role ?? 'assistant', - content: message?.content ?? '', - tool_calls: message?.tool_calls ?? [], - tool_call_id: message?.tool_call_id, - }; - } - parseChatResponse(raw) { - const parsed = JSON.parse(raw); - if (!this.isChatResponse(parsed)) { - throw new Error('Invalid chat response'); - } - return parsed; - } - isChatResponse(data) { - if (typeof data !== 'object' || data === null) { - return false; - } - const response = data; - return ((response.error === undefined || typeof response.error === 'string') && - (response.message === undefined || this.isPartialMessage(response.message))); - } - isPartialMessage(data) { - if (typeof data !== 'object' || data === null) { - return false; - } - const message = data; - const validRole = message.role === undefined || - message.role === 'system' || - message.role === 'user' || - message.role === 'assistant' || - message.role === 'tool'; - return (validRole && - (message.content === undefined || typeof message.content === 'string') && - (message.tool_calls === undefined || Array.isArray(message.tool_calls)) && - (message.tool_call_id === undefined || typeof message.tool_call_id === 'string')); - } - isRetryableError(error, controller) { - if (controller.signal.aborted) { - return false; - } - if (error instanceof types_1.ApiError && error.statusCode >= 400 && error.statusCode < 500) { - return false; - } - if (error instanceof Error) { - 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') { - return false; - } - } - return true; - } -} -exports.OllamaClient = OllamaClient; diff --git a/src/semantic-cache.js b/src/semantic-cache.js deleted file mode 100644 index 9b12c59..0000000 --- a/src/semantic-cache.js +++ /dev/null @@ -1,105 +0,0 @@ -"use strict"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -/* eslint-disable */ -// src/semantic-cache.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.SemanticCacheService = void 0; -const chromadb_1 = require("chromadb"); -const utils_1 = require("./utils"); -class SemanticCacheService { - constructor(ollamaURL, config) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.collection = null; - this.ollamaURL = ollamaURL.replace(/\/+$/, ''); - this.config = config; - // Use configurable ChromaDB URL or default to localhost - const chromaURL = config.chromaURL || 'http://localhost:8000'; - this.client = new chromadb_1.ChromaClient({ path: chromaURL }); - } - async initialize() { - if (!this.config.enabled) - return; - try { - this.collection = await this.client.getOrCreateCollection({ - name: this.config.collectionName, - metadata: { 'hnsw:space': 'cosine' }, - }); - utils_1.Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache'); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.error(`Failed to initialize semantic cache: ${errorMessage}`, 'semantic-cache'); - throw error; - } - } - async getCache(query) { - if (!this.config.enabled || !this.collection) - return null; - try { - const results = await this.collection.query({ - query_embeddings: await this.generateEmbedding(query), - n_results: 1, - where: { source: 'ollama' }, - }); - if (results.ids[0] && results.ids[0].length > 0) { - const [id] = results.ids[0]; - const [content] = results.documents[0]; - if (results.distances[0] && results.distances[0][0] > this.config.similarityThreshold) { - return content; - } - } - return null; - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Cache lookup failed: ${errorMessage}`, 'semantic-cache'); - return null; - } - } - async setCache(query, response) { - if (!this.config.enabled || !this.collection) - return; - try { - await this.collection.add({ - ids: [crypto.randomUUID()], - documents: [response], - embeddings: await this.generateEmbedding(query), - metadatas: [{ source: 'ollama' }], - }); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Cache set failed: ${errorMessage}`, 'semantic-cache'); - } - } - async clearCache() { - if (!this.config.enabled || !this.collection) - return; - try { - await this.collection.reset(); - utils_1.Logger.info('Semantic cache cleared', 'semantic-cache'); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.error(`Failed to clear semantic cache: ${errorMessage}`, 'semantic-cache'); - } - } - async generateEmbedding(text) { - const response = await fetch(`${this.ollamaURL}/api/embeddings`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: this.config.embeddingModel, - prompt: text, - }), - }); - if (!response.ok) { - throw new Error(`Failed to generate embedding: ${response.status} ${response.statusText}`); - } - const data = await response.json(); - return data.embedding; - } -} -exports.SemanticCacheService = SemanticCacheService; diff --git a/src/tool-executor.js b/src/tool-executor.js deleted file mode 100644 index 89ec8d3..0000000 --- a/src/tool-executor.js +++ /dev/null @@ -1,167 +0,0 @@ -"use strict"; -// src/tool-executor.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ToolExecutor = void 0; -const obsidian_1 = require("obsidian"); -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); - case 'read_vault_file': - return await this.handleReadVaultFile(parsedArgs); - case 'search_vault_files': - return this.handleSearchVaultFiles(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); - } - } - async executeTool(name, args) { - return this.handleToolCall({ - id: crypto.randomUUID(), - type: 'function', - function: { - name, - arguments: args, - }, - }); - } - async handleReadVaultFile(args) { - const path = args.path; - if (typeof path !== 'string') { - throw new Error('Path must be a string'); - } - if (!this.isSafePath(path)) { - throw new Error('Invalid file path detected'); - } - const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof obsidian_1.TFile)) { - throw new Error(`File not found: ${path}`); - } - const content = await this.vault.cachedRead(file); - return { - success: true, - message: 'File read successfully', - data: { path, content }, - }; - } - handleSearchVaultFiles(args) { - const query = args.query; - const limitArg = args.limit; - if (typeof query !== 'string') { - throw new Error('Query must be a string'); - } - const limit = typeof limitArg === 'number' && Number.isFinite(limitArg) ? limitArg : 10; - const normalizedQuery = query.toLowerCase(); - const files = this.vault - .getMarkdownFiles() - .filter((file) => file.path.toLowerCase().includes(normalizedQuery)) - .slice(0, limit) - .map((file) => ({ path: file.path, basename: file.basename })); - return { - success: true, - message: `Found ${files.length} matching files`, - data: files, - }; - } -} -exports.ToolExecutor = ToolExecutor; diff --git a/src/types.js b/src/types.js deleted file mode 100644 index 2e96d54..0000000 --- a/src/types.js +++ /dev/null @@ -1,72 +0,0 @@ -"use strict"; -// src/types.ts -Object.defineProperty(exports, "__esModule", { value: true }); -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 = 'unknown') { - 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; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 69ff631..0000000 --- a/src/utils.js +++ /dev/null @@ -1,168 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Logger = exports.LogLevel = void 0; -exports.validateOllamaUrl = validateOllamaUrl; -exports.validateModelName = validateModelName; -exports.validatePluginSettings = validatePluginSettings; -exports.safeParseJson = safeParseJson; -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 || (exports.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 in object keys only - const checkDangerousPatterns = (obj) => { - if (typeof obj !== 'object' || obj === null) { - return false; - } - const dangerousKeys = ['constructor', 'prototype', '__proto__']; - if (dangerousKeys.some((key) => Object.keys(obj).includes(key))) { - return true; - } - // Recursively check nested objects (own properties only) - const record = obj; - for (const key of Object.keys(obj)) { - if (checkDangerousPatterns(record[key])) { - return true; - } - } - return false; - }; - if (checkDangerousPatterns(parsed)) { - 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; -} -// ==================== Markdown Utilities ==================== diff --git a/src/vault-indexer.js b/src/vault-indexer.js deleted file mode 100644 index ea9b842..0000000 --- a/src/vault-indexer.js +++ /dev/null @@ -1,226 +0,0 @@ -"use strict"; -// src/vault-indexer.ts -Object.defineProperty(exports, "__esModule", { value: true }); -exports.VaultIndexer = exports.InMemoryCache = void 0; -const utils_1 = require("./utils"); -class InMemoryCache { - constructor() { - this.store = new Map(); - } - get(key) { - return Promise.resolve(this.store.get(key) ?? null); - } - put(key, value) { - this.store.set(key, value); - return Promise.resolve(); - } -} -exports.InMemoryCache = InMemoryCache; -const STOP_WORDS = new Set([ - 'a', 'an', 'the', 'is', 'it', 'in', 'on', 'at', 'to', 'for', 'of', - 'and', 'or', 'but', 'with', 'by', 'from', 'up', 'about', 'into', - 'this', 'that', 'these', 'those', 'be', 'been', 'being', 'have', - 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', - 'may', 'might', 'can', 'are', 'was', 'were', 'as', 'so', 'if', 'not', - 'no', 'my', 'your', 'our', 'its', 'we', 'you', 'he', 'she', 'they', -]); -const CONTENT_PREVIEW_LENGTH = 500; -class VaultIndexer { - constructor(vault, cache) { - this.SCORING_WEIGHTS = { - TITLE: 5, - FRONTMATTER_TITLE: 4, - FRONTMATTER_TAGS: 3, - HEADINGS: 2, - CONTENT: 1, - }; - this.vault = vault; - this.cache = cache; - } - tokenize(text) { - return text - .toLowerCase() - .replace(/[^\w\s]/g, '') - .split(/\s+/) - .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); - } - tokenizeContent(content, file) { - const parsed = this.parseMarkdown(content); - const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, ''); - const paragraphs = bodyWithoutFrontmatter - .split(/\n\n+/) - .map((p) => p.trim()) - .filter((p) => p && !p.startsWith('#')); - const firstParagraph = paragraphs[0] || ''; - return { - title: parsed.title || file.basename, - headings: parsed.headings, - frontmatter: parsed.frontmatter, - firstParagraph, - content: parsed.content, - basename: file.basename, - }; - } - calculateWeightedScore(tokenized, queryTokens) { - let score = 0; - for (const token of queryTokens) { - if (tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, token)) { - score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; - } - if (tokenized.basename && this.exactMatch(tokenized.basename, token)) { - score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; - } - if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, token)) { - score += this.SCORING_WEIGHTS.FRONTMATTER_TAGS; - } - if (tokenized.headings.some((h) => h.toLowerCase().includes(token.toLowerCase()))) { - score += this.SCORING_WEIGHTS.HEADINGS; - } - if (tokenized.content.toLowerCase().includes(token.toLowerCase())) { - score += this.SCORING_WEIGHTS.CONTENT; - } - if (tokenized.title && this.exactMatch(tokenized.title, token)) { - score += this.SCORING_WEIGHTS.TITLE; - } - } - return { score }; - } - async getVaultEntries() { - const files = this.vault.getMarkdownFiles(); - const entries = []; - for (const file of files) { - try { - const content = typeof this.vault.cachedRead === 'function' - ? await this.vault.cachedRead(file) - : await this.vault.read(file); - const parsed = this.parseMarkdown(content); - entries.push({ - file: file, - title: parsed.frontmatter.title || file.basename, - frontmatter: parsed.frontmatter, - headings: parsed.headings, - content: parsed.content.slice(0, CONTENT_PREVIEW_LENGTH), - basename: file.basename, - score: 0, - }); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Failed to read file ${file.path}: ${errorMessage}`, 'vault-indexer'); - } - } - return entries; - } - async searchVault(query, limit = 3) { - if (!query || !query.trim()) { - return []; - } - const cacheKey = `query:${query.trim()}:limit:${limit}`; - if (this.cache) { - let cachedResults = null; - try { - cachedResults = await this.cache.get(cacheKey); - } - catch { - cachedResults = null; - } - if (cachedResults) { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const parsedResults = JSON.parse(cachedResults); - return parsedResults.slice(0, limit); - } - catch { - // ignore parse errors - } - } - } - const queryTokens = this.tokenize(query); - if (queryTokens.length === 0) { - return []; - } - const entries = await this.getVaultEntries(); - const scored = entries - .map((entry) => { - const { score } = this.calculateWeightedScore({ - title: entry.title, - headings: entry.headings, - frontmatter: entry.frontmatter, - firstParagraph: '', - content: entry.content, - basename: entry.basename, - }, queryTokens); - return { ...entry, score }; - }) - .filter((e) => e.score > 0); - scored.sort((a, b) => b.score - a.score); - const results = scored.slice(0, limit); - if (this.cache) { - try { - await this.cache.put(cacheKey, JSON.stringify(results)); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - utils_1.Logger.warn(`Failed to cache results for query "${query}": ${errorMessage}`, 'vault-indexer'); - } - } - return results; - } - stemToken(token) { - if (token.endsWith('ing') && token.length > 4) - return token.slice(0, -3); - if (token.endsWith('ed') && token.length > 3) - return token.slice(0, -2); - if (token.endsWith('s') && token.length > 2) - return token.slice(0, -1); - return token; - } - exactMatch(text, queryToken) { - if (!text) - return false; - const textLower = text.toLowerCase(); - const queryLower = queryToken.toLowerCase(); - const queryStem = this.stemToken(queryLower); - return textLower.includes(queryLower) || textLower.includes(queryStem); - } - parseMarkdown(content) { - const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; - const frontmatterMatch = content.match(frontmatterRegex); - const frontmatter = {}; - if (frontmatterMatch) { - try { - const lines = frontmatterMatch[1].trim().split('\n'); - for (const line of lines) { - const [key, ...valueParts] = line.split(':'); - if (!key) - continue; - const value = valueParts.join(':').trim(); - if (key.trim() === 'title' && value) - frontmatter.title = value; - else if (key.trim() === 'tags' && value) - frontmatter.tags = value; - } - } - catch { - utils_1.Logger.warn('Failed to parse frontmatter', 'vault-indexer'); - } - } - const titleMatch = content.match(/^# (.+)$/m); - const title = titleMatch ? titleMatch[1] : ''; - const headings = []; - const headingRegex = /^#{1,6} (.+)$/gm; - let headingMatch; - while ((headingMatch = headingRegex.exec(content)) !== null) { - headings.push(headingMatch[1]); - } - const bodyWithoutFrontmatter = frontmatterMatch - ? content.substring(frontmatterMatch[0].length) - : content; - const bodyText = bodyWithoutFrontmatter - .replace(/#{1,6} .+/g, '') - .replace(/^\s*[\r\n]/gm, '') - .trim(); - return { frontmatter, title, headings, content: bodyText }; - } -} -exports.VaultIndexer = VaultIndexer; diff --git a/tests/ollama-client.test.js b/tests/ollama-client.test.js new file mode 100644 index 0000000..f4a2115 --- /dev/null +++ b/tests/ollama-client.test.js @@ -0,0 +1,1512 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __asyncValues = (this && this.__asyncValues) || function (o) { + if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); + var m = o[Symbol.asyncIterator], i; + return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); + function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } + function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var ollama_client_1 = require("../src/ollama-client"); +describe('OllamaClient', function () { + var client; + var mockFetch; + function createMockReader(data) { + var encoder = new TextEncoder(); + var encoded = encoder.encode(data); + var called = false; + return { + read: function () { + if (!called) { + called = true; + return Promise.resolve({ done: false, value: encoded }); + } + return Promise.resolve({ done: true, value: new Uint8Array(0) }); + }, + releaseLock: jest.fn(), + }; + } + var mockMessages = [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Hello' }, + ]; + var mockTools = [ + { + type: 'function', + function: { + name: 'test_tool', + description: 'A test tool', + parameters: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'], + }, + }, + }, + ]; + beforeEach(function () { + mockFetch = jest.fn(); + client = new ollama_client_1.OllamaClient('http://localhost:11434', 'llama3', mockFetch); + }); + afterEach(function () { + jest.clearAllMocks(); + // Removed cancelStream call as we now use local controllers + }); + describe('chat (non-streaming)', function () { + it('should send a non-streaming request and return the response', function () { return __awaiter(void 0, void 0, void 0, function () { + var mockResponse, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockResponse = { + ok: true, + json: function () { return Promise.resolve({ message: { content: 'Hello back!' } }); }, + }; + mockFetch.mockResolvedValue(mockResponse); + return [4 /*yield*/, client.chat(mockMessages, mockTools)]; + case 1: + result = _a.sent(); + expect(result.content).toBe('Hello back!'); + expect(mockFetch).toHaveBeenCalledWith('http://localhost:11434/api/chat', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + model: 'llama3', + messages: mockMessages, + tools: mockTools, + stream: false, + }), + })); + return [2 /*return*/]; + } + }); + }); }); + it('should throw on non-OK response', function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + return [4 /*yield*/, expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500')]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should handle missing message content gracefully', function () { return __awaiter(void 0, void 0, void 0, function () { + var result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ + ok: true, + json: function () { return Promise.resolve({}); }, + }); + return [4 /*yield*/, client.chat(mockMessages, mockTools)]; + case 1: + result = _a.sent(); + expect(result.content).toBe(''); + expect(result.tool_calls).toEqual([]); + return [2 /*return*/]; + } + }); + }); }); + it('should include abort signal in fetch options', function () { return __awaiter(void 0, void 0, void 0, function () { + var fetchOptions; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ + ok: true, + json: function () { return Promise.resolve({ message: { content: 'ok' } }); }, + }); + return [4 /*yield*/, client.chat(mockMessages, mockTools)]; + case 1: + _a.sent(); + fetchOptions = mockFetch.mock.calls[0][1]; + expect(fetchOptions.signal).toBeInstanceOf(AbortSignal); + return [2 /*return*/]; + } + }); + }); }); + it('should forward tool_calls from response when present', function () { return __awaiter(void 0, void 0, void 0, function () { + var result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ + ok: true, + json: function () { + return Promise.resolve({ + message: { + content: 'result', + tool_calls: [{ function: { name: 'create_file', arguments: '{}' } }], + }, + }); + }, + }); + return [4 /*yield*/, client.chat(mockMessages, mockTools)]; + case 1: + result = _a.sent(); + expect(result.tool_calls).toEqual([{ function: { name: 'create_file', arguments: '{}' } }]); + return [2 /*return*/]; + } + }); + }); }); + }); + describe('streamChat', function () { + it('should send a streaming request and yield chunks', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, chunks, _a, stream_1, stream_1_1, chunk, e_1_1; + var _b, e_1, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = [ + JSON.stringify({ message: { content: 'He' } }), + JSON.stringify({ message: { content: 'llo' } }), + JSON.stringify({ message: { content: '!' } }), + '', + ].join('\n'); + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + chunks = []; + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_1 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_1.next()]; + case 4: + if (!(stream_1_1 = _e.sent(), _b = stream_1_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_1_1.value; + _a = false; + chunk = _d; + chunks.push(chunk.content); + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_1_1 = _e.sent(); + e_1 = { error: e_1_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_1.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_1)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_1) throw e_1.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(chunks).toEqual(['He', 'llo', '!']); + expect(mockReader.releaseLock).toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should skip malformed JSON chunks and log a warning', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, consoleWarnSpy, stream, chunks, _a, stream_2, stream_2_1, chunk, e_2_1; + var _b, e_2, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = [ + JSON.stringify({ message: { content: 'valid' } }), + 'this is not json', + JSON.stringify({ message: { content: 'also valid' } }), + '', + ].join('\n'); + mockReader = createMockReader(streamData); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + chunks = []; + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_2 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_2.next()]; + case 4: + if (!(stream_2_1 = _e.sent(), _b = stream_2_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_2_1.value; + _a = false; + chunk = _d; + chunks.push(chunk.content); + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_2_1 = _e.sent(); + e_2 = { error: e_2_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_2.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_2)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_2) throw e_2.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(chunks).toEqual(['valid', 'also valid']); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[ollama-client] WARN: Skipped malformed chunk: this is not json... - Unexpected token \'h\', "this is not json" is not valid JSON')); + consoleWarnSpy.mockRestore(); + return [2 /*return*/]; + } + }); + }); }); + it('should throw when too many chunks are malformed', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + streamData = Array(51).fill('invalid json').join('\n') + '\n'; + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _a.sent(); + return [4 /*yield*/, expect((function () { return __awaiter(void 0, void 0, void 0, function () { + var _a, stream_3, stream_3_1, _, e_3_1; + var _b, e_3, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + _e.trys.push([0, 5, 6, 11]); + _a = true, stream_3 = __asyncValues(stream); + _e.label = 1; + case 1: return [4 /*yield*/, stream_3.next()]; + case 2: + if (!(stream_3_1 = _e.sent(), _b = stream_3_1.done, !_b)) return [3 /*break*/, 4]; + _d = stream_3_1.value; + _a = false; + _ = _d; + _e.label = 3; + case 3: + _a = true; + return [3 /*break*/, 1]; + case 4: return [3 /*break*/, 11]; + case 5: + e_3_1 = _e.sent(); + e_3 = { error: e_3_1 }; + return [3 /*break*/, 11]; + case 6: + _e.trys.push([6, , 9, 10]); + if (!(!_a && !_b && (_c = stream_3.return))) return [3 /*break*/, 8]; + return [4 /*yield*/, _c.call(stream_3)]; + case 7: + _e.sent(); + _e.label = 8; + case 8: return [3 /*break*/, 10]; + case 9: + if (e_3) throw e_3.error; + return [7 /*endfinally*/]; + case 10: return [7 /*endfinally*/]; + case 11: return [2 /*return*/]; + } + }); + }); })()).rejects.toThrow(/malformed/)]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should throw on non-OK response', function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + return [4 /*yield*/, expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 404')]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should throw when response has no body', function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ ok: true, body: undefined }); + return [4 /*yield*/, expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow('No response body')]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should throw on invalid content type', function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ + ok: true, + body: { + getReader: function () { return ({ + read: function () { return Promise.resolve({ done: true, value: new Uint8Array(0) }); }, + }); }, + }, + headers: { + get: function (name) { return (name === 'content-type' ? 'text/html' : null); }, + }, + }); + return [4 /*yield*/, expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow('Invalid response format')]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should propagate Ollama error messages from the stream', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + streamData = JSON.stringify({ error: 'model not found' }) + '\n'; + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _a.sent(); + return [4 /*yield*/, expect((function () { return __awaiter(void 0, void 0, void 0, function () { + var _a, stream_4, stream_4_1, _, e_4_1; + var _b, e_4, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + _e.trys.push([0, 5, 6, 11]); + _a = true, stream_4 = __asyncValues(stream); + _e.label = 1; + case 1: return [4 /*yield*/, stream_4.next()]; + case 2: + if (!(stream_4_1 = _e.sent(), _b = stream_4_1.done, !_b)) return [3 /*break*/, 4]; + _d = stream_4_1.value; + _a = false; + _ = _d; + _e.label = 3; + case 3: + _a = true; + return [3 /*break*/, 1]; + case 4: return [3 /*break*/, 11]; + case 5: + e_4_1 = _e.sent(); + e_4 = { error: e_4_1 }; + return [3 /*break*/, 11]; + case 6: + _e.trys.push([6, , 9, 10]); + if (!(!_a && !_b && (_c = stream_4.return))) return [3 /*break*/, 8]; + return [4 /*yield*/, _c.call(stream_4)]; + case 7: + _e.sent(); + _e.label = 8; + case 8: return [3 /*break*/, 10]; + case 9: + if (e_4) throw e_4.error; + return [7 /*endfinally*/]; + case 10: return [7 /*endfinally*/]; + case 11: return [2 /*return*/]; + } + }); + }); })()).rejects.toThrow('Ollama error: model not found')]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should yield tool_calls when present in streamed response', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, lastChunk, _a, stream_5, stream_5_1, chunk, e_5_1; + var _b, e_5, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = [ + JSON.stringify({ + message: { + content: '', + tool_calls: [{ function: { name: 'create_file', arguments: '{"path":"a.md"}' } }], + }, + }), + '', + ].join('\n'); + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_5 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_5.next()]; + case 4: + if (!(stream_5_1 = _e.sent(), _b = stream_5_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_5_1.value; + _a = false; + chunk = _d; + lastChunk = chunk; + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_5_1 = _e.sent(); + e_5 = { error: e_5_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_5.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_5)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_5) throw e_5.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(lastChunk.tool_calls).toEqual([ + { function: { name: 'create_file', arguments: '{"path":"a.md"}' } }, + ]); + return [2 /*return*/]; + } + }); + }); }); + it('should default tool_calls to empty array when not present', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, lastChunk, _a, stream_6, stream_6_1, chunk, e_6_1; + var _b, e_6, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = JSON.stringify({ message: { content: 'hello' } }) + '\n'; + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_6 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_6.next()]; + case 4: + if (!(stream_6_1 = _e.sent(), _b = stream_6_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_6_1.value; + _a = false; + chunk = _d; + lastChunk = chunk; + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_6_1 = _e.sent(); + e_6 = { error: e_6_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_6.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_6)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_6) throw e_6.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(lastChunk.tool_calls).toEqual([]); + return [2 /*return*/]; + } + }); + }); }); + it('should send correct request body with stream:true', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, _a, stream_7, stream_7_1, _, e_7_1; + var _b, e_7, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = JSON.stringify({ message: { content: 'ok' } }) + '\n'; + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_7 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_7.next()]; + case 4: + if (!(stream_7_1 = _e.sent(), _b = stream_7_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_7_1.value; + _a = false; + _ = _d; + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_7_1 = _e.sent(); + e_7 = { error: e_7_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_7.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_7)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_7) throw e_7.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(mockFetch).toHaveBeenCalledWith('http://localhost:11434/api/chat', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + model: 'llama3', + messages: mockMessages, + tools: mockTools, + stream: true, + }), + signal: expect.any(AbortSignal), + })); + return [2 /*return*/]; + } + }); + }); }); + }); + describe('streamChat with retry logic', function () { + it('should retry on 5xx errors and eventually succeed', function () { return __awaiter(void 0, void 0, void 0, function () { + var callCount, stream, chunks, _a, stream_8, stream_8_1, _, e_8_1; + var _b, e_8, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + callCount = 0; + mockFetch.mockImplementation(function () { return __awaiter(void 0, void 0, void 0, function () { + return __generator(this, function (_a) { + callCount++; + if (callCount === 1) { + return [2 /*return*/, { ok: false, status: 500 }]; + } + if (callCount === 2) { + return [2 /*return*/, { ok: false, status: 502 }]; + } + return [2 /*return*/, { + ok: true, + body: { + getReader: function () { return ({ + read: function () { return Promise.resolve({ done: true, value: new Uint8Array(0) }); }, + releaseLock: function () { }, + }); }, + }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }]; + }); + }); }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + chunks = []; + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_8 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_8.next()]; + case 4: + if (!(stream_8_1 = _e.sent(), _b = stream_8_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_8_1.value; + _a = false; + _ = _d; + chunks.push('chunk'); + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_8_1 = _e.sent(); + e_8 = { error: e_8_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_8.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_8)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_8) throw e_8.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(callCount).toBe(3); + expect(chunks.length).toBe(0); + return [2 /*return*/]; + } + }); + }); }); + it('should give up after maxRetries attempts', function () { return __awaiter(void 0, void 0, void 0, function () { + var stream; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _a.sent(); + return [4 /*yield*/, expect((function () { return __awaiter(void 0, void 0, void 0, function () { + var _a, stream_9, stream_9_1, _, e_9_1; + var _b, e_9, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + _e.trys.push([0, 5, 6, 11]); + _a = true, stream_9 = __asyncValues(stream); + _e.label = 1; + case 1: return [4 /*yield*/, stream_9.next()]; + case 2: + if (!(stream_9_1 = _e.sent(), _b = stream_9_1.done, !_b)) return [3 /*break*/, 4]; + _d = stream_9_1.value; + _a = false; + _ = _d; + _e.label = 3; + case 3: + _a = true; + return [3 /*break*/, 1]; + case 4: return [3 /*break*/, 11]; + case 5: + e_9_1 = _e.sent(); + e_9 = { error: e_9_1 }; + return [3 /*break*/, 11]; + case 6: + _e.trys.push([6, , 9, 10]); + if (!(!_a && !_b && (_c = stream_9.return))) return [3 /*break*/, 8]; + return [4 /*yield*/, _c.call(stream_9)]; + case 7: + _e.sent(); + _e.label = 8; + case 8: return [3 /*break*/, 10]; + case 9: + if (e_9) throw e_9.error; + return [7 /*endfinally*/]; + case 10: return [7 /*endfinally*/]; + case 11: return [2 /*return*/]; + } + }); + }); })()).rejects.toThrow('Ollama API error: 500')]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should not retry on 4xx errors', function () { return __awaiter(void 0, void 0, void 0, function () { + var stream; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _a.sent(); + return [4 /*yield*/, expect((function () { return __awaiter(void 0, void 0, void 0, function () { + var _a, stream_10, stream_10_1, _, e_10_1; + var _b, e_10, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + _e.trys.push([0, 5, 6, 11]); + _a = true, stream_10 = __asyncValues(stream); + _e.label = 1; + case 1: return [4 /*yield*/, stream_10.next()]; + case 2: + if (!(stream_10_1 = _e.sent(), _b = stream_10_1.done, !_b)) return [3 /*break*/, 4]; + _d = stream_10_1.value; + _a = false; + _ = _d; + _e.label = 3; + case 3: + _a = true; + return [3 /*break*/, 1]; + case 4: return [3 /*break*/, 11]; + case 5: + e_10_1 = _e.sent(); + e_10 = { error: e_10_1 }; + return [3 /*break*/, 11]; + case 6: + _e.trys.push([6, , 9, 10]); + if (!(!_a && !_b && (_c = stream_10.return))) return [3 /*break*/, 8]; + return [4 /*yield*/, _c.call(stream_10)]; + case 7: + _e.sent(); + _e.label = 8; + case 8: return [3 /*break*/, 10]; + case 9: + if (e_10) throw e_10.error; + return [7 /*endfinally*/]; + case 10: return [7 /*endfinally*/]; + case 11: return [2 /*return*/]; + } + }); + }); })()).rejects.toThrow('Ollama API error: 404')]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + }); + describe('cancelStream', function () { + it('should abort an active streaming request when cancelled before fetch resolves', function () { return __awaiter(void 0, void 0, void 0, function () { + var capturedSignal, consumeStream, consumePromise; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockImplementation(function (_url, options) { + capturedSignal = options === null || options === void 0 ? void 0 : options.signal; + return Promise.race([ + // Simulate slow network response + new Promise(function () { + // Never resolves on its own - relies on abort + }), + // Reject when signal is aborted (like real fetch does) + new Promise(function (_, reject) { + if (capturedSignal === null || capturedSignal === void 0 ? void 0 : capturedSignal.aborted) { + reject(new DOMException('The operation was aborted.', 'AbortError')); + return; + } + capturedSignal === null || capturedSignal === void 0 ? void 0 : capturedSignal.addEventListener('abort', function () { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }), + ]); + }); + consumeStream = function () { return __awaiter(void 0, void 0, void 0, function () { + var stream, _a, stream_11, stream_11_1, _, e_11_1; + var _b, e_11, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_11 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_11.next()]; + case 4: + if (!(stream_11_1 = _e.sent(), _b = stream_11_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_11_1.value; + _a = false; + _ = _d; + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_11_1 = _e.sent(); + e_11 = { error: e_11_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_11.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_11)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_11) throw e_11.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: return [2 /*return*/]; + } + }); + }); }; + consumePromise = consumeStream(); + // Wait a tick for fetch to be invoked + return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 10); })]; + case 1: + // Wait a tick for fetch to be invoked + _a.sent(); + // Verify controller is set after fetch starts + expect(client['currentStreamController']).not.toBeNull(); + // Cancel the stream - this aborts the controller and triggers fetch rejection + client.cancelStream(); + // Verify signal was aborted + expect(capturedSignal === null || capturedSignal === void 0 ? void 0 : capturedSignal.aborted).toBe(true); + // Verify controller was cleared by cancelStream + expect(client['currentStreamController']).toBeNull(); + // The stream consumption must reject with an abort error + return [4 /*yield*/, expect(consumePromise).rejects.toThrow('The operation was aborted.')]; + case 2: + // The stream consumption must reject with an abort error + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should handle cancel when no active stream', function () { + // Calling cancelStream with no active stream must not throw + expect(function () { return client.cancelStream(); }).not.toThrow(); + expect(client['currentStreamController']).toBeNull(); + }); + it('should clear the controller after stream completes normally', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, _a, stream_12, stream_12_1, _, e_12_1; + var _b, e_12, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = JSON.stringify({ message: { content: 'done' } }) + '\n'; + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_12 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_12.next()]; + case 4: + if (!(stream_12_1 = _e.sent(), _b = stream_12_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_12_1.value; + _a = false; + _ = _d; + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_12_1 = _e.sent(); + e_12 = { error: e_12_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_12.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_12)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_12) throw e_12.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + // Controller should be cleared after normal completion + expect(client['currentStreamController']).toBeNull(); + return [2 /*return*/]; + } + }); + }); }); + it('should allow a new stream after cancelling a previous one', function () { return __awaiter(void 0, void 0, void 0, function () { + var consumeFirst, firstStreamPromise, stream2, _a, stream2_1, stream2_1_1, _, e_13_1; + var _b, e_13, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + // First fetch: pending and abortable + mockFetch.mockImplementationOnce(function (_url, options) { + var signal = options === null || options === void 0 ? void 0 : options.signal; + return Promise.race([ + new Promise(function () { + // Never resolves on its own + }), + new Promise(function (_, reject) { + if (signal === null || signal === void 0 ? void 0 : signal.aborted) { + reject(new DOMException('The operation was aborted.', 'AbortError')); + return; + } + signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', function () { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }), + ]); + }); + // Second fetch: resolves immediately with valid stream + mockFetch.mockResolvedValueOnce({ + ok: true, + body: { + getReader: function () { return ({ + read: function () { return Promise.resolve({ done: true, value: new Uint8Array(0) }); }, + releaseLock: function () { }, + }); }, + }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + consumeFirst = function () { return __awaiter(void 0, void 0, void 0, function () { + var stream, _a, stream_13, stream_13_1, _, e_14_1; + var _b, e_14, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_13 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_13.next()]; + case 4: + if (!(stream_13_1 = _e.sent(), _b = stream_13_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_13_1.value; + _a = false; + _ = _d; + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_14_1 = _e.sent(); + e_14 = { error: e_14_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_13.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_13)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_14) throw e_14.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: return [2 /*return*/]; + } + }); + }); }; + firstStreamPromise = consumeFirst(); + return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 10); })]; + case 1: + _e.sent(); + // Cancel first stream + client.cancelStream(); + return [4 /*yield*/, expect(firstStreamPromise).rejects.toThrow('The operation was aborted.')]; + case 2: + _e.sent(); + // Controller is cleared, can start a new stream + expect(client['currentStreamController']).toBeNull(); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 3: + stream2 = _e.sent(); + _e.label = 4; + case 4: + _e.trys.push([4, 9, 10, 15]); + _a = true, stream2_1 = __asyncValues(stream2); + _e.label = 5; + case 5: return [4 /*yield*/, stream2_1.next()]; + case 6: + if (!(stream2_1_1 = _e.sent(), _b = stream2_1_1.done, !_b)) return [3 /*break*/, 8]; + _d = stream2_1_1.value; + _a = false; + _ = _d; + _e.label = 7; + case 7: + _a = true; + return [3 /*break*/, 5]; + case 8: return [3 /*break*/, 15]; + case 9: + e_13_1 = _e.sent(); + e_13 = { error: e_13_1 }; + return [3 /*break*/, 15]; + case 10: + _e.trys.push([10, , 13, 14]); + if (!(!_a && !_b && (_c = stream2_1.return))) return [3 /*break*/, 12]; + return [4 /*yield*/, _c.call(stream2_1)]; + case 11: + _e.sent(); + _e.label = 12; + case 12: return [3 /*break*/, 14]; + case 13: + if (e_13) throw e_13.error; + return [7 /*endfinally*/]; + case 14: return [7 /*endfinally*/]; + case 15: + // Second stream completes and clears controller + expect(client['currentStreamController']).toBeNull(); + return [2 /*return*/]; + } + }); + }); }); + }); + describe('cancelStream race condition', function () { + it('should not clear new stream controller when old stream finally block executes', function () { return __awaiter(void 0, void 0, void 0, function () { + var fireFirstAbort, stream1, consumeFirst, firstPromise, firstController, stream2, consumeSecond, secondPromise, secondController; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFetch.mockImplementationOnce(function (_url, options) { + var signal = options === null || options === void 0 ? void 0 : options.signal; + return new Promise(function (_, reject) { + if (signal === null || signal === void 0 ? void 0 : signal.aborted) { + reject(new DOMException('The operation was aborted.', 'AbortError')); + return; + } + signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', function () { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }, { once: true }); + // Capture abort trigger for manual control + fireFirstAbort = function () { + signal === null || signal === void 0 ? void 0 : signal.dispatchEvent(new CustomEvent('abort')); + }; + }); + }); + // Second fetch call: also pending, so controller stays assigned + // We don't need it to complete - just need to verify controller survives abort + mockFetch.mockImplementationOnce(function (_url, options) { + var signal = options === null || options === void 0 ? void 0 : options.signal; + return new Promise(function (_, reject) { + if (signal === null || signal === void 0 ? void 0 : signal.aborted) { + reject(new DOMException('The operation was aborted.', 'AbortError')); + return; + } + signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', function () { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }, { once: true }); + }); + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream1 = _a.sent(); + consumeFirst = function () { return __awaiter(void 0, void 0, void 0, function () { + var _a, stream1_1, stream1_1_1, _, e_15_1; + var _b, e_15, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + _e.trys.push([0, 5, 6, 11]); + _a = true, stream1_1 = __asyncValues(stream1); + _e.label = 1; + case 1: return [4 /*yield*/, stream1_1.next()]; + case 2: + if (!(stream1_1_1 = _e.sent(), _b = stream1_1_1.done, !_b)) return [3 /*break*/, 4]; + _d = stream1_1_1.value; + _a = false; + _ = _d; + _e.label = 3; + case 3: + _a = true; + return [3 /*break*/, 1]; + case 4: return [3 /*break*/, 11]; + case 5: + e_15_1 = _e.sent(); + e_15 = { error: e_15_1 }; + return [3 /*break*/, 11]; + case 6: + _e.trys.push([6, , 9, 10]); + if (!(!_a && !_b && (_c = stream1_1.return))) return [3 /*break*/, 8]; + return [4 /*yield*/, _c.call(stream1_1)]; + case 7: + _e.sent(); + _e.label = 8; + case 8: return [3 /*break*/, 10]; + case 9: + if (e_15) throw e_15.error; + return [7 /*endfinally*/]; + case 10: return [7 /*endfinally*/]; + case 11: return [2 /*return*/]; + } + }); + }); }; + firstPromise = consumeFirst(); + // Wait for fetch to be triggered (controller should be set) + return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 10); })]; + case 2: + // Wait for fetch to be triggered (controller should be set) + _a.sent(); + firstController = client['currentStreamController']; + expect(firstController).not.toBeNull(); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 3: + stream2 = _a.sent(); + consumeSecond = function () { return __awaiter(void 0, void 0, void 0, function () { + var _a, stream2_2, stream2_2_1, _, e_16_1; + var _b, e_16, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + _e.trys.push([0, 5, 6, 11]); + _a = true, stream2_2 = __asyncValues(stream2); + _e.label = 1; + case 1: return [4 /*yield*/, stream2_2.next()]; + case 2: + if (!(stream2_2_1 = _e.sent(), _b = stream2_2_1.done, !_b)) return [3 /*break*/, 4]; + _d = stream2_2_1.value; + _a = false; + _ = _d; + _e.label = 3; + case 3: + _a = true; + return [3 /*break*/, 1]; + case 4: return [3 /*break*/, 11]; + case 5: + e_16_1 = _e.sent(); + e_16 = { error: e_16_1 }; + return [3 /*break*/, 11]; + case 6: + _e.trys.push([6, , 9, 10]); + if (!(!_a && !_b && (_c = stream2_2.return))) return [3 /*break*/, 8]; + return [4 /*yield*/, _c.call(stream2_2)]; + case 7: + _e.sent(); + _e.label = 8; + case 8: return [3 /*break*/, 10]; + case 9: + if (e_16) throw e_16.error; + return [7 /*endfinally*/]; + case 10: return [7 /*endfinally*/]; + case 11: return [2 /*return*/]; + } + }); + }); }; + secondPromise = consumeSecond(); + // Wait for second stream fetch to trigger and assign its controller + return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 10); })]; + case 4: + // Wait for second stream fetch to trigger and assign its controller + _a.sent(); + secondController = client['currentStreamController']; + expect(secondController).not.toBeNull(); + expect(secondController).not.toBe(firstController); + // Defer abort to next microtask so Jest associates rejection with expectation + // Then immediately await the rejection + queueMicrotask(function () { return fireFirstAbort === null || fireFirstAbort === void 0 ? void 0 : fireFirstAbort(); }); + return [4 /*yield*/, expect(firstPromise).rejects.toThrow('The operation was aborted.')]; + case 5: + _a.sent(); + // Second stream's controller should still be intact + // (not cleared by first stream's finally block due to guard) + expect(client['currentStreamController']).toBe(secondController); + // Clean up second stream so it doesn't leak + secondController === null || secondController === void 0 ? void 0 : secondController.abort(); + return [4 /*yield*/, secondPromise.catch(function () { })]; + case 6: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + }); + describe('streamChat final buffer parsing', function () { + it('should parse final buffer content when stream ends with partial line', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, chunks, _a, stream_14, stream_14_1, chunk, e_17_1; + var _b, e_17, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = [ + JSON.stringify({ message: { content: 'First' } }), + JSON.stringify({ message: { content: 'Second' } }), + JSON.stringify({ message: { content: 'Third' } }), + '', // Final line should be empty to signal end + ].join('\n'); + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + chunks = []; + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_14 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_14.next()]; + case 4: + if (!(stream_14_1 = _e.sent(), _b = stream_14_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_14_1.value; + _a = false; + chunk = _d; + chunks.push(chunk.content); + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_17_1 = _e.sent(); + e_17 = { error: e_17_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_14.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_14)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_17) throw e_17.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(chunks).toEqual(['First', 'Second', 'Third']); + expect(mockReader.releaseLock).toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should handle malformed final buffer content gracefully', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, consoleWarnSpy, stream, chunks, _a, stream_15, stream_15_1, chunk, e_18_1; + var _b, e_18, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = [ + JSON.stringify({ message: { content: 'Valid' } }), + 'malformed json', + '', // Final line should be empty to signal end + ].join('\n'); + mockReader = createMockReader(streamData); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + chunks = []; + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_15 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_15.next()]; + case 4: + if (!(stream_15_1 = _e.sent(), _b = stream_15_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_15_1.value; + _a = false; + chunk = _d; + chunks.push(chunk.content); + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_18_1 = _e.sent(); + e_18 = { error: e_18_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_15.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_15)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_18) throw e_18.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(chunks).toEqual(['Valid']); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipped malformed chunk')); + consoleWarnSpy.mockRestore(); + return [2 /*return*/]; + } + }); + }); }); + it('should parse final buffer content even when it contains message data', function () { return __awaiter(void 0, void 0, void 0, function () { + var streamData, mockReader, stream, chunks, _a, stream_16, stream_16_1, chunk, e_19_1; + var _b, e_19, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + streamData = [ + JSON.stringify({ message: { content: 'First' } }), + '', // Final line should be empty to signal end + JSON.stringify({ message: { content: 'Final' } }), + ].join('\n'); + mockReader = createMockReader(streamData); + mockFetch.mockResolvedValue({ + ok: true, + body: { getReader: function () { return mockReader; } }, + headers: { + get: function (name) { return (name === 'content-type' ? 'application/x-ndjson' : null); }, + }, + }); + return [4 /*yield*/, client.streamChat(mockMessages, mockTools)]; + case 1: + stream = _e.sent(); + chunks = []; + _e.label = 2; + case 2: + _e.trys.push([2, 7, 8, 13]); + _a = true, stream_16 = __asyncValues(stream); + _e.label = 3; + case 3: return [4 /*yield*/, stream_16.next()]; + case 4: + if (!(stream_16_1 = _e.sent(), _b = stream_16_1.done, !_b)) return [3 /*break*/, 6]; + _d = stream_16_1.value; + _a = false; + chunk = _d; + chunks.push(chunk.content); + _e.label = 5; + case 5: + _a = true; + return [3 /*break*/, 3]; + case 6: return [3 /*break*/, 13]; + case 7: + e_19_1 = _e.sent(); + e_19 = { error: e_19_1 }; + return [3 /*break*/, 13]; + case 8: + _e.trys.push([8, , 11, 12]); + if (!(!_a && !_b && (_c = stream_16.return))) return [3 /*break*/, 10]; + return [4 /*yield*/, _c.call(stream_16)]; + case 9: + _e.sent(); + _e.label = 10; + case 10: return [3 /*break*/, 12]; + case 11: + if (e_19) throw e_19.error; + return [7 /*endfinally*/]; + case 12: return [7 /*endfinally*/]; + case 13: + expect(chunks).toEqual(['First', 'Final']); + expect(mockReader.releaseLock).toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + }); +}); diff --git a/tests/tool-executor.test.js b/tests/tool-executor.test.js new file mode 100644 index 0000000..f09742a --- /dev/null +++ b/tests/tool-executor.test.js @@ -0,0 +1,1138 @@ +"use strict"; +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + if (typeof b !== "function" && b !== null) + throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var tool_executor_1 = require("../src/tool-executor"); +var obsidian_1 = require("obsidian"); +// Mock Obsidian module +jest.mock('obsidian', function () { + var TFile = /** @class */ (function () { + function TFile() { + } + return TFile; + }()); + return { + Vault: jest.fn(), + App: jest.fn(), + Notice: jest.fn(), + TFile: TFile, + }; +}); +// Mock ErrorHandler +jest.mock('../src/error-handler', function () { return ({ + ErrorHandler: { + handleError: jest.fn(), + }, +}); }); +describe('ToolExecutor', function () { + var executor; + var mockVault; + var mockApp; + beforeEach(function () { + mockVault = { + create: jest.fn().mockResolvedValue(null), + getAbstractFileByPath: jest.fn(), + cachedRead: jest.fn().mockResolvedValue(''), + getMarkdownFiles: jest.fn().mockReturnValue([]), + }; + mockApp = {}; + executor = new tool_executor_1.ToolExecutor(mockVault, mockApp); + jest.clearAllMocks(); + }); + describe('handleToolCall', function () { + describe('create_file tool', function () { + it('should successfully create a file with valid arguments', function () { return __awaiter(void 0, void 0, void 0, function () { + var call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_1', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result).toEqual({ success: true, message: 'File created successfully' }); + expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content'); + return [2 /*return*/]; + } + }); + }); }); + it('should handle object arguments directly', function () { return __awaiter(void 0, void 0, void 0, function () { + var call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_2', + type: 'function', + function: { + name: 'create_file', + arguments: { + path: 'obj-args-file.md', + content: 'Object args content', + }, + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result).toEqual({ success: true, message: 'File created successfully' }); + expect(mockVault.create).toHaveBeenCalledWith('obj-args-file.md', 'Object args content'); + return [2 /*return*/]; + } + }); + }); }); + it('should successfully create a file in a subdirectory', function () { return __awaiter(void 0, void 0, void 0, function () { + var call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_3', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'subdirectory/test-file.md', + content: 'Subdir content', + }), + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result).toEqual({ success: true, message: 'File created successfully' }); + expect(mockVault.create).toHaveBeenCalledWith('subdirectory/test-file.md', 'Subdir content'); + return [2 /*return*/]; + } + }); + }); }); + it('should handle multiple slashes gracefully by normalizing path', function () { return __awaiter(void 0, void 0, void 0, function () { + var call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_4', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test//file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result).toEqual({ success: true, message: 'File created successfully' }); + expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content'); + return [2 /*return*/]; + } + }); + }); }); + it('should handle empty content gracefully', function () { return __awaiter(void 0, void 0, void 0, function () { + var call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_5', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'empty-file.md', + content: '', + }), + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result).toEqual({ success: true, message: 'File created successfully' }); + expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', ''); + return [2 /*return*/]; + } + }); + }); }); + it('should allow filenames with consecutive dots', function () { return __awaiter(void 0, void 0, void 0, function () { + var call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_6', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'project..notes.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result).toEqual({ success: true, message: 'File created successfully' }); + expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content'); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path traversal attempts with ..', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_7', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: '../test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path traversal attempts with .\\', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_8', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: '.\\test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path traversal attempts with /..', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_9', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: '/../test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject absolute paths starting with /', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_10', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: '/var/test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject absolute paths starting with \\', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_11', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: '\\var\\test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject Windows drive letters', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_12', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'C:\\test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject empty path', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_13', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: '', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject undefined path', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_14', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path with invalid characters <', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_15', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_16', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test>file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path with invalid characters :', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_17', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test:file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path with invalid characters |', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_18', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test|file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path with invalid characters ?', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_19', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test?file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path with invalid characters *', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_20', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test*file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path longer than 200 characters', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_21', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'a'.repeat(201) + '.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path with ~ character', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_22', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test~file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject non-string content', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_23', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test-file.md', + content: 123, + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject non-string path', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_24', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 123, + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + it('should handle vault.create rejection gracefully', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockVault.create = jest.fn().mockRejectedValue(new Error('Permission denied')); + call = { + id: 'call_25', + type: 'function', + function: { + name: 'create_file', + arguments: JSON.stringify({ + path: 'test-file.md', + content: 'Test content', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should handle invalid JSON in arguments', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_26', + type: 'function', + function: { + name: 'create_file', + arguments: 'invalid json', + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + expect(mockVault.create).not.toHaveBeenCalled(); + return [2 /*return*/]; + } + }); + }); }); + }); + describe('read_vault_file tool', function () { + it('should successfully read an existing file', function () { return __awaiter(void 0, void 0, void 0, function () { + var mockFile, MockTFile, call, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + mockFile = { + path: 'test-file.md', + basename: 'test-file.md', + }; + MockTFile = /** @class */ (function (_super) { + __extends(MockTFile, _super); + function MockTFile(path) { + var _this = _super.call(this) || this; + _this.path = path; + _this.basename = path.split('/').pop() || path; + _this.extension = _this.basename.split('.').pop() || ''; + return _this; + } + return MockTFile; + }(obsidian_1.TFile)); + mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('test-file.md')); + mockVault.cachedRead = jest.fn().mockResolvedValue('File content'); + mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]); + call = { + id: 'call_28', + type: 'function', + function: { + name: 'read_vault_file', + arguments: JSON.stringify({ + path: 'test-file.md', + }), + }, + }; + return [4 /*yield*/, executor.handleToolCall(call)]; + case 1: + result = _a.sent(); + expect(result.success).toBe(true); + expect(result.message).toBe('File read successfully'); + expect(result.data).toEqual({ path: 'test-file.md', content: 'File content' }); + return [2 /*return*/]; + } + }); + }); }); + it('should reject path traversal attempts', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_29', + type: 'function', + function: { + name: 'read_vault_file', + arguments: JSON.stringify({ + path: '../test-file.md', + }), + }, + }; + return [4 /*yield*/, expect(executor.handleToolCall(call)).rejects.toThrow()]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); }); + it('should reject invalid characters in path', function () { return __awaiter(void 0, void 0, void 0, function () { + var call; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + call = { + id: 'call_30', + type: 'function', + function: { + name: 'read_vault_file', + arguments: JSON.stringify({ + path: 'test