Refactor error handling, client, and tests for Ollama integration

This commit is contained in:
2026-05-06 16:30:16 +02:00
parent 59df2f6856
commit fd49abcdb9
35 changed files with 4970 additions and 3765 deletions
+3
View File
@@ -33,3 +33,6 @@ npm-debug.log*
# Test cache
.jest-cache/
# review agents
agent_loop_gemma4/
+92 -29
View File
@@ -1,50 +1,113 @@
// Enhanced Obsidian mock for testing
// Mock Vault
export class Vault {
getMarkdownFiles() {
return [];
}
getMarkdownFiles: () => any[];
read: (file: any) => Promise<string>;
create: (path: string, content: string) => Promise<any>;
async read(file: any) {
return '';
}
async create(path: string, content: string) {
return null;
constructor() {
this.getMarkdownFiles = () => [];
this.read = async () => '';
this.create = async () => null;
}
}
// Mock Workspace
export class Workspace {
getLeaf() {
return {
getLeaf: () => any;
constructor() {
this.getLeaf = () => ({
setViewState: jest.fn(),
};
revealLeaf: jest.fn(),
});
}
}
// Mock App
export class App {
vault = new Vault();
workspace = new Workspace();
vault: Vault;
workspace: Workspace;
constructor() {
this.vault = new Vault();
this.workspace = new Workspace();
}
}
// Mock WorkspaceLeaf
export class WorkspaceLeaf {
app: App;
view: any;
setViewState: jest.Mock;
constructor() {
this.app = new App();
this.view = null;
this.setViewState = jest.fn();
}
}
// Mock ItemView - accepts a leaf and derives app from it
export class ItemView {
contentEl: HTMLElement = document.createElement('div');
contentEl: HTMLElement;
app: App;
constructor(leaf?: WorkspaceLeaf) {
this.contentEl = document.createElement('div');
if (leaf && leaf.app) {
this.app = leaf.app;
} else {
this.app = new App();
}
}
}
// Mock Notice - can be called with `new Notice(msg)` or as a function
export class Notice {
message: string;
constructor(message: string) {
this.message = message;
// Also record via jest for testing
(Notice as any).lastMessage = message;
}
}
(Notice as any).lastMessage = '';
// Mock Setting
export class Setting {
containerEl: HTMLElement;
constructor(containerEl: HTMLElement) {
this.containerEl = containerEl;
}
setName(_name: string): this {
return this;
}
setDesc(_desc: string): this {
return this;
}
addText(_callback: (text: any) => void): this {
return this;
}
}
// TFile type
export interface TFile {
basename: string;
path: string;
}
// Plugin class (used by main.ts)
export class Plugin {
app: App;
constructor() {
this.app = new App();
}
}
export class Notice {
static create(message: string) {}
}
// Mock types for DOM elements
export type TFile = {
basename: string;
};
// Export additional types that might be used in tests
export const Plugin: any = jest.fn();
export const WorkspaceLeaf: any = jest.fn();
export const Setting: any = jest.fn();
+20 -25
View File
@@ -1,49 +1,44 @@
// Mock for ollama-client for testing
import { OllamaMessage, ToolCall } from '../src/types';
import type { APIError } from '../src/error-handler';
import type { OllamaMessage, OllamaTool } from '../src/types';
export class OllamaClient {
private url: string;
private model: string;
// Mock fetch function for testing
private fetchFn: jest.Mock<Promise<Response>, [string, RequestInit?]>> = jest.fn();
private fetchFn: typeof fetch;
constructor(url: string, model: string, fetchFn?: typeof fetch) {
this.url = url;
this.model = model;
if (fetchFn) this.fetchFn = jest.fn(fetchFn);
this.fetchFn = fetchFn ?? (jest.fn() as typeof fetch);
}
async *streamChatMessages(
prompt: string,
options: { abortSignal?: AbortSignal } = {}
async *streamChat(
_messages: OllamaMessage[],
_tools: OllamaTool[] = []
): AsyncGenerator<OllamaMessage, void, unknown> {
// Mock implementation - simulate streaming response
const mockResponse = [
{ role: 'assistant', content: 'Part 1' },
{ role: 'assistant', content: 'Part 2' }
const mockMessages: OllamaMessage[] = [
{ role: 'assistant', content: 'Part 1', tool_calls: [] },
{ role: 'assistant', content: 'Part 2', tool_calls: [] },
];
for (const message of mockResponse) {
for (const message of mockMessages) {
yield message;
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate delay
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
async *streamToolMessages(
toolCall: ToolCall,
options: { abortSignal?: AbortSignal } = {}
): AsyncGenerator<OllamaMessage, void, unknown> {
// Mock implementation - simulate streaming response for tool
const mockResponse = [
{ role: 'assistant', content: `Tool ${toolCall.tool_name} Part 1` },
{ role: 'assistant', content: `Tool ${toolCall.tool_name} Part 2` }
];
for (const message of mockResponse) {
yield message;
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate delay
async chat(_messages: OllamaMessage[], _tools: OllamaTool[] = []): Promise<OllamaMessage> {
return {
role: 'assistant',
content: 'Mock response',
tool_calls: [],
};
}
cancelStream(): void {
// No-op in mock
}
}
+358 -358
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">83.07% </span>
<span class="strong">86.15% </span>
<span class="quiet">Statements</span>
<span class='fraction'>162/195</span>
<span class='fraction'>168/195</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">72.3% </span>
<span class="strong">76.92% </span>
<span class="quiet">Branches</span>
<span class='fraction'>47/65</span>
<span class='fraction'>50/65</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">73.07% </span>
<span class="strong">76.92% </span>
<span class="quiet">Functions</span>
<span class='fraction'>19/26</span>
<span class='fraction'>20/26</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">86.18% </span>
<span class="strong">88.95% </span>
<span class="quiet">Lines</span>
<span class='fraction'>156/181</span>
<span class='fraction'>161/181</span>
</div>
@@ -519,26 +519,26 @@
<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">11x</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>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</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-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-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</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>
@@ -550,379 +550,379 @@
<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-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>
<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-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>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</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-yes">4x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">15x</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>
<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-yes">27x</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">27x</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">27x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">27x</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-yes">11x</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-yes">18x</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">27x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">254x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-yes">128x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-yes">243x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">243x</span>
<span class="cline-any cline-yes">104x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">139x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">139x</span>
<span class="cline-any cline-yes">139x</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">27x</span>
<span class="cline-any cline-yes">128x</span>
<span class="cline-any cline-yes">3656x</span>
<span class="cline-any cline-yes">24x</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">254x</span>
<span class="cline-any cline-yes">27x</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-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">5x</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-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-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-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-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</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-yes">5x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</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>
<span class="cline-any cline-neutral">&nbsp;</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>
<span class="cline-any cline-yes">8x</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-yes">8x</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-yes">8x</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-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">8x</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-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">138x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</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">9x</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">146x</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">9x</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-neutral">&nbsp;</span>
<span class="cline-any cline-yes">13x</span>
<span class="cline-any cline-yes">13x</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">12x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</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-neutral">&nbsp;</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-yes">10x</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-yes">10x</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">10x</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">10x</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">120x</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">10x</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">10x</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-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-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">10x</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">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-yes">10x</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-yes">10x</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-yes">10x</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>
<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">12x</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">12x</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">10x</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-yes">11x</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-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">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</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>
<span class="cline-any cline-yes">1x</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">1x</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">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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-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">1x</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-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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">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">8x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</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-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-yes">8x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</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-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>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</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">11x</span>
<span class="cline-any cline-yes">11x</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-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-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-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-yes">3x</span>
<span class="cline-any cline-yes">3x</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-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-yes">3x</span>
<span class="cline-any cline-yes">3x</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-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">5x</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">5x</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">5x</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">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>
<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-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">6x</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>
<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">6x</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-yes">3x</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-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-yes">2x</span>
<span class="cline-any cline-yes">2x</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-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-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-yes">2x</span>
<span class="cline-any cline-yes">2x</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-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-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-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-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-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>
<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">2x</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-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-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>
<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">2x</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-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</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-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</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-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-yes">2x</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">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-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-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-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">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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">1x</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">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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-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">1x</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-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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">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">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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">2x</span>
<span class="cline-any cline-no">&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>
<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-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</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>
@@ -1062,8 +1062,8 @@ export class ChatView extends ItemView {
// Remove messages that are no longer in the array
for (const el of Array.from(existingMessages)) {
const id = el.getAttribute('data-msg-id');
<span class="missing-if-branch" title="if path not taken" >I</span>if (!id || !nonStreamingMessages.some((m) =&gt; m.id === id)) {
<span class="cstat-no" title="statement not covered" > el.remove();</span>
if (!id || !nonStreamingMessages.some((m) =&gt; m.id === id)) {
el.remove();
}
}
&nbsp;
@@ -1176,7 +1176,7 @@ export class ChatView extends ItemView {
try {
// Guard against empty messages
const userMessage = content.trim();
<span class="missing-if-branch" title="if path not taken" >I</span>if (!userMessage) <span class="cstat-no" title="statement not covered" >return;</span>
if (!userMessage) return;
&nbsp;
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
@@ -1200,8 +1200,8 @@ export class ChatView extends ItemView {
const messages: OllamaMessage[] = [
systemMessage,
...this.messages.map(
<span class="fstat-no" title="function not covered" > (m</span>) =&gt;
(<span class="cstat-no" title="statement not covered" >{</span>
(m) =&gt;
({
role: m.role,
content: m.content,
tool_calls: m.tool_calls,
@@ -1344,14 +1344,14 @@ export class ChatView extends ItemView {
}
&nbsp;
// Limit conversation history to prevent memory issues
<span class="missing-if-branch" title="if path not taken" >I</span>if (this.messages.length &gt; MAX_MESSAGE_HISTORY) {
<span class="cstat-no" title="statement not covered" > this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);</span>
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
<span class="cstat-no" title="statement not covered" > ErrorHandler.handleError(error, 'ChatView.handleUserInput');</span>
<span class="cstat-no" title="statement not covered" > this.cleanupStreamingResources();</span>
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
this.cleanupStreamingResources();
} finally {
if (this.sendButton) {
(this.sendButton as HTMLButtonElement).disabled = false;
@@ -1366,7 +1366,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-04T18:57:30.341Z
at 2026-05-06T13:14:32.634Z
</div>
<script src="prettify.js"></script>
<script>
+113 -245
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">84.37% </span>
<span class="strong">94.23% </span>
<span class="quiet">Statements</span>
<span class='fraction'>54/64</span>
<span class='fraction'>49/52</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">80% </span>
<span class="strong">85.41% </span>
<span class="quiet">Branches</span>
<span class='fraction'>40/50</span>
<span class='fraction'>41/48</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>10/10</span>
<span class='fraction'>12/12</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">84.37% </span>
<span class="strong">94.23% </span>
<span class="quiet">Lines</span>
<span class='fraction'>54/64</span>
<span class='fraction'>49/52</span>
</div>
@@ -197,170 +197,87 @@
<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>
<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>
<a name='L140'></a><a href='#L140'>140</a>
<a name='L141'></a><a href='#L141'>141</a>
<a name='L142'></a><a href='#L142'>142</a>
<a name='L143'></a><a href='#L143'>143</a>
<a name='L144'></a><a href='#L144'>144</a>
<a name='L145'></a><a href='#L145'>145</a>
<a name='L146'></a><a href='#L146'>146</a>
<a name='L147'></a><a href='#L147'>147</a>
<a name='L148'></a><a href='#L148'>148</a>
<a name='L149'></a><a href='#L149'>149</a>
<a name='L150'></a><a href='#L150'>150</a>
<a name='L151'></a><a href='#L151'>151</a>
<a name='L152'></a><a href='#L152'>152</a>
<a name='L153'></a><a href='#L153'>153</a>
<a name='L154'></a><a href='#L154'>154</a>
<a name='L155'></a><a href='#L155'>155</a>
<a name='L156'></a><a href='#L156'>156</a>
<a name='L157'></a><a href='#L157'>157</a>
<a name='L158'></a><a href='#L158'>158</a>
<a name='L159'></a><a href='#L159'>159</a>
<a name='L160'></a><a href='#L160'>160</a>
<a name='L161'></a><a href='#L161'>161</a>
<a name='L162'></a><a href='#L162'>162</a>
<a name='L163'></a><a href='#L163'>163</a>
<a name='L164'></a><a href='#L164'>164</a>
<a name='L165'></a><a href='#L165'>165</a>
<a name='L166'></a><a href='#L166'>166</a>
<a name='L167'></a><a href='#L167'>167</a>
<a name='L168'></a><a href='#L168'>168</a>
<a name='L169'></a><a href='#L169'>169</a>
<a name='L170'></a><a href='#L170'>170</a>
<a name='L171'></a><a href='#L171'>171</a>
<a name='L172'></a><a href='#L172'>172</a>
<a name='L173'></a><a href='#L173'>173</a>
<a name='L174'></a><a href='#L174'>174</a>
<a name='L175'></a><a href='#L175'>175</a>
<a name='L176'></a><a href='#L176'>176</a>
<a name='L177'></a><a href='#L177'>177</a>
<a name='L178'></a><a href='#L178'>178</a>
<a name='L179'></a><a href='#L179'>179</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<a name='L135'></a><a href='#L135'>135</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>
<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">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-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">3x</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-yes">1x</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-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-yes">4x</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">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-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-yes">1x</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">10x</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-yes">3x</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-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">7x</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-no">&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-no">&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-yes">1x</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-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">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</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-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">1x</span>
<span class="cline-any cline-yes">1x</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-yes">1x</span>
<span class="cline-any cline-yes">1x</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-yes">1x</span>
<span class="cline-any cline-yes">1x</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-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-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">8x</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">2x</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-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">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -386,7 +303,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-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -395,10 +311,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">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -419,7 +331,9 @@
<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></td><td class="text"><pre class="prettyprint lang-js">import { Notice } from 'obsidian';
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">// src/error-handler.ts
&nbsp;
import { Notice } from 'obsidian';
import {
OllamaError,
ErrorType,
@@ -432,165 +346,119 @@ import {
} from './types';
&nbsp;
export class ErrorHandler {
/**
* Centralized error handling for the Ollama plugin
* Provides consistent error messages and logging
*/
static handleError(error: unknown, context?: string): void {
let userMessage = 'An unexpected error occurred';
let shouldShowError = true;
const message = this.getUserFriendlyMessage(error);
new Notice(message);
&nbsp;
if (error instanceof OllamaError) {
userMessage = this.getUserFriendlyMessage(error);
shouldShowError = true;
} else if (error instanceof Error) {
userMessage = this.getUserFriendlyMessageFromError(error);
shouldShowError = true;
} else {
userMessage = 'An unexpected error occurred';
shouldShowError = true;
}
&nbsp;
if (shouldShowError) {
new Notice(userMessage);
}
&nbsp;
// Log detailed error for debugging
console.error(
`[OllamaPlugin${context ? <span class="branch-0 cbranch-no" title="branch not covered" >' ' + context </span>: ''}] ${error instanceof Error ? error.message : 'Unknown error'}`
);
if (error instanceof Error) {
console.error('[Stack]', error.stack);
const ctx = context ? <span class="branch-0 cbranch-no" title="branch not covered" >` [${context}]` </span>: '';
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
if (error.stack) {
console.error(error.stack);
}
} else {
const ctx = context ? <span class="branch-0 cbranch-no" title="branch not covered" >` [${context}]` </span>: '';
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
&nbsp;
/**
* Get user-friendly message from specific error types
*/
private static getUserFriendlyMessage(error: OllamaError): string {
private static getUserFriendlyMessage(error: unknown): string {
if (error instanceof OllamaError) {
return this.getUserFriendlyMessageFromOllamaError(error);
}
&nbsp;
if (error instanceof Error) {
return this.getUserFriendlyMessageFromError(error);
}
&nbsp;
return 'An unexpected error occurred';
}
&nbsp;
private static getUserFriendlyMessageFromOllamaError(error: OllamaError): string {
switch (error.type) {
case ErrorType.NETWORK_ERROR:
if (error instanceof NetworkError) {
return 'Connection error. Please check if Ollama is running.';
}
<span class="cstat-no" title="statement not covered" > return 'Network error. Please check your connection to Ollama.';</span>
&nbsp;
<span class="branch-1 cbranch-no" title="branch not covered" > case ErrorType.API_ERROR:</span>
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (error instanceof ApiError) {</span>
<span class="cstat-no" title="statement not covered" > return 'Ollama API error. Please check the Ollama logs for details.';</span>
}
<span class="cstat-no" title="statement not covered" > return 'API communication error. Please try again.';</span>
&nbsp;
<span class="cstat-no" title="statement not covered" > return `API error: ${error.message}`;</span>
case ErrorType.VALIDATION_ERROR:
if (error instanceof ValidationError) {
const details = error.validationDetails;
if (details?.field) {
return `Invalid ${details.field}. ${details.message || <span class="branch-1 cbranch-no" title="branch not covered" >'Please check your input.'}</span>`;
return this.getUserFriendlyValidationMessage(error);
case ErrorType.STREAMING_ERROR:
return 'Response too long. Please try a shorter request.';
case ErrorType.TOOL_EXECUTION_ERROR:
return `Tool error for ${(error as ToolExecutionError).toolName}. ${error.message}`;
case ErrorType.PATH_VALIDATION_ERROR:
return `Invalid file path: ${(error as PathValidationError).path}`;
<span class="branch-6 cbranch-no" title="branch not covered" > case ErrorType.UNKNOWN_ERROR:</span>
<span class="cstat-no" title="statement not covered" > return 'An unexpected error occurred';</span>
<span class="branch-7 cbranch-no" title="branch not covered" > default:</span>
<span class="cstat-no" title="statement not covered" > return 'An unexpected error occurred';</span>
}
}
&nbsp;
private static getUserFriendlyValidationMessage(error: OllamaError): string {
if (error instanceof ValidationError &amp;&amp; error.details?.field) {
const fieldMsg = error.details.field.charAt(0).toUpperCase() + error.details.field.slice(1);
return `Invalid ${fieldMsg.toLowerCase()}. ${error.details.message ?? <span class="branch-1 cbranch-no" title="branch not covered" >error.message}</span>`;
}
return 'Input validation error. Please correct your input.';
}
<span class="cstat-no" title="statement not covered" > return 'Input validation error. Please correct your input.';</span>
&nbsp;
case ErrorType.STREAMING_ERROR:
if (error instanceof StreamingError) {
return 'Response too long. Please try a shorter request.';
}
<span class="cstat-no" title="statement not covered" > return 'Streaming error. Please try again.';</span>
&nbsp;
case ErrorType.TOOL_EXECUTION_ERROR:
if (error instanceof ToolExecutionError) {
return `Tool error: ${error.toolName || <span class="branch-1 cbranch-no" title="branch not covered" >'tool'}</span> failed to execute. Please try again.`;
}
<span class="cstat-no" title="statement not covered" > return 'Tool execution error. Please try a different command.';</span>
&nbsp;
case ErrorType.PATH_VALIDATION_ERROR:
if (error instanceof PathValidationError) {
return 'Invalid file path. Please use a relative path without special characters.';
}
<span class="cstat-no" title="statement not covered" > return 'Path validation error. Please check your file path.';</span>
&nbsp;
<span class="branch-6 cbranch-no" title="branch not covered" > case ErrorType.UNKNOWN_ERROR:</span>
<span class="cstat-no" title="statement not covered" > return 'An unexpected error occurred. Please try again.';</span>
&nbsp;
<span class="branch-7 cbranch-no" title="branch not covered" > default:</span>
<span class="cstat-no" title="statement not covered" > return error.message || 'An error occurred';</span>
}
}
&nbsp;
/**
* Get user-friendly message from generic Error
*/
/**
* Get user-friendly message from generic Error
* Note: This method uses substring matching which is inherently fragile.
* If an error message happens to contain certain keywords but isn't actually
* that type of error, it may be misclassified. This heuristic approach
* provides a good balance between robustness and accuracy for most common cases.
*/
private static getUserFriendlyMessageFromError(error: Error): string {
const message = error.message.toLowerCase();
const msg = error.message.toLowerCase();
&nbsp;
if (message.includes('timeout')) {
// Check timeout BEFORE network (more specific matches first)
if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('time out')) {
return 'Request timed out. Please check your Ollama connection.';
}
&nbsp;
if (
message.includes('network') ||
message.includes('fetch') ||
message.includes('connection')
) {
if (msg.includes('network') || msg.includes('connection') || msg.includes('fetch')) {
return 'Connection error. Please check if Ollama is running.';
}
&nbsp;
if (message.includes('validation') || message.includes('format')) {
return 'Invalid input. Please check your message.';
if (msg.includes('validation') || msg.includes('invalid')) {
return 'Invalid input. Please correct your input.';
}
&nbsp;
if (message.includes('stream') || message.includes('chunk')) {
if (msg.includes('stream') || msg.includes('chunk')) {
return 'Response too long. Please try a shorter request.';
}
&nbsp;
if (message.includes('tool') || message.includes('function')) {
return 'Tool execution error. Please try a different command.';
if (msg.includes('tool') || msg.includes('function')) {
return 'Tool error. Please try again.';
}
&nbsp;
if (message.includes('path') || message.includes('file')) {
return 'Invalid file path. Please use a relative path without special characters.';
if (msg.includes('path') || msg.includes('file')) {
return 'Invalid file path. Please check the path and try again.';
}
&nbsp;
return error.message;
return 'An unexpected error occurred';
}
&nbsp;
/**
* Create specific error instances from different error types
*/
// -- Factory methods --
&nbsp;
static createNetworkError(message: string, statusCode?: number): NetworkError {
return new NetworkError(message, statusCode);
}
&nbsp;
static createApiError(message: string, apiError?: any): ApiError {
return new ApiError(message, apiError);
static createApiError(message: string, statusCode?: number): ApiError {
return new ApiError(message, statusCode);
}
&nbsp;
static createValidationError(
message: string,
field?: string,
details?: Record&lt;string, string&gt;
): ValidationError {
const validationDetails = field ? { field, message } : <span class="branch-1 cbranch-no" title="branch not covered" >details;</span>
return new ValidationError(message, validationDetails);
static createValidationError(message: string, field?: string): ValidationError {
const details = field ? { field, message } : <span class="branch-1 cbranch-no" title="branch not covered" >undefined;</span>
return new ValidationError(message, details);
}
&nbsp;
static createStreamingError(message: string, chunkDetails?: any): StreamingError {
return new StreamingError(message, chunkDetails);
static createStreamingError(message: string): StreamingError {
return new StreamingError(message);
}
&nbsp;
static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
return new ToolExecutionError(message, toolName);
return new ToolExecutionError(message, toolName ?? 'unknown');
}
&nbsp;
static createPathValidationError(message: string, invalidPath?: string): PathValidationError {
return new PathValidationError(message, invalidPath);
static createPathValidationError(message: string, path?: string): PathValidationError {
return new PathValidationError(message, path ?? '');
}
&nbsp;
static createUnknownError(message: string): OllamaError {
@@ -604,7 +472,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-04T22:31:13.164Z
at 2026-05-06T14:12:40.215Z
</div>
<script src="prettify.js"></script>
<script>
+66 -51
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">87.6% </span>
<span class="strong">82.29% </span>
<span class="quiet">Statements</span>
<span class='fraction'>325/371</span>
<span class='fraction'>395/480</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">78.31% </span>
<span class="strong">71.12% </span>
<span class="quiet">Branches</span>
<span class='fraction'>130/166</span>
<span class='fraction'>170/239</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">83.33% </span>
<span class="strong">78.87% </span>
<span class="quiet">Functions</span>
<span class='fraction'>45/54</span>
<span class='fraction'>56/71</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">87.29% </span>
<span class="strong">82.83% </span>
<span class="quiet">Lines</span>
<span class='fraction'>316/362</span>
<span class='fraction'>386/466</span>
</div>
@@ -80,32 +80,47 @@
</thead>
<tbody><tr>
<td class="file high" data-value="error-handler.ts"><a href="error-handler.ts.html">error-handler.ts</a></td>
<td data-value="84.37" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 84%"></div><div class="cover-empty" style="width: 16%"></div></div>
<td data-value="94.23" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 94%"></div><div class="cover-empty" style="width: 6%"></div></div>
</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="80" class="pct high">80%</td>
<td data-value="50" class="abs high">40/50</td>
<td data-value="94.23" class="pct high">94.23%</td>
<td data-value="52" class="abs high">49/52</td>
<td data-value="85.41" class="pct high">85.41%</td>
<td data-value="48" class="abs high">41/48</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="10" class="abs high">10/10</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="12" class="abs high">12/12</td>
<td data-value="94.23" class="pct high">94.23%</td>
<td data-value="52" class="abs high">49/52</td>
</tr>
<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>
<td data-value="76.84" class="pct medium">76.84%</td>
<td data-value="95" class="abs medium">73/95</td>
<td data-value="53.44" class="pct medium">53.44%</td>
<td data-value="58" class="abs medium">31/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>
</tr>
<tr>
<td class="file high" data-value="tool-executor.ts"><a href="tool-executor.ts.html">tool-executor.ts</a></td>
<td data-value="100" class="pic high">
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
<td data-value="91.37" 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.37" class="pct high">91.37%</td>
<td data-value="58" class="abs high">53/58</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="100" class="pct high">100%</td>
<td data-value="24" class="abs high">24/24</td>
<td data-value="83.33" class="pct high">83.33%</td>
<td data-value="12" class="abs high">10/12</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="2" class="abs high">2/2</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="24" class="abs high">24/24</td>
<td data-value="4" class="abs high">4/4</td>
<td data-value="91.37" class="pct high">91.37%</td>
<td data-value="58" class="abs high">53/58</td>
</tr>
<tr>
@@ -114,43 +129,43 @@
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="37" class="abs high">37/37</td>
<td data-value="36" class="abs high">36/36</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="2" class="abs high">2/2</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="8" class="abs high">8/8</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="37" class="abs high">37/37</td>
<td data-value="36" class="abs high">36/36</td>
</tr>
<tr>
<td class="file medium" data-value="utils.ts"><a href="utils.ts.html">utils.ts</a></td>
<td data-value="72.22" class="pic medium">
<div class="chart"><div class="cover-fill" style="width: 72%"></div><div class="cover-empty" style="width: 28%"></div></div>
<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>
<td data-value="72.22" class="pct medium">72.22%</td>
<td data-value="126" class="abs medium">91/126</td>
<td data-value="68.18" class="pct medium">68.18%</td>
<td data-value="66" class="abs medium">45/66</td>
<td data-value="50" class="pct medium">50%</td>
<td data-value="18" class="abs medium">9/18</td>
<td data-value="72.22" class="pct medium">72.22%</td>
<td data-value="126" class="abs medium">91/126</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="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>
</tr>
<tr>
<td class="file high" data-value="vault-indexer.ts"><a href="vault-indexer.ts.html">vault-indexer.ts</a></td>
<td data-value="99.16" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 99%"></div><div class="cover-empty" style="width: 1%"></div></div>
<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">
<div class="chart"><div class="cover-fill" style="width: 74%"></div><div class="cover-empty" style="width: 26%"></div></div>
</td>
<td data-value="99.16" class="pct high">99.16%</td>
<td data-value="120" class="abs high">119/120</td>
<td data-value="91.66" class="pct high">91.66%</td>
<td data-value="36" class="abs high">33/36</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="16" class="abs high">16/16</td>
<td data-value="99.09" class="pct high">99.09%</td>
<td data-value="111" class="abs high">110/111</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>
</tr>
</tbody>
@@ -161,7 +176,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-04T22:31:13.164Z
at 2026-05-06T14:12:40.215Z
</div>
<script src="prettify.js"></script>
<script>
+365 -233
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">87.65% </span>
<span class="strong">76.84% </span>
<span class="quiet">Statements</span>
<span class='fraction'>71/81</span>
<span class='fraction'>73/95</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">82.05% </span>
<span class="strong">53.44% </span>
<span class="quiet">Branches</span>
<span class='fraction'>32/39</span>
<span class='fraction'>31/58</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">71.42% </span>
<span class="strong">88.88% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/7</span>
<span class='fraction'>8/9</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">88.46% </span>
<span class="strong">78.02% </span>
<span class="quiet">Lines</span>
<span class='fraction'>69/78</span>
<span class='fraction'>71/91</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>
@@ -270,53 +270,110 @@
<a name='L205'></a><a href='#L205'>205</a>
<a name='L206'></a><a href='#L206'>206</a>
<a name='L207'></a><a href='#L207'>207</a>
<a name='L208'></a><a href='#L208'>208</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<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>
<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>
<a name='L218'></a><a href='#L218'>218</a>
<a name='L219'></a><a href='#L219'>219</a>
<a name='L220'></a><a href='#L220'>220</a>
<a name='L221'></a><a href='#L221'>221</a>
<a name='L222'></a><a href='#L222'>222</a>
<a name='L223'></a><a href='#L223'>223</a>
<a name='L224'></a><a href='#L224'>224</a>
<a name='L225'></a><a href='#L225'>225</a>
<a name='L226'></a><a href='#L226'>226</a>
<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>
<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-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>
<span class="cline-any cline-yes">17x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">17x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">17x</span>
<span class="cline-any cline-yes">17x</span>
<span class="cline-any cline-yes">17x</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-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>
<span class="cline-any cline-yes">28x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">28x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">28x</span>
<span class="cline-any cline-yes">28x</span>
<span class="cline-any cline-yes">28x</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">10x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-yes">19x</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-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-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">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>
<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-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>
<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-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>
@@ -332,15 +389,18 @@
<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-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>
<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-yes">1x</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-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-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -348,98 +408,103 @@
<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">8x</span>
<span class="cline-any cline-yes">8x</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">7x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</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-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</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-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-yes">61x</span>
<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-yes">9x</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>
<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">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-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-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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-yes">52x</span>
<span class="cline-any cline-yes">52x</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">52x</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">51x</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-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-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-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</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-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">5x</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-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>
<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-yes">7x</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-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-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -448,239 +513,306 @@
<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">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>
<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-neutral">&nbsp;</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>
<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">4x</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>
<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-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">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-yes">4x</span>
<span class="cline-any cline-yes">4x</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-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">21x</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>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import { OllamaMessage, OllamaTool, ToolCall } from './types';
<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></td><td class="text"><pre class="prettyprint lang-js">// src/ollama-client.ts
&nbsp;
interface FetchResponse {
ok: boolean;
status: number;
headers?: {
get: (name: string) =&gt; string | null;
};
body?: {
getReader: () =&gt; ReadableStreamDefaultReader&lt;Uint8Array&gt;;
} | null;
json?: () =&gt; Promise&lt;any&gt;;
}
&nbsp;
interface FetchOptions {
method: string;
headers: Record&lt;string, string&gt;;
body: string;
signal?: AbortSignal;
}
import type { OllamaMessage, OllamaTool } from './types';
import { ApiError, NetworkError } from './types';
import { Logger } from './utils';
&nbsp;
export class OllamaClient {
private url: string;
private baseURL: string;
private model: string;
private abortController: AbortController | null = null;
private fetchFn: typeof fetch;
private readonly maxRetries: number = 3;
&nbsp;
// Mock fetch function for testing
private fetchFn: typeof fetch = fetch;
&nbsp;
constructor(url: string, model: string, fetchFn?: typeof fetch) {
this.url = url;
constructor(baseURL: string, model: string, fetchFn?: typeof fetch) {
this.baseURL = baseURL;
this.model = model;
if (fetchFn) this.fetchFn = fetchFn;
this.fetchFn = fetchFn ?? <span class="branch-1 cbranch-no" title="branch not covered" >fetch;</span>
}
&nbsp;
async streamChat(
cancelStream(): void {
<span class="missing-if-branch" title="if path not taken" >I</span>if (this.abortController) {
<span class="cstat-no" title="statement not covered" > this.abortController.abort();</span>
<span class="cstat-no" title="statement not covered" > this.abortController = null;</span>
}
}
&nbsp;
async *streamChat(
messages: OllamaMessage[],
tools: OllamaTool[],
timeoutMs: number = 60000
): Promise&lt;AsyncIterable&lt;{ content: string; tool_calls?: ToolCall[] }&gt;&gt; {
tools: OllamaTool[] = <span class="branch-0 cbranch-no" title="branch not covered" >[]</span>
): AsyncGenerator&lt;OllamaMessage, void, unknown&gt; {
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;
}
}
&nbsp;
private async *streamChatWithRetry(
messages: OllamaMessage[],
tools: OllamaTool[] = <span class="branch-0 cbranch-no" title="branch not covered" >[],</span>
attempt: number = <span class="branch-0 cbranch-no" title="branch not covered" >0</span>
): AsyncGenerator&lt;OllamaMessage, void, unknown&gt; {
this.abortController = new AbortController();
&nbsp;
const timeoutId = setTimeout(<span class="fstat-no" title="function not covered" >() =</span>&gt; {
<span class="cstat-no" title="statement not covered" > this.abortController?.abort();</span>
}, timeoutMs);
&nbsp;
let response: FetchResponse;
try {
response = await this.fetchFn(`${this.url}/api/chat`, {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
tools,
messages: messages,
tools: tools,
stream: true,
}),
signal: this.abortController.signal,
} as FetchOptions);
} catch (fetchError: any) {
<span class="cstat-no" title="statement not covered" > clearTimeout(timeoutId);</span>
<span class="cstat-no" title="statement not covered" > this.abortController = null;</span>
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (fetchError.name === 'AbortError' || fetchError.code === 'ABORT_ERR') {</span>
<span class="cstat-no" title="statement not covered" > throw new Error('Request timeout while connecting to Ollama');</span>
}
<span class="cstat-no" title="statement not covered" > throw fetchError;</span>
}
});
&nbsp;
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
// 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
<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'
);
<span class="cstat-no" title="statement not covered" > await new Promise(<span class="fstat-no" title="function not covered" >(r</span>esolve) =&gt; <span class="cstat-no" title="statement not covered" >setTimeout(resolve, retryDelay))</span>;</span>
<span class="cstat-no" title="statement not covered" > yield* this.streamChatWithRetry(messages, tools, attempt + 1);</span>
<span class="cstat-no" title="statement not covered" > return;</span>
}
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
}
&nbsp;
if (!response.body) {
throw new Error('No response body');
}
&nbsp;
// Validate response structure
const contentType = response.headers?.get('content-type');
if (!contentType?.match(/application\/(x-ndjson|json)/)) {
const contentType = response.headers.get('content-type');
if (!contentType || (!contentType.includes('ndjson') &amp;&amp; !contentType.includes('json'))) {
throw new Error('Invalid response format');
}
&nbsp;
const reader = response.body.getReader();
&nbsp;
const self = this;
return {
[Symbol.asyncIterator]: async function* () {
const decoder = new TextDecoder();
let buffer = '';
let chunkCount = 0;
let skippedChunks = 0;
const maxChunks = 1000; // Safety limit
const maxSkipped = 50; // Fail if too many chunks are malformed
let malformedCount = 0;
const MAX_MALFORMED = 50;
&nbsp;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
<span class="missing-if-branch" title="if path not taken" >I</span>if (++chunkCount &gt; maxChunks) {
<span class="cstat-no" title="statement not covered" > throw new Error('Response too long, stopped streaming');</span>
}
&nbsp;
buffer += decoder.decode(value, { stream: true });
&nbsp;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
&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>
<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 data = JSON.parse(line);
if (data.message &amp;&amp; typeof data.message === 'object') {
// Validate message structure
if (data.message.error &amp;&amp; typeof data.message.error === 'string') {
throw new Error(`Ollama error: ${data.message.error}`);
}
yield {
content: data.message.content || '',
tool_calls: Array.isArray(data.message.tool_calls)
? data.message.tool_calls
: [],
};
}
} catch (parseError) {
// Check if this is an Ollama error (thrown intentionally) vs a parse error
if (parseError instanceof Error &amp;&amp; parseError.message.startsWith('Ollama error:')) {
// This is an intentional Ollama error, re-throw it
self.abortController = null;
reader.releaseLock();
throw parseError;
const parsed = JSON.parse(line) as Record&lt;string, unknown&gt;;
&nbsp;
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
&nbsp;
// This is a parse error, skip the malformed chunk
skippedChunks++;
console.warn(
`[OllamaClient] Skipped malformed chunk ${skippedChunks}/${maxSkipped}:`,
parseError instanceof Error ? parseError.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(parseError)</span>
);
if (skippedChunks &gt; maxSkipped) {
throw new Error(
`Too many malformed response chunks (${skippedChunks}). Connection may be degraded.`
const message = parsed.message as OllamaMessage | undefined;
<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
}
&nbsp;
malformedCount++;
if (malformedCount &gt; MAX_MALFORMED) {
throw new Error('Too many malformed chunks in stream');
}
&nbsp;
Logger.warn(
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e as Error).message}`,
'ollama-client'
);
}
// Skip invalid chunks but continue streaming
continue;
}
}
&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>
}
&nbsp;
const message = <span class="cstat-no" title="statement not covered" >parsed.message as OllamaMessage | undefined;</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 ?? [],
};
}
} 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>
}
<span class="cstat-no" title="statement not covered" > Logger.warn(</span>
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
'ollama-client'
);
}
} catch (streamError) {
self.abortController = null;
<span class="missing-if-branch" title="if path not taken" >I</span>if (streamError instanceof Error &amp;&amp; streamError.name === 'AbortError') {
<span class="cstat-no" title="statement not covered" > throw new Error('Streaming request was cancelled');</span>
}
throw streamError;
} finally {
reader.releaseLock();
self.abortController = null;
}
},
};
} finally {
this.abortController = null;
}
}
&nbsp;
async chat(
async chat(messages: OllamaMessage[], tools: OllamaTool[] = <span class="branch-0 cbranch-no" title="branch not covered" >[])</span>: Promise&lt;OllamaMessage&gt; {
return this.chatWithRetry(messages, tools, 0);
}
&nbsp;
private async chatWithRetry(
messages: OllamaMessage[],
tools: OllamaTool[],
timeoutMs: number = 30000
): Promise&lt;{ content: string; tool_calls?: ToolCall[] }&gt; {
const abortController = new AbortController();
const timeoutId = setTimeout(<span class="fstat-no" title="function not covered" >() =</span>&gt; {
<span class="cstat-no" title="statement not covered" > abortController.abort();</span>
}, timeoutMs);
tools: OllamaTool[] = <span class="branch-0 cbranch-no" title="branch not covered" >[],</span>
attempt: number = <span class="branch-0 cbranch-no" title="branch not covered" >0</span>
): Promise&lt;OllamaMessage&gt; {
const controller = new AbortController();
&nbsp;
const response = await this.fetchFn(`${this.url}/api/chat`, {
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
tools,
messages: messages,
tools: tools,
stream: false,
}),
signal: abortController.signal,
} as FetchOptions);
&nbsp;
clearTimeout(timeoutId);
signal: controller.signal,
});
&nbsp;
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
// 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);
}
&nbsp;
const responseData = await response.json();
const data = await response.json();
&nbsp;
const data = responseData;
const messageData = data.message;
// Handle missing message content gracefully
if (!data.message) {
return {
content: messageData?.content || '',
tool_calls: messageData?.tool_calls || [],
role: 'assistant',
content: '',
tool_calls: [],
};
}
&nbsp;
cancelStream(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
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
}
}
}
@@ -691,7 +823,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-04T18:57:30.341Z
at 2026-05-06T14:12:40.215Z
</div>
<script src="prettify.js"></script>
<script>
+298 -85
View File
@@ -23,30 +23,30 @@
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="strong">91.37% </span>
<span class="quiet">Statements</span>
<span class='fraction'>24/24</span>
<span class='fraction'>53/58</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">83.33% </span>
<span class="strong">78.78% </span>
<span class="quiet">Branches</span>
<span class='fraction'>10/12</span>
<span class='fraction'>26/33</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>2/2</span>
<span class='fraction'>4/4</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="strong">91.37% </span>
<span class="quiet">Lines</span>
<span class='fraction'>24/24</span>
<span class='fraction'>53/58</span>
</div>
@@ -123,7 +123,85 @@
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a>
<a name='L109'></a><a href='#L109'>109</a>
<a name='L110'></a><a href='#L110'>110</a>
<a name='L111'></a><a href='#L111'>111</a>
<a name='L112'></a><a href='#L112'>112</a>
<a name='L113'></a><a href='#L113'>113</a>
<a name='L114'></a><a href='#L114'>114</a>
<a name='L115'></a><a href='#L115'>115</a>
<a name='L116'></a><a href='#L116'>116</a>
<a name='L117'></a><a href='#L117'>117</a>
<a name='L118'></a><a href='#L118'>118</a>
<a name='L119'></a><a href='#L119'>119</a>
<a name='L120'></a><a href='#L120'>120</a>
<a name='L121'></a><a href='#L121'>121</a>
<a name='L122'></a><a href='#L122'>122</a>
<a name='L123'></a><a href='#L123'>123</a>
<a name='L124'></a><a href='#L124'>124</a>
<a name='L125'></a><a href='#L125'>125</a>
<a name='L126'></a><a href='#L126'>126</a>
<a name='L127'></a><a href='#L127'>127</a>
<a name='L128'></a><a href='#L128'>128</a>
<a name='L129'></a><a href='#L129'>129</a>
<a name='L130'></a><a href='#L130'>130</a>
<a name='L131'></a><a href='#L131'>131</a>
<a name='L132'></a><a href='#L132'>132</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>
<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">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -138,54 +216,125 @@
<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">27x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">27x</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-neutral">&nbsp;</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">25x</span>
<span class="cline-any cline-yes">25x</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>
<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">25x</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-yes">24x</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-yes">22x</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-yes">21x</span>
<span class="cline-any cline-yes">21x</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-yes">7x</span>
<span class="cline-any cline-yes">6x</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>
<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 { Vault, TFile, Notice, App } from 'obsidian';
import { ToolCall, ToolResult, ToolExecutionError, PathValidationError } from './types';
import { validatePath, safeParseJson } from './utils';
<span class="cline-any cline-yes">20x</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-yes">12x</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">9x</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">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-yes">8x</span>
<span class="cline-any cline-yes">8x</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-yes">7x</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-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">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>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-yes">27x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">27x</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">27x</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">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</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">26x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">25x</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>
<span class="cline-any cline-yes">20x</span>
<span class="cline-any cline-yes">20x</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">25x</span>
<span class="cline-any cline-yes">25x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">25x</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-yes">23x</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">22x</span>
<span class="cline-any cline-yes">15x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">7x</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</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></td><td class="text"><pre class="prettyprint lang-js">// src/tool-executor.ts
&nbsp;
import { Vault, App } from 'obsidian';
import type { ToolCall, ToolResult } from './types';
import { safeParseJson } from './utils';
&nbsp;
// Disallow characters that are invalid in file paths
const INVALID_PATH_CHARS = /[&lt;&gt;:"|?*~]/;
const MAX_PATH_LENGTH = 200;
const FORBIDDEN_DIRS = ['.obsidian', '.git'];
&nbsp;
export class ToolExecutor {
private vault: Vault;
@@ -196,50 +345,114 @@ export class ToolExecutor {
this.app = app;
}
&nbsp;
async handleToolCall(call: ToolCall): Promise&lt;ToolResult&gt; {
const {
function: { name, arguments: args },
} = call;
private isSafePath(path: string): boolean {
// Reject empty paths
if (!path || path.trim().length === 0) {
return false;
}
&nbsp;
switch (name) {
case 'create_file': {
let filePath: string, content: string;
// Reject paths that are too long
if (path.length &gt; MAX_PATH_LENGTH) {
return false;
}
&nbsp;
// Reject paths with invalid characters
if (INVALID_PATH_CHARS.test(path)) {
return false;
}
&nbsp;
// Reject absolute paths
if (path.startsWith('/') || path.startsWith('\\')) {
return false;
}
&nbsp;
// Reject Windows drive letters (e.g., C:)
<span class="missing-if-branch" title="if path not taken" >I</span>if (/^[a-zA-Z]:/.test(path)) {
<span class="cstat-no" title="statement not covered" > return false;</span>
}
&nbsp;
// Reject paths containing backslashes (Windows-style path separators)
if (path.includes('\\')) {
return false;
}
&nbsp;
// Reject paths that traverse to parent directories
const normalized = path.replace(/^(\.\/)+/, '');
if (normalized.includes('../')) {
return false;
}
&nbsp;
// Reject forbidden directories
for (const dir of FORBIDDEN_DIRS) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
<span class="cstat-no" title="statement not covered" > return false;</span>
}
<span class="missing-if-branch" title="if path not taken" >I</span>if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
<span class="cstat-no" title="statement not covered" > return false;</span>
}
}
&nbsp;
return true;
}
&nbsp;
async handleToolCall(toolCall: ToolCall): Promise&lt;ToolResult&gt; {
try {
// Handle both string (JSON) and object arguments, since some Ollama versions return args as an object
const parsedArgs = typeof args === 'string' ? safeParseJson(args) : args;
filePath = parsedArgs.path;
content = parsedArgs.content;
} catch (e) {
throw new ToolExecutionError(
`Invalid arguments provided for create_file: ${e instanceof Error ? e.message : <span class="branch-1 cbranch-no" title="branch not covered" >'Unknown parsing error'}</span>`,
'create_file'
);
const toolName = toolCall.function?.name;
const rawArgs = toolCall.function?.arguments;
&nbsp;
<span class="missing-if-branch" title="if path not taken" >I</span>if (!toolName) {
<span class="cstat-no" title="statement not covered" > throw new Error('Tool name is required');</span>
}
&nbsp;
// Validate content is a string
if (typeof content !== 'string') {
throw new ToolExecutionError('Content must be a string', 'create_file');
// Parse arguments whether they're a string or object
let parsedArgs: Record&lt;string, unknown&gt;;
if (typeof rawArgs === 'string') {
try {
parsedArgs = safeParseJson(rawArgs) as Record&lt;string, unknown&gt;;
} catch {
throw new Error('Invalid JSON arguments');
}
} else if (rawArgs &amp;&amp; typeof rawArgs === 'object') {
parsedArgs = rawArgs as Record&lt;string, unknown&gt;;
} 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>
}
&nbsp;
// Validate path using shared utility
if (typeof filePath !== 'string') {
throw new ToolExecutionError('Path must be a string', 'create_file');
}
&nbsp;
if (!filePath) {
throw new ToolExecutionError('Path is required', 'create_file');
}
&nbsp;
const pathValidation = validatePath(filePath);
if (!pathValidation.valid) {
throw new PathValidationError(pathValidation.error || <span class="branch-1 cbranch-no" title="branch not covered" >'Path validation failed',</span> filePath);
}
&nbsp;
await this.vault.create(filePath, content);
return { success: true, message: 'File created successfully' };
}
// Process the tool call based on its type
switch (toolName) {
case 'create_file':
return await this.handleCreateFile(parsedArgs);
default:
return { success: false, message: `Unknown tool: ${name}` };
return { success: false, message: `Unknown tool: ${toolName}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(error);</span>
throw new Error(errorMessage);
}
}
&nbsp;
private async handleCreateFile(args: Record&lt;string, unknown&gt;): Promise&lt;ToolResult&gt; {
const path = args.path;
const content = args.content;
&nbsp;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
&nbsp;
if (typeof content !== 'string') {
throw new Error('Content must be a string');
}
&nbsp;
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
&nbsp;
try {
await this.vault.create(path, content);
return { success: true, message: 'File created successfully' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(error);</span>
throw new Error(errorMessage);
}
}
}
@@ -250,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-04T22:31:13.164Z
at 2026-05-06T14:12:40.215Z
</div>
<script src="prettify.js"></script>
<script>
+157 -367
View File
@@ -25,7 +25,7 @@
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>37/37</span>
<span class='fraction'>36/36</span>
</div>
@@ -46,7 +46,7 @@
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>37/37</span>
<span class='fraction'>36/36</span>
</div>
@@ -249,163 +249,91 @@
<a name='L184'></a><a href='#L184'>184</a>
<a name='L185'></a><a href='#L185'>185</a>
<a name='L186'></a><a href='#L186'>186</a>
<a name='L187'></a><a href='#L187'>187</a>
<a name='L188'></a><a href='#L188'>188</a>
<a name='L189'></a><a href='#L189'>189</a>
<a name='L190'></a><a href='#L190'>190</a>
<a name='L191'></a><a href='#L191'>191</a>
<a name='L192'></a><a href='#L192'>192</a>
<a name='L193'></a><a href='#L193'>193</a>
<a name='L194'></a><a href='#L194'>194</a>
<a name='L195'></a><a href='#L195'>195</a>
<a name='L196'></a><a href='#L196'>196</a>
<a name='L197'></a><a href='#L197'>197</a>
<a name='L198'></a><a href='#L198'>198</a>
<a name='L199'></a><a href='#L199'>199</a>
<a name='L200'></a><a href='#L200'>200</a>
<a name='L201'></a><a href='#L201'>201</a>
<a name='L202'></a><a href='#L202'>202</a>
<a name='L203'></a><a href='#L203'>203</a>
<a name='L204'></a><a href='#L204'>204</a>
<a name='L205'></a><a href='#L205'>205</a>
<a name='L206'></a><a href='#L206'>206</a>
<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>
<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>
<a name='L218'></a><a href='#L218'>218</a>
<a name='L219'></a><a href='#L219'>219</a>
<a name='L220'></a><a href='#L220'>220</a>
<a name='L221'></a><a href='#L221'>221</a>
<a name='L222'></a><a href='#L222'>222</a>
<a name='L223'></a><a href='#L223'>223</a>
<a name='L224'></a><a href='#L224'>224</a>
<a name='L225'></a><a href='#L225'>225</a>
<a name='L226'></a><a href='#L226'>226</a>
<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>
<a name='L253'></a><a href='#L253'>253</a>
<a name='L254'></a><a href='#L254'>254</a>
<a name='L255'></a><a href='#L255'>255</a>
<a name='L256'></a><a href='#L256'>256</a>
<a name='L257'></a><a href='#L257'>257</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<a name='L187'></a><a href='#L187'>187</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>
<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-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">2x</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-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-yes">3x</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>
<span class="cline-any cline-yes">34x</span>
<span class="cline-any cline-yes">34x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">34x</span>
<span class="cline-any cline-yes">34x</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">2x</span>
<span class="cline-any cline-yes">17x</span>
<span class="cline-any cline-yes">17x</span>
<span class="cline-any cline-yes">17x</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-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">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</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">2x</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-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</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-yes">2x</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-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>
<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-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-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">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>
<span class="cline-any cline-yes">3x</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-yes">2x</span>
<span class="cline-any cline-neutral">&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-yes">7x</span>
<span class="cline-any cline-yes">7x</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">2x</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-yes">16x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">16x</span>
<span class="cline-any cline-yes">16x</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">2x</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>
@@ -421,6 +349,7 @@
<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-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
@@ -506,80 +435,11 @@
<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-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-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-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></td><td class="text"><pre class="prettyprint lang-js">export interface PluginSettings {
ollamaUrl: string;
model: string;
lastIndexTime: number;
}
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">// src/types.ts
&nbsp;
// ============================================================
// Error Type Hierarchy
// ============================================================
&nbsp;
export enum ErrorType {
NETWORK_ERROR = 'network_error',
@@ -592,88 +452,100 @@ export enum ErrorType {
}
&nbsp;
export class OllamaError extends Error {
constructor(
message: string,
public readonly type: ErrorType,
public readonly details?: Record&lt;string, any&gt;
) {
public readonly type: ErrorType;
&nbsp;
constructor(message: string, type: ErrorType) {
super(message);
this.name = 'OllamaError';
this.type = type;
Object.setPrototypeOf(this, OllamaError.prototype);
}
}
&nbsp;
export class NetworkError extends OllamaError {
constructor(
message: string,
public readonly statusCode?: number
) {
super(message, ErrorType.NETWORK_ERROR, { statusCode });
this.name = 'NetworkError';
public readonly statusCode?: number;
&nbsp;
constructor(message: string, statusCode?: number) {
super(message, ErrorType.NETWORK_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
&nbsp;
export class ApiError extends OllamaError {
constructor(
message: string,
public readonly apiError?: any
) {
super(message, ErrorType.API_ERROR, { apiError });
this.name = 'ApiError';
public readonly statusCode?: number;
&nbsp;
constructor(message: string, statusCode?: number) {
super(message, ErrorType.API_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, ApiError.prototype);
}
}
&nbsp;
export interface ValidationFieldDetails {
field?: string;
message?: string;
}
&nbsp;
export class ValidationError extends OllamaError {
constructor(
message: string,
public readonly validationDetails?: Record&lt;string, string&gt;
) {
super(message, ErrorType.VALIDATION_ERROR, validationDetails);
this.name = 'ValidationError';
public readonly details?: ValidationFieldDetails;
&nbsp;
constructor(message: string, details?: ValidationFieldDetails) {
super(message, ErrorType.VALIDATION_ERROR);
this.details = details;
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
&nbsp;
export class StreamingError extends OllamaError {
constructor(
message: string,
public readonly chunkDetails?: any
) {
super(message, ErrorType.STREAMING_ERROR, chunkDetails);
this.name = 'StreamingError';
constructor(message: string) {
super(message, ErrorType.STREAMING_ERROR);
Object.setPrototypeOf(this, StreamingError.prototype);
}
}
&nbsp;
export class ToolExecutionError extends OllamaError {
constructor(
message: string,
public readonly toolName?: string
) {
super(message, ErrorType.TOOL_EXECUTION_ERROR, { toolName });
this.name = 'ToolExecutionError';
public readonly toolName: string;
&nbsp;
constructor(message: string, toolName: string) {
super(message, ErrorType.TOOL_EXECUTION_ERROR);
this.toolName = toolName;
Object.setPrototypeOf(this, ToolExecutionError.prototype);
}
}
&nbsp;
export class PathValidationError extends OllamaError {
constructor(
message: string,
public readonly invalidPath?: string
) {
super(message, ErrorType.PATH_VALIDATION_ERROR, { invalidPath });
this.name = 'PathValidationError';
public readonly path: string;
&nbsp;
constructor(message: string, path: string) {
super(message, ErrorType.PATH_VALIDATION_ERROR);
this.path = path;
Object.setPrototypeOf(this, PathValidationError.prototype);
}
}
&nbsp;
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_calls?: ToolCall[];
// ============================================================
// Plugin Configuration
// ============================================================
&nbsp;
export interface PluginSettings {
ollamaUrl: string;
model: string;
vaultSearchLimit: number;
maxMessageHistory: number;
lastIndexTime: number;
}
&nbsp;
export interface ToolCall {
function: {
name: string;
arguments: string | Record&lt;string, any&gt;;
};
}
export const DEFAULT_SETTINGS: PluginSettings = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
&nbsp;
// ============================================================
// Ollama Protocol Types
// ============================================================
&nbsp;
export interface OllamaTool {
type: 'function';
@@ -682,154 +554,72 @@ export interface OllamaTool {
description: string;
parameters: {
type: 'object';
properties: Record&lt;string, { type: string }&gt;;
properties: Record&lt;string, unknown&gt;;
required: string[];
};
};
}
&nbsp;
export interface OllamaToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string | Record&lt;string, unknown&gt;;
};
}
&nbsp;
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_calls?: OllamaToolCall[];
}
&nbsp;
// ============================================================
// Tool Execution Types
// ============================================================
&nbsp;
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string | Record&lt;string, unknown&gt;;
};
}
&nbsp;
export interface ToolResult {
success: boolean;
message: string;
// Adding optional details field for better error reporting
details?: Record&lt;string, any&gt;;
}
&nbsp;
export interface VaultIndexEntry {
title: string;
content: string;
score: number;
export interface ExecutionResult {
success: boolean;
output: string;
}
&nbsp;
// ============================================================
// Chat Message Types
// ============================================================
&nbsp;
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
role: 'user' | 'assistant';
content: string;
timestamp: number;
isStreaming?: boolean;
tool_calls?: ToolCall[];
}
&nbsp;
/**
* API Response Types
*/
// ============================================================
// Vault Index Types
// ============================================================
&nbsp;
export interface OllamaChatResponse {
model: string;
created_at: string;
message: {
role: 'assistant';
export interface VaultIndexEntry {
path: string;
title: string;
content: string;
tool_calls?: ToolCall[];
};
done: boolean;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
eval_count?: number;
}
&nbsp;
export interface OllamaStreamResponse {
model: string;
created_at: string;
message: {
role: 'assistant';
content: string;
tool_calls?: ToolCall[];
};
done: boolean;
}
&nbsp;
export interface OllamaErrorResponse {
error: string;
}
&nbsp;
export interface OllamaModelList {
models: Array&lt;{
name: string;
id: string;
modified_at: string;
size: number;
}&gt;;
}
&nbsp;
export interface OllamaGenerateResponse {
model: string;
created_at: string;
response: string;
done: boolean;
context?: number[];
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
eval_count?: number;
}
&nbsp;
export interface OllamaPullStatus {
status: string;
digest: string;
total_size: number;
completed_size: number;
}
&nbsp;
export interface OllamaEmbeddingResponse {
embeddings: number[];
model: string;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
eval_count?: number;
}
&nbsp;
/**
* Client Configuration Types
*/
&nbsp;
export interface OllamaClientConfig {
url: string;
model: string;
timeout?: number;
maxRetries?: number;
}
&nbsp;
export interface OllamaRequestOptions {
timeout?: number;
signal?: AbortSignal;
}
&nbsp;
/**
* Streaming Types
*/
&nbsp;
export interface StreamChunk {
content: string;
tool_calls?: ToolCall[];
done?: boolean;
error?: string;
}
&nbsp;
export interface StreamMetadata {
model: string;
created_at: string;
done: boolean;
total_duration?: number;
load_duration?: number;
}
&nbsp;
export interface ChatSession {
id: string;
messages: OllamaMessage[];
createdAt: number;
model: string;
}
&nbsp;
export interface ToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: Record&lt;string, any&gt;;
};
score: number;
}
&nbsp;</pre></td></tr></table></pre>
@@ -838,7 +628,7 @@ export interface ToolDefinition {
<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-04T22:31:13.164Z
at 2026-05-06T14:12:40.215Z
</div>
<script src="prettify.js"></script>
<script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+849 -629
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
// Debug the heading matching
const heading = "Algorithm Design";
const content = "This file mentions algorithm somewhere in the body text";
const query = "algorithm";
function stemToken(token) {
if (token.endsWith('s')) return token.slice(0, -1);
if (token.endsWith('ed')) return token.slice(0, -2);
if (token.endsWith('ing')) return token.slice(0, -3);
return token;
}
function tokenize(text) {
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'of', 'for', 'with', 'as', 'by', 'it', 'its', 'that', 'this', 'these', 'those']);
return text
.toLowerCase()
.split(/\W+/)
.filter((token) => token.length > 1 && !stopWords.has(token));
}
const queryTokens = tokenize(query);
const headingTokens = tokenize(heading);
const contentTokens = tokenize(content);
console.log("Query:", query);
console.log("Query tokens:", queryTokens);
console.log("Heading:", heading);
console.log("Heading tokens:", headingTokens);
console.log("Content:", content);
console.log("Content tokens:", contentTokens);
const queryStemmed = queryTokens.map(t => stemToken(t));
const headingStemmed = headingTokens.map(t => stemToken(t));
const contentStemmed = contentTokens.map(t => stemToken(t));
console.log("Query stemmed:", queryStemmed);
console.log("Heading stemmed:", headingStemmed);
console.log("Content stemmed:", contentStemmed);
// Check heading match
const headingMatch = headingStemmed.some(h => h.includes(stemToken(queryStemmed[0])));
console.log("Heading match:", headingMatch);
// Check content match
const contentMatch = contentStemmed.includes(stemToken(queryStemmed[0]));
console.log("Content match:", contentMatch);
+34
View File
@@ -0,0 +1,34 @@
// Debug heading extraction
const file1Content = "# Algorithm Design\n\nThis discusses design patterns";
const file2Content = "This file mentions algorithm somewhere in the body text";
function extractHeadings(content) {
const headingMatches = content.match(/^# (.*?)$/gm);
if (headingMatches) {
return headingMatches.map((h) => h.replace(/^# /, ''));
}
return [];
}
console.log("File 1 content:", file1Content);
console.log("File 1 headings:", extractHeadings(file1Content));
console.log("File 2 content:", file2Content);
console.log("File 2 headings:", extractHeadings(file2Content));
// Check if there's any issue with the regex
const allLines1 = file1Content.split('\n');
const allLines2 = file2Content.split('\n');
console.log("File 1 lines:", allLines1);
console.log("File 2 lines:", allLines2);
// Check each line for heading match
allLines1.forEach((line, i) => {
const match = line.match(/^# (.*?)$/);
console.log(`File 1 line ${i}: "${line}" -> heading match: ${!!match}`);
});
allLines2.forEach((line, i) => {
const match = line.match(/^# (.*?)$/);
console.log(`File 2 line ${i}: "${line}" -> heading match: ${!!match}`);
});
+92
View File
@@ -0,0 +1,92 @@
// Debug the scoring logic
const file1Content = "# Algorithm Design\n\nThis discusses design patterns";
const file2Content = "This file mentions algorithm somewhere in the body text";
const query = "algorithm";
function stemToken(token) {
if (token.endsWith('s')) return token.slice(0, -1);
if (token.endsWith('ed')) return token.slice(0, -2);
if (token.endsWith('ing')) return token.slice(0, -3);
return token;
}
function tokenize(text) {
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'of', 'for', 'with', 'as', 'by', 'it', 'its', 'that', 'this', 'these', 'those']);
return text
.toLowerCase()
.split(/\W+/)
.filter((token) => token.length > 1 && !stopWords.has(token));
}
function exactMatch(content, token) {
const stemmed = stemToken(token);
const contentTokens = tokenize(content);
return contentTokens.some((ct) => stemToken(ct) === stemmed);
}
function extractHeadings(content) {
const headingMatches = content.match(/^# (.*?)$/gm);
if (headingMatches) {
return headingMatches.map((h) => h.replace(/^# /, ''));
}
return [];
}
function extractContentTokens(content) {
const allText = content
.replace(/^---.*?---/s, '')
.replace(/^#.*?$/gm, '')
.replace(/```.*?```/gs, '')
.replace(/`.*?`/g, '')
.replace(/\[.*?\]\(.*?\)/g, '');
return tokenize(allText);
}
const queryTokens = tokenize(query);
const file1Headings = extractHeadings(file1Content);
const file1Tokens = extractContentTokens(file1Content);
const file2Headings = extractHeadings(file2Content);
const file2Tokens = extractContentTokens(file2Content);
console.log("Query tokens:", queryTokens);
console.log("File 1 headings:", file1Headings);
console.log("File 1 content tokens:", file1Tokens);
console.log("File 2 headings:", file2Headings);
console.log("File 2 content tokens:", file2Tokens);
// Calculate scores
function calculateScore(headings, contentTokens, queryTokens) {
let totalScore = 0;
for (const queryToken of queryTokens) {
let tokenScore = 0;
const stemmed = stemToken(queryToken);
let matched = false;
// Weight 2: Heading check
if (headings.some((heading) => heading.toLowerCase().includes(stemmed))) {
tokenScore += 2;
matched = true;
}
// Weight 1: Content token check
const contentMatch = contentTokens.includes(stemmed);
if (contentMatch) {
tokenScore += 1;
matched = true;
}
if (matched) {
totalScore += tokenScore;
}
}
return totalScore;
}
const score1 = calculateScore(file1Headings, file1Tokens, queryTokens);
const score2 = calculateScore(file2Headings, file2Tokens, queryTokens);
console.log("File 1 score:", score1);
console.log("File 2 score:", score2);
console.log("File 1 should be first:", score1 > score2);
+30
View File
@@ -0,0 +1,30 @@
// Debug the exactMatch function
const text = "algorithm";
const query = "algorithm";
function stemToken(token) {
if (token.endsWith('s')) return token.slice(0, -1);
if (token.endsWith('ed')) return token.slice(0, -2);
if (token.endsWith('ing')) return token.slice(0, -3);
return token;
}
function tokenize(text) {
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'of', 'for', 'with', 'as', 'by', 'it', 'its', 'that', 'this', 'these', 'those']);
return text
.toLowerCase()
.split(/\W+/)
.filter((token) => token.length > 1 && !stopWords.has(token));
}
function exactMatch(content, token) {
const stemmed = stemToken(token);
const contentTokens = tokenize(content);
return contentTokens.some((ct) => stemToken(ct) === stemmed);
}
console.log("Text:", text);
console.log("Query:", query);
console.log("Tokenized text:", tokenize(text));
console.log("Stemmed query:", stemToken(query));
console.log("Exact match result:", exactMatch(text, query));
+70
View File
@@ -0,0 +1,70 @@
// jest.setup.js
// Setup global browser APIs not provided by jsdom
// Mock confirm/alert
global.confirm = jest.fn().mockReturnValue(true);
global.alert = jest.fn();
// Mock fetch as a global no-op that can be overridden per-test
global.fetch = jest.fn();
// Ensure TextEncoder/TextDecoder are available (Node.js polyfill)
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// Ensure AbortController/AbortSignal are available
if (!global.AbortController) {
global.AbortController = class AbortController {
constructor() {
this.signal = {
aborted: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
throwIfAborted: () => {},
};
}
abort() {
this.signal.aborted = true;
}
};
}
// Extend HTMLElement with Obsidian-style createEl helper
// This allows test elements to create children just like Obsidian's DOM API
if (typeof HTMLElement !== 'undefined') {
HTMLElement.prototype.createEl = function (tag, options) {
const el = document.createElement(tag);
if (options) {
if (options.cls) {
const classes = Array.isArray(options.cls) ? options.cls : options.cls.split(' ');
classes.forEach((c) => {
if (c) el.classList.add(c);
});
}
if (options.text) {
el.textContent = options.text;
}
if (options.html) {
el.innerHTML = options.html;
}
if (options.attr) {
Object.entries(options.attr).forEach(([k, v]) => {
el.setAttribute(k, v);
});
}
if (options.prop) {
Object.entries(options.prop).forEach(([k, v]) => {
el[k] = v;
});
}
if (options.select) {
options.select(el);
}
}
this.appendChild(el);
return el;
};
}
+16
View File
@@ -0,0 +1,16 @@
{
"id": "ollama-plugin",
"name": "Ollama Plugin",
"version": "1.0.0",
"minAppVersion": "1.4.11",
"description": "Ollama integration plugin for Obsidian",
"author": "Anonymous",
"authorUrl": "",
"isDesktopOnly": false,
"main": "main.js",
"authorization": [],
"permissions": [],
"defaultEnabled": true,
"scripts": [],
"styles": []
}
+13
View File
@@ -36,6 +36,19 @@ export class ChatView extends ItemView {
private newChatButtonClickHandler: (() => void) | null = null;
private listenersAttached = false;
// Getters for testing
public getSendButtonClickHandler(): (() => Promise<void>) | null {
return this.sendButtonClickHandler;
}
public getInputKeyDownHandler(): ((e: KeyboardEvent) => Promise<void>) | null {
return this.inputKeyDownHandler;
}
public getNewChatButtonClickHandler(): (() => void) | null {
return this.newChatButtonClickHandler;
}
constructor(leaf: WorkspaceLeaf, settings: PluginSettings) {
super(leaf);
this.settings = settings;
+12
View File
@@ -0,0 +1,12 @@
// Default plugin settings
export const DEFAULT_SETTINGS = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
// Model validation regex - lowercase letters, numbers, dashes, underscores only
export const MODEL_NAME_REGEX = /^[a-z0-9-_]+$/;
+127 -14
View File
@@ -1,21 +1,134 @@
// src/error-handler.ts
import { NetworkError, ApiError, UserInputError } from './errors';
import { Notice } from 'obsidian';
import {
OllamaError,
ErrorType,
NetworkError,
ApiError,
ValidationError,
StreamingError,
ToolExecutionError,
PathValidationError,
} from './types';
export class ErrorHandler {
static handle(error: unknown): void {
if (error instanceof NetworkError) {
console.error('Network Error:', error.message);
// Handle network errors, e.g., show a notification to the user
} else if (error instanceof ApiError) {
console.error('API Error:', error.message, 'Status Code:', error.statusCode);
// Handle API errors, e.g., show a notification with status code
} else if (error instanceof UserInputError) {
console.warn('User Input Error:', error.message);
// Handle user input errors, e.g., highlight the input field
} else {
console.error('Unexpected Error:', error);
// Handle unexpected errors, e.g., log to a service or show a generic message
static handleError(error: unknown, context?: string): void {
const message = this.getUserFriendlyMessage(error);
new Notice(message);
if (error instanceof Error) {
const ctx = context ? ` [${context}]` : '';
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
if (error.stack) {
console.error(error.stack);
}
} else {
const ctx = context ? ` [${context}]` : '';
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
private static getUserFriendlyMessage(error: unknown): string {
if (error instanceof OllamaError) {
return this.getUserFriendlyMessageFromOllamaError(error);
}
if (error instanceof Error) {
return this.getUserFriendlyMessageFromError(error);
}
return 'An unexpected error occurred';
}
private static getUserFriendlyMessageFromOllamaError(error: OllamaError): string {
switch (error.type) {
case ErrorType.NETWORK_ERROR:
return 'Connection error. Please check if Ollama is running.';
case ErrorType.API_ERROR:
return `API error: ${error.message}`;
case ErrorType.VALIDATION_ERROR:
return this.getUserFriendlyValidationMessage(error);
case ErrorType.STREAMING_ERROR:
return 'Response too long. Please try a shorter request.';
case ErrorType.TOOL_EXECUTION_ERROR:
return `Tool error for ${(error as ToolExecutionError).toolName}. ${error.message}`;
case ErrorType.PATH_VALIDATION_ERROR:
return `Invalid file path: ${(error as PathValidationError).path}`;
case ErrorType.UNKNOWN_ERROR:
return 'An unexpected error occurred';
default:
return 'An unexpected error occurred';
}
}
private static getUserFriendlyValidationMessage(error: OllamaError): string {
if (error instanceof ValidationError && error.details?.field) {
const fieldMsg = error.details.field.charAt(0).toUpperCase() + error.details.field.slice(1);
return `Invalid ${fieldMsg.toLowerCase()}. ${error.details.message ?? error.message}`;
}
return 'Input validation error. Please correct your input.';
}
private static getUserFriendlyMessageFromError(error: Error): string {
const msg = error.message.toLowerCase();
// Check timeout BEFORE network (more specific matches first)
if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('time out')) {
return 'Request timed out. Please check your Ollama connection.';
}
if (msg.includes('network') || msg.includes('connection') || msg.includes('fetch')) {
return 'Connection error. Please check if Ollama is running.';
}
if (msg.includes('validation') || msg.includes('invalid')) {
return 'Invalid input. Please correct your input.';
}
if (msg.includes('stream') || msg.includes('chunk')) {
return 'Response too long. Please try a shorter request.';
}
if (msg.includes('tool') || msg.includes('function')) {
return 'Tool error. Please try again.';
}
if (msg.includes('path') || msg.includes('file')) {
return 'Invalid file path. Please check the path and try again.';
}
return 'An unexpected error occurred';
}
// -- Factory methods --
static createNetworkError(message: string, statusCode?: number): NetworkError {
return new NetworkError(message, statusCode);
}
static createApiError(message: string, statusCode?: number): ApiError {
return new ApiError(message, statusCode);
}
static createValidationError(message: string, field?: string): ValidationError {
const details = field ? { field, message } : undefined;
return new ValidationError(message, details);
}
static createStreamingError(message: string): StreamingError {
return new StreamingError(message);
}
static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
return new ToolExecutionError(message, toolName ?? 'unknown');
}
static createPathValidationError(message: string, path?: string): PathValidationError {
return new PathValidationError(message, path ?? '');
}
static createUnknownError(message: string): OllamaError {
return new OllamaError(message, ErrorType.UNKNOWN_ERROR);
}
}
+24 -10
View File
@@ -1,20 +1,17 @@
import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab } from 'obsidian';
import { ChatView } from './src/chat-view';
import { PluginSettings } from './src/types';
import { ChatView } from './chat-view';
import { PluginSettings } from './types';
import {
isValidHttpUrl,
validatePluginSettings,
validateOllamaUrl,
validateModelName,
Logger,
} from './src/utils';
} from './utils';
import { DEFAULT_SETTINGS } from './constants';
export default class OllamaPlugin extends Plugin {
settings: PluginSettings = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
lastIndexTime: 0,
};
settings: PluginSettings = DEFAULT_SETTINGS;
async onload() {
// Initialize logging
@@ -23,11 +20,18 @@ export default class OllamaPlugin extends Plugin {
await this.loadSettings();
Logger.info('Plugin loaded successfully', 'plugin');
try {
this.registerView(
'ollama-chat-view',
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings)
);
} catch (error) {
Logger.error('Failed to register view: ' + (error as Error).message, 'plugin');
new Notice('Failed to register Ollama chat view');
// Don't throw - let the plugin continue loading other features
}
try {
this.addRibbonIcon('message-square', 'Ollama Chat', async () => {
const leaf = this.app.workspace.getLeaf();
await leaf.setViewState({
@@ -36,6 +40,11 @@ export default class OllamaPlugin extends Plugin {
});
this.app.workspace.revealLeaf(leaf);
});
} catch (error) {
Logger.error('Failed to add ribbon icon: ' + (error as Error).message, 'plugin');
new Notice('Failed to add Ollama ribbon icon');
// Don't throw - let the plugin continue loading other features
}
this.addSettingTab(new OllamaSettingTab(this.app, this));
}
@@ -49,7 +58,7 @@ export default class OllamaPlugin extends Plugin {
}
} catch (error) {
// Use centralized error handling
const { ErrorHandler } = await import('./src/error-handler');
const { ErrorHandler } = await import('./error-handler');
ErrorHandler.handleError(error, 'settings load');
}
}
@@ -73,7 +82,7 @@ export default class OllamaPlugin extends Plugin {
return true;
} catch (error) {
// Use centralized error handling
const { ErrorHandler } = await import('./src/error-handler');
const { ErrorHandler } = await import('./error-handler');
ErrorHandler.handleError(error, 'settings save');
return false;
}
@@ -89,6 +98,10 @@ class OllamaSettingTab extends PluginSettingTab {
}
display(): void {
// Clear any existing content first to prevent duplicates
this.containerEl.empty();
// Create container for settings
const container = this.containerEl.createDiv() as HTMLElement;
container.empty();
@@ -128,6 +141,7 @@ class OllamaSettingTab extends PluginSettingTab {
}
hide(): void {
// Clear the container to prevent duplicate elements
this.containerEl.empty();
}
}
+201 -74
View File
@@ -1,124 +1,251 @@
// src/ollama-client.ts
import type { OllamaMessage, ToolCall } from './types';
import { ApiError, NetworkError, UserInputError } from './error-handler';
import type { OllamaMessage, OllamaTool } from './types';
import { ApiError, NetworkError } from './types';
import { Logger } from './utils';
export class OllamaClient {
private url: string;
private baseURL: string;
private model: string;
private abortController: AbortController | null = null;
private fetchFn: typeof fetch;
private readonly maxRetries: number = 3;
constructor(url: string, model: string, fetchFn?: typeof fetch) {
this.url = url;
constructor(baseURL: string, model: string, fetchFn?: typeof fetch) {
this.baseURL = baseURL;
this.model = model;
this.fetchFn = fetchFn ?? fetch;
}
async *streamChatMessages(
prompt: string,
options: { abortSignal?: AbortSignal } = {}
): AsyncGenerator<OllamaMessage, void, unknown> {
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener('abort', () => controller.abort());
cancelStream(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
async *streamChat(
messages: OllamaMessage[],
tools: OllamaTool[] = []
): AsyncGenerator<OllamaMessage, void, unknown> {
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 {
const response = await this.fetchFn(`${this.url}/chat`, {
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;
}
}
private async *streamChatWithRetry(
messages: OllamaMessage[],
tools: OllamaTool[] = [],
attempt: number = 0
): AsyncGenerator<OllamaMessage, void, unknown> {
this.abortController = new AbortController();
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, prompt }),
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: true,
}),
signal: this.abortController.signal,
});
if (!response.ok) {
throw new ApiError('Failed to fetch chat messages', response.status);
// 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));
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
return;
}
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
if (!response.body) {
throw new Error('No response body');
}
const contentType = response.headers.get('content-type');
if (!contentType || (!contentType.includes('ndjson') && !contentType.includes('json'))) {
throw new Error('Invalid response format');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let malformedCount = 0;
const MAX_MALFORMED = 50;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
const messages: OllamaMessage[] = JSON.parse(chunk);
for (const message of messages) {
yield message;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message as OllamaMessage | undefined;
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++;
if (malformedCount > MAX_MALFORMED) {
throw new Error('Too many malformed chunks in stream');
}
Logger.warn(
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e 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)}`);
}
const message = parsed.message as OllamaMessage | undefined;
if (message) {
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;
}
Logger.warn(
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
'ollama-client'
);
}
}
} finally {
reader.releaseLock();
}
} catch (error) {
controller.abort();
throw error;
} finally {
this.abortController = null;
}
}
async *streamToolMessages(
toolCall: ToolCall,
options: { abortSignal?: AbortSignal } = {}
): AsyncGenerator<OllamaMessage, void, unknown> {
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener('abort', () => controller.abort());
async chat(messages: OllamaMessage[], tools: OllamaTool[] = []): Promise<OllamaMessage> {
return this.chatWithRetry(messages, tools, 0);
}
private async chatWithRetry(
messages: OllamaMessage[],
tools: OllamaTool[] = [],
attempt: number = 0
): Promise<OllamaMessage> {
const controller = new AbortController();
try {
const response = await this.fetchFn(`${this.url}/tool`, {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, toolCall }),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new ApiError('Failed to fetch tool messages', response.status);
// 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);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
const data = await response.json();
// Handle missing message content gracefully
if (!data.message) {
return {
role: 'assistant',
content: '',
tool_calls: [],
};
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
const messages: OllamaMessage[] = JSON.parse(chunk);
for (const message of messages) {
yield message;
}
}
return {
role: data.message.role ?? 'assistant',
content: typeof data.message.content === 'string' ? data.message.content : '',
tool_calls: data.message.tool_calls ?? [],
};
} finally {
reader.releaseLock();
}
} catch (error) {
controller.abort();
throw error;
}
}
async summarizeText(text: string): Promise<{ summary: string }> {
try {
const response = await this.fetchFn(`${this.url}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, text }),
});
if (!response.ok) {
throw new ApiError('Failed to summarize text', response.status);
}
return await response.json();
} catch (error) {
throw error;
// No need to abort after successful response, but signal is available
}
}
}
+105 -32
View File
@@ -1,58 +1,131 @@
// src/tool-executor.ts
import { OllamaClient } from './ollama-client';
import type { ToolCall, ExecutionResult } from './types';
import { ErrorHandler } from './error-handler';
import { safeWriteFile } from './utils';
import { UserInputError, ApiError } from './errors';
import { Vault, App } from 'obsidian';
import type { ToolCall, ToolResult } from './types';
import { safeParseJson } from './utils';
// Disallow characters that are invalid in file paths
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
const MAX_PATH_LENGTH = 200;
const FORBIDDEN_DIRS = ['.obsidian', '.git'];
export class ToolExecutor {
private ollamaClient: OllamaClient;
private vault: Vault;
private app: App;
constructor(ollamaClient: OllamaClient) {
this.ollamaClient = ollamaClient;
constructor(vault: Vault, app: App) {
this.vault = vault;
this.app = app;
}
async executeTool(toolCall: ToolCall, options: { abortSignal?: AbortSignal } = {}): Promise<ExecutionResult> {
private isSafePath(path: string): boolean {
// Reject empty paths
if (!path || path.trim().length === 0) {
return false;
}
// Reject paths that are too long
if (path.length > MAX_PATH_LENGTH) {
return false;
}
// Reject paths with invalid characters
if (INVALID_PATH_CHARS.test(path)) {
return false;
}
// Reject absolute paths
if (path.startsWith('/') || path.startsWith('\\')) {
return false;
}
// Reject Windows drive letters (e.g., C:)
if (/^[a-zA-Z]:/.test(path)) {
return false;
}
// Reject paths containing backslashes (Windows-style path separators)
if (path.includes('\\')) {
return false;
}
// Reject paths that traverse to parent directories
const normalized = path.replace(/^(\.\/)+/, '');
if (normalized.includes('../')) {
return false;
}
// Reject forbidden directories
for (const dir of FORBIDDEN_DIRS) {
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
return false;
}
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
return false;
}
}
return true;
}
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
try {
const messages: string[] = [];
const toolName = toolCall.function?.name;
const rawArgs = toolCall.function?.arguments;
for await (const message of this.ollamaClient.streamToolMessages(toolCall, options)) {
if (options.abortSignal?.aborted) {
throw new Error('Operation aborted');
}
messages.push(message.content);
if (!toolName) {
throw new Error('Tool name is required');
}
const finalOutput = messages.join('\n');
// Parse arguments whether they're a string or object
let parsedArgs: Record<string, unknown>;
if (typeof rawArgs === 'string') {
try {
parsedArgs = safeParseJson(rawArgs) as Record<string, unknown>;
} catch {
throw new Error('Invalid JSON arguments');
}
} else if (rawArgs && typeof rawArgs === 'object') {
parsedArgs = rawArgs as Record<string, unknown>;
} else {
throw new Error('Arguments must be an object or JSON string');
}
// Process the tool output based on its type
switch (toolCall.tool_name) {
// Process the tool call based on its type
switch (toolName) {
case 'create_file':
await this.handleCreateFile(toolCall.arguments, finalOutput);
break;
// Add more cases for other tools as needed
return await this.handleCreateFile(parsedArgs);
default:
console.warn(`Unsupported tool: ${toolCall.tool_name}`);
return { success: false, message: `Unknown tool: ${toolName}` };
}
return { success: true, output: finalOutput };
} catch (error) {
ErrorHandler.handle(error);
return { success: false, output: error instanceof Error ? error.message : 'An unknown error occurred' };
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
}
}
private async handleCreateFile(args: Record<string, string>, content: string): Promise<void> {
const filePath = args.path;
if (!filePath) {
throw new UserInputError('Path argument is required for create_file tool');
private async handleCreateFile(args: Record<string, unknown>): Promise<ToolResult> {
const path = args.path;
const content = args.content;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (typeof content !== 'string') {
throw new Error('Content must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
try {
await safeWriteFile(filePath, content);
await this.vault.create(path, content);
return { success: true, message: 'File created successfully' };
} catch (error) {
throw new ApiError('Failed to write file', 500);
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
}
}
}
+173 -13
View File
@@ -1,26 +1,186 @@
// src/types.ts
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant';
content: string;
// ============================================================
// Error Type Hierarchy
// ============================================================
export enum ErrorType {
NETWORK_ERROR = 'network_error',
API_ERROR = 'api_error',
VALIDATION_ERROR = 'validation_error',
STREAMING_ERROR = 'streaming_error',
TOOL_EXECUTION_ERROR = 'tool_execution_error',
PATH_VALIDATION_ERROR = 'path_validation_error',
UNKNOWN_ERROR = 'unknown_error',
}
export class OllamaError extends Error {
public readonly type: ErrorType;
constructor(message: string, type: ErrorType) {
super(message);
this.type = type;
Object.setPrototypeOf(this, OllamaError.prototype);
}
}
export class NetworkError extends OllamaError {
public readonly statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message, ErrorType.NETWORK_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
export class ApiError extends OllamaError {
public readonly statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message, ErrorType.API_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, ApiError.prototype);
}
}
export interface ValidationFieldDetails {
field?: string;
message?: string;
}
export class ValidationError extends OllamaError {
public readonly details?: ValidationFieldDetails;
constructor(message: string, details?: ValidationFieldDetails) {
super(message, ErrorType.VALIDATION_ERROR);
this.details = details;
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
export class StreamingError extends OllamaError {
constructor(message: string) {
super(message, ErrorType.STREAMING_ERROR);
Object.setPrototypeOf(this, StreamingError.prototype);
}
}
export class ToolExecutionError extends OllamaError {
public readonly toolName: string;
constructor(message: string, toolName: string) {
super(message, ErrorType.TOOL_EXECUTION_ERROR);
this.toolName = toolName;
Object.setPrototypeOf(this, ToolExecutionError.prototype);
}
}
export class PathValidationError extends OllamaError {
public readonly path: string;
constructor(message: string, path: string) {
super(message, ErrorType.PATH_VALIDATION_ERROR);
this.path = path;
Object.setPrototypeOf(this, PathValidationError.prototype);
}
}
// ============================================================
// Plugin Configuration
// ============================================================
export interface PluginSettings {
ollamaUrl: string;
model: string;
vaultSearchLimit: number;
maxMessageHistory: number;
lastIndexTime: number;
}
export const DEFAULT_SETTINGS: PluginSettings = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
// ============================================================
// Ollama Protocol Types
// ============================================================
export interface OllamaTool {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required: string[];
};
};
}
export interface OllamaToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string | Record<string, unknown>;
};
}
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_calls?: OllamaToolCall[];
}
// ============================================================
// Tool Execution Types
// ============================================================
export interface ToolCall {
tool_name: string;
arguments: Record<string, unknown>;
id: string;
type: 'function';
function: {
name: string;
arguments: string | Record<string, unknown>;
};
}
export interface ModelConfig {
model: string;
url: string;
}
export enum RequestType {
Chat = 'chat',
Tool = 'tool',
export interface ToolResult {
success: boolean;
message: string;
}
export interface ExecutionResult {
success: boolean;
output: string;
}
// ============================================================
// Chat Message Types
// ============================================================
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
isStreaming?: boolean;
tool_calls?: ToolCall[];
}
// ============================================================
// Vault Index Types
// ============================================================
export interface VaultIndexEntry {
path: string;
title: string;
content: string;
score: number;
}
+193 -13
View File
@@ -1,29 +1,209 @@
// src/utils.ts
import { UserInputError, ApiError } from './errors';
// ==================== Logger ====================
export function convertMarkdownToHtml(markdown: string): string {
// Simple markdown to HTML conversion for demonstration purposes
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
const SEVERITY_ORDER: Record<string, number> = {
debug: LogLevel.DEBUG,
info: LogLevel.INFO,
warn: LogLevel.WARN,
error: LogLevel.ERROR,
};
export class Logger {
private static minLevel: LogLevel = LogLevel.DEBUG;
static setLevel(level: string | LogLevel): void {
if (typeof level === 'string') {
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
} else {
Logger.minLevel = level;
}
}
static debug(message: string, category: string = 'general'): void {
if (LogLevel.DEBUG >= Logger.minLevel) {
console.debug(`[${category}] DEBUG: ${message}`);
}
}
static info(message: string, category: string = 'general'): void {
if (LogLevel.INFO >= Logger.minLevel) {
console.info(`[${category}] INFO: ${message}`);
}
}
static warn(message: string, category: string = 'general'): void {
if (LogLevel.WARN >= Logger.minLevel) {
console.warn(`[${category}] WARN: ${message}`);
}
}
static error(message: string, category: string = 'general'): void {
if (LogLevel.ERROR >= Logger.minLevel) {
console.error(`[${category}] ERROR: ${message}`);
}
}
}
// ==================== URL & Model Validation ====================
export function validateOllamaUrl(url: string): { valid: boolean; error?: string } {
if (typeof url !== 'string' || !url.trim()) {
return { valid: false, error: 'URL cannot be empty' };
}
const trimmedUrl = url.trim();
if (trimmedUrl.endsWith('/')) {
return { valid: false, error: 'URL should not end with a slash' };
}
try {
const parsed = new URL(trimmedUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' };
}
}
export function validateModelName(model: string): { valid: boolean; error?: string } {
if (typeof model !== 'string') {
return { valid: false, error: 'Model name must be a string' };
}
const trimmedModel = model.trim();
// Explicit check for empty string after trimming
if (!trimmedModel || trimmedModel.length === 0) {
return { valid: false, error: 'Model name cannot be empty' };
}
if (trimmedModel.length < 2) {
return { valid: false, error: 'Model name must be at least 2 characters long' };
}
if (trimmedModel.length > 100) {
return { valid: false, error: 'Model name must be less than 100 characters long' };
}
if (!/^[a-zA-Z0-9._-]+$/.test(trimmedModel)) {
return {
valid: false,
error: 'Model name can only contain letters, numbers, dots, dashes, and underscores',
};
}
return { valid: true };
}
export function validatePluginSettings(settings: { ollamaUrl: string; model: string }): string[] {
const errors: string[] = [];
const urlValidation = validateOllamaUrl(settings.ollamaUrl);
if (!urlValidation.valid) {
errors.push(`Invalid Ollama URL: ${urlValidation.error}`);
}
const modelValidation = validateModelName(settings.model);
if (!modelValidation.valid) {
errors.push(`Invalid Model Name: ${modelValidation.error}`);
}
return errors;
}
// ==================== Safe JSON Parsing ====================
const MAX_JSON_SIZE = 1_000_000;
const MAX_JSON_NESTING = 24;
function countNestingDepth(value: unknown, depth: number = 0): number {
if (depth > MAX_JSON_NESTING) {
return depth;
}
if (Array.isArray(value)) {
return Math.max(...value.map((item) => countNestingDepth(item, depth + 1)), depth);
}
if (value !== null && typeof value === 'object') {
const entries = Object.values(value as Record<string, unknown>);
if (entries.length === 0) return depth;
return Math.max(...entries.map((item) => countNestingDepth(item, depth + 1)), depth);
}
return depth;
}
export function safeParseJson(jsonString: string): unknown {
if (typeof jsonString !== 'string') {
throw new Error('Input must be a string');
}
if (jsonString.length > MAX_JSON_SIZE) {
throw new Error('JSON input too large');
}
let parsed: unknown;
try {
parsed = JSON.parse(jsonString);
} catch {
throw new Error('Invalid JSON');
}
// Check for dangerous prototype pollution patterns
const reStringified = JSON.stringify(parsed);
if (
reStringified.includes('constructor') ||
reStringified.includes('prototype') ||
reStringified.includes('__proto__') ||
reStringified.includes('function')
) {
throw new Error('dangerous code pattern detected');
}
// Check nesting depth
if (countNestingDepth(parsed) > MAX_JSON_NESTING) {
throw new Error('JSON nesting too deep');
}
return parsed;
}
// ==================== Path & File Utilities ====================
export function sanitizeFilePath(path: string): string {
if (path.includes('..')) {
throw new UserInputError('Invalid path - cannot contain .. segments');
throw new Error('Invalid path - cannot contain .. segments');
}
return path;
}
export async function safeWriteFile(filePath: string, content: string): Promise<void> {
const sanitizedPath = sanitizeFilePath(filePath);
try {
// Simulate file writing operation
console.log(`Writing to ${sanitizedPath}:`, content);
// In a real scenario, you would use fs.promises.writeFile or similar here
} catch (error) {
if (error instanceof UserInputError) {
throw error;
}
throw new ApiError('Failed to write file', 500);
}
// ==================== HTTP Helpers ====================
export function isValidHttpUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
// ==================== Markdown Utilities ====================
export function convertMarkdownToHtml(markdown: string): string {
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
}
+262 -14
View File
@@ -1,7 +1,7 @@
// src/vault-indexer.ts
import { OllamaClient } from './ollama-client';
import { ApiError, UserInputError, VaultIndexerError } from './errors';
import { ApiError, NetworkError, ValidationError } from './types';
import { ErrorHandler } from './error-handler';
import { sanitizeFilePath } from './utils';
@@ -10,12 +10,23 @@ interface FileSummary {
summary: string;
}
class VaultIndexer {
private ollamaClient: OllamaClient;
private summaries: Map<string, string> = new Map();
interface Frontmatter {
title?: string;
tags?: string;
}
constructor(ollamaClient: OllamaClient) {
this.ollamaClient = ollamaClient;
class VaultIndexer {
private ollamaClient: OllamaClient | null = null;
private summaries: Map<string, string> = new Map();
private vault: any;
constructor(vaultOrClient: any) {
// Support both old (OllamaClient) and new (VaultLike) interfaces
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
this.vault = vaultOrClient;
} else {
this.ollamaClient = vaultOrClient || null;
}
}
async indexVault(vaultPath: string): Promise<void> {
@@ -27,10 +38,10 @@ class VaultIndexer {
this.storeSummary(file.path, summary);
}
} catch (error: unknown) {
if (error instanceof VaultIndexerError) {
ErrorHandler.handle(error);
if (error instanceof Error) {
ErrorHandler.handleError(error, 'VaultIndexer.indexVault');
} else {
throw new VaultIndexerError('An unexpected error occurred while indexing the vault', error);
throw new ValidationError('An unexpected error occurred while indexing the vault');
}
}
}
@@ -42,7 +53,7 @@ class VaultIndexer {
// This is a placeholder for actual file system operations
return [{ path: `${sanitizedPath}/file1.md` }, { path: `${sanitizedPath}/file2.md` }];
} catch (error) {
throw new VaultIndexerError('Failed to get markdown files from vault', error);
throw new ValidationError('Failed to get markdown files from vault');
}
}
@@ -52,16 +63,32 @@ class VaultIndexer {
// This is a placeholder for actual file reading operations
return `Content of ${sanitizedPath}`;
} catch (error) {
throw new VaultIndexerError('Failed to read file content', error);
throw new ValidationError('Failed to read file content');
}
}
private async summarizeFile(content: string): Promise<string> {
if (!this.ollamaClient) {
throw new ValidationError('OllamaClient not available for summarization');
}
try {
const response = await this.ollamaClient.summarizeText(content);
return response.summary;
// Use the existing chat API to summarize text
const messages = [
{
role: 'system' as const,
content: 'Summarize the following text concisely:',
},
{
role: 'user' as const,
content: content,
},
];
const response = await this.ollamaClient.chat(messages);
return response.content;
} catch (error) {
throw new VaultIndexerError('Failed to summarize file', error);
throw new ValidationError('Failed to summarize file');
}
}
@@ -74,6 +101,227 @@ class VaultIndexer {
const sanitizedPath = sanitizeFilePath(filePath);
return this.summaries.get(sanitizedPath);
}
async searchVault(query: string, limit: number = 5): Promise<any[]> {
if (!query || !query.trim()) {
return [];
}
if (!this.vault) {
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);
return results
.filter((result): result is NonNullable<typeof result> => result !== null)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
private async processFilesInBatches(files: any[], queryTokens: string[]): Promise<any[]> {
const batchSize = 10;
const results: any[] = [];
const seenPaths = new Set<string>();
for (let i = 0; i < files.length; i += batchSize) {
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 scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
if (scoreResult.score > 0) {
const entry = {
path: file.path,
title: file.basename.replace(/\.md$/, ''),
content: content.substring(0, 500),
score: scoreResult.score,
};
if (!seenPaths.has(entry.path)) {
seenPaths.add(entry.path);
return entry;
}
return null;
}
return null;
} catch (error) {
console.warn(
`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
})
);
const validResults = batchResults.filter(
(result): result is NonNullable<typeof result> => result !== null
);
results.push(...validResults);
if (results.length >= 50) {
break;
}
}
return results;
}
private tokenize(text: string): string[] {
const stopWords = new Set([
'the',
'a',
'an',
'and',
'or',
'but',
'is',
'are',
'was',
'were',
'in',
'on',
'at',
'to',
'of',
'for',
'with',
'as',
'by',
'it',
'its',
'that',
'this',
'these',
'those',
]);
return text
.toLowerCase()
.split(/\W+/)
.filter((token) => token.length > 1 && !stopWords.has(token));
}
private tokenizeContent(content: string, file: any): any {
const tokens: string[] = [];
const headings: string[] = [];
let frontmatter: Frontmatter = {};
let firstParagraph: string | undefined;
const frontmatterMatch = content.match(/^---(.*?)---/s);
if (frontmatterMatch) {
try {
const frontmatterContent = frontmatterMatch[1];
const lines = frontmatterContent.trim().split('\n');
for (const line of lines) {
const [key, ...valueParts] = line.split(':');
if (!key) continue;
const value = valueParts.join(':').trim();
if (key.trim() === 'title') {
if (value) {
frontmatter.title = value;
}
} else if (key.trim() === 'tags') {
if (value) {
frontmatter.tags = value;
}
}
}
} catch (e) {
console.warn('Failed to parse frontmatter');
}
}
const headingMatches = content.match(/^# (.*?)$/gm);
if (headingMatches) {
headings.push(...headingMatches.map((h: string) => h.replace(/^# /, '')));
}
const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s);
if (paragraphMatch) {
firstParagraph = paragraphMatch[1].trim();
}
const allText = content
.replace(/^---.*?---/s, '')
.replace(/^#.*?$/gm, '')
.replace(/```.*?```/gs, '')
.replace(/`.*?`/g, '')
.replace(/\[.*?\]\(.*?\)/g, '');
tokens.push(...this.tokenize(allText));
return { tokens, headings, frontmatter, firstParagraph };
}
private calculateWeightedScore(tokenized: any, queryTokens: string[], file?: any): any {
let totalScore = 0;
const matchedTokens: Set<string> = new Set<string>();
for (const queryToken of queryTokens) {
let tokenScore = 0;
const stemmed = this.stemToken(queryToken);
let matched = false;
if (
tokenized.frontmatter?.title &&
this.exactMatch(tokenized.frontmatter.title, queryToken)
) {
tokenScore += 3;
matched = true;
} else if (
file &&
file.basename &&
this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken)
) {
tokenScore += 3;
matched = true;
}
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) {
tokenScore += 2.5;
matched = true;
}
if (tokenized.headings.some((heading: string) => heading.toLowerCase().includes(stemmed))) {
tokenScore += 5;
matched = true;
}
if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) {
tokenScore += 1.5;
matched = true;
}
if (tokenized.tokens.includes(stemmed)) {
tokenScore += 1;
matched = true;
}
if (matched) {
totalScore += tokenScore;
matchedTokens.add(queryToken);
}
}
return {
score: totalScore,
matchedFields: Array.from(matchedTokens),
};
}
private stemToken(token: string): string {
if (token.endsWith('s')) return token.slice(0, -1);
if (token.endsWith('ed')) return token.slice(0, -2);
if (token.endsWith('ing')) return token.slice(0, -3);
return token;
}
private exactMatch(content: string, token: string): boolean {
const stemmedToken = this.stemToken(token);
return content.toLowerCase().includes(stemmedToken);
}
}
export { VaultIndexer };
+338 -11
View File
@@ -28,6 +28,8 @@ jest.mock('obsidian', () => ({
const mockSettings: PluginSettings = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
@@ -157,37 +159,362 @@ describe('ChatView', () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield { content: 'test' };
},
} as any);
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'test' };
})()
);
await (view as any).handleUserInput('test');
expect(chatSpy).toHaveBeenCalled();
// Verify that messages were added to conversation history
expect((view as any).messages.length).toBeGreaterThan(0);
});
it('should handle empty user input gracefully', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = ' ';
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat');
await (view as any).handleUserInput(' ');
expect(chatSpy).not.toHaveBeenCalled();
});
it('should handle streaming responses and update UI', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'First ' };
yield { role: 'assistant', content: 'chunk ' };
yield { role: 'assistant', content: 'of response' };
})()
);
await (view as any).handleUserInput('test');
expect(chatSpy).toHaveBeenCalled();
// Should have processed all chunks
const messages = (view as any).messages;
const lastMessage = messages[messages.length - 1];
expect(lastMessage.isStreaming).toBe(false);
});
it('should limit conversation history to maxMessageHistory', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
// Mock a stream that returns quickly
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
})()
);
// Add enough messages to exceed maxMessageHistory
for (let i = 0; i < 60; i++) {
(view as any).messages.push({
id: `msg-${i}`,
role: 'user',
content: `message ${i}`,
timestamp: Date.now(),
});
}
await (view as any).handleUserInput('test');
// Should be limited to maxMessageHistory
expect((view as any).messages.length).toBeLessThanOrEqual(50);
});
it('should call vaultIndexer.searchVault with user input', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
const searchSpy = jest.spyOn(view['vaultIndexer'], 'searchVault').mockResolvedValue([]);
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
})()
);
await (view as any).handleUserInput('search query');
expect(searchSpy).toHaveBeenCalledWith('search query', 3); // Should use DEFAULT_VAULT_SEARCH_LIMIT
expect(chatSpy).toHaveBeenCalled();
});
it('should handle errors during user input gracefully', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
// Mock a stream that throws an error
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
throw new Error('Network error');
})()
);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await (view as any).handleUserInput('test');
expect(chatSpy).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith('Error handling user input:', expect.any(Error));
});
it('should handle empty user input gracefully', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = ' ';
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat');
await (view as any).handleUserInput(' ');
expect(chatSpy).not.toHaveBeenCalled();
});
it('should handle streaming responses and update UI', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'First ' };
yield { role: 'assistant', content: 'chunk ' };
yield { role: 'assistant', content: 'of response' };
})()
);
await (view as any).handleUserInput('test');
expect(chatSpy).toHaveBeenCalled();
// Should have processed all chunks
const messages = (view as any).messages;
const lastMessage = messages[messages.length - 1];
expect(lastMessage.isStreaming).toBe(false);
});
it('should process tool calls with follow-up context', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield {
role: 'assistant',
content: 'test',
tool_calls: [{ function: { name: 'create_file', arguments: '{}' } }],
};
tool_calls: [
{
id: 'tool_1',
type: 'function',
function: { name: 'create_file', arguments: '{}' },
},
} as any);
],
};
})()
);
const followUpSpy = jest
.spyOn(view['ollamaClient'], 'chat')
.mockResolvedValue({ content: ' follow-up' });
.mockResolvedValue({ role: 'assistant', content: ' follow-up' });
await (view as any).handleUserInput('test');
expect(followUpSpy).toHaveBeenCalled();
// Verify that tool calls resulted in follow-up messages
expect((view as any).messages.length).toBeGreaterThan(1);
});
it('should limit conversation history to maxMessageHistory', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
// Mock a stream that returns quickly
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
})()
);
// Add enough messages to exceed maxMessageHistory
for (let i = 0; i < 60; i++) {
(view as any).messages.push({
id: `msg-${i}`,
role: 'user',
content: `message ${i}`,
timestamp: Date.now(),
});
}
await (view as any).handleUserInput('test');
// Should be limited to maxMessageHistory
expect((view as any).messages.length).toBeLessThanOrEqual(50);
});
it('should call vaultIndexer.searchVault with user input', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
const searchSpy = jest.spyOn(view['vaultIndexer'], 'searchVault').mockResolvedValue([]);
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
})()
);
await (view as any).handleUserInput('search query');
expect(searchSpy).toHaveBeenCalledWith('search query', 3); // Should use DEFAULT_VAULT_SEARCH_LIMIT
expect(chatSpy).toHaveBeenCalled();
});
it('should handle errors during user input gracefully', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
// Mock a stream that throws an error
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
throw new Error('Network error');
})()
);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await (view as any).handleUserInput('test');
expect(chatSpy).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith('Error handling user input:', expect.any(Error));
});
describe('event handlers', () => {
it('should handle send button click', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
const handler = view.getSendButtonClickHandler?.bind(view);
if (!handler) throw new Error('Handler not available');
await handler();
expect(handleSpy).toHaveBeenCalledWith('test');
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
});
it('should handle Enter key press in input', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
const event = new KeyboardEvent('keydown', { key: 'Enter' }) as any;
await (view as any).inputKeyDownHandler!(event);
expect(handleSpy).toHaveBeenCalledWith('test');
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
});
it('should not handle Shift+Enter in input', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }) as any;
await (view as any).inputKeyDownHandler!(event);
expect(handleSpy).not.toHaveBeenCalled();
});
it('should handle new chat button click', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
view['newChatButton'] = document.createElement('button');
const clearSpy = jest.spyOn(view as any, 'clearConversation');
const handler = view.getNewChatButtonClickHandler?.bind(view);
if (!handler) throw new Error('Handler not available');
await handler();
expect(clearSpy).toHaveBeenCalled();
});
});
describe('event listeners', () => {
it('should setup event listeners on open', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
await view.onOpen();
expect(view['sendButtonClickHandler']).not.toBeNull();
expect(view['inputKeyDownHandler']).not.toBeNull();
});
it('should remove event listeners on close', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
await view.onOpen();
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
await view.onClose();
expect(removeSpy).toHaveBeenCalled();
});
});
});
describe('event handlers', () => {
it('should handle send button click', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
const handler = view.getSendButtonClickHandler?.bind(view);
if (!handler) throw new Error('Handler not available');
await handler();
expect(handleSpy).toHaveBeenCalledWith('test');
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
});
it('should handle Enter key press in input', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
const event = new KeyboardEvent('keydown', { key: 'Enter' }) as any;
await (view as any).inputKeyDownHandler!(event);
expect(handleSpy).toHaveBeenCalledWith('test');
expect((view['inputEl'] as HTMLTextAreaElement).value).toBe('');
});
it('should not handle Shift+Enter in input', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
const handleSpy = jest.spyOn(view as any, 'handleUserInput');
const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }) as any;
await (view as any).inputKeyDownHandler!(event);
expect(handleSpy).not.toHaveBeenCalled();
});
it('should handle new chat button click', async () => {
view['sendButton'] = document.createElement('button');
view['inputEl'] = document.createElement('textarea');
view['newChatButton'] = document.createElement('button');
const clearSpy = jest.spyOn(view as any, 'clearConversation');
await (view as any).newChatButtonClickHandler!();
expect(clearSpy).toHaveBeenCalled();
});
});
describe('event listeners', () => {
+1 -1
View File
@@ -179,7 +179,7 @@ describe('ErrorHandler', () => {
it('should detect tool errors in generic Error', () => {
const error = new Error('Tool function error');
const message = ErrorHandler['getUserFriendlyMessageFromError'](error);
expect(message).toContain('Tool execution error');
expect(message).toContain('Tool error');
});
it('should detect path errors in generic Error', () => {
+9 -6
View File
@@ -179,8 +179,9 @@ describe('OllamaClient', () => {
expect(chunks).toEqual(['valid', 'also valid']);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Skipped malformed chunk'),
expect.stringContaining('is not valid JSON')
expect.stringContaining(
'[ollama-client] WARN: Skipped malformed chunk: this is not json... - Unexpected token \'h\', "this is not json" is not valid JSON'
)
);
consoleWarnSpy.mockRestore();
});
@@ -212,7 +213,7 @@ describe('OllamaClient', () => {
it('should throw on non-OK response', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 404 });
await expect(client.streamChat(mockMessages, mockTools)).rejects.toThrow(
await expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow(
'Ollama API error: 404'
);
});
@@ -220,7 +221,9 @@ describe('OllamaClient', () => {
it('should throw when response has no body', async () => {
mockFetch.mockResolvedValue({ ok: true, body: undefined });
await expect(client.streamChat(mockMessages, mockTools)).rejects.toThrow('No response body');
await expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow(
'No response body'
);
});
it('should throw on invalid content type', async () => {
@@ -236,13 +239,13 @@ describe('OllamaClient', () => {
},
});
await expect(client.streamChat(mockMessages, mockTools)).rejects.toThrow(
await expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow(
'Invalid response format'
);
});
it('should propagate Ollama error messages from the stream', async () => {
const streamData = JSON.stringify({ message: { error: 'model not found' } }) + '\n';
const streamData = JSON.stringify({ error: 'model not found' }) + '\n';
const mockReader = createMockReader(streamData);
+54
View File
@@ -45,6 +45,8 @@ describe('ToolExecutor', () => {
describe('create_file tool', () => {
it('should successfully create a file with valid arguments', async () => {
const call: ToolCall = {
id: 'call_1',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -60,6 +62,8 @@ describe('ToolExecutor', () => {
it('should handle object arguments directly', async () => {
const call: ToolCall = {
id: 'call_2',
type: 'function',
function: {
name: 'create_file',
arguments: {
@@ -75,6 +79,8 @@ describe('ToolExecutor', () => {
it('should successfully create a file in a subdirectory', async () => {
const call: ToolCall = {
id: 'call_3',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -93,6 +99,8 @@ describe('ToolExecutor', () => {
it('should handle multiple slashes gracefully by normalizing path', async () => {
const call: ToolCall = {
id: 'call_4',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -108,6 +116,8 @@ describe('ToolExecutor', () => {
it('should handle empty content gracefully', async () => {
const call: ToolCall = {
id: 'call_5',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -123,6 +133,8 @@ describe('ToolExecutor', () => {
it('should allow filenames with consecutive dots', async () => {
const call: ToolCall = {
id: 'call_6',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -138,6 +150,8 @@ describe('ToolExecutor', () => {
it('should reject path traversal attempts with ..', async () => {
const call: ToolCall = {
id: 'call_7',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -152,6 +166,8 @@ describe('ToolExecutor', () => {
it('should reject path traversal attempts with .\\', async () => {
const call: ToolCall = {
id: 'call_8',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -166,6 +182,8 @@ describe('ToolExecutor', () => {
it('should reject path traversal attempts with /..', async () => {
const call: ToolCall = {
id: 'call_9',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -180,6 +198,8 @@ describe('ToolExecutor', () => {
it('should reject absolute paths starting with /', async () => {
const call: ToolCall = {
id: 'call_10',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -194,6 +214,8 @@ describe('ToolExecutor', () => {
it('should reject absolute paths starting with \\', async () => {
const call: ToolCall = {
id: 'call_11',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -208,6 +230,8 @@ describe('ToolExecutor', () => {
it('should reject Windows drive letters', async () => {
const call: ToolCall = {
id: 'call_12',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -222,6 +246,8 @@ describe('ToolExecutor', () => {
it('should reject empty path', async () => {
const call: ToolCall = {
id: 'call_13',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -236,6 +262,8 @@ describe('ToolExecutor', () => {
it('should reject undefined path', async () => {
const call: ToolCall = {
id: 'call_14',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -249,6 +277,8 @@ describe('ToolExecutor', () => {
it('should reject path with invalid characters <', async () => {
const call: ToolCall = {
id: 'call_15',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -263,6 +293,8 @@ describe('ToolExecutor', () => {
it('should reject path with invalid characters >', async () => {
const call: ToolCall = {
id: 'call_16',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -277,6 +309,8 @@ describe('ToolExecutor', () => {
it('should reject path with invalid characters :', async () => {
const call: ToolCall = {
id: 'call_17',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -291,6 +325,8 @@ describe('ToolExecutor', () => {
it('should reject path with invalid characters |', async () => {
const call: ToolCall = {
id: 'call_18',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -305,6 +341,8 @@ describe('ToolExecutor', () => {
it('should reject path with invalid characters ?', async () => {
const call: ToolCall = {
id: 'call_19',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -319,6 +357,8 @@ describe('ToolExecutor', () => {
it('should reject path with invalid characters *', async () => {
const call: ToolCall = {
id: 'call_20',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -333,6 +373,8 @@ describe('ToolExecutor', () => {
it('should reject path longer than 200 characters', async () => {
const call: ToolCall = {
id: 'call_21',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -347,6 +389,8 @@ describe('ToolExecutor', () => {
it('should reject path with ~ character', async () => {
const call: ToolCall = {
id: 'call_22',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -361,6 +405,8 @@ describe('ToolExecutor', () => {
it('should reject non-string content', async () => {
const call: ToolCall = {
id: 'call_23',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -375,6 +421,8 @@ describe('ToolExecutor', () => {
it('should reject non-string path', async () => {
const call: ToolCall = {
id: 'call_24',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -390,6 +438,8 @@ describe('ToolExecutor', () => {
it('should handle vault.create rejection gracefully', async () => {
mockVault.create = jest.fn().mockRejectedValue(new Error('Permission denied'));
const call: ToolCall = {
id: 'call_25',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
@@ -403,6 +453,8 @@ describe('ToolExecutor', () => {
it('should handle invalid JSON in arguments', async () => {
const call: ToolCall = {
id: 'call_26',
type: 'function',
function: {
name: 'create_file',
arguments: 'invalid json',
@@ -416,6 +468,8 @@ describe('ToolExecutor', () => {
describe('unknown tool', () => {
it('should return failure for unknown tool', async () => {
const call: ToolCall = {
id: 'call_27',
type: 'function',
function: {
name: 'unknown_tool',
arguments: JSON.stringify({}),
-4
View File
@@ -95,7 +95,6 @@ describe('Validation Functions', () => {
const errors = validatePluginSettings({
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
lastIndexTime: 0,
});
expect(errors).toEqual([]);
});
@@ -104,7 +103,6 @@ describe('Validation Functions', () => {
const errors = validatePluginSettings({
ollamaUrl: 'invalid-url',
model: 'llama3',
lastIndexTime: 0,
});
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Ollama URL');
@@ -114,7 +112,6 @@ describe('Validation Functions', () => {
const errors = validatePluginSettings({
ollamaUrl: 'http://localhost:11434',
model: 'invalid@model',
lastIndexTime: 0,
});
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Model');
@@ -124,7 +121,6 @@ describe('Validation Functions', () => {
const errors = validatePluginSettings({
ollamaUrl: 'invalid-url',
model: 'invalid@model',
lastIndexTime: 0,
});
expect(errors.length).toBe(2);
expect(errors[0]).toContain('Ollama URL');
+8 -9
View File
@@ -190,6 +190,9 @@ describe('VaultIndexer', () => {
const results = await indexer.searchVault('algorithm', 5);
expect(results.length).toBe(2);
// File with heading match should score higher
console.log('Results:', JSON.stringify(results, null, 2));
console.log('file1 content:', mockVault.read({ basename: 'file1', path: 'file1.md' }));
console.log('file2 content:', mockVault.read({ basename: 'file2', path: 'file2.md' }));
expect(results[0].title).toBe('file1');
});
@@ -286,8 +289,8 @@ describe('VaultIndexer', () => {
basename: 'test',
path: 'test.md',
} as any);
const score = (indexer as any).calculateWeightedScore(tokenized, '', queryTokens);
expect(score).toBe(0);
const score = (indexer as any).calculateWeightedScore(tokenized, queryTokens);
expect(score.score).toBe(0);
});
it('should score higher when more tokens match', () => {
@@ -300,15 +303,13 @@ describe('VaultIndexer', () => {
const query2 = 'algorithm design pattern';
const score1 = (indexer as any).calculateWeightedScore(
tokenized,
query1,
(indexer as any).tokenize(query1)
);
const score2 = (indexer as any).calculateWeightedScore(
tokenized,
query2,
(indexer as any).tokenize(query2)
);
expect(score2).toBeGreaterThan(score1);
expect(score2.score).toBeGreaterThan(score1.score);
});
it('should be case insensitive', () => {
@@ -320,10 +321,9 @@ describe('VaultIndexer', () => {
const query = 'important algorithm';
const score = (indexer as any).calculateWeightedScore(
tokenized,
query,
(indexer as any).tokenize(query)
);
expect(score).toBeGreaterThan(0);
expect(score.score).toBeGreaterThan(0);
});
it('should handle word boundary matching', () => {
@@ -335,10 +335,9 @@ describe('VaultIndexer', () => {
const query = 'algorithm';
const score = (indexer as any).calculateWeightedScore(
tokenized,
query,
(indexer as any).tokenize(query)
);
expect(score).toBeGreaterThan(0);
expect(score.score).toBeGreaterThan(0);
});
});
+1 -1
View File
@@ -12,7 +12,7 @@
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
},
"include": ["src/**/*", "main.ts"],
"include": ["src/**/*"],
"typeRoots": ["node_modules/@types", "./src"],
"exclude": ["node_modules"],
}
+4 -3
View File
@@ -2,8 +2,9 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"types": ["node", "jest", "jsdom"]
"types": ["node", "jest", "jsdom"],
"rootDir": ".",
},
"include": ["tests/**/*"],
"exclude": ["node_modules"]
"include": ["tests/**/*", "src/**/*"],
"exclude": ["node_modules"],
}