Update coverage reports and improve error handling

This commit is contained in:
2026-05-06 19:28:40 +02:00
parent 2968932d69
commit d57ade0ba8
22 changed files with 1851 additions and 1707 deletions
+161 -122
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">91.54% </span>
<span class="strong">91.03% </span>
<span class="quiet">Statements</span>
<span class='fraction'>184/201</span>
<span class='fraction'>193/212</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">83.07% </span>
<span class="strong">84.37% </span>
<span class="quiet">Branches</span>
<span class='fraction'>54/65</span>
<span class='fraction'>54/64</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">87.09% </span>
<span class="strong">78.78% </span>
<span class="quiet">Functions</span>
<span class='fraction'>27/31</span>
<span class='fraction'>26/33</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">93.58% </span>
<span class="strong">92.5% </span>
<span class="quiet">Lines</span>
<span class='fraction'>175/187</span>
<span class='fraction'>185/200</span>
</div>
@@ -517,7 +517,21 @@
<a name='L452'></a><a href='#L452'>452</a>
<a name='L453'></a><a href='#L453'>453</a>
<a name='L454'></a><a href='#L454'>454</a>
<a name='L455'></a><a href='#L455'>455</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
<a name='L455'></a><a href='#L455'>455</a>
<a name='L456'></a><a href='#L456'>456</a>
<a name='L457'></a><a href='#L457'>457</a>
<a name='L458'></a><a href='#L458'>458</a>
<a name='L459'></a><a href='#L459'>459</a>
<a name='L460'></a><a href='#L460'>460</a>
<a name='L461'></a><a href='#L461'>461</a>
<a name='L462'></a><a href='#L462'>462</a>
<a name='L463'></a><a href='#L463'>463</a>
<a name='L464'></a><a href='#L464'>464</a>
<a name='L465'></a><a href='#L465'>465</a>
<a name='L466'></a><a href='#L466'>466</a>
<a name='L467'></a><a href='#L467'>467</a>
<a name='L468'></a><a href='#L468'>468</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -554,6 +568,9 @@
<span class="cline-any cline-yes">31x</span>
<span class="cline-any cline-yes">31x</span>
<span class="cline-any cline-yes">31x</span>
<span class="cline-any cline-yes">31x</span>
<span class="cline-any cline-yes">31x</span>
<span class="cline-any cline-yes">31x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -593,6 +610,7 @@
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -607,6 +625,7 @@
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -654,12 +673,9 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">33x</span>
<span class="cline-any cline-yes">128x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">33x</span>
<span class="cline-any cline-yes">247x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">247x</span>
<span class="cline-any cline-yes">104x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -714,12 +730,21 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -747,6 +772,9 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -771,10 +799,10 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">146x</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">13x</span>
@@ -793,6 +821,8 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
@@ -865,6 +895,7 @@
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-yes">14x</span>
@@ -874,7 +905,6 @@
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-no">&nbsp;</span>
@@ -892,20 +922,12 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
@@ -962,6 +984,10 @@
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -971,12 +997,13 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian';
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
/// &lt;reference lib="dom" /&gt;
// Use global types from JSDOM setup
type KeyboardEvent = globalThis.KeyboardEvent;
type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
type HTMLButtonElement = globalThis.HTMLButtonElement;
type MouseEvent = globalThis.MouseEvent;
&nbsp;
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
const MAX_MESSAGE_HISTORY = 50;
@@ -1007,6 +1034,9 @@ export class ChatView extends ItemView {
private sendButtonClickHandler: (() =&gt; Promise&lt;void&gt;) | null = null;
private inputKeyDownHandler: ((e: KeyboardEvent) =&gt; Promise&lt;void&gt;) | null = null;
private newChatButtonClickHandler: (() =&gt; void) | null = null;
private sendButtonEventHandler: ((e: MouseEvent) =&gt; void) | null = null;
private inputKeyDownEventHandler: ((e: KeyboardEvent) =&gt; void) | null = null;
private newChatButtonEventHandler: ((e: MouseEvent) =&gt; void) | null = null;
private listenersAttached = false;
&nbsp;
// Getters for testing
@@ -1043,17 +1073,18 @@ export class ChatView extends ItemView {
return 'Ollama Chat';
}
&nbsp;
async onOpen() {
await this.render();
onOpen(): Promise&lt;void&gt; {
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
return Promise.resolve();
}
&nbsp;
public <span class="fstat-no" title="function not covered" >onSettingsChange(</span>newSettings: PluginSettings): void {
<span class="cstat-no" title="statement not covered" > this.updateSettings(newSettings);</span>
}
&nbsp;
async onClose() {
onClose(): Promise&lt;void&gt; {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
@@ -1061,6 +1092,7 @@ export class ChatView extends ItemView {
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
return Promise.resolve();
}
&nbsp;
private cleanupStreamingResources(): void {
@@ -1071,7 +1103,7 @@ export class ChatView extends ItemView {
}
}
&nbsp;
async render() {
render() {
const container =
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
this.chatContainer = container;
@@ -1085,7 +1117,7 @@ export class ChatView extends ItemView {
if (!this.sendButton) {
this.sendButton = inputContainer.createEl('button', {
cls: 'ollama-send-button',
}) as HTMLButtonElement;
});
(this.sendButton as HTMLButtonElement).textContent = 'Send';
}
&nbsp;
@@ -1095,7 +1127,7 @@ export class ChatView extends ItemView {
this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
this.newChatButton = newChatContainer.createEl('button', {
cls: 'ollama-new-chat-button',
}) as HTMLButtonElement;
});
(this.newChatButton as HTMLButtonElement).textContent = '🔄 New Chat';
(this.newChatButton as HTMLButtonElement).title = 'Start a new conversation';
}
@@ -1108,12 +1140,9 @@ export class ChatView extends ItemView {
&nbsp;
// Differential update: only update messages that have changed
const existingMessages = container.querySelectorAll('.ollama-message');
const existingIds = Array.from(existingMessages).map((el) =&gt; el.getAttribute('data-msg-id'));
&nbsp;
for (const msg of nonStreamingMessages) {
const existingEl = container.querySelector(
`.ollama-message[data-msg-id="${msg.id}"]`
) as HTMLElement | null;
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
if (existingEl) {
existingEl.textContent = msg.content;
} else {
@@ -1167,39 +1196,51 @@ export class ChatView extends ItemView {
}
&nbsp;
// Add event listeners
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonClickHandler!);
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownHandler!);
this.sendButtonEventHandler = <span class="fstat-no" title="function not covered" >() =</span>&gt; {
<span class="cstat-no" title="statement not covered" > void this.sendButtonClickHandler?.();</span>
};
this.inputKeyDownEventHandler = <span class="fstat-no" title="function not covered" >(e</span>: KeyboardEvent) =&gt; {
<span class="cstat-no" title="statement not covered" > void this.inputKeyDownHandler?.(e);</span>
};
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonEventHandler);
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownEventHandler);
if (this.newChatButton) {
if (!this.newChatButtonClickHandler) {
this.newChatButtonClickHandler = () =&gt; this.clearConversation();
}
this.newChatButtonEventHandler = <span class="fstat-no" title="function not covered" >() =</span>&gt; {
<span class="cstat-no" title="statement not covered" > this.newChatButtonClickHandler?.();</span>
};
(this.newChatButton as HTMLButtonElement).addEventListener(
'click',
this.newChatButtonClickHandler!
this.newChatButtonEventHandler
);
}
this.listenersAttached = true;
}
&nbsp;
private removeEventListeners(): void {
if (this.sendButton &amp;&amp; this.sendButtonClickHandler) {
if (this.sendButton &amp;&amp; this.sendButtonEventHandler) {
(this.sendButton as HTMLButtonElement).removeEventListener(
'click',
this.sendButtonClickHandler!
this.sendButtonEventHandler
);
}
if (this.inputEl &amp;&amp; this.inputKeyDownHandler) {
if (this.inputEl &amp;&amp; this.inputKeyDownEventHandler) {
(this.inputEl as HTMLTextAreaElement).removeEventListener(
'keydown',
this.inputKeyDownHandler!
this.inputKeyDownEventHandler
);
}
if (this.newChatButton &amp;&amp; this.newChatButtonClickHandler) {
if (this.newChatButton &amp;&amp; this.newChatButtonEventHandler) {
(this.newChatButton as HTMLButtonElement).removeEventListener(
'click',
this.newChatButtonClickHandler!
this.newChatButtonEventHandler
);
}
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.listenersAttached = false;
}
&nbsp;
@@ -1222,7 +1263,7 @@ export class ChatView extends ItemView {
return true;
}
&nbsp;
private async updateLastMessage(content: string) {
private updateLastMessage(content: string) {
const streamingMessage = this.messages.find((msg) =&gt; msg.isStreaming);
if (streamingMessage &amp;&amp; !this.lastMessageEl) {
this.lastMessageEl = this.contentEl.createEl('div', {
@@ -1246,7 +1287,9 @@ export class ChatView extends ItemView {
&nbsp;
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
let context = entries.map(<span class="fstat-no" title="function not covered" >(e</span>) =&gt; <span class="cstat-no" title="statement not covered" >`### ${e.title}\n${e.content}`)</span>.join('\n\n');
let context = entries
.map(<span class="fstat-no" title="function not covered" >(e</span>ntry) =&gt; <span class="cstat-no" title="statement not covered" >`### ${entry.title}\n${entry.content}`)</span>
.join('\n\n');
&nbsp;
// Cap context size to prevent prompt bloat with large vaults
const MAX_CONTEXT_LENGTH = 4000;
@@ -1317,17 +1360,17 @@ export class ChatView extends ItemView {
&nbsp;
// Update messages immutably
this.messages = [...this.messages, userChatMessage, assistantMessage];
&nbsp;
await this.render();
&nbsp;
const stream = await this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls: ToolCall[] = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
&nbsp;
try {
this.render();
&nbsp;
const stream = this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls: ToolCall[] = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
&nbsp;
for await (const chunk of stream) {
chunkCount++;
<span class="missing-if-branch" title="if path not taken" >I</span>if (chunkCount &gt; maxChunks) {
@@ -1342,78 +1385,74 @@ export class ChatView extends ItemView {
toolCalls = toolCalls.concat(chunk.tool_calls);
}
&nbsp;
await this.updateLastMessage(fullResponse);
this.updateLastMessage(fullResponse);
}
&nbsp;
// Update the assistant message with the full response immutably
this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
});
&nbsp;
// Process tool calls with proper follow-up context
if (toolCalls.length &gt; 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
<span class="missing-if-branch" title="if path not taken" >I</span>if (toolCalls.length &gt; MAX_TOOL_CALLS) {
<span class="cstat-no" title="statement not covered" > throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);</span>
}
&nbsp;
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(
toolCalls.map((call) =&gt; this.toolExecutor.handleToolCall(call))
);
&nbsp;
const toolResults: ToolResult[] = [];
for (const result of settledResults) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (result.status === 'fulfilled') {
<span class="cstat-no" title="statement not covered" > toolResults.push(result.value);</span>
} else {
// Use centralized error handler for tool errors
ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
&nbsp;
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages: OllamaMessage[] = [
...messages,
{ role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls },
...toolResults.map(<span class="fstat-no" title="function not covered" >(r</span>esult) =&gt; (<span class="cstat-no" title="statement not covered" >{</span>
role: 'tool' as const,
content: JSON.stringify(result),
})),
];
&nbsp;
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
this.updateLastMessage(fullResponse);
&nbsp;
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
&nbsp;
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex &gt;= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
&nbsp;
// Limit conversation history to prevent memory issues
if (this.messages.length &gt; MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
this.render();
} finally {
// Clean up streaming resources regardless of outcome
this.cleanupStreamingResources();
}
&nbsp;
// Update the assistant message with the full response immutably
<span class="missing-if-branch" title="if path not taken" >I</span>if (
!this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
})
) {
<span class="cstat-no" title="statement not covered" > throw new Error('Assistant message not found');</span>
}
&nbsp;
// Process tool calls with proper follow-up context
if (toolCalls.length &gt; 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
<span class="missing-if-branch" title="if path not taken" >I</span>if (toolCalls.length &gt; MAX_TOOL_CALLS) {
<span class="cstat-no" title="statement not covered" > throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);</span>
}
&nbsp;
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(
toolCalls.map((call) =&gt; this.toolExecutor.handleToolCall(call))
);
&nbsp;
let toolResults: ToolResult[] = [];
for (const result of settledResults) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (result.status === 'fulfilled') {
<span class="cstat-no" title="statement not covered" > toolResults.push(result.value);</span>
} else {
// Use centralized error handler for tool errors
ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
&nbsp;
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages: OllamaMessage[] = [
...messages,
{ role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls },
...toolResults.map(<span class="fstat-no" title="function not covered" >(r</span>esult) =&gt; (<span class="cstat-no" title="statement not covered" >{</span>
role: 'tool' as const,
content: JSON.stringify(result),
})),
];
&nbsp;
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
await this.updateLastMessage(fullResponse);
&nbsp;
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
&nbsp;
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex &gt;= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
&nbsp;
// Limit conversation history to prevent memory issues
if (this.messages.length &gt; MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
await this.render();
} catch (error) {
// Use centralized error handler
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
@@ -1432,7 +1471,7 @@ export class ChatView extends ItemView {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+14 -2
View File
@@ -197,7 +197,11 @@
<a name='L132'></a><a href='#L132'>132</a>
<a name='L133'></a><a href='#L133'>133</a>
<a name='L134'></a><a href='#L134'>134</a>
<a name='L135'></a><a href='#L135'>135</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<a name='L135'></a><a href='#L135'>135</a>
<a name='L136'></a><a href='#L136'>136</a>
<a name='L137'></a><a href='#L137'>137</a>
<a name='L138'></a><a href='#L138'>138</a>
<a name='L139'></a><a href='#L139'>139</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
@@ -218,12 +222,16 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -352,12 +360,16 @@ export class ErrorHandler {
&nbsp;
if (error instanceof Error) {
const ctx = context ? ` [${context}]` : '';
// Use console.error instead of ErrorHandler.error for fatal errors
// eslint-disable-next-line no-console
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
if (error.stack) {
// eslint-disable-next-line no-console
console.error(error.stack);
}
} else {
const ctx = context ? <span class="branch-0 cbranch-no" title="branch not covered" >` [${context}]` </span>: '';
// eslint-disable-next-line no-console
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
@@ -472,7 +484,7 @@ export class ErrorHandler {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+44 -44
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">85.02% </span>
<span class="strong">84.91% </span>
<span class="quiet">Statements</span>
<span class='fraction'>579/681</span>
<span class='fraction'>591/696</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">74.34% </span>
<span class="strong">77.02% </span>
<span class="quiet">Branches</span>
<span class='fraction'>226/304</span>
<span class='fraction'>228/296</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">81.37% </span>
<span class="strong">79.43% </span>
<span class="quiet">Functions</span>
<span class='fraction'>83/102</span>
<span class='fraction'>85/107</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">85.91% </span>
<span class="strong">85.67% </span>
<span class="quiet">Lines</span>
<span class='fraction'>561/653</span>
<span class='fraction'>574/670</span>
</div>
@@ -80,17 +80,17 @@
</thead>
<tbody><tr>
<td class="file high" data-value="chat-view.ts"><a href="chat-view.ts.html">chat-view.ts</a></td>
<td data-value="91.54" class="pic high">
<td data-value="91.03" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
</td>
<td data-value="91.54" class="pct high">91.54%</td>
<td data-value="201" class="abs high">184/201</td>
<td data-value="83.07" class="pct high">83.07%</td>
<td data-value="65" class="abs high">54/65</td>
<td data-value="87.09" class="pct high">87.09%</td>
<td data-value="31" class="abs high">27/31</td>
<td data-value="93.58" class="pct high">93.58%</td>
<td data-value="187" class="abs high">175/187</td>
<td data-value="91.03" class="pct high">91.03%</td>
<td data-value="212" class="abs high">193/212</td>
<td data-value="84.37" class="pct high">84.37%</td>
<td data-value="64" class="abs high">54/64</td>
<td data-value="78.78" class="pct medium">78.78%</td>
<td data-value="33" class="abs medium">26/33</td>
<td data-value="92.5" class="pct high">92.5%</td>
<td data-value="200" class="abs high">185/200</td>
</tr>
<tr>
@@ -110,17 +110,17 @@
<tr>
<td class="file medium" data-value="ollama-client.ts"><a href="ollama-client.ts.html">ollama-client.ts</a></td>
<td data-value="76.84" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 76%"></div><div class="cover-empty" style="width: 24%"></div></div>
<td data-value="77.65" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 77%"></div><div class="cover-empty" style="width: 23%"></div></div>
</td>
<td data-value="76.84" class="pct medium">76.84%</td>
<td data-value="95" class="abs medium">73/95</td>
<td data-value="55.17" class="pct medium">55.17%</td>
<td data-value="58" class="abs medium">32/58</td>
<td data-value="88.88" class="pct high">88.88%</td>
<td data-value="9" class="abs high">8/9</td>
<td data-value="78.02" class="pct medium">78.02%</td>
<td data-value="91" class="abs medium">71/91</td>
<td data-value="77.65" class="pct medium">77.65%</td>
<td data-value="94" class="abs medium">73/94</td>
<td data-value="61.22" class="pct medium">61.22%</td>
<td data-value="49" class="abs medium">30/49</td>
<td data-value="90.9" class="pct high">90.9%</td>
<td data-value="11" class="abs high">10/11</td>
<td data-value="78.88" class="pct medium">78.88%</td>
<td data-value="90" class="abs medium">71/90</td>
</tr>
<tr>
@@ -154,33 +154,33 @@
</tr>
<tr>
<td class="file high" data-value="utils.ts"><a href="utils.ts.html">utils.ts</a></td>
<td data-value="81.05" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 81%"></div><div class="cover-empty" style="width: 19%"></div></div>
<td class="file medium" data-value="utils.ts"><a href="utils.ts.html">utils.ts</a></td>
<td data-value="79.38" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 79%"></div><div class="cover-empty" style="width: 21%"></div></div>
</td>
<td data-value="81.05" class="pct high">81.05%</td>
<td data-value="95" class="abs high">77/95</td>
<td data-value="79.38" class="pct medium">79.38%</td>
<td data-value="97" class="abs medium">77/97</td>
<td data-value="70.83" class="pct medium">70.83%</td>
<td data-value="48" class="abs medium">34/48</td>
<td data-value="52.94" class="pct medium">52.94%</td>
<td data-value="17" class="abs medium">9/17</td>
<td data-value="81.52" class="pct high">81.52%</td>
<td data-value="92" class="abs high">75/92</td>
<td data-value="79.78" class="pct medium">79.78%</td>
<td data-value="94" class="abs medium">75/94</td>
</tr>
<tr>
<td class="file medium" data-value="vault-indexer.ts"><a href="vault-indexer.ts.html">vault-indexer.ts</a></td>
<td data-value="74.3" class="pic medium">
<td data-value="74.82" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 74%"></div><div class="cover-empty" style="width: 26%"></div></div>
</td>
<td data-value="74.3" class="pct medium">74.3%</td>
<td data-value="144" class="abs medium">107/144</td>
<td data-value="72" class="pct medium">72%</td>
<td data-value="50" class="abs medium">36/50</td>
<td data-value="71.42" class="pct medium">71.42%</td>
<td data-value="21" class="abs medium">15/21</td>
<td data-value="74.45" class="pct medium">74.45%</td>
<td data-value="137" class="abs medium">102/137</td>
<td data-value="74.82" class="pct medium">74.82%</td>
<td data-value="147" class="abs medium">110/147</td>
<td data-value="76.92" class="pct medium">76.92%</td>
<td data-value="52" class="abs medium">40/52</td>
<td data-value="72.72" class="pct medium">72.72%</td>
<td data-value="22" class="abs medium">16/22</td>
<td data-value="75" class="pct medium">75%</td>
<td data-value="140" class="abs medium">105/140</td>
</tr>
</tbody>
@@ -191,7 +191,7 @@
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+95 -161
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">76.84% </span>
<span class="strong">77.65% </span>
<span class="quiet">Statements</span>
<span class='fraction'>73/95</span>
<span class='fraction'>73/94</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">55.17% </span>
<span class="strong">61.22% </span>
<span class="quiet">Branches</span>
<span class='fraction'>32/58</span>
<span class='fraction'>30/49</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">88.88% </span>
<span class="strong">90.9% </span>
<span class="quiet">Functions</span>
<span class='fraction'>8/9</span>
<span class='fraction'>10/11</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">78.02% </span>
<span class="strong">78.88% </span>
<span class="quiet">Lines</span>
<span class='fraction'>71/91</span>
<span class='fraction'>71/90</span>
</div>
@@ -292,34 +292,16 @@
<a name='L227'></a><a href='#L227'>227</a>
<a name='L228'></a><a href='#L228'>228</a>
<a name='L229'></a><a href='#L229'>229</a>
<a name='L230'></a><a href='#L230'>230</a>
<a name='L231'></a><a href='#L231'>231</a>
<a name='L232'></a><a href='#L232'>232</a>
<a name='L233'></a><a href='#L233'>233</a>
<a name='L234'></a><a href='#L234'>234</a>
<a name='L235'></a><a href='#L235'>235</a>
<a name='L236'></a><a href='#L236'>236</a>
<a name='L237'></a><a href='#L237'>237</a>
<a name='L238'></a><a href='#L238'>238</a>
<a name='L239'></a><a href='#L239'>239</a>
<a name='L240'></a><a href='#L240'>240</a>
<a name='L241'></a><a href='#L241'>241</a>
<a name='L242'></a><a href='#L242'>242</a>
<a name='L243'></a><a href='#L243'>243</a>
<a name='L244'></a><a href='#L244'>244</a>
<a name='L245'></a><a href='#L245'>245</a>
<a name='L246'></a><a href='#L246'>246</a>
<a name='L247'></a><a href='#L247'>247</a>
<a name='L248'></a><a href='#L248'>248</a>
<a name='L249'></a><a href='#L249'>249</a>
<a name='L250'></a><a href='#L250'>250</a>
<a name='L251'></a><a href='#L251'>251</a>
<a name='L252'></a><a href='#L252'>252</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<a name='L230'></a><a href='#L230'>230</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -351,11 +333,6 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-no">&nbsp;</span>
@@ -363,10 +340,6 @@
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -390,7 +363,6 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
@@ -434,11 +406,7 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">61x</span>
<span class="cline-any cline-yes">61x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
@@ -446,13 +414,8 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">53x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -470,23 +433,16 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -516,7 +472,6 @@
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -531,7 +486,6 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
@@ -545,20 +499,22 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -568,9 +524,13 @@
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">// src/ollama-client.ts
&nbsp;
import type { OllamaMessage, OllamaTool } from './types';
import { ApiError, NetworkError } from './types';
import { ApiError } from './types';
import { Logger } from './utils';
&nbsp;
interface OllamaChatResponse {
message?: Partial&lt;OllamaMessage&gt;;
}
&nbsp;
export class OllamaClient {
private baseURL: string;
private model: string;
@@ -598,24 +558,15 @@ export class OllamaClient {
yield* this.streamChatWithRetry(messages, tools, 0);
}
&nbsp;
/**
* Wrapper method for testing that converts async generator to Promise
* This allows testing with .rejects.toThrow() syntax
*/
async streamChatAsPromise(
messages: OllamaMessage[],
tools: OllamaTool[] = <span class="branch-0 cbranch-no" title="branch not covered" >[]</span>
): Promise&lt;OllamaMessage[]&gt; {
const chunks: OllamaMessage[] = [];
try {
for await (const chunk of this.streamChat(messages, tools)) {
<span class="cstat-no" title="statement not covered" > chunks.push(chunk);</span>
}
<span class="cstat-no" title="statement not covered" > return chunks;</span>
} catch (error) {
// Re-throw the error so tests can catch it
throw error;
for await (const chunk of this.streamChat(messages, tools)) {
<span class="cstat-no" title="statement not covered" > chunks.push(chunk);</span>
}
<span class="cstat-no" title="statement not covered" > return chunks;</span>
}
&nbsp;
private async *streamChatWithRetry(
@@ -633,17 +584,16 @@ export class OllamaClient {
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
messages,
tools,
stream: true,
}),
signal: this.abortController.signal,
});
&nbsp;
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
<span class="missing-if-branch" title="if path not taken" >I</span>if (response.status &gt;= 500 &amp;&amp; <span class="branch-1 cbranch-no" title="branch not covered" >attempt &lt; this.maxRetries)</span> {
const retryDelay = <span class="cstat-no" title="statement not covered" >Math.pow(2, attempt) * 100;</span> // Exponential backoff: 200ms, 400ms, 800ms
const retryDelay = <span class="cstat-no" title="statement not covered" >Math.pow(2, attempt) * 100;</span>
<span class="cstat-no" title="statement not covered" > Logger.warn(</span>
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client'
@@ -668,7 +618,7 @@ export class OllamaClient {
const decoder = new TextDecoder();
let buffer = '';
let malformedCount = 0;
const MAX_MALFORMED = 50;
const maxMalformed = 50;
&nbsp;
try {
while (true) {
@@ -678,68 +628,52 @@ export class OllamaClient {
buffer += decoder.decode(value, { stream: true });
&nbsp;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
buffer = lines.pop() ?? <span class="branch-1 cbranch-no" title="branch not covered" >'';</span>
&nbsp;
for (const line of lines) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (!line.trim()) <span class="cstat-no" title="statement not covered" >continue;</span>
&nbsp;
try {
const parsed = JSON.parse(line) as Record&lt;string, unknown&gt;;
this.throwIfOllamaError(parsed);
&nbsp;
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
&nbsp;
const message = parsed.message as OllamaMessage | undefined;
const message = this.toOllamaMessage(parsed.message);
<span class="missing-if-branch" title="if path not taken" >I</span>if (!message) {
<span class="cstat-no" title="statement not covered" > continue;</span>
}
&nbsp;
malformedCount = 0; // Reset on successful parse
&nbsp;
yield {
role: message.role ?? 'assistant',
content: message.content ?? <span class="branch-1 cbranch-no" title="branch not covered" >'',</span>
tool_calls: message.tool_calls ?? [],
};
} catch (e) {
if (e instanceof Error &amp;&amp; e.message.startsWith('Ollama error:')) {
throw e; // Re-throw Ollama errors
malformedCount = 0;
yield message;
} catch (error) {
if (error instanceof Error &amp;&amp; error.message.startsWith('Ollama error:')) {
throw error;
}
&nbsp;
malformedCount++;
if (malformedCount &gt; MAX_MALFORMED) {
if (malformedCount &gt; maxMalformed) {
throw new Error('Too many malformed chunks in stream');
}
&nbsp;
Logger.warn(
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e as Error).message}`,
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`,
'ollama-client'
);
}
}
}
&nbsp;
// Process any remaining data in buffer
<span class="missing-if-branch" title="if path not taken" >I</span>if (buffer.trim()) {
<span class="cstat-no" title="statement not covered" > try {</span>
const parsed = <span class="cstat-no" title="statement not covered" >JSON.parse(buffer) as Record&lt;string, unknown&gt;;</span>
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (parsed.error) {</span>
<span class="cstat-no" title="statement not covered" > throw new Error(`Ollama error: ${String(parsed.error)}`);</span>
}
<span class="cstat-no" title="statement not covered" > this.throwIfOllamaError(parsed);</span>
&nbsp;
const message = <span class="cstat-no" title="statement not covered" >parsed.message as OllamaMessage | undefined;</span>
const message = <span class="cstat-no" title="statement not covered" >this.toOllamaMessage(parsed.message);</span>
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (message) {</span>
<span class="cstat-no" title="statement not covered" > yield {</span>
role: message.role ?? 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls ?? [],
};
<span class="cstat-no" title="statement not covered" > yield message;</span>
}
} catch (e) {
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (e instanceof Error &amp;&amp; e.message.startsWith('Ollama error:')) {</span>
<span class="cstat-no" title="statement not covered" > throw e;</span>
} catch (error) {
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (error instanceof Error &amp;&amp; error.message.startsWith('Ollama error:')) {</span>
<span class="cstat-no" title="statement not covered" > throw error;</span>
}
<span class="cstat-no" title="statement not covered" > Logger.warn(</span>
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
@@ -766,54 +700,54 @@ export class OllamaClient {
): Promise&lt;OllamaMessage&gt; {
const controller = new AbortController();
&nbsp;
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,
});
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
tools,
stream: false,
}),
signal: controller.signal,
});
&nbsp;
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status &gt;= 500 &amp;&amp; attempt &lt; this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
Logger.warn(
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client'
);
await new Promise((resolve) =&gt; setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
if (!response.ok) {
if (response.status &gt;= 500 &amp;&amp; attempt &lt; this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100;
Logger.warn(
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client'
);
await new Promise((resolve) =&gt; setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
&nbsp;
const data = await response.json();
&nbsp;
// Handle missing message content gracefully
if (!data.message) {
return {
role: 'assistant',
content: '',
tool_calls: [],
};
}
&nbsp;
return {
role: data.message.role ?? 'assistant',
content: typeof data.message.content === 'string' ? data.message.content : <span class="branch-1 cbranch-no" title="branch not covered" >'',</span>
tool_calls: data.message.tool_calls ?? [],
};
} finally {
// No need to abort after successful response, but signal is available
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
}
&nbsp;
const data = (await response.json()) as OllamaChatResponse;
return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
}
&nbsp;
private throwIfOllamaError(parsed: Record&lt;string, unknown&gt;): void {
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
}
&nbsp;
private toOllamaMessage(value: unknown): OllamaMessage | null {
if (!value || typeof value !== 'object') {
return null;
}
&nbsp;
const record = value as Partial&lt;OllamaMessage&gt;;
return {
role: record.role ?? 'assistant',
content: typeof record.content === 'string' ? record.content : <span class="branch-1 cbranch-no" title="branch not covered" >'',</span>
tool_calls: record.tool_calls ?? [],
};
}
}
&nbsp;</pre></td></tr></table></pre>
@@ -823,7 +757,7 @@ export class OllamaClient {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+2 -2
View File
@@ -413,7 +413,7 @@ export class ToolExecutor {
throw new Error('Invalid JSON arguments');
}
} else if (rawArgs &amp;&amp; typeof rawArgs === 'object') {
parsedArgs = rawArgs as Record&lt;string, unknown&gt;;
parsedArgs = rawArgs;
} else <span class="missing-if-branch" title="else path not taken" >E</span>{
<span class="cstat-no" title="statement not covered" > throw new Error('Arguments must be an object or JSON string');</span>
}
@@ -463,7 +463,7 @@ export class ToolExecutor {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+1 -1
View File
@@ -628,7 +628,7 @@ export interface VaultIndexEntry {
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+32 -11
View File
@@ -23,9 +23,9 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">81.05% </span>
<span class="strong">79.38% </span>
<span class="quiet">Statements</span>
<span class='fraction'>77/95</span>
<span class='fraction'>77/97</span>
</div>
@@ -44,9 +44,9 @@
<div class='fl pad1y space-right2'>
<span class="strong">81.52% </span>
<span class="strong">79.78% </span>
<span class="quiet">Lines</span>
<span class='fraction'>75/92</span>
<span class='fraction'>75/94</span>
</div>
@@ -61,7 +61,7 @@
</div>
</template>
</div>
<div class='status-line high'></div>
<div class='status-line medium'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
@@ -272,7 +272,14 @@
<a name='L207'></a><a href='#L207'>207</a>
<a name='L208'></a><a href='#L208'>208</a>
<a name='L209'></a><a href='#L209'>209</a>
<a name='L210'></a><a href='#L210'>210</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<a name='L210'></a><a href='#L210'>210</a>
<a name='L211'></a><a href='#L211'>211</a>
<a name='L212'></a><a href='#L212'>212</a>
<a name='L213'></a><a href='#L213'>213</a>
<a name='L214'></a><a href='#L214'>214</a>
<a name='L215'></a><a href='#L215'>215</a>
<a name='L216'></a><a href='#L216'>216</a>
<a name='L217'></a><a href='#L217'>217</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -296,6 +303,7 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -303,24 +311,28 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">54x</span>
<span class="cline-any cline-yes">54x</span>
<span class="cline-any cline-yes">55x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">55x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -462,6 +474,8 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -504,7 +518,8 @@ export class Logger {
&nbsp;
<span class="fstat-no" title="function not covered" > static </span>setLevel(level: string | LogLevel): void {
<span class="cstat-no" title="statement not covered" > if (typeof level === 'string') {</span>
<span class="cstat-no" title="statement not covered" > Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;</span>
const lowerLevel = <span class="cstat-no" title="statement not covered" >level.toLowerCase();</span>
<span class="cstat-no" title="statement not covered" > Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;</span>
} else {
<span class="cstat-no" title="statement not covered" > Logger.minLevel = level;</span>
}
@@ -512,24 +527,28 @@ export class Logger {
&nbsp;
<span class="fstat-no" title="function not covered" > static </span>debug(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (LogLevel.DEBUG &gt;= Logger.minLevel) {</span>
// eslint-disable-next-line no-console
<span class="cstat-no" title="statement not covered" > console.debug(`[${category}] DEBUG: ${message}`);</span>
}
}
&nbsp;
<span class="fstat-no" title="function not covered" > static </span>info(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (LogLevel.INFO &gt;= Logger.minLevel) {</span>
// eslint-disable-next-line no-console
<span class="cstat-no" title="statement not covered" > console.info(`[${category}] INFO: ${message}`);</span>
}
}
&nbsp;
static warn(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
if (LogLevel.WARN &gt;= Logger.minLevel) {
// eslint-disable-next-line no-console
console.warn(`[${category}] WARN: ${message}`);
}
}
&nbsp;
<span class="fstat-no" title="function not covered" > static </span>error(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (LogLevel.ERROR &gt;= Logger.minLevel) {</span>
// eslint-disable-next-line no-console
<span class="cstat-no" title="statement not covered" > console.error(`[${category}] ERROR: ${message}`);</span>
}
}
@@ -669,9 +688,11 @@ export function <span class="fstat-no" title="function not covered" >sanitizeFil
<span class="cstat-no" title="statement not covered" > return path;</span>
}
&nbsp;
export async function <span class="fstat-no" title="function not covered" >safeWriteFile(</span>filePath: string, content: string): Promise&lt;void&gt; {
export function <span class="fstat-no" title="function not covered" >safeWriteFile(</span>filePath: string, content: string): Promise&lt;void&gt; {
const sanitizedPath = <span class="cstat-no" title="statement not covered" >sanitizeFilePath(filePath);</span>
// eslint-disable-next-line no-console
<span class="cstat-no" title="statement not covered" > console.log(`Writing to ${sanitizedPath}:`, content);</span>
<span class="cstat-no" title="statement not covered" > return Promise.resolve();</span>
}
&nbsp;
// ==================== HTTP Helpers ====================
@@ -697,7 +718,7 @@ export function <span class="fstat-no" title="function not covered" >convertMark
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+155 -41
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">74.3% </span>
<span class="strong">74.82% </span>
<span class="quiet">Statements</span>
<span class='fraction'>107/144</span>
<span class='fraction'>110/147</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">72% </span>
<span class="strong">76.92% </span>
<span class="quiet">Branches</span>
<span class='fraction'>36/50</span>
<span class='fraction'>40/52</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">71.42% </span>
<span class="strong">72.72% </span>
<span class="quiet">Functions</span>
<span class='fraction'>15/21</span>
<span class='fraction'>16/22</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">74.45% </span>
<span class="strong">75% </span>
<span class="quiet">Lines</span>
<span class='fraction'>102/137</span>
<span class='fraction'>105/140</span>
</div>
@@ -390,7 +390,45 @@
<a name='L325'></a><a href='#L325'>325</a>
<a name='L326'></a><a href='#L326'>326</a>
<a name='L327'></a><a href='#L327'>327</a>
<a name='L328'></a><a href='#L328'>328</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<a name='L328'></a><a href='#L328'>328</a>
<a name='L329'></a><a href='#L329'>329</a>
<a name='L330'></a><a href='#L330'>330</a>
<a name='L331'></a><a href='#L331'>331</a>
<a name='L332'></a><a href='#L332'>332</a>
<a name='L333'></a><a href='#L333'>333</a>
<a name='L334'></a><a href='#L334'>334</a>
<a name='L335'></a><a href='#L335'>335</a>
<a name='L336'></a><a href='#L336'>336</a>
<a name='L337'></a><a href='#L337'>337</a>
<a name='L338'></a><a href='#L338'>338</a>
<a name='L339'></a><a href='#L339'>339</a>
<a name='L340'></a><a href='#L340'>340</a>
<a name='L341'></a><a href='#L341'>341</a>
<a name='L342'></a><a href='#L342'>342</a>
<a name='L343'></a><a href='#L343'>343</a>
<a name='L344'></a><a href='#L344'>344</a>
<a name='L345'></a><a href='#L345'>345</a>
<a name='L346'></a><a href='#L346'>346</a>
<a name='L347'></a><a href='#L347'>347</a>
<a name='L348'></a><a href='#L348'>348</a>
<a name='L349'></a><a href='#L349'>349</a>
<a name='L350'></a><a href='#L350'>350</a>
<a name='L351'></a><a href='#L351'>351</a>
<a name='L352'></a><a href='#L352'>352</a>
<a name='L353'></a><a href='#L353'>353</a>
<a name='L354'></a><a href='#L354'>354</a>
<a name='L355'></a><a href='#L355'>355</a>
<a name='L356'></a><a href='#L356'>356</a>
<a name='L357'></a><a href='#L357'>357</a>
<a name='L358'></a><a href='#L358'>358</a>
<a name='L359'></a><a href='#L359'>359</a>
<a name='L360'></a><a href='#L360'>360</a>
<a name='L361'></a><a href='#L361'>361</a>
<a name='L362'></a><a href='#L362'>362</a>
<a name='L363'></a><a href='#L363'>363</a>
<a name='L364'></a><a href='#L364'>364</a>
<a name='L365'></a><a href='#L365'>365</a>
<a name='L366'></a><a href='#L366'>366</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
@@ -408,12 +446,40 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-yes">62x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -506,6 +572,7 @@
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">50x</span>
@@ -514,6 +581,10 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
@@ -544,6 +615,7 @@
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -648,6 +720,10 @@
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-yes">58x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -720,39 +796,67 @@
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">// src/vault-indexer.ts
&nbsp;
import { OllamaClient } from './ollama-client';
import { ApiError, NetworkError, ValidationError } from './types';
import { ValidationError, VaultIndexEntry } from './types';
import { ErrorHandler } from './error-handler';
import { sanitizeFilePath } from './utils';
&nbsp;
interface FileSummary {
path: string;
summary: string;
}
import { Logger, sanitizeFilePath } from './utils';
&nbsp;
interface Frontmatter {
title?: string;
tags?: string;
}
&nbsp;
interface VaultFile {
basename: string;
path: string;
}
&nbsp;
interface VaultLike {
getMarkdownFiles(): VaultFile[];
read(file: VaultFile): Promise&lt;string&gt;;
}
&nbsp;
interface TokenizedContent {
tokens: string[];
headings: string[];
frontmatter: Frontmatter;
firstParagraph?: string;
}
&nbsp;
interface ScoreResult {
score: number;
matchedFields: string[];
}
&nbsp;
function isVaultLike(value: unknown): value is VaultLike {
return (
typeof value === 'object' &amp;&amp;
value !== null &amp;&amp;
'getMarkdownFiles' in value &amp;&amp;
typeof value.getMarkdownFiles === 'function' &amp;&amp;
'read' in value &amp;&amp;
typeof value.read === 'function'
);
}
&nbsp;
class VaultIndexer {
private ollamaClient: OllamaClient | null = null;
private summaries: Map&lt;string, string&gt; = new Map();
private vault: any;
private vault: VaultLike | null = null;
&nbsp;
constructor(vaultOrClient: any) {
constructor(vaultOrClient: VaultLike | OllamaClient | null) {
// Support both old (OllamaClient) and new (VaultLike) interfaces
if (vaultOrClient &amp;&amp; typeof vaultOrClient.getMarkdownFiles === 'function') {
if (isVaultLike(vaultOrClient)) {
this.vault = vaultOrClient;
} else <span class="missing-if-branch" title="else path not taken" >E</span>{
<span class="cstat-no" title="statement not covered" > this.ollamaClient = vaultOrClient || null;</span>
<span class="cstat-no" title="statement not covered" > this.ollamaClient = vaultOrClient;</span>
}
}
&nbsp;
<span class="fstat-no" title="function not covered" > async </span>indexVault(vaultPath: string): Promise&lt;void&gt; {
<span class="cstat-no" title="statement not covered" > try {</span>
const files = <span class="cstat-no" title="statement not covered" >await this.getMarkdownFilesInVault(vaultPath);</span>
const files = <span class="cstat-no" title="statement not covered" >this.getMarkdownFilesInVault(vaultPath);</span>
<span class="cstat-no" title="statement not covered" > for (const file of files) {</span>
const content = <span class="cstat-no" title="statement not covered" >await this.readFileContent(file.path);</span>
const content = <span class="cstat-no" title="statement not covered" >this.readFileContent(file.path);</span>
const summary = <span class="cstat-no" title="statement not covered" >await this.summarizeFile(content);</span>
<span class="cstat-no" title="statement not covered" > this.storeSummary(file.path, summary);</span>
}
@@ -765,7 +869,7 @@ class VaultIndexer {
}
}
&nbsp;
private <span class="fstat-no" title="function not covered" >async </span>getMarkdownFilesInVault(vaultPath: string): Promise&lt;{ path: string }[]&gt; {
private <span class="fstat-no" title="function not covered" >getMarkdownFilesInVault(</span>vaultPath: string): { path: string }[] {
// Simulate getting markdown files from the vault
const sanitizedPath = <span class="cstat-no" title="statement not covered" >sanitizeFilePath(vaultPath);</span>
<span class="cstat-no" title="statement not covered" > try {</span>
@@ -776,7 +880,7 @@ class VaultIndexer {
}
}
&nbsp;
private <span class="fstat-no" title="function not covered" >async </span>readFileContent(filePath: string): Promise&lt;string&gt; {
private <span class="fstat-no" title="function not covered" >readFileContent(</span>filePath: string): string {
const sanitizedPath = <span class="cstat-no" title="statement not covered" >sanitizeFilePath(filePath);</span>
<span class="cstat-no" title="statement not covered" > try {</span>
// This is a placeholder for actual file reading operations
@@ -821,7 +925,7 @@ class VaultIndexer {
<span class="cstat-no" title="statement not covered" > return this.summaries.get(sanitizedPath);</span>
}
&nbsp;
async searchVault(query: string, limit: number = <span class="branch-0 cbranch-no" title="branch not covered" >5)</span>: Promise&lt;any[]&gt; {
async searchVault(query: string, limit: number = <span class="branch-0 cbranch-no" title="branch not covered" >5)</span>: Promise&lt;VaultIndexEntry[]&gt; {
if (!query || !query.trim()) {
return [];
}
@@ -831,8 +935,9 @@ class VaultIndexer {
}
&nbsp;
const queryTokens = this.tokenize(query.trim());
const allFiles = this.vault.getMarkdownFiles();
const results = await this.processFilesInBatches(allFiles, queryTokens);
const vault = this.vault;
const allFiles = vault.getMarkdownFiles();
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
&nbsp;
return results
.filter((result): result is NonNullable&lt;typeof result&gt; =&gt; result !== null)
@@ -840,9 +945,13 @@ class VaultIndexer {
.slice(0, limit);
}
&nbsp;
private async processFilesInBatches(files: any[], queryTokens: string[]): Promise&lt;any[]&gt; {
private async processFilesInBatches(
vault: VaultLike,
files: VaultFile[],
queryTokens: string[]
): Promise&lt;Array&lt;VaultIndexEntry | null&gt;&gt; {
const batchSize = 10;
const results: any[] = [];
const results: VaultIndexEntry[] = [];
const seenPaths = new Set&lt;string&gt;();
&nbsp;
for (let i = 0; i &lt; files.length; i += batchSize) {
@@ -850,11 +959,11 @@ class VaultIndexer {
const batchResults = await Promise.all(
batch.map(async (file) =&gt; {
try {
const content = await this.vault.read(file);
const tokenized = this.tokenizeContent(content, file);
const content = await vault.read(file);
const tokenized = this.tokenizeContent(content);
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
if (scoreResult.score &gt; 0) {
const entry = {
const entry: VaultIndexEntry = {
path: file.path,
title: file.basename.replace(/\.md$/, ''),
content: content.substring(0, 500),
@@ -868,8 +977,9 @@ class VaultIndexer {
}
return null;
} catch (error) {
console.warn(
`Failed to process ${file.path}: ${error instanceof Error ? error.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(error)}</span>`
Logger.warn(
`Failed to process ${file.path}: ${error instanceof Error ? error.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(error)}</span>`,
'vault-indexer'
);
return null;
}
@@ -923,10 +1033,10 @@ class VaultIndexer {
.filter((token) =&gt; token.length &gt; 1 &amp;&amp; !stopWords.has(token));
}
&nbsp;
private tokenizeContent(content: string, file: any): any {
private tokenizeContent(content: string): TokenizedContent {
const tokens: string[] = [];
const headings: string[] = [];
let frontmatter: Frontmatter = {};
const frontmatter: Frontmatter = {};
let firstParagraph: string | undefined;
&nbsp;
const frontmatterMatch = content.match(/^---(.*?)---/s);
@@ -948,8 +1058,8 @@ class VaultIndexer {
}
}
}
} catch (e) {
<span class="cstat-no" title="statement not covered" > console.warn('Failed to parse frontmatter');</span>
} catch {
<span class="cstat-no" title="statement not covered" > Logger.warn('Failed to parse frontmatter', 'vault-indexer');</span>
}
}
&nbsp;
@@ -974,7 +1084,11 @@ class VaultIndexer {
return { tokens, headings, frontmatter, firstParagraph };
}
&nbsp;
private calculateWeightedScore(tokenized: any, queryTokens: string[], file?: any): any {
private calculateWeightedScore(
tokenized: TokenizedContent,
queryTokens: string[],
file?: VaultFile
): ScoreResult {
let totalScore = 0;
const matchedTokens: Set&lt;string&gt; = new Set&lt;string&gt;();
&nbsp;
@@ -1051,7 +1165,7 @@ export { VaultIndexer };
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-05-06T15:30:53.720Z
at 2026-05-06T17:23:45.797Z
</div>
<script src="prettify.js"></script>
<script>
+878 -859
View File
File diff suppressed because it is too large Load Diff
+94 -78
View File
@@ -30,6 +30,9 @@ class ChatView extends obsidian_1.ItemView {
this.sendButtonClickHandler = null;
this.inputKeyDownHandler = null;
this.newChatButtonClickHandler = null;
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.listenersAttached = false;
this.settings = settings;
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
@@ -46,15 +49,16 @@ class ChatView extends obsidian_1.ItemView {
getDisplayText() {
return 'Ollama Chat';
}
async onOpen() {
await this.render();
onOpen() {
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
return Promise.resolve();
}
onSettingsChange(newSettings) {
this.updateSettings(newSettings);
}
async onClose() {
onClose() {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
@@ -62,6 +66,7 @@ class ChatView extends obsidian_1.ItemView {
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
return Promise.resolve();
}
cleanupStreamingResources() {
// Ensure any ongoing streaming is properly cleaned up
@@ -70,7 +75,7 @@ class ChatView extends obsidian_1.ItemView {
this.lastMessageEl = null;
}
}
async render() {
render() {
const container = this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
this.chatContainer = container;
const inputContainer = this.contentEl.querySelector('.ollama-input-container') ||
@@ -99,7 +104,6 @@ class ChatView extends obsidian_1.ItemView {
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
// Differential update: only update messages that have changed
const existingMessages = container.querySelectorAll('.ollama-message');
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
for (const msg of nonStreamingMessages) {
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
if (existingEl) {
@@ -151,26 +155,38 @@ class ChatView extends obsidian_1.ItemView {
};
}
// Add event listeners
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
this.sendButtonEventHandler = () => {
void this.sendButtonClickHandler?.();
};
this.inputKeyDownEventHandler = (e) => {
void this.inputKeyDownHandler?.(e);
};
this.sendButton.addEventListener('click', this.sendButtonEventHandler);
this.inputEl.addEventListener('keydown', this.inputKeyDownEventHandler);
if (this.newChatButton) {
if (!this.newChatButtonClickHandler) {
this.newChatButtonClickHandler = () => this.clearConversation();
}
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
this.newChatButtonEventHandler = () => {
this.newChatButtonClickHandler?.();
};
this.newChatButton.addEventListener('click', this.newChatButtonEventHandler);
}
this.listenersAttached = true;
}
removeEventListeners() {
if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
if (this.sendButton && this.sendButtonEventHandler) {
this.sendButton.removeEventListener('click', this.sendButtonEventHandler);
}
if (this.inputEl && this.inputKeyDownHandler) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
if (this.inputEl && this.inputKeyDownEventHandler) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownEventHandler);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
if (this.newChatButton && this.newChatButtonEventHandler) {
this.newChatButton.removeEventListener('click', this.newChatButtonEventHandler);
}
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.listenersAttached = false;
}
clearConversation() {
@@ -191,7 +207,7 @@ class ChatView extends obsidian_1.ItemView {
];
return true;
}
async updateLastMessage(content) {
updateLastMessage(content) {
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && !this.lastMessageEl) {
this.lastMessageEl = this.contentEl.createEl('div', {
@@ -214,7 +230,9 @@ class ChatView extends obsidian_1.ItemView {
return;
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
let context = entries.map((e) => `### ${e.title}\n${e.content}`).join('\n\n');
let context = entries
.map((entry) => `### ${entry.title}\n${entry.content}`)
.join('\n\n');
// Cap context size to prevent prompt bloat with large vaults
const MAX_CONTEXT_LENGTH = 4000;
if (context.length > MAX_CONTEXT_LENGTH) {
@@ -273,14 +291,14 @@ class ChatView extends obsidian_1.ItemView {
};
// Update messages immutably
this.messages = [...this.messages, userChatMessage, assistantMessage];
await this.render();
const stream = await this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
try {
this.render();
const stream = this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
for await (const chunk of stream) {
chunkCount++;
if (chunkCount > maxChunks) {
@@ -292,67 +310,65 @@ class ChatView extends obsidian_1.ItemView {
if (chunk.tool_calls) {
toolCalls = toolCalls.concat(chunk.tool_calls);
}
await this.updateLastMessage(fullResponse);
this.updateLastMessage(fullResponse);
}
// Update the assistant message with the full response immutably
this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
});
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
if (toolCalls.length > MAX_TOOL_CALLS) {
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
}
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call)));
const toolResults = [];
for (const result of settledResults) {
if (result.status === 'fulfilled') {
toolResults.push(result.value);
}
else {
// Use centralized error handler for tool errors
error_handler_1.ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages = [
...messages,
{ role: 'assistant', content: fullResponse, tool_calls: toolCalls },
...toolResults.map((result) => ({
role: 'tool',
content: JSON.stringify(result),
})),
];
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
this.updateLastMessage(fullResponse);
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex >= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
this.render();
}
finally {
// Clean up streaming resources regardless of outcome
this.cleanupStreamingResources();
}
// Update the assistant message with the full response immutably
if (!this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
})) {
throw new Error('Assistant message not found');
}
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
if (toolCalls.length > MAX_TOOL_CALLS) {
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
}
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call)));
let toolResults = [];
for (const result of settledResults) {
if (result.status === 'fulfilled') {
toolResults.push(result.value);
}
else {
// Use centralized error handler for tool errors
error_handler_1.ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages = [
...messages,
{ role: 'assistant', content: fullResponse, tool_calls: toolCalls },
...toolResults.map((result) => ({
role: 'tool',
content: JSON.stringify(result),
})),
];
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
await this.updateLastMessage(fullResponse);
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex >= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
await this.render();
}
catch (error) {
// Use centralized error handler
+111 -98
View File
@@ -1,9 +1,10 @@
import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian';
import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
/// <reference lib="dom" />
// Use global types from JSDOM setup
type KeyboardEvent = globalThis.KeyboardEvent;
type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
type HTMLButtonElement = globalThis.HTMLButtonElement;
type MouseEvent = globalThis.MouseEvent;
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
const MAX_MESSAGE_HISTORY = 50;
@@ -34,6 +35,9 @@ export class ChatView extends ItemView {
private sendButtonClickHandler: (() => Promise<void>) | null = null;
private inputKeyDownHandler: ((e: KeyboardEvent) => Promise<void>) | null = null;
private newChatButtonClickHandler: (() => void) | null = null;
private sendButtonEventHandler: ((e: MouseEvent) => void) | null = null;
private inputKeyDownEventHandler: ((e: KeyboardEvent) => void) | null = null;
private newChatButtonEventHandler: ((e: MouseEvent) => void) | null = null;
private listenersAttached = false;
// Getters for testing
@@ -70,17 +74,18 @@ export class ChatView extends ItemView {
return 'Ollama Chat';
}
async onOpen() {
await this.render();
onOpen(): Promise<void> {
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
return Promise.resolve();
}
public onSettingsChange(newSettings: PluginSettings): void {
this.updateSettings(newSettings);
}
async onClose() {
onClose(): Promise<void> {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
@@ -88,6 +93,7 @@ export class ChatView extends ItemView {
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
return Promise.resolve();
}
private cleanupStreamingResources(): void {
@@ -98,7 +104,7 @@ export class ChatView extends ItemView {
}
}
async render() {
render() {
const container =
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
this.chatContainer = container;
@@ -112,7 +118,7 @@ export class ChatView extends ItemView {
if (!this.sendButton) {
this.sendButton = inputContainer.createEl('button', {
cls: 'ollama-send-button',
}) as HTMLButtonElement;
});
(this.sendButton as HTMLButtonElement).textContent = 'Send';
}
@@ -122,7 +128,7 @@ export class ChatView extends ItemView {
this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
this.newChatButton = newChatContainer.createEl('button', {
cls: 'ollama-new-chat-button',
}) as HTMLButtonElement;
});
(this.newChatButton as HTMLButtonElement).textContent = '🔄 New Chat';
(this.newChatButton as HTMLButtonElement).title = 'Start a new conversation';
}
@@ -135,12 +141,9 @@ export class ChatView extends ItemView {
// Differential update: only update messages that have changed
const existingMessages = container.querySelectorAll('.ollama-message');
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
for (const msg of nonStreamingMessages) {
const existingEl = container.querySelector(
`.ollama-message[data-msg-id="${msg.id}"]`
) as HTMLElement | null;
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
if (existingEl) {
existingEl.textContent = msg.content;
} else {
@@ -194,39 +197,51 @@ export class ChatView extends ItemView {
}
// Add event listeners
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonClickHandler!);
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownHandler!);
this.sendButtonEventHandler = () => {
void this.sendButtonClickHandler?.();
};
this.inputKeyDownEventHandler = (e: KeyboardEvent) => {
void this.inputKeyDownHandler?.(e);
};
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonEventHandler);
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownEventHandler);
if (this.newChatButton) {
if (!this.newChatButtonClickHandler) {
this.newChatButtonClickHandler = () => this.clearConversation();
}
this.newChatButtonEventHandler = () => {
this.newChatButtonClickHandler?.();
};
(this.newChatButton as HTMLButtonElement).addEventListener(
'click',
this.newChatButtonClickHandler!
this.newChatButtonEventHandler
);
}
this.listenersAttached = true;
}
private removeEventListeners(): void {
if (this.sendButton && this.sendButtonClickHandler) {
if (this.sendButton && this.sendButtonEventHandler) {
(this.sendButton as HTMLButtonElement).removeEventListener(
'click',
this.sendButtonClickHandler!
this.sendButtonEventHandler
);
}
if (this.inputEl && this.inputKeyDownHandler) {
if (this.inputEl && this.inputKeyDownEventHandler) {
(this.inputEl as HTMLTextAreaElement).removeEventListener(
'keydown',
this.inputKeyDownHandler!
this.inputKeyDownEventHandler
);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
if (this.newChatButton && this.newChatButtonEventHandler) {
(this.newChatButton as HTMLButtonElement).removeEventListener(
'click',
this.newChatButtonClickHandler!
this.newChatButtonEventHandler
);
}
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.listenersAttached = false;
}
@@ -249,7 +264,7 @@ export class ChatView extends ItemView {
return true;
}
private async updateLastMessage(content: string) {
private updateLastMessage(content: string) {
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && !this.lastMessageEl) {
this.lastMessageEl = this.contentEl.createEl('div', {
@@ -273,7 +288,9 @@ export class ChatView extends ItemView {
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
let context = entries.map((e) => `### ${e.title}\n${e.content}`).join('\n\n');
let context = entries
.map((entry) => `### ${entry.title}\n${entry.content}`)
.join('\n\n');
// Cap context size to prevent prompt bloat with large vaults
const MAX_CONTEXT_LENGTH = 4000;
@@ -345,16 +362,16 @@ export class ChatView extends ItemView {
// Update messages immutably
this.messages = [...this.messages, userChatMessage, assistantMessage];
await this.render();
const stream = await this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls: ToolCall[] = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
try {
this.render();
const stream = this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls: ToolCall[] = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
for await (const chunk of stream) {
chunkCount++;
if (chunkCount > maxChunks) {
@@ -369,78 +386,74 @@ export class ChatView extends ItemView {
toolCalls = toolCalls.concat(chunk.tool_calls);
}
await this.updateLastMessage(fullResponse);
this.updateLastMessage(fullResponse);
}
// Update the assistant message with the full response immutably
this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
});
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
if (toolCalls.length > MAX_TOOL_CALLS) {
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
}
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(
toolCalls.map((call) => this.toolExecutor.handleToolCall(call))
);
const toolResults: ToolResult[] = [];
for (const result of settledResults) {
if (result.status === 'fulfilled') {
toolResults.push(result.value);
} else {
// Use centralized error handler for tool errors
ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages: OllamaMessage[] = [
...messages,
{ role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls },
...toolResults.map((result) => ({
role: 'tool' as const,
content: JSON.stringify(result),
})),
];
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
this.updateLastMessage(fullResponse);
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex >= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
this.render();
} finally {
// Clean up streaming resources regardless of outcome
this.cleanupStreamingResources();
}
// Update the assistant message with the full response immutably
if (
!this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
})
) {
throw new Error('Assistant message not found');
}
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
if (toolCalls.length > MAX_TOOL_CALLS) {
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
}
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(
toolCalls.map((call) => this.toolExecutor.handleToolCall(call))
);
let toolResults: ToolResult[] = [];
for (const result of settledResults) {
if (result.status === 'fulfilled') {
toolResults.push(result.value);
} else {
// Use centralized error handler for tool errors
ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages: OllamaMessage[] = [
...messages,
{ role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls },
...toolResults.map((result) => ({
role: 'tool' as const,
content: JSON.stringify(result),
})),
];
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
await this.updateLastMessage(fullResponse);
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex >= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
await this.render();
} catch (error) {
// Use centralized error handler
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
+4
View File
@@ -10,13 +10,17 @@ class ErrorHandler {
new obsidian_1.Notice(message);
if (error instanceof Error) {
const ctx = context ? ` [${context}]` : '';
// Use console.error instead of ErrorHandler.error for fatal errors
// eslint-disable-next-line no-console
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
if (error.stack) {
// eslint-disable-next-line no-console
console.error(error.stack);
}
}
else {
const ctx = context ? ` [${context}]` : '';
// eslint-disable-next-line no-console
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
+4
View File
@@ -19,12 +19,16 @@ export class ErrorHandler {
if (error instanceof Error) {
const ctx = context ? ` [${context}]` : '';
// Use console.error instead of ErrorHandler.error for fatal errors
// eslint-disable-next-line no-console
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
if (error.stack) {
// eslint-disable-next-line no-console
console.error(error.stack);
}
} else {
const ctx = context ? ` [${context}]` : '';
// eslint-disable-next-line no-console
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
+6 -42
View File
@@ -1,42 +1,10 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const obsidian_1 = require("obsidian");
const chat_view_1 = require("./chat-view");
const utils_1 = require("./utils");
const constants_1 = require("./constants");
const error_handler_1 = require("./error-handler");
class OllamaPlugin extends obsidian_1.Plugin {
constructor() {
super(...arguments);
@@ -62,7 +30,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
type: 'ollama-chat-view',
active: true,
});
this.app.workspace.revealLeaf(leaf);
await this.app.workspace.revealLeaf(leaf);
});
}
catch (error) {
@@ -74,16 +42,14 @@ class OllamaPlugin extends obsidian_1.Plugin {
}
async loadSettings() {
try {
const data = await this.loadData();
const data = (await this.loadData());
if (data) {
utils_1.Logger.debug('Loading saved settings', 'settings');
this.settings = Object.assign({}, this.settings, data);
}
}
catch (error) {
// Use centralized error handling
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
ErrorHandler.handleError(error, 'settings load');
error_handler_1.ErrorHandler.handleError(error, 'settings load');
}
}
async saveSettings() {
@@ -101,9 +67,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
return true;
}
catch (error) {
// Use centralized error handling
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
ErrorHandler.handleError(error, 'settings save');
error_handler_1.ErrorHandler.handleError(error, 'settings save');
return false;
}
}
@@ -112,7 +76,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
leaves.forEach((leaf) => {
const view = leaf.view;
if (view && view.onSettingsChange) {
if (view instanceof chat_view_1.ChatView) {
view.onSettingsChange(this.settings);
}
});
+6 -15
View File
@@ -1,14 +1,9 @@
import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab } from 'obsidian';
import { ChatView } from './chat-view';
import { PluginSettings } from './types';
import {
isValidHttpUrl,
validatePluginSettings,
validateOllamaUrl,
validateModelName,
Logger,
} from './utils';
import { validatePluginSettings, validateOllamaUrl, validateModelName, Logger } from './utils';
import { DEFAULT_SETTINGS } from './constants';
import { ErrorHandler } from './error-handler';
export default class OllamaPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
@@ -38,7 +33,7 @@ export default class OllamaPlugin extends Plugin {
type: 'ollama-chat-view',
active: true,
});
this.app.workspace.revealLeaf(leaf);
await this.app.workspace.revealLeaf(leaf);
});
} catch (error) {
Logger.error('Failed to add ribbon icon: ' + (error as Error).message, 'plugin');
@@ -51,14 +46,12 @@ export default class OllamaPlugin extends Plugin {
async loadSettings() {
try {
const data = await this.loadData();
const data = (await this.loadData()) as Partial<PluginSettings> | null;
if (data) {
Logger.debug('Loading saved settings', 'settings');
this.settings = Object.assign({}, this.settings, data);
}
} catch (error) {
// Use centralized error handling
const { ErrorHandler } = await import('./error-handler');
ErrorHandler.handleError(error, 'settings load');
}
}
@@ -81,8 +74,6 @@ export default class OllamaPlugin extends Plugin {
Logger.info('Settings saved successfully', 'settings');
return true;
} catch (error) {
// Use centralized error handling
const { ErrorHandler } = await import('./error-handler');
ErrorHandler.handleError(error, 'settings save');
return false;
}
@@ -92,8 +83,8 @@ export default class OllamaPlugin extends Plugin {
public notifyChatViews(): void {
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
leaves.forEach((leaf) => {
const view = leaf.view as ChatView;
if (view && view.onSettingsChange) {
const view = leaf.view;
if (view instanceof ChatView) {
view.onSettingsChange(this.settings);
}
});
+60 -87
View File
@@ -21,22 +21,12 @@ class OllamaClient {
async *streamChat(messages, tools = []) {
yield* this.streamChatWithRetry(messages, tools, 0);
}
/**
* Wrapper method for testing that converts async generator to Promise
* This allows testing with .rejects.toThrow() syntax
*/
async streamChatAsPromise(messages, tools = []) {
const chunks = [];
try {
for await (const chunk of this.streamChat(messages, tools)) {
chunks.push(chunk);
}
return chunks;
}
catch (error) {
// Re-throw the error so tests can catch it
throw error;
for await (const chunk of this.streamChat(messages, tools)) {
chunks.push(chunk);
}
return chunks;
}
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
this.abortController = new AbortController();
@@ -48,16 +38,15 @@ class OllamaClient {
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
messages,
tools,
stream: true,
}),
signal: this.abortController.signal,
});
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
const retryDelay = Math.pow(2, attempt) * 100;
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
await new Promise((resolve) => setTimeout(resolve, retryDelay));
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
@@ -76,7 +65,7 @@ class OllamaClient {
const decoder = new TextDecoder();
let buffer = '';
let malformedCount = 0;
const MAX_MALFORMED = 50;
const maxMalformed = 50;
try {
while (true) {
const { done, value } = await reader.read();
@@ -84,58 +73,44 @@ class OllamaClient {
break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim())
continue;
try {
const parsed = JSON.parse(line);
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message;
this.throwIfOllamaError(parsed);
const message = this.toOllamaMessage(parsed.message);
if (!message) {
continue;
}
malformedCount = 0; // Reset on successful parse
yield {
role: message.role ?? 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls ?? [],
};
malformedCount = 0;
yield message;
}
catch (e) {
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
throw e; // Re-throw Ollama errors
catch (error) {
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
throw error;
}
malformedCount++;
if (malformedCount > MAX_MALFORMED) {
if (malformedCount > maxMalformed) {
throw new Error('Too many malformed chunks in stream');
}
utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${e.message}`, 'ollama-client');
utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${error.message}`, 'ollama-client');
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer);
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message;
this.throwIfOllamaError(parsed);
const message = this.toOllamaMessage(parsed.message);
if (message) {
yield {
role: message.role ?? 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls ?? [],
};
yield message;
}
}
catch (e) {
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
throw e;
catch (error) {
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
throw error;
}
utils_1.Logger.warn(`Failed to parse final chunk: ${buffer.substring(0, 80)}...`, 'ollama-client');
}
@@ -154,48 +129,46 @@ class OllamaClient {
}
async chatWithRetry(messages, tools = [], attempt = 0) {
const controller = new AbortController();
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100;
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
const data = await response.json();
// Handle missing message content gracefully
if (!data.message) {
return {
role: 'assistant',
content: '',
tool_calls: [],
};
}
return {
role: data.message.role ?? 'assistant',
content: typeof data.message.content === 'string' ? data.message.content : '',
tool_calls: data.message.tool_calls ?? [],
};
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
}
finally {
// No need to abort after successful response, but signal is available
const data = (await response.json());
return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
}
throwIfOllamaError(parsed) {
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
}
toOllamaMessage(value) {
if (!value || typeof value !== 'object') {
return null;
}
const record = value;
return {
role: record.role ?? 'assistant',
content: typeof record.content === 'string' ? record.content : '',
tool_calls: record.tool_calls ?? [],
};
}
}
exports.OllamaClient = OllamaClient;
+71 -93
View File
@@ -1,9 +1,13 @@
// src/ollama-client.ts
import type { OllamaMessage, OllamaTool } from './types';
import { ApiError, NetworkError } from './types';
import { ApiError } from './types';
import { Logger } from './utils';
interface OllamaChatResponse {
message?: Partial<OllamaMessage>;
}
export class OllamaClient {
private baseURL: string;
private model: string;
@@ -31,24 +35,15 @@ export class OllamaClient {
yield* this.streamChatWithRetry(messages, tools, 0);
}
/**
* Wrapper method for testing that converts async generator to Promise
* This allows testing with .rejects.toThrow() syntax
*/
async streamChatAsPromise(
messages: OllamaMessage[],
tools: OllamaTool[] = []
): Promise<OllamaMessage[]> {
const chunks: OllamaMessage[] = [];
try {
for await (const chunk of this.streamChat(messages, tools)) {
chunks.push(chunk);
}
return chunks;
} catch (error) {
// Re-throw the error so tests can catch it
throw error;
for await (const chunk of this.streamChat(messages, tools)) {
chunks.push(chunk);
}
return chunks;
}
private async *streamChatWithRetry(
@@ -66,17 +61,16 @@ export class OllamaClient {
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
messages,
tools,
stream: true,
}),
signal: this.abortController.signal,
});
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
const retryDelay = Math.pow(2, attempt) * 100;
Logger.warn(
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client'
@@ -101,7 +95,7 @@ export class OllamaClient {
const decoder = new TextDecoder();
let buffer = '';
let malformedCount = 0;
const MAX_MALFORMED = 50;
const maxMalformed = 50;
try {
while (true) {
@@ -111,68 +105,52 @@ export class OllamaClient {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
this.throwIfOllamaError(parsed);
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message as OllamaMessage | undefined;
const message = this.toOllamaMessage(parsed.message);
if (!message) {
continue;
}
malformedCount = 0; // Reset on successful parse
yield {
role: message.role ?? 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls ?? [],
};
} catch (e) {
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
throw e; // Re-throw Ollama errors
malformedCount = 0;
yield message;
} catch (error) {
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
throw error;
}
malformedCount++;
if (malformedCount > MAX_MALFORMED) {
if (malformedCount > maxMalformed) {
throw new Error('Too many malformed chunks in stream');
}
Logger.warn(
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e as Error).message}`,
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`,
'ollama-client'
);
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer) as Record<string, unknown>;
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
this.throwIfOllamaError(parsed);
const message = parsed.message as OllamaMessage | undefined;
const message = this.toOllamaMessage(parsed.message);
if (message) {
yield {
role: message.role ?? 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls ?? [],
};
yield message;
}
} catch (e) {
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
throw e;
} catch (error) {
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
throw error;
}
Logger.warn(
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
@@ -199,53 +177,53 @@ export class OllamaClient {
): Promise<OllamaMessage> {
const controller = new AbortController();
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: false,
}),
signal: controller.signal,
});
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
Logger.warn(
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client'
);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
if (!response.ok) {
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100;
Logger.warn(
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client'
);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
}
const data = await response.json();
const data = (await response.json()) as OllamaChatResponse;
return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
}
// Handle missing message content gracefully
if (!data.message) {
return {
role: 'assistant',
content: '',
tool_calls: [],
};
}
return {
role: data.message.role ?? 'assistant',
content: typeof data.message.content === 'string' ? data.message.content : '',
tool_calls: data.message.tool_calls ?? [],
};
} finally {
// No need to abort after successful response, but signal is available
private throwIfOllamaError(parsed: Record<string, unknown>): void {
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
}
private toOllamaMessage(value: unknown): OllamaMessage | null {
if (!value || typeof value !== 'object') {
return null;
}
const record = value as Partial<OllamaMessage>;
return {
role: record.role ?? 'assistant',
content: typeof record.content === 'string' ? record.content : '',
tool_calls: record.tool_calls ?? [],
};
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ export class ToolExecutor {
throw new Error('Invalid JSON arguments');
}
} else if (rawArgs && typeof rawArgs === 'object') {
parsedArgs = rawArgs as Record<string, unknown>;
parsedArgs = rawArgs;
} else {
throw new Error('Arguments must be an object or JSON string');
}
+9 -2
View File
@@ -27,7 +27,8 @@ const SEVERITY_ORDER = {
class Logger {
static setLevel(level) {
if (typeof level === 'string') {
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
const lowerLevel = level.toLowerCase();
Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;
}
else {
Logger.minLevel = level;
@@ -35,21 +36,25 @@ class Logger {
}
static debug(message, category = 'general') {
if (LogLevel.DEBUG >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.debug(`[${category}] DEBUG: ${message}`);
}
}
static info(message, category = 'general') {
if (LogLevel.INFO >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.info(`[${category}] INFO: ${message}`);
}
}
static warn(message, category = 'general') {
if (LogLevel.WARN >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.warn(`[${category}] WARN: ${message}`);
}
}
static error(message, category = 'general') {
if (LogLevel.ERROR >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.error(`[${category}] ERROR: ${message}`);
}
}
@@ -164,9 +169,11 @@ function sanitizeFilePath(path) {
}
return path;
}
async function safeWriteFile(filePath, content) {
function safeWriteFile(filePath, content) {
const sanitizedPath = sanitizeFilePath(filePath);
// eslint-disable-next-line no-console
console.log(`Writing to ${sanitizedPath}:`, content);
return Promise.resolve();
}
// ==================== HTTP Helpers ====================
function isValidHttpUrl(url) {
+9 -2
View File
@@ -21,7 +21,8 @@ export class Logger {
static setLevel(level: string | LogLevel): void {
if (typeof level === 'string') {
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
const lowerLevel = level.toLowerCase();
Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;
} else {
Logger.minLevel = level;
}
@@ -29,24 +30,28 @@ export class Logger {
static debug(message: string, category: string = 'general'): void {
if (LogLevel.DEBUG >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.debug(`[${category}] DEBUG: ${message}`);
}
}
static info(message: string, category: string = 'general'): void {
if (LogLevel.INFO >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.info(`[${category}] INFO: ${message}`);
}
}
static warn(message: string, category: string = 'general'): void {
if (LogLevel.WARN >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.warn(`[${category}] WARN: ${message}`);
}
}
static error(message: string, category: string = 'general'): void {
if (LogLevel.ERROR >= Logger.minLevel) {
// eslint-disable-next-line no-console
console.error(`[${category}] ERROR: ${message}`);
}
}
@@ -186,9 +191,11 @@ export function sanitizeFilePath(path: string): string {
return path;
}
export async function safeWriteFile(filePath: string, content: string): Promise<void> {
export function safeWriteFile(filePath: string, content: string): Promise<void> {
const sanitizedPath = sanitizeFilePath(filePath);
// eslint-disable-next-line no-console
console.log(`Writing to ${sanitizedPath}:`, content);
return Promise.resolve();
}
// ==================== HTTP Helpers ====================
+26 -16
View File
@@ -5,23 +5,32 @@ exports.VaultIndexer = void 0;
const types_1 = require("./types");
const error_handler_1 = require("./error-handler");
const utils_1 = require("./utils");
function isVaultLike(value) {
return (typeof value === 'object' &&
value !== null &&
'getMarkdownFiles' in value &&
typeof value.getMarkdownFiles === 'function' &&
'read' in value &&
typeof value.read === 'function');
}
class VaultIndexer {
constructor(vaultOrClient) {
this.ollamaClient = null;
this.summaries = new Map();
this.vault = null;
// Support both old (OllamaClient) and new (VaultLike) interfaces
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
if (isVaultLike(vaultOrClient)) {
this.vault = vaultOrClient;
}
else {
this.ollamaClient = vaultOrClient || null;
this.ollamaClient = vaultOrClient;
}
}
async indexVault(vaultPath) {
try {
const files = await this.getMarkdownFilesInVault(vaultPath);
const files = this.getMarkdownFilesInVault(vaultPath);
for (const file of files) {
const content = await this.readFileContent(file.path);
const content = this.readFileContent(file.path);
const summary = await this.summarizeFile(content);
this.storeSummary(file.path, summary);
}
@@ -35,7 +44,7 @@ class VaultIndexer {
}
}
}
async getMarkdownFilesInVault(vaultPath) {
getMarkdownFilesInVault(vaultPath) {
// Simulate getting markdown files from the vault
const sanitizedPath = (0, utils_1.sanitizeFilePath)(vaultPath);
try {
@@ -46,7 +55,7 @@ class VaultIndexer {
throw new types_1.ValidationError('Failed to get markdown files from vault');
}
}
async readFileContent(filePath) {
readFileContent(filePath) {
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
try {
// This is a placeholder for actual file reading operations
@@ -95,14 +104,15 @@ class VaultIndexer {
throw new Error('Vault-like object not provided to VaultIndexer');
}
const queryTokens = this.tokenize(query.trim());
const allFiles = this.vault.getMarkdownFiles();
const results = await this.processFilesInBatches(allFiles, queryTokens);
const vault = this.vault;
const allFiles = vault.getMarkdownFiles();
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
return results
.filter((result) => result !== null)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
async processFilesInBatches(files, queryTokens) {
async processFilesInBatches(vault, files, queryTokens) {
const batchSize = 10;
const results = [];
const seenPaths = new Set();
@@ -110,8 +120,8 @@ class VaultIndexer {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (file) => {
try {
const content = await this.vault.read(file);
const tokenized = this.tokenizeContent(content, file);
const content = await vault.read(file);
const tokenized = this.tokenizeContent(content);
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
if (scoreResult.score > 0) {
const entry = {
@@ -129,7 +139,7 @@ class VaultIndexer {
return null;
}
catch (error) {
console.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`);
utils_1.Logger.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer');
return null;
}
}));
@@ -174,10 +184,10 @@ class VaultIndexer {
.split(/\W+/)
.filter((token) => token.length > 1 && !stopWords.has(token));
}
tokenizeContent(content, file) {
tokenizeContent(content) {
const tokens = [];
const headings = [];
let frontmatter = {};
const frontmatter = {};
let firstParagraph;
const frontmatterMatch = content.match(/^---(.*?)---/s);
if (frontmatterMatch) {
@@ -201,8 +211,8 @@ class VaultIndexer {
}
}
}
catch (e) {
console.warn('Failed to parse frontmatter');
catch {
utils_1.Logger.warn('Failed to parse frontmatter', 'vault-indexer');
}
}
const headingMatches = content.match(/^# (.*?)$/gm);
+68 -30
View File
@@ -1,39 +1,67 @@
// src/vault-indexer.ts
import { OllamaClient } from './ollama-client';
import { ApiError, NetworkError, ValidationError } from './types';
import { ValidationError, VaultIndexEntry } from './types';
import { ErrorHandler } from './error-handler';
import { sanitizeFilePath } from './utils';
interface FileSummary {
path: string;
summary: string;
}
import { Logger, sanitizeFilePath } from './utils';
interface Frontmatter {
title?: string;
tags?: string;
}
interface VaultFile {
basename: string;
path: string;
}
interface VaultLike {
getMarkdownFiles(): VaultFile[];
read(file: VaultFile): Promise<string>;
}
interface TokenizedContent {
tokens: string[];
headings: string[];
frontmatter: Frontmatter;
firstParagraph?: string;
}
interface ScoreResult {
score: number;
matchedFields: string[];
}
function isVaultLike(value: unknown): value is VaultLike {
return (
typeof value === 'object' &&
value !== null &&
'getMarkdownFiles' in value &&
typeof value.getMarkdownFiles === 'function' &&
'read' in value &&
typeof value.read === 'function'
);
}
class VaultIndexer {
private ollamaClient: OllamaClient | null = null;
private summaries: Map<string, string> = new Map();
private vault: any;
private vault: VaultLike | null = null;
constructor(vaultOrClient: any) {
constructor(vaultOrClient: VaultLike | OllamaClient | null) {
// Support both old (OllamaClient) and new (VaultLike) interfaces
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
if (isVaultLike(vaultOrClient)) {
this.vault = vaultOrClient;
} else {
this.ollamaClient = vaultOrClient || null;
this.ollamaClient = vaultOrClient;
}
}
async indexVault(vaultPath: string): Promise<void> {
try {
const files = await this.getMarkdownFilesInVault(vaultPath);
const files = this.getMarkdownFilesInVault(vaultPath);
for (const file of files) {
const content = await this.readFileContent(file.path);
const content = this.readFileContent(file.path);
const summary = await this.summarizeFile(content);
this.storeSummary(file.path, summary);
}
@@ -46,7 +74,7 @@ class VaultIndexer {
}
}
private async getMarkdownFilesInVault(vaultPath: string): Promise<{ path: string }[]> {
private getMarkdownFilesInVault(vaultPath: string): { path: string }[] {
// Simulate getting markdown files from the vault
const sanitizedPath = sanitizeFilePath(vaultPath);
try {
@@ -57,7 +85,7 @@ class VaultIndexer {
}
}
private async readFileContent(filePath: string): Promise<string> {
private readFileContent(filePath: string): string {
const sanitizedPath = sanitizeFilePath(filePath);
try {
// This is a placeholder for actual file reading operations
@@ -102,7 +130,7 @@ class VaultIndexer {
return this.summaries.get(sanitizedPath);
}
async searchVault(query: string, limit: number = 5): Promise<any[]> {
async searchVault(query: string, limit: number = 5): Promise<VaultIndexEntry[]> {
if (!query || !query.trim()) {
return [];
}
@@ -112,8 +140,9 @@ class VaultIndexer {
}
const queryTokens = this.tokenize(query.trim());
const allFiles = this.vault.getMarkdownFiles();
const results = await this.processFilesInBatches(allFiles, queryTokens);
const vault = this.vault;
const allFiles = vault.getMarkdownFiles();
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
return results
.filter((result): result is NonNullable<typeof result> => result !== null)
@@ -121,9 +150,13 @@ class VaultIndexer {
.slice(0, limit);
}
private async processFilesInBatches(files: any[], queryTokens: string[]): Promise<any[]> {
private async processFilesInBatches(
vault: VaultLike,
files: VaultFile[],
queryTokens: string[]
): Promise<Array<VaultIndexEntry | null>> {
const batchSize = 10;
const results: any[] = [];
const results: VaultIndexEntry[] = [];
const seenPaths = new Set<string>();
for (let i = 0; i < files.length; i += batchSize) {
@@ -131,11 +164,11 @@ class VaultIndexer {
const batchResults = await Promise.all(
batch.map(async (file) => {
try {
const content = await this.vault.read(file);
const tokenized = this.tokenizeContent(content, file);
const content = await vault.read(file);
const tokenized = this.tokenizeContent(content);
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
if (scoreResult.score > 0) {
const entry = {
const entry: VaultIndexEntry = {
path: file.path,
title: file.basename.replace(/\.md$/, ''),
content: content.substring(0, 500),
@@ -149,8 +182,9 @@ class VaultIndexer {
}
return null;
} catch (error) {
console.warn(
`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`
Logger.warn(
`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`,
'vault-indexer'
);
return null;
}
@@ -204,10 +238,10 @@ class VaultIndexer {
.filter((token) => token.length > 1 && !stopWords.has(token));
}
private tokenizeContent(content: string, file: any): any {
private tokenizeContent(content: string): TokenizedContent {
const tokens: string[] = [];
const headings: string[] = [];
let frontmatter: Frontmatter = {};
const frontmatter: Frontmatter = {};
let firstParagraph: string | undefined;
const frontmatterMatch = content.match(/^---(.*?)---/s);
@@ -229,8 +263,8 @@ class VaultIndexer {
}
}
}
} catch (e) {
console.warn('Failed to parse frontmatter');
} catch {
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
}
}
@@ -255,7 +289,11 @@ class VaultIndexer {
return { tokens, headings, frontmatter, firstParagraph };
}
private calculateWeightedScore(tokenized: any, queryTokens: string[], file?: any): any {
private calculateWeightedScore(
tokenized: TokenizedContent,
queryTokens: string[],
file?: VaultFile
): ScoreResult {
let totalScore = 0;
const matchedTokens: Set<string> = new Set<string>();