From 3ab326ffc2543a86a7e7a3c64a1eabdb31e27560 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 7 May 2026 19:02:36 +0200 Subject: [PATCH 1/2] Refactor retry logic and extract scoring weights --- src/main.js | 2 +- src/main.ts | 2 +- src/ollama-client.js | 107 ++++++++++++++++-------------------- src/ollama-client.ts | 125 +++++++++++++++++++++---------------------- src/vault-indexer.js | 26 ++++++--- src/vault-indexer.ts | 27 +++++++--- 6 files changed, 151 insertions(+), 138 deletions(-) diff --git a/src/main.js b/src/main.js index d2937a3..98701cd 100644 --- a/src/main.js +++ b/src/main.js @@ -45,7 +45,7 @@ class OllamaPlugin extends obsidian_1.Plugin { const data = (await this.loadData()); if (data) { utils_1.Logger.debug('Loading saved settings', 'settings'); - this.settings = Object.assign({}, this.settings, data); + Object.assign(this.settings, data); } } catch (error) { diff --git a/src/main.ts b/src/main.ts index f46518c..9c735ea 100755 --- a/src/main.ts +++ b/src/main.ts @@ -49,7 +49,7 @@ export default class OllamaPlugin extends Plugin { const data = (await this.loadData()) as Partial | null; if (data) { Logger.debug('Loading saved settings', 'settings'); - this.settings = Object.assign({}, this.settings, data); + Object.assign(this.settings, data); } } catch (error) { ErrorHandler.handleError(error, 'settings load'); diff --git a/src/ollama-client.js b/src/ollama-client.js index 55723d5..92ea301 100644 --- a/src/ollama-client.js +++ b/src/ollama-client.js @@ -19,7 +19,9 @@ class OllamaClient { } } async *streamChat(messages, tools = []) { - yield* this.streamChatWithRetry(messages, tools, 0); + for await (const message of this.streamChatWithRetry(messages, tools)) { + yield message; + } } async streamChatAsPromise(messages, tools = []) { const chunks = []; @@ -29,7 +31,6 @@ class OllamaClient { return chunks; } async *streamChatWithRetry(messages, tools = [], attempt = 0) { - // Create a local controller for this request instead of using the instance variable const controller = new AbortController(); this.currentStreamController = controller; try { @@ -46,38 +47,42 @@ class OllamaClient { }), signal: controller.signal, }); + if (!response) { + throw new Error('No response received from Ollama API'); + } if (!response.ok) { if (response.status >= 500 && attempt < this.maxRetries) { const retryDelay = Math.pow(2, attempt) * 100; utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client'); - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - utils_1.Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); + // Wait for the delay, but also check if the stream was cancelled during backoff + await new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(), retryDelay); + // If the controller was aborted during the delay, cancel the retry + if (controller.signal.aborted) { + clearTimeout(timer); + reject(controller.signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError')); + return; } - finally { - signal.removeEventListener('abort', abortListener); - } - } - else { - await retryTimeout; + controller.signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(controller.signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError')); + }, { once: true }); + }); + // Only proceed with retry if this controller is still the active one. + // If a newer stream replaced currentStreamController during backoff, + // abandon the retry to avoid overwriting the newer stream's controller. + if (this.currentStreamController !== controller) { + return; } yield* this.streamChatWithRetry(messages, tools, attempt + 1); + // After successful retry, we need to return (not continue processing this response) return; } - throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); + else { + throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); + } } if (!response.body) { throw new Error('No response body'); @@ -145,13 +150,18 @@ class OllamaClient { reader.releaseLock(); } } - finally { - // Abort the local controller to release underlying fetch resources if not already aborted - if (!controller.signal.aborted) { - controller.abort(); + catch (error) { + if (!(error instanceof Error) || error.name !== 'AbortError') { + utils_1.Logger.error(`Stream encountered an error: ${String(error)}`, 'ollama-client'); + throw error; } - // Clean up the reference only if this is still the current stream + // Re-throw the abort error to allow stream consumers to handle it + throw error; + } + finally { + // Only clear controller if it's still the current one (not replaced by a new stream) if (this.currentStreamController === controller) { + controller.abort(); this.currentStreamController = null; } } @@ -160,7 +170,6 @@ class OllamaClient { return this.chatWithRetry(messages, tools, 0); } async chatWithRetry(messages, tools = [], attempt = 0) { - // Create a local controller for this request instead of using the instance variable const controller = new AbortController(); try { const response = await this.fetchFn(`${this.baseURL}/api/chat`, { @@ -176,34 +185,14 @@ class OllamaClient { }), signal: controller.signal, }); + if (!response) { + throw new Error('No response received from Ollama API'); + } if (!response.ok) { if (response.status >= 500 && attempt < this.maxRetries) { const retryDelay = Math.pow(2, attempt) * 100; utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client'); - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - utils_1.Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } - finally { - signal.removeEventListener('abort', abortListener); - } - } - else { - await retryTimeout; - } + await new Promise((resolve) => setTimeout(resolve, retryDelay)); return this.chatWithRetry(messages, tools, attempt + 1); } throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); @@ -212,10 +201,7 @@ class OllamaClient { return (this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }); } finally { - // Abort the local controller to release underlying fetch resources if not already aborted - if (!controller.signal.aborted) { - controller.abort(); - } + controller.abort(); } } throwIfOllamaError(parsed) { @@ -228,10 +214,11 @@ class OllamaClient { return null; } const record = value; + const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : []; return { role: record.role ?? 'assistant', content: typeof record.content === 'string' ? record.content : '', - tool_calls: record.tool_calls ?? [], + tool_calls: toolCalls, }; } } diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 3c4dae3..000f389 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -32,7 +32,9 @@ export class OllamaClient { messages: OllamaMessage[], tools: OllamaTool[] = [] ): AsyncGenerator { - yield* this.streamChatWithRetry(messages, tools, 0); + for await (const message of this.streamChatWithRetry(messages, tools)) { + yield message; + } } async streamChatAsPromise( @@ -51,7 +53,6 @@ export class OllamaClient { tools: OllamaTool[] = [], attempt: number = 0 ): AsyncGenerator { - // Create a local controller for this request instead of using the instance variable const controller = new AbortController(); this.currentStreamController = controller; @@ -70,6 +71,10 @@ export class OllamaClient { signal: controller.signal, }); + if (!response) { + throw new Error('No response received from Ollama API'); + } + if (!response.ok) { if (response.status >= 500 && attempt < this.maxRetries) { const retryDelay = Math.pow(2, attempt) * 100; @@ -77,36 +82,47 @@ export class OllamaClient { `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client' ); - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } finally { - signal.removeEventListener('abort', abortListener); + + // Wait for the delay, but also check if the stream was cancelled during backoff + await new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(), retryDelay); + + // If the controller was aborted during the delay, cancel the retry + if (controller.signal.aborted) { + clearTimeout(timer); + reject( + controller.signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError') + ); + return; } - // Check if signal was aborted before retrying - if (signal.aborted) { - throw new Error('Stream cancelled by user'); - } - } else { - await retryTimeout; + + controller.signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject( + controller.signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError') + ); + }, + { once: true } + ); + }); + + // Only proceed with retry if this controller is still the active one. + // If a newer stream replaced currentStreamController during backoff, + // abandon the retry to avoid overwriting the newer stream's controller. + if (this.currentStreamController !== controller) { + return; } + yield* this.streamChatWithRetry(messages, tools, attempt + 1); + // After successful retry, we need to return (not continue processing this response) return; + } else { + throw new ApiError(`Ollama API error: ${response.status}`, response.status); } - throw new ApiError(`Ollama API error: ${response.status}`, response.status); } if (!response.body) { @@ -188,13 +204,17 @@ export class OllamaClient { } finally { reader.releaseLock(); } - } finally { - // Abort the local controller to release underlying fetch resources if not already aborted - if (!controller.signal.aborted) { - controller.abort(); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'AbortError') { + Logger.error(`Stream encountered an error: ${String(error)}`, 'ollama-client'); + throw error; } - // Clean up the reference only if this is still the current stream + // Re-throw the abort error to allow stream consumers to handle it + throw error; + } finally { + // Only clear controller if it's still the current one (not replaced by a new stream) if (this.currentStreamController === controller) { + controller.abort(); this.currentStreamController = null; } } @@ -209,7 +229,6 @@ export class OllamaClient { tools: OllamaTool[] = [], attempt: number = 0 ): Promise { - // Create a local controller for this request instead of using the instance variable const controller = new AbortController(); try { const response = await this.fetchFn(`${this.baseURL}/api/chat`, { @@ -226,6 +245,10 @@ export class OllamaClient { signal: controller.signal, }); + if (!response) { + throw new Error('No response received from Ollama API'); + } + if (!response.ok) { if (response.status >= 500 && attempt < this.maxRetries) { const retryDelay = Math.pow(2, attempt) * 100; @@ -233,32 +256,7 @@ export class OllamaClient { `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client' ); - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } finally { - signal.removeEventListener('abort', abortListener); - } - // Check if signal was aborted before retrying - if (signal.aborted) { - throw new Error('Stream cancelled by user'); - } - } else { - await retryTimeout; - } + await new Promise((resolve) => setTimeout(resolve, retryDelay)); return this.chatWithRetry(messages, tools, attempt + 1); } throw new ApiError(`Ollama API error: ${response.status}`, response.status); @@ -269,10 +267,7 @@ export class OllamaClient { this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] } ); } finally { - // Abort the local controller to release underlying fetch resources if not already aborted - if (!controller.signal.aborted) { - controller.abort(); - } + controller.abort(); } } @@ -288,10 +283,12 @@ export class OllamaClient { } const record = value as Partial; + const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : []; + return { role: record.role ?? 'assistant', content: typeof record.content === 'string' ? record.content : '', - tool_calls: record.tool_calls ?? [], + tool_calls: toolCalls, }; } } diff --git a/src/vault-indexer.js b/src/vault-indexer.js index 2422384..d840c1e 100644 --- a/src/vault-indexer.js +++ b/src/vault-indexer.js @@ -24,6 +24,14 @@ exports.InMemoryCache = InMemoryCache; class VaultIndexer { constructor(vault, cache) { this.vault = null; + // Define weights for scoring + this.SCORING_WEIGHTS = { + HEADING: 5, + FRONTMATTER_TITLE: 3, + FRONTMATTER_TAGS: 2.5, + FIRST_PARAGRAPH: 1.5, + TOKEN: 1, + }; this.vault = vault; this.cache = cache; } @@ -195,29 +203,29 @@ class VaultIndexer { let matched = false; if (tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, queryToken)) { - tokenScore += 3; + tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; matched = true; } else if (file && file.basename && this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken)) { - tokenScore += 3; + tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; matched = true; } if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) { - tokenScore += 2.5; + tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TAGS; matched = true; } if (tokenized.headings.some((heading) => heading.toLowerCase().includes(stemmed))) { - tokenScore += 5; + tokenScore += this.SCORING_WEIGHTS.HEADING; matched = true; } if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) { - tokenScore += 1.5; + tokenScore += this.SCORING_WEIGHTS.FIRST_PARAGRAPH; matched = true; } if (tokenized.tokens.includes(stemmed)) { - tokenScore += 1; + tokenScore += this.SCORING_WEIGHTS.TOKEN; matched = true; } if (matched) { @@ -232,6 +240,12 @@ class VaultIndexer { } stemToken(token) { // Improved stemmer that handles edge cases + // + // Limitations: + // - Simple suffix removal (e.g., 's', 'ed', 'ing') may lead to over-stemming or incorrect roots. + // - For example, stemming "mice" results in "mic", which is incorrect. + // - Consider using a more robust NLP library if the plugin environment permits. + // if (token.length <= 3) return token; // Don't stem very short tokens if (token.endsWith('s')) diff --git a/src/vault-indexer.ts b/src/vault-indexer.ts index 341f2ac..bedf4a2 100644 --- a/src/vault-indexer.ts +++ b/src/vault-indexer.ts @@ -62,6 +62,15 @@ class VaultIndexer { private vault: VaultLike | null = null; private cache?: Cache; + // Define weights for scoring + private readonly SCORING_WEIGHTS = { + HEADING: 5, + FRONTMATTER_TITLE: 3, + FRONTMATTER_TAGS: 2.5, + FIRST_PARAGRAPH: 1.5, + TOKEN: 1, + }; + constructor(vault: VaultLike, cache?: Cache) { this.vault = vault; this.cache = cache; @@ -268,34 +277,34 @@ class VaultIndexer { tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, queryToken) ) { - tokenScore += 3; + tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; matched = true; } else if ( file && file.basename && this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken) ) { - tokenScore += 3; + tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TITLE; matched = true; } if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) { - tokenScore += 2.5; + tokenScore += this.SCORING_WEIGHTS.FRONTMATTER_TAGS; matched = true; } if (tokenized.headings.some((heading: string) => heading.toLowerCase().includes(stemmed))) { - tokenScore += 5; + tokenScore += this.SCORING_WEIGHTS.HEADING; matched = true; } if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) { - tokenScore += 1.5; + tokenScore += this.SCORING_WEIGHTS.FIRST_PARAGRAPH; matched = true; } if (tokenized.tokens.includes(stemmed)) { - tokenScore += 1; + tokenScore += this.SCORING_WEIGHTS.TOKEN; matched = true; } @@ -313,6 +322,12 @@ class VaultIndexer { private stemToken(token: string): string { // Improved stemmer that handles edge cases + // + // Limitations: + // - Simple suffix removal (e.g., 's', 'ed', 'ing') may lead to over-stemming or incorrect roots. + // - For example, stemming "mice" results in "mic", which is incorrect. + // - Consider using a more robust NLP library if the plugin environment permits. + // if (token.length <= 3) return token; // Don't stem very short tokens if (token.endsWith('s')) return token.slice(0, -1); if (token.endsWith('ed') && token.length > 4) return token.slice(0, -2); // Don't stem 3-letter words ending in ed -- 2.52.0 From 79db888f9e1ed88374ebb02758bcf3fcd492da21 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 7 May 2026 19:10:15 +0200 Subject: [PATCH 2/2] Refactor README with comprehensive documentation --- README.md | 131 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 99 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e983eed..7d6eba9 100755 --- a/README.md +++ b/README.md @@ -1,64 +1,131 @@ # Ollama Chat Plugin for Obsidian -A plugin that integrates Ollama with Obsidian to create a chat interface that can access your vault content. +A plugin that integrates [Ollama](https://ollama.ai) with [Obsidian](https://obsidian.md) to create a chat interface that can access your vault content contextually. ## Features -- Chat with Ollama models directly in Obsidian -- Vault context search - the assistant can reference your notes -- Tool integration - create files based on chat responses -- Streaming responses -- Customizable model and URL settings +- **Chat with local AI models** — Interact with Ollama models directly in Obsidian's sidebar +- **Vault context search** — The assistant automatically searches your notes using weighted scoring (headings, frontmatter titles/tags, content) +- **Tool integration** — AI can create files in your vault via the `create_file` tool +- **Streaming responses** — Real-time token streaming for a responsive chat experience +- **Configurable settings** — Customize model, URL, search limits, and message history ## Installation -1. Install the plugin via Obsidian's community plugins -2. Make sure you have Ollama installed and running +### From Source -## Setup +1. Clone this repository into your Obsidian vault's `.obsidian/plugins/` folder: + ```bash + cd .obsidian/plugins + git clone obsidian-ollama + cd obsidian-ollama + npm install + npm run build + ``` +2. Enable the plugin in Obsidian: Settings → Community plugins → Ollama Plugin -1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai) to install Ollama -2. **Start Ollama service**: `ollama serve` -3. **Pull a model**: `ollama pull llama3` (or any other model you prefer) +## Prerequisites + +1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai) +2. **Start Ollama**: `ollama serve` +3. **Pull a model**: `ollama pull llama3` (or any model you prefer) ## Configuration -1. Open the plugin settings via Obsidian's settings panel -2. Configure the Ollama URL (default: `http://localhost:11434`) -3. Configure the model name (default: `llama3`) -4. Restart the plugin if needed +Open Settings → Ollama Plugin to configure: + +| Setting | Default | Description | +|---------|---------|-------------| +| Ollama URL | `http://localhost:11434` | Your Ollama instance URL | +| Model | `llama3` | Model to use for chat | + +### Internal Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Vault Search Limit | `3` | Max notes returned for context | +| Max Message History | `50` | Messages kept in conversation context | ## Usage -1. Click the ribbon icon to open the chat view -2. Type your message in the input box -3. Press Enter or click Send to send your message -4. Click the "New Chat" button to start a fresh conversation +1. Click the chat icon in the left ribbon to open the chat view +2. Type your message and press **Enter** or click **Send** +3. Click **New Chat** to start a fresh conversation + +### Vault Context + +When you send a message, the plugin automatically searches your vault for relevant notes and includes them as context. The search uses weighted scoring: + +- **Headings** — 5x weight +- **Frontmatter title** — 3x weight +- **Frontmatter tags** — 2.5x weight +- **First paragraph** — 1.5x weight +- **General content** — 1x weight + +### Tools + +The plugin exposes a `create_file` tool that allows the AI to create new markdown files in your vault. Paths are validated for safety (no `.obsidian`/`.git` access, no path traversal). ## Supported Models -Any model supported by Ollama should work, including: +Any Ollama-supported model works. Popular choices: -- llama3 -- llama2 -- mistral -- codellama -- etc. +| Model | Best For | +|-------|----------| +| `llama3` | General chat | +| `mistral` | Fast responses | +| `codellama` | Code assistance | +| `gemma` | Lightweight local use | ## Development -To build from source: +```bash +npm install # Install dependencies +npm run build # Compile TypeScript +npm run watch # Watch mode for development +npm test # Run tests (Jest) +npm run lint # Lint with ESLint +npm run format # Format with Prettier +``` + +### Project Structure + +``` +src/ +├── main.ts # Plugin entry point and settings +├── chat-view.ts # Chat UI and message handling +├── ollama-client.ts # Ollama API communication +├── vault-indexer.ts # Vault search and scoring +├── tool-executor.ts # Tool execution (create_file) +├── error-handler.ts # Centralized error handling +├── utils.ts # Validation and logging utilities +├── types.ts # TypeScript type definitions +└── constants.ts # Default configuration +``` + +### Testing + +The project includes tests for all major components. Run with: ```bash -npm install -npm run build +npm test ``` ## Troubleshooting -- **Connection issues**: Ensure Ollama is running and accessible at the configured URL -- **Model not found**: Make sure you've pulled the model (`ollama pull `) -- **Permission issues**: Check that your Obsidian vault has proper write permissions +| Issue | Solution | +|-------|----------| +| Connection refused | Ensure Ollama is running (`ollama serve`) | +| Model not found | Pull the model first (`ollama pull `) | +| Permission errors | Check vault write permissions | +| Empty responses | Try a different model or check Ollama logs | + +## Security + +- File paths are validated to prevent access to `.obsidian/` and `.git/` directories +- Path traversal attempts (`..`) are blocked +- Absolute paths and Windows drive letters are rejected +- Maximum path length is enforced (200 characters) ## License -- 2.52.0