Add error handler and improve coverage

This commit is contained in:
2026-05-06 18:24:24 +02:00
parent aa8e45912a
commit 2968932d69
24 changed files with 2553 additions and 604 deletions
+369
View File
@@ -0,0 +1,369 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ChatView = void 0;
const obsidian_1 = require("obsidian");
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
const MAX_MESSAGE_HISTORY = 50;
const ollama_client_1 = require("./ollama-client");
const vault_indexer_1 = require("./vault-indexer");
const tool_executor_1 = require("./tool-executor");
const error_handler_1 = require("./error-handler");
class ChatView extends obsidian_1.ItemView {
// Getters for testing
getSendButtonClickHandler() {
return this.sendButtonClickHandler;
}
getInputKeyDownHandler() {
return this.inputKeyDownHandler;
}
getNewChatButtonClickHandler() {
return this.newChatButtonClickHandler;
}
constructor(leaf, settings) {
super(leaf);
this.messages = [];
this.lastMessageEl = null;
this.newChatButton = null;
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
this.sendButtonClickHandler = null;
this.inputKeyDownHandler = null;
this.newChatButtonClickHandler = null;
this.listenersAttached = false;
this.settings = settings;
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault);
this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app);
}
updateSettings(newSettings) {
this.settings = newSettings;
this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model);
}
getViewType() {
return 'ollama-chat-view';
}
getDisplayText() {
return 'Ollama Chat';
}
async onOpen() {
await this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
}
onSettingsChange(newSettings) {
this.updateSettings(newSettings);
}
async onClose() {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
this.lastMessageEl = null;
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
}
cleanupStreamingResources() {
// Ensure any ongoing streaming is properly cleaned up
if (this.lastMessageEl && this.lastMessageEl.parentElement) {
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
this.lastMessageEl = null;
}
}
async render() {
const container = this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
this.chatContainer = container;
const inputContainer = this.contentEl.querySelector('.ollama-input-container') ||
this.contentEl.createEl('div', { cls: 'ollama-input-container' });
if (!this.inputEl) {
this.inputEl = inputContainer.createEl('textarea', { cls: 'ollama-input' });
}
if (!this.sendButton) {
this.sendButton = inputContainer.createEl('button', {
cls: 'ollama-send-button',
});
this.sendButton.textContent = 'Send';
}
if (!this.newChatButton) {
const newChatContainer = this.contentEl.querySelector('.ollama-new-chat') ||
this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
this.newChatButton = newChatContainer.createEl('button', {
cls: 'ollama-new-chat-button',
});
this.newChatButton.textContent = '🔄 New Chat';
this.newChatButton.title = 'Start a new conversation';
}
// Create immutable snapshot for rendering
const messagesSnapshot = [...this.messages];
// Only render messages that are not currently streaming
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
// Differential update: only update messages that have changed
const existingMessages = container.querySelectorAll('.ollama-message');
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
for (const msg of nonStreamingMessages) {
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
if (existingEl) {
existingEl.textContent = msg.content;
}
else {
const messageEl = container.createEl('div', {
cls: `ollama-message ${msg.role}`,
});
messageEl.setAttribute('data-msg-id', msg.id);
messageEl.textContent = msg.content;
}
}
// Remove messages that are no longer in the array
for (const el of Array.from(existingMessages)) {
const id = el.getAttribute('data-msg-id');
if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
el.remove();
}
}
// Re-attach streaming message if it exists
const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
if (streamingMessage && this.lastMessageEl) {
const existingStreamingEl = container.querySelector(`.ollama-message[data-msg-id="${streamingMessage.id}"]`);
if (!existingStreamingEl) {
container.appendChild(this.lastMessageEl);
}
}
}
setupEventListeners() {
if (!this.sendButton || !this.inputEl || this.listenersAttached)
return;
// Create handlers if they don't exist
if (!this.sendButtonClickHandler) {
this.sendButtonClickHandler = async () => {
if (!this.inputEl)
return;
await this.handleUserInput(this.inputEl.value);
this.inputEl.value = '';
};
}
if (!this.inputKeyDownHandler) {
this.inputKeyDownHandler = async (e) => {
if (!this.inputEl || e.key !== 'Enter' || e.shiftKey)
return;
e.preventDefault();
await this.handleUserInput(this.inputEl.value);
this.inputEl.value = '';
};
}
// Add event listeners
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
if (this.newChatButton) {
if (!this.newChatButtonClickHandler) {
this.newChatButtonClickHandler = () => this.clearConversation();
}
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
}
this.listenersAttached = true;
}
removeEventListeners() {
if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
}
if (this.inputEl && this.inputKeyDownHandler) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
}
this.listenersAttached = false;
}
clearConversation() {
// Create new array to ensure immutability
this.messages = [];
this.lastMessageEl = null;
this.render();
new obsidian_1.Notice('Conversation cleared');
}
updateMessageById(id, partial) {
const index = this.messages.findIndex((m) => m.id === id);
if (index < 0)
return false;
this.messages = [
...this.messages.slice(0, index),
{ ...this.messages[index], ...partial },
...this.messages.slice(index + 1),
];
return true;
}
async updateLastMessage(content) {
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && !this.lastMessageEl) {
this.lastMessageEl = this.contentEl.createEl('div', {
cls: `ollama-message assistant`,
});
this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
}
if (this.lastMessageEl) {
this.lastMessageEl.textContent = content;
}
}
async handleUserInput(content) {
if (!this.sendButton || !this.inputEl)
return;
this.sendButton.disabled = true;
try {
// Guard against empty messages
const userMessage = content.trim();
if (!userMessage)
return;
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
let context = entries.map((e) => `### ${e.title}\n${e.content}`).join('\n\n');
// Cap context size to prevent prompt bloat with large vaults
const MAX_CONTEXT_LENGTH = 4000;
if (context.length > MAX_CONTEXT_LENGTH) {
context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)';
}
const systemMessage = {
role: 'system',
content: 'You are a helpful assistant.',
};
const userMessageWithContext = {
role: 'user',
content: `${context}\n\n${userMessage}`,
};
const messages = [
systemMessage,
...this.messages.map((m) => ({
role: m.role,
content: m.content,
tool_calls: m.tool_calls,
})),
userMessageWithContext,
];
const tools = [
{
type: 'function',
function: {
name: 'create_file',
description: 'Create a new file in the vault',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
content: { type: 'string' },
},
required: ['path', 'content'],
},
},
},
];
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const userMessageId = messageId;
const assistantMessageId = `${messageId}-assistant`;
// Store user message in conversation history
const userChatMessage = {
id: userMessageId,
role: 'user',
content: userMessage,
timestamp: Date.now(),
};
const assistantMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
};
// Update messages immutably
this.messages = [...this.messages, userChatMessage, assistantMessage];
await this.render();
const stream = await this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls = [];
let chunkCount = 0;
const MAX_STREAM_CHUNKS = 1000;
const maxChunks = MAX_STREAM_CHUNKS;
try {
for await (const chunk of stream) {
chunkCount++;
if (chunkCount > maxChunks) {
throw new Error('Response too long, stopped streaming');
}
if (chunk.content) {
fullResponse += chunk.content;
}
if (chunk.tool_calls) {
toolCalls = toolCalls.concat(chunk.tool_calls);
}
await this.updateLastMessage(fullResponse);
}
}
finally {
// Clean up streaming resources regardless of outcome
this.cleanupStreamingResources();
}
// Update the assistant message with the full response immutably
if (!this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
})) {
throw new Error('Assistant message not found');
}
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
// Validate tool calls before processing
const MAX_TOOL_CALLS = 10;
if (toolCalls.length > MAX_TOOL_CALLS) {
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
}
// Collect all tool results using allSettled to support partial results
const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call)));
let toolResults = [];
for (const result of settledResults) {
if (result.status === 'fulfilled') {
toolResults.push(result.value);
}
else {
// Use centralized error handler for tool errors
error_handler_1.ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
}
}
// Create follow-up messages including the assistant's tool calls and results
const followUpMessages = [
...messages,
{ role: 'assistant', content: fullResponse, tool_calls: toolCalls },
...toolResults.map((result) => ({
role: 'tool',
content: JSON.stringify(result),
})),
];
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
fullResponse += followUp.content;
await this.updateLastMessage(fullResponse);
// Update the assistant message with the final response immutably
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
}
// Update last message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
const lastMessageIndex = this.messages.length - 1;
if (lastMessageIndex >= 0) {
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
}
await this.render();
}
catch (error) {
// Use centralized error handler
error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput');
this.cleanupStreamingResources();
}
finally {
if (this.sendButton) {
this.sendButton.disabled = false;
}
}
}
}
exports.ChatView = ChatView;
+9
View File
@@ -57,6 +57,11 @@ export class ChatView extends ItemView {
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
}
public updateSettings(newSettings: PluginSettings): void {
this.settings = newSettings;
this.ollamaClient = new OllamaClient(newSettings.ollamaUrl, newSettings.model);
}
getViewType(): string {
return 'ollama-chat-view';
}
@@ -71,6 +76,10 @@ export class ChatView extends ItemView {
this.setupEventListeners();
}
public onSettingsChange(newSettings: PluginSettings): void {
this.updateSettings(newSettings);
}
async onClose() {
this.ollamaClient.cancelStream();
this.removeEventListeners();
+13
View File
@@ -0,0 +1,13 @@
"use strict";
// Default plugin settings
Object.defineProperty(exports, "__esModule", { value: true });
exports.MODEL_NAME_REGEX = exports.DEFAULT_SETTINGS = void 0;
exports.DEFAULT_SETTINGS = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
// Model validation regex - lowercase letters, numbers, dashes, underscores only
exports.MODEL_NAME_REGEX = /^[a-z0-9-_]+$/;
+106
View File
@@ -0,0 +1,106 @@
"use strict";
// src/error-handler.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.ErrorHandler = void 0;
const obsidian_1 = require("obsidian");
const types_1 = require("./types");
class ErrorHandler {
static handleError(error, context) {
const message = this.getUserFriendlyMessage(error);
new obsidian_1.Notice(message);
if (error instanceof Error) {
const ctx = context ? ` [${context}]` : '';
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
if (error.stack) {
console.error(error.stack);
}
}
else {
const ctx = context ? ` [${context}]` : '';
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
static getUserFriendlyMessage(error) {
if (error instanceof types_1.OllamaError) {
return this.getUserFriendlyMessageFromOllamaError(error);
}
if (error instanceof Error) {
return this.getUserFriendlyMessageFromError(error);
}
return 'An unexpected error occurred';
}
static getUserFriendlyMessageFromOllamaError(error) {
switch (error.type) {
case types_1.ErrorType.NETWORK_ERROR:
return 'Connection error. Please check if Ollama is running.';
case types_1.ErrorType.API_ERROR:
return `API error: ${error.message}`;
case types_1.ErrorType.VALIDATION_ERROR:
return this.getUserFriendlyValidationMessage(error);
case types_1.ErrorType.STREAMING_ERROR:
return 'Response too long. Please try a shorter request.';
case types_1.ErrorType.TOOL_EXECUTION_ERROR:
return `Tool error for ${error.toolName}. ${error.message}`;
case types_1.ErrorType.PATH_VALIDATION_ERROR:
return `Invalid file path: ${error.path}`;
case types_1.ErrorType.UNKNOWN_ERROR:
return 'An unexpected error occurred';
default:
return 'An unexpected error occurred';
}
}
static getUserFriendlyValidationMessage(error) {
if (error instanceof types_1.ValidationError && error.details?.field) {
const fieldMsg = error.details.field.charAt(0).toUpperCase() + error.details.field.slice(1);
return `Invalid ${fieldMsg.toLowerCase()}. ${error.details.message ?? error.message}`;
}
return 'Input validation error. Please correct your input.';
}
static getUserFriendlyMessageFromError(error) {
const msg = error.message.toLowerCase();
// Check timeout BEFORE network (more specific matches first)
if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('time out')) {
return 'Request timed out. Please check your Ollama connection.';
}
if (msg.includes('network') || msg.includes('connection') || msg.includes('fetch')) {
return 'Connection error. Please check if Ollama is running.';
}
if (msg.includes('validation') || msg.includes('invalid')) {
return 'Invalid input. Please correct your input.';
}
if (msg.includes('stream') || msg.includes('chunk')) {
return 'Response too long. Please try a shorter request.';
}
if (msg.includes('tool') || msg.includes('function')) {
return 'Tool error. Please try again.';
}
if (msg.includes('path') || msg.includes('file')) {
return 'Invalid file path. Please check the path and try again.';
}
return 'An unexpected error occurred';
}
// -- Factory methods --
static createNetworkError(message, statusCode) {
return new types_1.NetworkError(message, statusCode);
}
static createApiError(message, statusCode) {
return new types_1.ApiError(message, statusCode);
}
static createValidationError(message, field) {
const details = field ? { field, message } : undefined;
return new types_1.ValidationError(message, details);
}
static createStreamingError(message) {
return new types_1.StreamingError(message);
}
static createToolExecutionError(message, toolName) {
return new types_1.ToolExecutionError(message, toolName ?? 'unknown');
}
static createPathValidationError(message, path) {
return new types_1.PathValidationError(message, path ?? '');
}
static createUnknownError(message) {
return new types_1.OllamaError(message, types_1.ErrorType.UNKNOWN_ERROR);
}
}
exports.ErrorHandler = ErrorHandler;
+170
View File
@@ -0,0 +1,170 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const obsidian_1 = require("obsidian");
const chat_view_1 = require("./chat-view");
const utils_1 = require("./utils");
const constants_1 = require("./constants");
class OllamaPlugin extends obsidian_1.Plugin {
constructor() {
super(...arguments);
this.settings = constants_1.DEFAULT_SETTINGS;
}
async onload() {
// Initialize logging
utils_1.Logger.info('Ollama Plugin loading...', 'plugin');
await this.loadSettings();
utils_1.Logger.info('Plugin loaded successfully', 'plugin');
try {
this.registerView('ollama-chat-view', (leaf) => new chat_view_1.ChatView(leaf, this.settings));
}
catch (error) {
utils_1.Logger.error('Failed to register view: ' + error.message, 'plugin');
new obsidian_1.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({
type: 'ollama-chat-view',
active: true,
});
this.app.workspace.revealLeaf(leaf);
});
}
catch (error) {
utils_1.Logger.error('Failed to add ribbon icon: ' + error.message, 'plugin');
new obsidian_1.Notice('Failed to add Ollama ribbon icon');
// Don't throw - let the plugin continue loading other features
}
this.addSettingTab(new OllamaSettingTab(this.app, this));
}
async loadSettings() {
try {
const data = await this.loadData();
if (data) {
utils_1.Logger.debug('Loading saved settings', 'settings');
this.settings = Object.assign({}, this.settings, data);
}
}
catch (error) {
// Use centralized error handling
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
ErrorHandler.handleError(error, 'settings load');
}
}
async saveSettings() {
try {
// Validate settings before saving
const validationErrors = (0, utils_1.validatePluginSettings)(this.settings);
if (validationErrors.length > 0) {
utils_1.Logger.error('Validation errors prevented saving settings: ' + validationErrors.join('; '), 'settings');
new obsidian_1.Notice(`Cannot save settings: ${validationErrors[0]}`);
return false;
}
utils_1.Logger.debug('Saving settings: ' + JSON.stringify(this.settings), 'settings');
await this.saveData(this.settings);
utils_1.Logger.info('Settings saved successfully', 'settings');
return true;
}
catch (error) {
// Use centralized error handling
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
ErrorHandler.handleError(error, 'settings save');
return false;
}
}
// Notify all open ChatView instances when settings change
notifyChatViews() {
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
leaves.forEach((leaf) => {
const view = leaf.view;
if (view && view.onSettingsChange) {
view.onSettingsChange(this.settings);
}
});
}
}
exports.default = OllamaPlugin;
class OllamaSettingTab extends obsidian_1.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
// Clear any existing content first to prevent duplicates
this.containerEl.empty();
// Create container for settings
const container = this.containerEl.createDiv();
container.empty();
new obsidian_1.Setting(container)
.setName('Ollama URL')
.setDesc('URL of your Ollama instance')
.addText((text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
const urlValidation = (0, utils_1.validateOllamaUrl)(value);
if (urlValidation.valid) {
utils_1.Logger.debug('URL changed to: ' + value, 'settings');
this.plugin.settings.ollamaUrl = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
}
else {
utils_1.Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings');
new obsidian_1.Notice(urlValidation.error || 'Invalid Ollama URL format.');
}
}));
new obsidian_1.Setting(container)
.setName('Model')
.setDesc('Model to use for chat')
.addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => {
const modelValidation = (0, utils_1.validateModelName)(value);
if (modelValidation.valid) {
utils_1.Logger.debug('Model changed to: ' + value, 'settings');
this.plugin.settings.model = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
}
else {
utils_1.Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings');
new obsidian_1.Notice(modelValidation.error || 'Invalid model name format.');
}
}));
}
hide() {
// Clear the container to prevent duplicate elements
this.containerEl.empty();
}
}
+13
View File
@@ -87,6 +87,17 @@ export default class OllamaPlugin extends Plugin {
return false;
}
}
// Notify all open ChatView instances when settings change
public notifyChatViews(): void {
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
leaves.forEach((leaf) => {
const view = leaf.view as ChatView;
if (view && view.onSettingsChange) {
view.onSettingsChange(this.settings);
}
});
}
}
class OllamaSettingTab extends PluginSettingTab {
@@ -115,6 +126,7 @@ class OllamaSettingTab extends PluginSettingTab {
Logger.debug('URL changed to: ' + value, 'settings');
this.plugin.settings.ollamaUrl = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
} else {
Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings');
new Notice(urlValidation.error || 'Invalid Ollama URL format.');
@@ -132,6 +144,7 @@ class OllamaSettingTab extends PluginSettingTab {
Logger.debug('Model changed to: ' + value, 'settings');
this.plugin.settings.model = value;
await this.plugin.saveSettings();
this.plugin.notifyChatViews();
} else {
Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings');
new Notice(modelValidation.error || 'Invalid model name format.');
+201
View File
@@ -0,0 +1,201 @@
"use strict";
// src/ollama-client.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaClient = void 0;
const types_1 = require("./types");
const utils_1 = require("./utils");
class OllamaClient {
constructor(baseURL, model, fetchFn) {
this.abortController = null;
this.maxRetries = 3;
this.baseURL = baseURL;
this.model = model;
this.fetchFn = fetchFn ?? fetch;
}
cancelStream() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
async *streamChat(messages, tools = []) {
yield* this.streamChatWithRetry(messages, tools, 0);
}
/**
* Wrapper method for testing that converts async generator to Promise
* This allows testing with .rejects.toThrow() syntax
*/
async streamChatAsPromise(messages, tools = []) {
const chunks = [];
try {
for await (const chunk of this.streamChat(messages, tools)) {
chunks.push(chunk);
}
return chunks;
}
catch (error) {
// Re-throw the error so tests can catch it
throw error;
}
}
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
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,
messages: messages,
tools: tools,
stream: true,
}),
signal: this.abortController.signal,
});
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
await new Promise((resolve) => setTimeout(resolve, retryDelay));
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
return;
}
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
}
if (!response.body) {
throw new Error('No response body');
}
const contentType = response.headers.get('content-type');
if (!contentType || (!contentType.includes('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;
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);
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message;
if (!message) {
continue;
}
malformedCount = 0; // Reset on successful parse
yield {
role: message.role ?? 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls ?? [],
};
}
catch (e) {
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
throw e; // Re-throw Ollama errors
}
malformedCount++;
if (malformedCount > MAX_MALFORMED) {
throw new Error('Too many malformed chunks in stream');
}
utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${e.message}`, 'ollama-client');
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer);
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message;
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;
}
utils_1.Logger.warn(`Failed to parse final chunk: ${buffer.substring(0, 80)}...`, 'ollama-client');
}
}
}
finally {
reader.releaseLock();
}
}
finally {
this.abortController = null;
}
}
async chat(messages, tools = []) {
return this.chatWithRetry(messages, tools, 0);
}
async chatWithRetry(messages, tools = [], attempt = 0) {
const controller = new AbortController();
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
// For network errors (5xx), retry with exponential backoff
if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return this.chatWithRetry(messages, tools, attempt + 1);
}
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
}
const data = await response.json();
// Handle missing message content gracefully
if (!data.message) {
return {
role: 'assistant',
content: '',
tool_calls: [],
};
}
return {
role: data.message.role ?? 'assistant',
content: typeof data.message.content === 'string' ? data.message.content : '',
tool_calls: data.message.tool_calls ?? [],
};
}
finally {
// No need to abort after successful response, but signal is available
}
}
}
exports.OllamaClient = OllamaClient;
+114
View File
@@ -0,0 +1,114 @@
"use strict";
// src/tool-executor.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.ToolExecutor = void 0;
const utils_1 = require("./utils");
// Disallow characters that are invalid in file paths
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
const MAX_PATH_LENGTH = 200;
const FORBIDDEN_DIRS = ['.obsidian', '.git'];
class ToolExecutor {
constructor(vault, app) {
this.vault = vault;
this.app = app;
}
isSafePath(path) {
// Reject empty paths
if (!path || path.trim().length === 0) {
return false;
}
// Reject paths that are too long
if (path.length > MAX_PATH_LENGTH) {
return false;
}
// Reject paths with invalid characters
if (INVALID_PATH_CHARS.test(path)) {
return false;
}
// Reject absolute paths
if (path.startsWith('/') || path.startsWith('\\')) {
return false;
}
// Reject Windows drive letters (e.g., C:)
if (/^[a-zA-Z]:/.test(path)) {
return false;
}
// Reject paths containing backslashes (Windows-style path separators)
if (path.includes('\\')) {
return false;
}
// Reject paths that traverse to parent directories
const normalized = path.replace(/^(\.\/)+/, '');
if (normalized.includes('../')) {
return false;
}
// Reject forbidden directories
for (const dir of FORBIDDEN_DIRS) {
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
return false;
}
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
return false;
}
}
return true;
}
async handleToolCall(toolCall) {
try {
const toolName = toolCall.function?.name;
const rawArgs = toolCall.function?.arguments;
if (!toolName) {
throw new Error('Tool name is required');
}
// Parse arguments whether they're a string or object
let parsedArgs;
if (typeof rawArgs === 'string') {
try {
parsedArgs = (0, utils_1.safeParseJson)(rawArgs);
}
catch {
throw new Error('Invalid JSON arguments');
}
}
else if (rawArgs && typeof rawArgs === 'object') {
parsedArgs = rawArgs;
}
else {
throw new Error('Arguments must be an object or JSON string');
}
// Process the tool call based on its type
switch (toolName) {
case 'create_file':
return await this.handleCreateFile(parsedArgs);
default:
return { success: false, message: `Unknown tool: ${toolName}` };
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
}
}
async handleCreateFile(args) {
const path = args.path;
const content = args.content;
if (typeof path !== 'string') {
throw new Error('Path must be a string');
}
if (typeof content !== 'string') {
throw new Error('Content must be a string');
}
if (!this.isSafePath(path)) {
throw new Error('Invalid file path detected');
}
try {
await this.vault.create(path, content);
return { success: true, message: 'File created successfully' };
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
}
}
}
exports.ToolExecutor = ToolExecutor;
+79
View File
@@ -0,0 +1,79 @@
"use strict";
// src/types.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_SETTINGS = exports.PathValidationError = exports.ToolExecutionError = exports.StreamingError = exports.ValidationError = exports.ApiError = exports.NetworkError = exports.OllamaError = exports.ErrorType = void 0;
// ============================================================
// Error Type Hierarchy
// ============================================================
var ErrorType;
(function (ErrorType) {
ErrorType["NETWORK_ERROR"] = "network_error";
ErrorType["API_ERROR"] = "api_error";
ErrorType["VALIDATION_ERROR"] = "validation_error";
ErrorType["STREAMING_ERROR"] = "streaming_error";
ErrorType["TOOL_EXECUTION_ERROR"] = "tool_execution_error";
ErrorType["PATH_VALIDATION_ERROR"] = "path_validation_error";
ErrorType["UNKNOWN_ERROR"] = "unknown_error";
})(ErrorType || (exports.ErrorType = ErrorType = {}));
class OllamaError extends Error {
constructor(message, type) {
super(message);
this.type = type;
Object.setPrototypeOf(this, OllamaError.prototype);
}
}
exports.OllamaError = OllamaError;
class NetworkError extends OllamaError {
constructor(message, statusCode) {
super(message, ErrorType.NETWORK_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
exports.NetworkError = NetworkError;
class ApiError extends OllamaError {
constructor(message, statusCode) {
super(message, ErrorType.API_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, ApiError.prototype);
}
}
exports.ApiError = ApiError;
class ValidationError extends OllamaError {
constructor(message, details) {
super(message, ErrorType.VALIDATION_ERROR);
this.details = details;
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
exports.ValidationError = ValidationError;
class StreamingError extends OllamaError {
constructor(message) {
super(message, ErrorType.STREAMING_ERROR);
Object.setPrototypeOf(this, StreamingError.prototype);
}
}
exports.StreamingError = StreamingError;
class ToolExecutionError extends OllamaError {
constructor(message, toolName) {
super(message, ErrorType.TOOL_EXECUTION_ERROR);
this.toolName = toolName;
Object.setPrototypeOf(this, ToolExecutionError.prototype);
}
}
exports.ToolExecutionError = ToolExecutionError;
class PathValidationError extends OllamaError {
constructor(message, path) {
super(message, ErrorType.PATH_VALIDATION_ERROR);
this.path = path;
Object.setPrototypeOf(this, PathValidationError.prototype);
}
}
exports.PathValidationError = PathValidationError;
exports.DEFAULT_SETTINGS = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
+184
View File
@@ -0,0 +1,184 @@
"use strict";
// src/utils.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.Logger = void 0;
exports.validateOllamaUrl = validateOllamaUrl;
exports.validateModelName = validateModelName;
exports.validatePluginSettings = validatePluginSettings;
exports.safeParseJson = safeParseJson;
exports.sanitizeFilePath = sanitizeFilePath;
exports.safeWriteFile = safeWriteFile;
exports.isValidHttpUrl = isValidHttpUrl;
exports.convertMarkdownToHtml = convertMarkdownToHtml;
// ==================== Logger ====================
var LogLevel;
(function (LogLevel) {
LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
LogLevel[LogLevel["INFO"] = 1] = "INFO";
LogLevel[LogLevel["WARN"] = 2] = "WARN";
LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
})(LogLevel || (LogLevel = {}));
const SEVERITY_ORDER = {
debug: LogLevel.DEBUG,
info: LogLevel.INFO,
warn: LogLevel.WARN,
error: LogLevel.ERROR,
};
class Logger {
static setLevel(level) {
if (typeof level === 'string') {
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
}
else {
Logger.minLevel = level;
}
}
static debug(message, category = 'general') {
if (LogLevel.DEBUG >= Logger.minLevel) {
console.debug(`[${category}] DEBUG: ${message}`);
}
}
static info(message, category = 'general') {
if (LogLevel.INFO >= Logger.minLevel) {
console.info(`[${category}] INFO: ${message}`);
}
}
static warn(message, category = 'general') {
if (LogLevel.WARN >= Logger.minLevel) {
console.warn(`[${category}] WARN: ${message}`);
}
}
static error(message, category = 'general') {
if (LogLevel.ERROR >= Logger.minLevel) {
console.error(`[${category}] ERROR: ${message}`);
}
}
}
exports.Logger = Logger;
Logger.minLevel = LogLevel.DEBUG;
// ==================== URL & Model Validation ====================
function validateOllamaUrl(url) {
if (typeof url !== 'string' || !url.trim()) {
return { valid: false, error: 'URL cannot be empty' };
}
const trimmedUrl = url.trim();
if (trimmedUrl.endsWith('/')) {
return { valid: false, error: 'URL should not end with a slash' };
}
try {
const parsed = new URL(trimmedUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' };
}
return { valid: true };
}
catch {
return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' };
}
}
function validateModelName(model) {
if (typeof model !== 'string') {
return { valid: false, error: 'Model name must be a string' };
}
const trimmedModel = model.trim();
// Explicit check for empty string after trimming
if (!trimmedModel || trimmedModel.length === 0) {
return { valid: false, error: 'Model name cannot be empty' };
}
if (trimmedModel.length < 2) {
return { valid: false, error: 'Model name must be at least 2 characters long' };
}
if (trimmedModel.length > 100) {
return { valid: false, error: 'Model name must be less than 100 characters long' };
}
if (!/^[a-zA-Z0-9._-]+$/.test(trimmedModel)) {
return {
valid: false,
error: 'Model name can only contain letters, numbers, dots, dashes, and underscores',
};
}
return { valid: true };
}
function validatePluginSettings(settings) {
const errors = [];
const urlValidation = validateOllamaUrl(settings.ollamaUrl);
if (!urlValidation.valid) {
errors.push(`Invalid Ollama URL: ${urlValidation.error}`);
}
const modelValidation = validateModelName(settings.model);
if (!modelValidation.valid) {
errors.push(`Invalid Model Name: ${modelValidation.error}`);
}
return errors;
}
// ==================== Safe JSON Parsing ====================
const MAX_JSON_SIZE = 1000000;
const MAX_JSON_NESTING = 24;
function countNestingDepth(value, depth = 0) {
if (depth > MAX_JSON_NESTING) {
return depth;
}
if (Array.isArray(value)) {
return Math.max(...value.map((item) => countNestingDepth(item, depth + 1)), depth);
}
if (value !== null && typeof value === 'object') {
const entries = Object.values(value);
if (entries.length === 0)
return depth;
return Math.max(...entries.map((item) => countNestingDepth(item, depth + 1)), depth);
}
return depth;
}
function safeParseJson(jsonString) {
if (typeof jsonString !== 'string') {
throw new Error('Input must be a string');
}
if (jsonString.length > MAX_JSON_SIZE) {
throw new Error('JSON input too large');
}
let parsed;
try {
parsed = JSON.parse(jsonString);
}
catch {
throw new Error('Invalid JSON');
}
// Check for dangerous prototype pollution patterns
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 ====================
function sanitizeFilePath(path) {
if (path.includes('..')) {
throw new Error('Invalid path - cannot contain .. segments');
}
return path;
}
async function safeWriteFile(filePath, content) {
const sanitizedPath = sanitizeFilePath(filePath);
console.log(`Writing to ${sanitizedPath}:`, content);
}
// ==================== HTTP Helpers ====================
function isValidHttpUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}
catch {
return false;
}
}
// ==================== Markdown Utilities ====================
function convertMarkdownToHtml(markdown) {
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
}
+283
View File
@@ -0,0 +1,283 @@
"use strict";
// src/vault-indexer.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.VaultIndexer = void 0;
const types_1 = require("./types");
const error_handler_1 = require("./error-handler");
const utils_1 = require("./utils");
class VaultIndexer {
constructor(vaultOrClient) {
this.ollamaClient = null;
this.summaries = new Map();
// 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) {
try {
const files = await this.getMarkdownFilesInVault(vaultPath);
for (const file of files) {
const content = await this.readFileContent(file.path);
const summary = await this.summarizeFile(content);
this.storeSummary(file.path, summary);
}
}
catch (error) {
if (error instanceof Error) {
error_handler_1.ErrorHandler.handleError(error, 'VaultIndexer.indexVault');
}
else {
throw new types_1.ValidationError('An unexpected error occurred while indexing the vault');
}
}
}
async getMarkdownFilesInVault(vaultPath) {
// Simulate getting markdown files from the vault
const sanitizedPath = (0, utils_1.sanitizeFilePath)(vaultPath);
try {
// This is a placeholder for actual file system operations
return [{ path: `${sanitizedPath}/file1.md` }, { path: `${sanitizedPath}/file2.md` }];
}
catch (error) {
throw new types_1.ValidationError('Failed to get markdown files from vault');
}
}
async readFileContent(filePath) {
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
try {
// This is a placeholder for actual file reading operations
return `Content of ${sanitizedPath}`;
}
catch (error) {
throw new types_1.ValidationError('Failed to read file content');
}
}
async summarizeFile(content) {
if (!this.ollamaClient) {
throw new types_1.ValidationError('OllamaClient not available for summarization');
}
try {
// Use the existing chat API to summarize text
const messages = [
{
role: 'system',
content: 'Summarize the following text concisely:',
},
{
role: 'user',
content: content,
},
];
const response = await this.ollamaClient.chat(messages);
return response.content;
}
catch (error) {
throw new types_1.ValidationError('Failed to summarize file');
}
}
storeSummary(filePath, summary) {
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
this.summaries.set(sanitizedPath, summary);
}
getSummary(filePath) {
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
return this.summaries.get(sanitizedPath);
}
async searchVault(query, limit = 5) {
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 !== null)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
async processFilesInBatches(files, queryTokens) {
const batchSize = 10;
const results = [];
const seenPaths = new Set();
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = 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 !== null);
results.push(...validResults);
if (results.length >= 50) {
break;
}
}
return results;
}
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));
}
tokenizeContent(content, file) {
const tokens = [];
const headings = [];
let frontmatter = {};
let firstParagraph;
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) => 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 };
}
calculateWeightedScore(tokenized, queryTokens, file) {
let totalScore = 0;
const matchedTokens = new Set();
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) => 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),
};
}
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;
}
exactMatch(content, token) {
const stemmedToken = this.stemToken(token);
return content.toLowerCase().includes(stemmedToken);
}
}
exports.VaultIndexer = VaultIndexer;