Refactor retry logic and extract scoring weights #1

Merged
fegger merged 2 commits from agent/review into main 2026-05-07 20:56:27 +02:00
7 changed files with 250 additions and 170 deletions
+99 -32
View File
@@ -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 <repo-url> 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 <modelname>`)
- **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 <model>`) |
| 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
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -49,7 +49,7 @@ export default class OllamaPlugin extends Plugin {
const data = (await this.loadData()) as Partial<PluginSettings> | 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');
+47 -60
View File
@@ -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,
};
}
}
+61 -64
View File
@@ -32,7 +32,9 @@ export class OllamaClient {
messages: OllamaMessage[],
tools: OllamaTool[] = []
): AsyncGenerator<OllamaMessage, void, unknown> {
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<OllamaMessage, void, unknown> {
// 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<void>((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<void>((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<OllamaMessage> {
// 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<void>((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<OllamaMessage>;
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,
};
}
}
+20 -6
View File
@@ -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'))
+21 -6
View File
@@ -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