chore: move compiled output to dist
Update the plugin entrypoint and TypeScript output directory to use dist instead of writing generated JavaScript into src. Remove previously checked-in compiled source files and add coverage for the Ollama client and tool executor.
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "ollama-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Ollama integration plugin for Obsidian",
|
||||
"main": "main.ts",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -1,457 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChatView = exports.VIEW_TYPE_OLLAMA_CHAT = void 0;
|
||||
const obsidian_1 = require("obsidian");
|
||||
const ollama_client_1 = require("./ollama-client");
|
||||
const vault_indexer_1 = require("./vault-indexer");
|
||||
const tool_executor_1 = require("./tool-executor");
|
||||
const conversation_state_1 = require("./conversation-state");
|
||||
const error_handler_1 = require("./error-handler");
|
||||
exports.VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
||||
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);
|
||||
// State
|
||||
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.sendButtonClickWrapper = null;
|
||||
this.inputKeyDownWrapper = null;
|
||||
this.newChatButtonClickWrapper = null;
|
||||
this.listenersAttached = false;
|
||||
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.sendButtonClickWrapper = null;
|
||||
this.inputKeyDownWrapper = null;
|
||||
this.newChatButtonClickWrapper = null;
|
||||
this.listenersAttached = false;
|
||||
this.settings = settings;
|
||||
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model, undefined, settings.cacheConfig);
|
||||
this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault);
|
||||
this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app);
|
||||
this.conversationStateManager = new conversation_state_1.ConversationStateManager();
|
||||
}
|
||||
updateSettings(newSettings) {
|
||||
this.settings = newSettings;
|
||||
this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model, undefined, newSettings.cacheConfig);
|
||||
void this.ollamaClient.initializeCache().catch(() => {
|
||||
new obsidian_1.Notice('Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.');
|
||||
});
|
||||
}
|
||||
async clearCache() {
|
||||
await this.ollamaClient.clearCache();
|
||||
}
|
||||
getViewType() {
|
||||
return 'ollama-chat-view';
|
||||
}
|
||||
getDisplayText() {
|
||||
return 'Ollama Chat';
|
||||
}
|
||||
async onOpen() {
|
||||
try {
|
||||
await this.ollamaClient.initializeCache();
|
||||
}
|
||||
catch {
|
||||
new obsidian_1.Notice('Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.');
|
||||
}
|
||||
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;
|
||||
return Promise.resolve();
|
||||
}
|
||||
cleanupStreamingResources() {
|
||||
// Only cleanup if there's still an active streaming message
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
|
||||
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
|
||||
this.lastMessageEl = null;
|
||||
}
|
||||
}
|
||||
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' });
|
||||
const newChatContainer = this.contentEl.querySelector('.ollama-new-chat-container') ||
|
||||
this.contentEl.createEl('div', { cls: 'ollama-new-chat-container' });
|
||||
const messagesSnapshot = [...this.messages];
|
||||
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
|
||||
const existingMessages = container.querySelectorAll('.ollama-message');
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
// Render non-streaming messages
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
if (existingEl) {
|
||||
const contentEl = existingEl.querySelector('.ollama-message-content');
|
||||
if (contentEl) {
|
||||
contentEl.textContent = msg.content;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const messageEl = container.createEl('div', { cls: 'ollama-message' });
|
||||
messageEl.setAttribute('data-msg-id', msg.id);
|
||||
messageEl.createEl('div', { cls: 'ollama-message-role', text: msg.role });
|
||||
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
|
||||
contentEl.textContent = msg.content;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// Setup new chat button
|
||||
if (!this.newChatButton) {
|
||||
this.newChatButton = newChatContainer.createEl('button', {
|
||||
cls: 'ollama-new-chat-button',
|
||||
text: 'New Chat',
|
||||
});
|
||||
}
|
||||
else {
|
||||
newChatContainer.appendChild(this.newChatButton);
|
||||
}
|
||||
// Setup input area
|
||||
if (!this.inputEl) {
|
||||
this.inputEl = inputContainer.createEl('textarea', {
|
||||
cls: 'ollama-input',
|
||||
attr: { placeholder: 'Type your message...' },
|
||||
});
|
||||
}
|
||||
else {
|
||||
inputContainer.appendChild(this.inputEl);
|
||||
}
|
||||
// Setup send button
|
||||
if (!this.sendButton) {
|
||||
this.sendButton = inputContainer.createEl('button', {
|
||||
cls: 'ollama-send-button',
|
||||
text: 'Send',
|
||||
});
|
||||
}
|
||||
else {
|
||||
inputContainer.appendChild(this.sendButton);
|
||||
}
|
||||
// Append containers to contentEl
|
||||
this.contentEl.appendChild(newChatContainer);
|
||||
this.contentEl.appendChild(inputContainer);
|
||||
this.contentEl.appendChild(container);
|
||||
// Focus input on open
|
||||
this.inputEl.focus();
|
||||
}
|
||||
setupEventListeners() {
|
||||
if (this.listenersAttached) {
|
||||
return;
|
||||
}
|
||||
this.sendButtonClickHandler = () => {
|
||||
void this.handleUserInput(this.inputEl?.value);
|
||||
};
|
||||
this.inputKeyDownHandler = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void this.handleUserInput(this.inputEl?.value);
|
||||
}
|
||||
};
|
||||
this.newChatButtonClickHandler = () => {
|
||||
this.clearConversation();
|
||||
};
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
||||
}
|
||||
if (this.inputEl && this.inputKeyDownHandler) {
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
|
||||
}
|
||||
if (this.newChatButton && this.newChatButtonClickHandler) {
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
|
||||
}
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
removeEventListeners() {
|
||||
if (!this.listenersAttached) {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
this.messages = [];
|
||||
this.conversationStateManager.clear();
|
||||
this.render();
|
||||
}
|
||||
updateMessageById(id, updates) {
|
||||
const index = this.messages.findIndex((m) => m.id === id);
|
||||
if (index !== -1) {
|
||||
this.messages[index] = { ...this.messages[index], ...updates };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
updateLastMessage(updates) {
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage) {
|
||||
const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id);
|
||||
if (index !== -1) {
|
||||
this.messages[index] = { ...this.messages[index], ...updates };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
getTools() {
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_vault_file',
|
||||
description: 'Reads the content of a file from the vault',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'The path to the file to read',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The content of the file to read',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
description: 'Searches for files in the vault that match a given query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The search query to use',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'The maximum number of results to return',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
buildMessages(userMessageContent, tools) {
|
||||
const systemContent = `You are an assistant that can help answer questions using the contents of a vault.
|
||||
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
||||
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
||||
Only use the tools if you need to access vault content that is not already in the context.`;
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content: systemContent,
|
||||
};
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: userMessageContent,
|
||||
};
|
||||
const messages = [systemMessage, userMessage];
|
||||
if (tools && tools.length > 0) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: 'I have access to the following tools to help answer your questions:',
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) {
|
||||
const toolResults = (await Promise.all(toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
||||
try {
|
||||
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
|
||||
return { ...toolResult, id: toolCall.id };
|
||||
}
|
||||
catch (error) {
|
||||
error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
return null;
|
||||
}
|
||||
}))).filter((result) => result !== null);
|
||||
const followUpMessages = toolResults.map((result) => {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
tool_call_id: result.id ?? '',
|
||||
};
|
||||
});
|
||||
const followUp = {
|
||||
role: 'assistant',
|
||||
content: 'I have processed your request using the following tools. Here are the results:',
|
||||
tool_calls: toolCalls,
|
||||
};
|
||||
if (followUpMessages.length > 0) {
|
||||
const finalMessages = [...messages, followUp, ...followUpMessages];
|
||||
const response = await this.ollamaClient.chat(finalMessages, tools);
|
||||
const finalResponse = response.content || fullResponse;
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: finalResponse,
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
async handleUserInput(inputValue) {
|
||||
const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim();
|
||||
if (!userMessage) {
|
||||
return;
|
||||
}
|
||||
const MAX_CONTEXT_LENGTH = 2000;
|
||||
const tools = this.getTools();
|
||||
const messageId = crypto.randomUUID();
|
||||
const userMessageId = `${messageId}-user`;
|
||||
const assistantMessageId = `${messageId}-assistant`;
|
||||
const userChatMessage = {
|
||||
id: userMessageId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const assistantMessage = {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
};
|
||||
const previousStreamingEl = this.lastMessageEl;
|
||||
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
||||
this.render();
|
||||
if (this.inputEl) {
|
||||
this.inputEl.value = '';
|
||||
}
|
||||
// Add the assistant message to the DOM to enable streaming
|
||||
this.lastMessageEl =
|
||||
this.chatContainer?.querySelector(`.ollama-message[data-msg-id="${assistantMessageId}"]`) ??
|
||||
null;
|
||||
if (!this.lastMessageEl && previousStreamingEl) {
|
||||
previousStreamingEl.classList.add('ollama-message');
|
||||
previousStreamingEl.setAttribute('data-msg-id', assistantMessageId);
|
||||
this.contentEl.appendChild(previousStreamingEl);
|
||||
this.lastMessageEl = previousStreamingEl;
|
||||
}
|
||||
try {
|
||||
const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit);
|
||||
const context = entries
|
||||
.map((entry) => `${entry.title}\n${entry.content}`)
|
||||
.join('\n\n')
|
||||
.slice(0, MAX_CONTEXT_LENGTH);
|
||||
const userMessageWithContext = context
|
||||
? `Relevant vault context:\n${context}\n\nUser question:\n${userMessage}`
|
||||
: userMessage;
|
||||
// Get the complete messages array for the LLM with all context layers
|
||||
const completeMessages = this.conversationStateManager.getCompleteMessages(userMessageWithContext);
|
||||
const stream = this.ollamaClient.streamChat(completeMessages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls = [];
|
||||
let chunkCount = 0;
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
this.updateLastMessage({
|
||||
content: fullResponse,
|
||||
isStreaming: true,
|
||||
});
|
||||
}
|
||||
if (chunk.tool_calls) {
|
||||
toolCalls = [...toolCalls, ...chunk.tool_calls];
|
||||
}
|
||||
chunkCount++;
|
||||
if (chunkCount > MAX_STREAM_CHUNKS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Process tool calls if any
|
||||
if (toolCalls.length > 0) {
|
||||
await this.processToolCalls(toolCalls, completeMessages, tools, fullResponse, assistantMessageId);
|
||||
}
|
||||
// Update assistant message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
// Update conversation state with the assistant's response
|
||||
this.conversationStateManager.updateShortTermContext({ role: 'user', content: userMessage });
|
||||
this.conversationStateManager.updateShortTermContext({
|
||||
role: 'assistant',
|
||||
content: fullResponse,
|
||||
});
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > this.settings.maxMessageHistory) {
|
||||
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
catch (error) {
|
||||
error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: 'An error occurred while processing your request.',
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ChatView = ChatView;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
@@ -1,17 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_SETTINGS = void 0;
|
||||
exports.DEFAULT_SETTINGS = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: 'llama3',
|
||||
vaultSearchLimit: 3,
|
||||
maxMessageHistory: 50,
|
||||
lastIndexTime: 0,
|
||||
cacheConfig: {
|
||||
enabled: false,
|
||||
similarityThreshold: 0.85,
|
||||
collectionName: 'ollama_semantic_cache',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
"use strict";
|
||||
// src/conversation-state.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ConversationStateManager = void 0;
|
||||
class ConversationStateManager {
|
||||
constructor() {
|
||||
this.shortTermContext = [];
|
||||
this.mediumTermContext = [];
|
||||
this.longTermContext = [];
|
||||
this.maxShortTermTurns = 10;
|
||||
this.maxMediumTermMessages = 20;
|
||||
// Initialize with default system context
|
||||
this.longTermContext = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are an assistant that can help answer questions using the contents of a vault.
|
||||
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
||||
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
||||
Only use the tools if you need to access vault content that is not already in the context.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Updates the short-term context with a new message
|
||||
* @param message The message to add to short-term context
|
||||
*/
|
||||
updateShortTermContext(message) {
|
||||
// Add new message
|
||||
this.shortTermContext.push(message);
|
||||
// Limit to max turns
|
||||
if (this.shortTermContext.length > this.maxShortTermTurns) {
|
||||
this.shortTermContext = this.shortTermContext.slice(-this.maxShortTermTurns);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates the medium-term context with a new message
|
||||
* @param message The message to add to medium-term context
|
||||
*/
|
||||
updateMediumTermContext(message) {
|
||||
// Add new message
|
||||
this.mediumTermContext.push(message);
|
||||
// Limit to max messages
|
||||
if (this.mediumTermContext.length > this.maxMediumTermMessages) {
|
||||
this.mediumTermContext = this.mediumTermContext.slice(-this.maxMediumTermMessages);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sets the user's persona or core knowledge as long-term context
|
||||
* @param personaContent The persona or core knowledge content
|
||||
*/
|
||||
setPersona(personaContent) {
|
||||
// Remove any existing persona messages
|
||||
this.longTermContext = this.longTermContext.filter((msg) => msg.role !== 'system' ||
|
||||
!msg.content.includes('You are an assistant that can help answer questions using the contents of a vault'));
|
||||
// Add the new persona
|
||||
this.longTermContext.push({
|
||||
role: 'system',
|
||||
content: personaContent,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Gets the combined conversation context for the current turn
|
||||
* @param userMessage The user's current message
|
||||
* @returns Complete conversation context with all three layers
|
||||
*/
|
||||
getConversationContext(_userMessage) {
|
||||
return {
|
||||
shortTermContext: this.shortTermContext,
|
||||
mediumTermContext: this.mediumTermContext,
|
||||
longTermContext: this.longTermContext,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets the complete messages array for sending to the LLM
|
||||
* @param userMessage The user's current message
|
||||
* @returns Complete message array for the LLM
|
||||
*/
|
||||
getCompleteMessages(userMessage) {
|
||||
const userMessageWithContext = {
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
};
|
||||
// Build messages in the proper order:
|
||||
// 1. Long-term context (user persona, system instructions)
|
||||
// 2. Medium-term context (session knowledge base query results)
|
||||
// 3. Short-term context (last N turns)
|
||||
// 4. Current user message
|
||||
return [
|
||||
...this.longTermContext,
|
||||
...this.mediumTermContext,
|
||||
...this.shortTermContext,
|
||||
userMessageWithContext,
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Clears all conversation context
|
||||
*/
|
||||
clear() {
|
||||
this.shortTermContext = [];
|
||||
this.mediumTermContext = [];
|
||||
this.longTermContext = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are an assistant that can help answer questions using the contents of a vault.
|
||||
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
||||
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
||||
Only use the tools if you need to access vault content that is not already in the context.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Sets the medium-term context from a knowledge base query result
|
||||
* @param queryResult The result from a knowledge base query
|
||||
*/
|
||||
setMediumTermContextFromQuery(queryResult) {
|
||||
// Clear previous medium-term context
|
||||
this.mediumTermContext = [];
|
||||
// Add the query result as context
|
||||
if (queryResult.trim()) {
|
||||
this.mediumTermContext.push({
|
||||
role: 'system',
|
||||
content: `Knowledge base results for current query:\n${queryResult}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Gets the current short-term context
|
||||
*/
|
||||
getShortTermContext() {
|
||||
return [...this.shortTermContext];
|
||||
}
|
||||
/**
|
||||
* Gets the current medium-term context
|
||||
*/
|
||||
getMediumTermContext() {
|
||||
return [...this.mediumTermContext];
|
||||
}
|
||||
/**
|
||||
* Gets the current long-term context
|
||||
*/
|
||||
getLongTermContext() {
|
||||
return [...this.longTermContext];
|
||||
}
|
||||
}
|
||||
exports.ConversationStateManager = ConversationStateManager;
|
||||
@@ -1,107 +0,0 @@
|
||||
"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}]` : '';
|
||||
// Use console.error instead of ErrorHandler.error for fatal errors
|
||||
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 ?? 500);
|
||||
}
|
||||
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;
|
||||
@@ -1,90 +0,0 @@
|
||||
"use strict";
|
||||
// src/indexing-pipeline/extraction.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ContentExtractor = void 0;
|
||||
/**
|
||||
* Extracts raw content from a vault file including:
|
||||
* - Markdown content
|
||||
* - YAML frontmatter
|
||||
* - Headings
|
||||
* - Embedded code blocks
|
||||
* - First paragraph
|
||||
*/
|
||||
class ContentExtractor {
|
||||
extractFromFile(file, content) {
|
||||
const frontmatter = {};
|
||||
const headings = [];
|
||||
const embeddedCodeBlocks = [];
|
||||
let firstParagraph;
|
||||
// Extract frontmatter
|
||||
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;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Store other frontmatter fields as-is
|
||||
frontmatter[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If frontmatter parsing fails, continue with empty frontmatter
|
||||
}
|
||||
}
|
||||
// Extract headings
|
||||
const headingMatches = content.match(/^#{1,6} (.*?)$/gm);
|
||||
if (headingMatches) {
|
||||
headings.push(...headingMatches.map((h) => h.replace(/^#{1,6} /, '')));
|
||||
}
|
||||
// Extract embedded code blocks
|
||||
const codeBlockMatches = content.match(/```([\s\S]*?)```/g);
|
||||
if (codeBlockMatches) {
|
||||
embeddedCodeBlocks.push(...codeBlockMatches);
|
||||
}
|
||||
// Extract first paragraph
|
||||
const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s);
|
||||
if (paragraphMatch) {
|
||||
firstParagraph = paragraphMatch[1].trim();
|
||||
}
|
||||
return {
|
||||
basename: file.basename,
|
||||
path: file.path,
|
||||
content,
|
||||
frontmatter,
|
||||
headings,
|
||||
embeddedCodeBlocks,
|
||||
firstParagraph,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Extracts just the raw text content without headers, frontmatter, etc.
|
||||
*/
|
||||
extractRawText(content) {
|
||||
return content
|
||||
.replace(/^---.*?---/s, '')
|
||||
.replace(/^#.*?$/gm, '')
|
||||
.replace(/```.*?```/gs, '')
|
||||
.replace(/`.*?`/g, '')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
exports.ContentExtractor = ContentExtractor;
|
||||
@@ -1,12 +0,0 @@
|
||||
"use strict";
|
||||
// src/indexing-pipeline/index.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.IndexingPipeline = exports.ContentVectorizer = exports.ContentNormalizer = exports.ContentExtractor = void 0;
|
||||
var extraction_1 = require("./extraction");
|
||||
Object.defineProperty(exports, "ContentExtractor", { enumerable: true, get: function () { return extraction_1.ContentExtractor; } });
|
||||
var normalization_1 = require("./normalization");
|
||||
Object.defineProperty(exports, "ContentNormalizer", { enumerable: true, get: function () { return normalization_1.ContentNormalizer; } });
|
||||
var vectorization_1 = require("./vectorization");
|
||||
Object.defineProperty(exports, "ContentVectorizer", { enumerable: true, get: function () { return vectorization_1.ContentVectorizer; } });
|
||||
var pipeline_1 = require("./pipeline");
|
||||
Object.defineProperty(exports, "IndexingPipeline", { enumerable: true, get: function () { return pipeline_1.IndexingPipeline; } });
|
||||
@@ -1,159 +0,0 @@
|
||||
"use strict";
|
||||
// src/indexing-pipeline/normalization.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ContentNormalizer = void 0;
|
||||
/**
|
||||
* Normalizes and enriches extracted content
|
||||
*/
|
||||
class ContentNormalizer {
|
||||
/**
|
||||
* Normalizes content by:
|
||||
* - Standardizing dates to ISO 8601
|
||||
* - Converting to lowercase for tokenization
|
||||
* - Extracting tokens
|
||||
* - Adding metadata
|
||||
*/
|
||||
normalize(extractedContent) {
|
||||
const { basename, path, content, frontmatter, headings, firstParagraph } = extractedContent;
|
||||
// Standardize title (remove .md extension)
|
||||
const title = basename.replace(/\.md$/, '');
|
||||
// Extract tokens (lowercase, remove stop words, etc.)
|
||||
const tokens = this.tokenize(content);
|
||||
// Normalize dates (if present in frontmatter)
|
||||
const normalizedFrontmatter = this.normalizeFrontmatter(frontmatter);
|
||||
// Calculate word count
|
||||
const wordCount = content.split(/\s+/).filter(Boolean).length;
|
||||
return {
|
||||
path,
|
||||
title,
|
||||
content,
|
||||
tokens,
|
||||
headings,
|
||||
frontmatter: normalizedFrontmatter,
|
||||
firstParagraph,
|
||||
wordCount,
|
||||
// Add timestamps if available in frontmatter
|
||||
createdAt: this.extractDate(frontmatter, 'created') || this.extractDate(frontmatter, 'date'),
|
||||
updatedAt: this.extractDate(frontmatter, 'updated'),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Tokenizes text content by splitting on whitespace and removing stop words
|
||||
*/
|
||||
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',
|
||||
'from',
|
||||
'up',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'further',
|
||||
'then',
|
||||
'once',
|
||||
'here',
|
||||
'there',
|
||||
'when',
|
||||
'where',
|
||||
'why',
|
||||
'how',
|
||||
'all',
|
||||
'any',
|
||||
'both',
|
||||
'each',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'no',
|
||||
'nor',
|
||||
'not',
|
||||
'only',
|
||||
'own',
|
||||
'same',
|
||||
'so',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'now',
|
||||
]);
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.filter((token) => token.length > 1 && !stopWords.has(token));
|
||||
}
|
||||
/**
|
||||
* Normalizes frontmatter by standardizing data types and formats
|
||||
*/
|
||||
normalizeFrontmatter(frontmatter) {
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (key === 'tags' && typeof value === 'string') {
|
||||
// Convert tag string to array if needed
|
||||
normalized.tags = value.split(',').map((tag) => tag.trim());
|
||||
}
|
||||
else if (key === 'date' || key === 'created' || key === 'updated') {
|
||||
// Try to parse and standardize date formats
|
||||
if (typeof value === 'string') {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
normalized[key] = date.toISOString();
|
||||
}
|
||||
else {
|
||||
normalized[key] = value; // Keep original if invalid date
|
||||
}
|
||||
}
|
||||
else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
/**
|
||||
* Extracts a date from frontmatter
|
||||
*/
|
||||
extractDate(frontmatter, key) {
|
||||
const value = frontmatter[key];
|
||||
if (typeof value === 'string') {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
exports.ContentNormalizer = ContentNormalizer;
|
||||
@@ -1,67 +0,0 @@
|
||||
"use strict";
|
||||
// src/indexing-pipeline/pipeline.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.IndexingPipeline = void 0;
|
||||
const extraction_1 = require("./extraction");
|
||||
const normalization_1 = require("./normalization");
|
||||
const vectorization_1 = require("./vectorization");
|
||||
class IndexingPipeline {
|
||||
constructor(config) {
|
||||
this.extractor = new extraction_1.ContentExtractor();
|
||||
this.normalizer = new normalization_1.ContentNormalizer();
|
||||
this.vectorizer = new vectorization_1.ContentVectorizer({
|
||||
model: config.embeddingModel,
|
||||
ollamaUrl: config.ollamaUrl,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Processes a vault file through the entire pipeline
|
||||
*/
|
||||
processFile(file, content) {
|
||||
try {
|
||||
if (!content.trim()) {
|
||||
return null;
|
||||
}
|
||||
// Extraction step
|
||||
const extracted = this.extractor.extractFromFile(file, content);
|
||||
// Normalization/Enrichment step
|
||||
const normalized = this.normalizer.normalize(extracted);
|
||||
// Return the normalized content as an index entry
|
||||
return {
|
||||
path: normalized.path,
|
||||
title: normalized.title,
|
||||
content: this.extractor.extractRawText(content).substring(0, 500),
|
||||
score: 0, // Score will be calculated during search
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Processes multiple files in batches
|
||||
*/
|
||||
processFilesInBatches(files, fileContents, 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 = batch.map((file) => {
|
||||
const content = fileContents[file.path];
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const entry = this.processFile(file, content);
|
||||
if (entry && !seenPaths.has(entry.path)) {
|
||||
seenPaths.add(entry.path);
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const validResults = batchResults.filter((result) => result !== null);
|
||||
results.push(...validResults);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
exports.IndexingPipeline = IndexingPipeline;
|
||||
@@ -1,65 +0,0 @@
|
||||
"use strict";
|
||||
// src/indexing-pipeline/vectorization.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ContentVectorizer = void 0;
|
||||
const utils_1 = require("../utils");
|
||||
/**
|
||||
* Vectorizes content chunks using Ollama embeddings
|
||||
*/
|
||||
class ContentVectorizer {
|
||||
constructor(config, fetchFn) {
|
||||
this.model = config.model;
|
||||
this.ollamaUrl = config.ollamaUrl;
|
||||
this.fetchFn = fetchFn ?? fetch;
|
||||
}
|
||||
/**
|
||||
* Generates embeddings for a content chunk
|
||||
*/
|
||||
async vectorize(chunk) {
|
||||
try {
|
||||
const prompt = this.createPrompt(chunk);
|
||||
const response = await this.fetchFn(`${this.ollamaUrl}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Embedding failed with status ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!this.isEmbeddingResponse(data)) {
|
||||
throw new Error('Invalid embedding response');
|
||||
}
|
||||
return data.embedding;
|
||||
}
|
||||
catch (error) {
|
||||
// Return empty array on failure to maintain compatibility
|
||||
utils_1.Logger.warn(`Failed to generate embedding: ${String(error)}`, 'indexing-pipeline');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
isEmbeddingResponse(data) {
|
||||
return (typeof data === 'object' &&
|
||||
data !== null &&
|
||||
Array.isArray(data.embedding) &&
|
||||
data.embedding.every((value) => typeof value === 'number'));
|
||||
}
|
||||
/**
|
||||
* Creates a prompt from content chunk for embedding
|
||||
*/
|
||||
createPrompt(chunk) {
|
||||
// Combine important elements for embedding
|
||||
const parts = [
|
||||
chunk.title,
|
||||
chunk.firstParagraph,
|
||||
chunk.content.substring(0, 1000), // Limit content to avoid long prompts
|
||||
chunk.headings.join(' '),
|
||||
JSON.stringify(chunk.frontmatter),
|
||||
].filter(Boolean);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
}
|
||||
exports.ContentVectorizer = ContentVectorizer;
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const obsidian_1 = require("obsidian");
|
||||
const chat_view_1 = require("./chat-view");
|
||||
const constants_1 = require("./constants");
|
||||
const semantic_cache_1 = require("./semantic-cache");
|
||||
class OllamaPlugin extends obsidian_1.Plugin {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.settings = constants_1.DEFAULT_SETTINGS;
|
||||
}
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
// Register the chat view
|
||||
this.registerView('ollama-chat-view', (leaf) => new chat_view_1.ChatView(leaf, this.settings));
|
||||
// Add a command to open the chat view
|
||||
this.addCommand({
|
||||
id: 'open-ollama-chat',
|
||||
name: 'Open Ollama Chat',
|
||||
callback: async () => {
|
||||
await this.activateChatView();
|
||||
},
|
||||
});
|
||||
// Add a command to clear the semantic cache
|
||||
this.addCommand({
|
||||
id: 'clear-semantic-cache',
|
||||
name: 'Clear Semantic Cache',
|
||||
callback: async () => {
|
||||
await this.clearSemanticCache();
|
||||
new obsidian_1.Notice('Semantic cache cleared.');
|
||||
},
|
||||
});
|
||||
// Add a settings tab
|
||||
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
||||
// Initialize the semantic cache
|
||||
if (this.settings.cacheConfig) {
|
||||
this.semanticCache = new semantic_cache_1.SemanticCacheService(this.settings.ollamaUrl, this.settings.cacheConfig);
|
||||
try {
|
||||
await this.semanticCache.initialize();
|
||||
}
|
||||
catch {
|
||||
new obsidian_1.Notice('Semantic cache initialization failed. Check console for details.');
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onunload() {
|
||||
// Clean up any active semantic cache resources on plugin unload
|
||||
// Using fire-and-forget pattern since onunload cannot be async per Obsidian API
|
||||
if (this.semanticCache) {
|
||||
void this.semanticCache.clearCache();
|
||||
}
|
||||
// No explicit unregisterView needed; relying on Obsidian lifecycle management.
|
||||
}
|
||||
async loadSettings() {
|
||||
const loadedSettings = ((await this.loadData()) ?? {});
|
||||
this.settings = Object.assign({}, constants_1.DEFAULT_SETTINGS, loadedSettings);
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
async activateChatView() {
|
||||
const existing = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||||
if (existing.length > 0) {
|
||||
await this.app.workspace.revealLeaf(existing[0]);
|
||||
}
|
||||
else {
|
||||
const leaf = this.app.workspace.getRightLeaf(false);
|
||||
if (leaf) {
|
||||
await leaf.setViewState({
|
||||
type: 'ollama-chat-view',
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
async clearSemanticCache() {
|
||||
if (this.semanticCache) {
|
||||
await this.semanticCache.clearCache();
|
||||
}
|
||||
}
|
||||
notifyChatViews() {
|
||||
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||||
leaves.forEach((leaf) => {
|
||||
if (leaf.view instanceof chat_view_1.ChatView) {
|
||||
leaf.view.updateSettings(this.settings);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
exports.default = OllamaPlugin;
|
||||
class OllamaSettingTab extends obsidian_1.PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl('h2', { text: 'Ollama Settings' });
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Ollama URL')
|
||||
.setDesc('URL for your Ollama instance (default: http://localhost:11434)')
|
||||
.addText((text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
|
||||
this.plugin.settings.ollamaUrl = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Model')
|
||||
.setDesc('Ollama model to use (default: llama3)')
|
||||
.addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => {
|
||||
this.plugin.settings.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Vault Search Limit')
|
||||
.setDesc('Maximum number of vault entries to include in context (default: 3)')
|
||||
.addText((text) => text.setValue(String(this.plugin.settings.vaultSearchLimit)).onChange(async (value) => {
|
||||
const parsed = parseInt(value);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
this.plugin.settings.vaultSearchLimit = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
else {
|
||||
new obsidian_1.Notice('Vault search limit must be a positive integer.');
|
||||
}
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Max Message History')
|
||||
.setDesc('Maximum number of messages to keep in conversation history (default: 50)')
|
||||
.addText((text) => text.setValue(String(this.plugin.settings.maxMessageHistory)).onChange(async (value) => {
|
||||
const parsed = parseInt(value);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
this.plugin.settings.maxMessageHistory = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
else {
|
||||
new obsidian_1.Notice('Max message history must be a positive integer.');
|
||||
}
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Enable Semantic Cache')
|
||||
.setDesc('Use semantic cache to store and retrieve previous responses')
|
||||
.addToggle((toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => {
|
||||
this.plugin.settings.cacheConfig.enabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.notifyChatViews();
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('ChromaDB URL')
|
||||
.setDesc('URL for your ChromaDB instance (default: http://localhost:8000)')
|
||||
.addText((text) => text
|
||||
.setValue(this.plugin.settings.cacheConfig.chromaURL || 'http://localhost:8000')
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.cacheConfig.chromaURL = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Cache Embedding Model')
|
||||
.setDesc('Ollama model used to generate embeddings for the semantic cache')
|
||||
.addText((text) => text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => {
|
||||
this.plugin.settings.cacheConfig.embeddingModel = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.notifyChatViews();
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Cache Similarity Threshold')
|
||||
.setDesc('Minimum cosine similarity (0–1) for a cache hit. Higher values require closer matches.')
|
||||
.addText((text) => text
|
||||
.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold))
|
||||
.onChange(async (value) => {
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
this.plugin.settings.cacheConfig.similarityThreshold = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
else {
|
||||
new obsidian_1.Notice('Similarity threshold must be a number between 0 and 1.');
|
||||
}
|
||||
}));
|
||||
new obsidian_1.Setting(containerEl)
|
||||
.setName('Clear Semantic Cache')
|
||||
.setDesc('Delete all cached responses from ChromaDB')
|
||||
.addButton((button) => button.setButtonText('Clear Cache').onClick(async () => {
|
||||
try {
|
||||
await this.plugin.clearSemanticCache();
|
||||
new obsidian_1.Notice('Semantic cache cleared.');
|
||||
}
|
||||
catch {
|
||||
new obsidian_1.Notice('Failed to clear semantic cache. Is ChromaDB running?');
|
||||
}
|
||||
}));
|
||||
}
|
||||
hide() {
|
||||
// Clear the container to prevent duplicate elements
|
||||
this.containerEl.empty();
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
"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");
|
||||
const semantic_cache_1 = require("./semantic-cache");
|
||||
class OllamaClient {
|
||||
constructor(baseURL, model, fetchFn, cacheConfig) {
|
||||
this.maxRetries = 3;
|
||||
this.maxMalformedChunks = 50;
|
||||
this.currentStreamController = null;
|
||||
this.baseURL = baseURL;
|
||||
this.model = model;
|
||||
this.fetchFn = fetchFn ?? fetch;
|
||||
if (cacheConfig?.enabled) {
|
||||
this.cacheService = new semantic_cache_1.SemanticCacheService(baseURL, cacheConfig);
|
||||
void this.cacheService.initialize();
|
||||
}
|
||||
}
|
||||
async initializeCache() {
|
||||
if (this.cacheService) {
|
||||
await this.cacheService.initialize();
|
||||
}
|
||||
}
|
||||
async clearCache() {
|
||||
if (this.cacheService) {
|
||||
await this.cacheService.clearCache();
|
||||
}
|
||||
}
|
||||
cancelStream() {
|
||||
if (this.currentStreamController) {
|
||||
this.currentStreamController.abort();
|
||||
this.currentStreamController = null;
|
||||
}
|
||||
}
|
||||
async *streamChat(messages, tools = []) {
|
||||
// Bypass cache if tools are involved to prevent state corruption
|
||||
if (tools.length > 0) {
|
||||
yield* this.streamChatWithRetry(messages, tools, 0);
|
||||
return;
|
||||
}
|
||||
// Find the last user message
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||
if (lastUserMsg && this.cacheService) {
|
||||
const cached = await this.cacheService.getCache(lastUserMsg.content);
|
||||
if (cached) {
|
||||
yield { role: 'assistant', content: cached, tool_calls: [] };
|
||||
return;
|
||||
}
|
||||
}
|
||||
const chunks = [];
|
||||
for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) {
|
||||
chunks.push(chunk);
|
||||
yield chunk;
|
||||
}
|
||||
const fullContent = chunks.map((c) => c.content).join('');
|
||||
if (this.cacheService && lastUserMsg) {
|
||||
void this.cacheService.setCache(lastUserMsg.content, fullContent);
|
||||
}
|
||||
}
|
||||
async chat(messages, tools = []) {
|
||||
// Bypass cache if tools are involved to prevent state corruption
|
||||
if (tools.length > 0) {
|
||||
return this.chatWithRetry(messages, tools, 0);
|
||||
}
|
||||
// Find the last user message
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||
if (lastUserMsg && this.cacheService) {
|
||||
const cached = await this.cacheService.getCache(lastUserMsg.content);
|
||||
if (cached) {
|
||||
return { role: 'assistant', content: cached };
|
||||
}
|
||||
}
|
||||
const response = await this.chatWithRetry(messages, tools, 0);
|
||||
if (this.cacheService && lastUserMsg) {
|
||||
void this.cacheService.setCache(lastUserMsg.content, response.content);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
async streamChatAsPromise(messages, tools = []) {
|
||||
let content = '';
|
||||
let role = 'assistant';
|
||||
let toolCalls;
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
role = chunk.role ?? role;
|
||||
content += chunk.content ?? '';
|
||||
if (chunk.tool_calls) {
|
||||
toolCalls = [...(toolCalls ?? []), ...chunk.tool_calls];
|
||||
}
|
||||
}
|
||||
return { role, content, tool_calls: toolCalls };
|
||||
}
|
||||
async *streamChatWithRetry(messages, tools = [], retryCount) {
|
||||
const controller = new AbortController();
|
||||
this.currentStreamController = controller;
|
||||
let reader = null;
|
||||
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: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
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('application/x-ndjson')) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let malformedChunks = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value);
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = this.parseChatResponse(line);
|
||||
}
|
||||
catch (error) {
|
||||
malformedChunks++;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Skipped malformed chunk: ${line.slice(0, 50)}... - ${errorMessage}`, 'ollama-client');
|
||||
if (malformedChunks > this.maxMalformedChunks) {
|
||||
throw new Error('Too many malformed chunks in Ollama response');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${parsed.error}`);
|
||||
}
|
||||
yield this.normalizeMessage(parsed.message);
|
||||
}
|
||||
}
|
||||
if (buffer.trim() !== '') {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = this.parseChatResponse(buffer);
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Skipped malformed chunk: ${buffer.slice(0, 50)}... - ${errorMessage}`, 'ollama-client');
|
||||
}
|
||||
if (parsed?.error) {
|
||||
throw new Error(`Ollama error: ${parsed.error}`);
|
||||
}
|
||||
if (parsed?.message) {
|
||||
yield this.normalizeMessage(parsed.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
||||
yield* this.streamChatWithRetry(messages, tools, retryCount + 1);
|
||||
}
|
||||
else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
reader?.releaseLock();
|
||||
if (this.currentStreamController === controller) {
|
||||
this.currentStreamController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
async chatWithRetry(messages, tools = [], retryCount) {
|
||||
const controller = new AbortController();
|
||||
this.currentStreamController = controller;
|
||||
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) {
|
||||
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!this.isChatResponse(data)) {
|
||||
return this.normalizeMessage();
|
||||
}
|
||||
return this.normalizeMessage(data.message);
|
||||
}
|
||||
catch (error) {
|
||||
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
||||
return this.chatWithRetry(messages, tools, retryCount + 1);
|
||||
}
|
||||
else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (this.currentStreamController === controller) {
|
||||
this.currentStreamController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
normalizeMessage(message) {
|
||||
return {
|
||||
role: message?.role ?? 'assistant',
|
||||
content: message?.content ?? '',
|
||||
tool_calls: message?.tool_calls ?? [],
|
||||
tool_call_id: message?.tool_call_id,
|
||||
};
|
||||
}
|
||||
parseChatResponse(raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!this.isChatResponse(parsed)) {
|
||||
throw new Error('Invalid chat response');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
isChatResponse(data) {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
const response = data;
|
||||
return ((response.error === undefined || typeof response.error === 'string') &&
|
||||
(response.message === undefined || this.isPartialMessage(response.message)));
|
||||
}
|
||||
isPartialMessage(data) {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
const message = data;
|
||||
const validRole = message.role === undefined ||
|
||||
message.role === 'system' ||
|
||||
message.role === 'user' ||
|
||||
message.role === 'assistant' ||
|
||||
message.role === 'tool';
|
||||
return (validRole &&
|
||||
(message.content === undefined || typeof message.content === 'string') &&
|
||||
(message.tool_calls === undefined || Array.isArray(message.tool_calls)) &&
|
||||
(message.tool_call_id === undefined || typeof message.tool_call_id === 'string'));
|
||||
}
|
||||
isRetryableError(error, controller) {
|
||||
if (controller.signal.aborted) {
|
||||
return false;
|
||||
}
|
||||
if (error instanceof types_1.ApiError && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return false;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return false;
|
||||
}
|
||||
if (error.message.startsWith('Ollama error:') ||
|
||||
error.message.includes('Too many malformed chunks') ||
|
||||
error.message === 'No response body' ||
|
||||
error.message === 'Invalid response format') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
exports.OllamaClient = OllamaClient;
|
||||
@@ -1,105 +0,0 @@
|
||||
"use strict";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
|
||||
/* eslint-disable */
|
||||
// src/semantic-cache.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SemanticCacheService = void 0;
|
||||
const chromadb_1 = require("chromadb");
|
||||
const utils_1 = require("./utils");
|
||||
class SemanticCacheService {
|
||||
constructor(ollamaURL, config) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.collection = null;
|
||||
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
|
||||
this.config = config;
|
||||
// Use configurable ChromaDB URL or default to localhost
|
||||
const chromaURL = config.chromaURL || 'http://localhost:8000';
|
||||
this.client = new chromadb_1.ChromaClient({ path: chromaURL });
|
||||
}
|
||||
async initialize() {
|
||||
if (!this.config.enabled)
|
||||
return;
|
||||
try {
|
||||
this.collection = await this.client.getOrCreateCollection({
|
||||
name: this.config.collectionName,
|
||||
metadata: { 'hnsw:space': 'cosine' },
|
||||
});
|
||||
utils_1.Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache');
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.error(`Failed to initialize semantic cache: ${errorMessage}`, 'semantic-cache');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async getCache(query) {
|
||||
if (!this.config.enabled || !this.collection)
|
||||
return null;
|
||||
try {
|
||||
const results = await this.collection.query({
|
||||
query_embeddings: await this.generateEmbedding(query),
|
||||
n_results: 1,
|
||||
where: { source: 'ollama' },
|
||||
});
|
||||
if (results.ids[0] && results.ids[0].length > 0) {
|
||||
const [id] = results.ids[0];
|
||||
const [content] = results.documents[0];
|
||||
if (results.distances[0] && results.distances[0][0] > this.config.similarityThreshold) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Cache lookup failed: ${errorMessage}`, 'semantic-cache');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async setCache(query, response) {
|
||||
if (!this.config.enabled || !this.collection)
|
||||
return;
|
||||
try {
|
||||
await this.collection.add({
|
||||
ids: [crypto.randomUUID()],
|
||||
documents: [response],
|
||||
embeddings: await this.generateEmbedding(query),
|
||||
metadatas: [{ source: 'ollama' }],
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Cache set failed: ${errorMessage}`, 'semantic-cache');
|
||||
}
|
||||
}
|
||||
async clearCache() {
|
||||
if (!this.config.enabled || !this.collection)
|
||||
return;
|
||||
try {
|
||||
await this.collection.reset();
|
||||
utils_1.Logger.info('Semantic cache cleared', 'semantic-cache');
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.error(`Failed to clear semantic cache: ${errorMessage}`, 'semantic-cache');
|
||||
}
|
||||
}
|
||||
async generateEmbedding(text) {
|
||||
const response = await fetch(`${this.ollamaURL}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.embeddingModel,
|
||||
prompt: text,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to generate embedding: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.embedding;
|
||||
}
|
||||
}
|
||||
exports.SemanticCacheService = SemanticCacheService;
|
||||
@@ -1,167 +0,0 @@
|
||||
"use strict";
|
||||
// src/tool-executor.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ToolExecutor = void 0;
|
||||
const obsidian_1 = require("obsidian");
|
||||
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.split('/').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);
|
||||
case 'read_vault_file':
|
||||
return await this.handleReadVaultFile(parsedArgs);
|
||||
case 'search_vault_files':
|
||||
return this.handleSearchVaultFiles(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);
|
||||
}
|
||||
}
|
||||
async executeTool(name, args) {
|
||||
return this.handleToolCall({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
}
|
||||
async handleReadVaultFile(args) {
|
||||
const path = args.path;
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error('Path must be a string');
|
||||
}
|
||||
if (!this.isSafePath(path)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof obsidian_1.TFile)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
const content = await this.vault.cachedRead(file);
|
||||
return {
|
||||
success: true,
|
||||
message: 'File read successfully',
|
||||
data: { path, content },
|
||||
};
|
||||
}
|
||||
handleSearchVaultFiles(args) {
|
||||
const query = args.query;
|
||||
const limitArg = args.limit;
|
||||
if (typeof query !== 'string') {
|
||||
throw new Error('Query must be a string');
|
||||
}
|
||||
const limit = typeof limitArg === 'number' && Number.isFinite(limitArg) ? limitArg : 10;
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const files = this.vault
|
||||
.getMarkdownFiles()
|
||||
.filter((file) => file.path.toLowerCase().includes(normalizedQuery))
|
||||
.slice(0, limit)
|
||||
.map((file) => ({ path: file.path, basename: file.basename }));
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${files.length} matching files`,
|
||||
data: files,
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.ToolExecutor = ToolExecutor;
|
||||
@@ -1,72 +0,0 @@
|
||||
"use strict";
|
||||
// src/types.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
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 = 'unknown') {
|
||||
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;
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Logger = exports.LogLevel = void 0;
|
||||
exports.validateOllamaUrl = validateOllamaUrl;
|
||||
exports.validateModelName = validateModelName;
|
||||
exports.validatePluginSettings = validatePluginSettings;
|
||||
exports.safeParseJson = safeParseJson;
|
||||
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 || (exports.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') {
|
||||
const lowerLevel = level.toLowerCase();
|
||||
Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? 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, underscores, and colons',
|
||||
};
|
||||
}
|
||||
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 in object keys only
|
||||
const checkDangerousPatterns = (obj) => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
const dangerousKeys = ['constructor', 'prototype', '__proto__'];
|
||||
if (dangerousKeys.some((key) => Object.keys(obj).includes(key))) {
|
||||
return true;
|
||||
}
|
||||
// Recursively check nested objects (own properties only)
|
||||
const record = obj;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (checkDangerousPatterns(record[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (checkDangerousPatterns(parsed)) {
|
||||
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;
|
||||
}
|
||||
// ==================== Markdown Utilities ====================
|
||||
@@ -1,226 +0,0 @@
|
||||
"use strict";
|
||||
// src/vault-indexer.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VaultIndexer = exports.InMemoryCache = void 0;
|
||||
const utils_1 = require("./utils");
|
||||
class InMemoryCache {
|
||||
constructor() {
|
||||
this.store = new Map();
|
||||
}
|
||||
get(key) {
|
||||
return Promise.resolve(this.store.get(key) ?? null);
|
||||
}
|
||||
put(key, value) {
|
||||
this.store.set(key, value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
exports.InMemoryCache = InMemoryCache;
|
||||
const STOP_WORDS = new Set([
|
||||
'a', 'an', 'the', 'is', 'it', 'in', 'on', 'at', 'to', 'for', 'of',
|
||||
'and', 'or', 'but', 'with', 'by', 'from', 'up', 'about', 'into',
|
||||
'this', 'that', 'these', 'those', 'be', 'been', 'being', 'have',
|
||||
'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
|
||||
'may', 'might', 'can', 'are', 'was', 'were', 'as', 'so', 'if', 'not',
|
||||
'no', 'my', 'your', 'our', 'its', 'we', 'you', 'he', 'she', 'they',
|
||||
]);
|
||||
const CONTENT_PREVIEW_LENGTH = 500;
|
||||
class VaultIndexer {
|
||||
constructor(vault, cache) {
|
||||
this.SCORING_WEIGHTS = {
|
||||
TITLE: 5,
|
||||
FRONTMATTER_TITLE: 4,
|
||||
FRONTMATTER_TAGS: 3,
|
||||
HEADINGS: 2,
|
||||
CONTENT: 1,
|
||||
};
|
||||
this.vault = vault;
|
||||
this.cache = cache;
|
||||
}
|
||||
tokenize(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 1 && !STOP_WORDS.has(token));
|
||||
}
|
||||
tokenizeContent(content, file) {
|
||||
const parsed = this.parseMarkdown(content);
|
||||
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, '');
|
||||
const paragraphs = bodyWithoutFrontmatter
|
||||
.split(/\n\n+/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p && !p.startsWith('#'));
|
||||
const firstParagraph = paragraphs[0] || '';
|
||||
return {
|
||||
title: parsed.title || file.basename,
|
||||
headings: parsed.headings,
|
||||
frontmatter: parsed.frontmatter,
|
||||
firstParagraph,
|
||||
content: parsed.content,
|
||||
basename: file.basename,
|
||||
};
|
||||
}
|
||||
calculateWeightedScore(tokenized, queryTokens) {
|
||||
let score = 0;
|
||||
for (const token of queryTokens) {
|
||||
if (tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, token)) {
|
||||
score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
}
|
||||
if (tokenized.basename && this.exactMatch(tokenized.basename, token)) {
|
||||
score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
}
|
||||
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, token)) {
|
||||
score += this.SCORING_WEIGHTS.FRONTMATTER_TAGS;
|
||||
}
|
||||
if (tokenized.headings.some((h) => h.toLowerCase().includes(token.toLowerCase()))) {
|
||||
score += this.SCORING_WEIGHTS.HEADINGS;
|
||||
}
|
||||
if (tokenized.content.toLowerCase().includes(token.toLowerCase())) {
|
||||
score += this.SCORING_WEIGHTS.CONTENT;
|
||||
}
|
||||
if (tokenized.title && this.exactMatch(tokenized.title, token)) {
|
||||
score += this.SCORING_WEIGHTS.TITLE;
|
||||
}
|
||||
}
|
||||
return { score };
|
||||
}
|
||||
async getVaultEntries() {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = typeof this.vault.cachedRead === 'function'
|
||||
? await this.vault.cachedRead(file)
|
||||
: await this.vault.read(file);
|
||||
const parsed = this.parseMarkdown(content);
|
||||
entries.push({
|
||||
file: file,
|
||||
title: parsed.frontmatter.title || file.basename,
|
||||
frontmatter: parsed.frontmatter,
|
||||
headings: parsed.headings,
|
||||
content: parsed.content.slice(0, CONTENT_PREVIEW_LENGTH),
|
||||
basename: file.basename,
|
||||
score: 0,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Failed to read file ${file.path}: ${errorMessage}`, 'vault-indexer');
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async searchVault(query, limit = 3) {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
const cacheKey = `query:${query.trim()}:limit:${limit}`;
|
||||
if (this.cache) {
|
||||
let cachedResults = null;
|
||||
try {
|
||||
cachedResults = await this.cache.get(cacheKey);
|
||||
}
|
||||
catch {
|
||||
cachedResults = null;
|
||||
}
|
||||
if (cachedResults) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsedResults = JSON.parse(cachedResults);
|
||||
return parsedResults.slice(0, limit);
|
||||
}
|
||||
catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
const queryTokens = this.tokenize(query);
|
||||
if (queryTokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const entries = await this.getVaultEntries();
|
||||
const scored = entries
|
||||
.map((entry) => {
|
||||
const { score } = this.calculateWeightedScore({
|
||||
title: entry.title,
|
||||
headings: entry.headings,
|
||||
frontmatter: entry.frontmatter,
|
||||
firstParagraph: '',
|
||||
content: entry.content,
|
||||
basename: entry.basename,
|
||||
}, queryTokens);
|
||||
return { ...entry, score };
|
||||
})
|
||||
.filter((e) => e.score > 0);
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const results = scored.slice(0, limit);
|
||||
if (this.cache) {
|
||||
try {
|
||||
await this.cache.put(cacheKey, JSON.stringify(results));
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
utils_1.Logger.warn(`Failed to cache results for query "${query}": ${errorMessage}`, 'vault-indexer');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
stemToken(token) {
|
||||
if (token.endsWith('ing') && token.length > 4)
|
||||
return token.slice(0, -3);
|
||||
if (token.endsWith('ed') && token.length > 3)
|
||||
return token.slice(0, -2);
|
||||
if (token.endsWith('s') && token.length > 2)
|
||||
return token.slice(0, -1);
|
||||
return token;
|
||||
}
|
||||
exactMatch(text, queryToken) {
|
||||
if (!text)
|
||||
return false;
|
||||
const textLower = text.toLowerCase();
|
||||
const queryLower = queryToken.toLowerCase();
|
||||
const queryStem = this.stemToken(queryLower);
|
||||
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
||||
}
|
||||
parseMarkdown(content) {
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||
const frontmatterMatch = content.match(frontmatterRegex);
|
||||
const frontmatter = {};
|
||||
if (frontmatterMatch) {
|
||||
try {
|
||||
const lines = frontmatterMatch[1].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' && value)
|
||||
frontmatter.title = value;
|
||||
else if (key.trim() === 'tags' && value)
|
||||
frontmatter.tags = value;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
utils_1.Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
||||
}
|
||||
}
|
||||
const titleMatch = content.match(/^# (.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : '';
|
||||
const headings = [];
|
||||
const headingRegex = /^#{1,6} (.+)$/gm;
|
||||
let headingMatch;
|
||||
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
||||
headings.push(headingMatch[1]);
|
||||
}
|
||||
const bodyWithoutFrontmatter = frontmatterMatch
|
||||
? content.substring(frontmatterMatch[0].length)
|
||||
: content;
|
||||
const bodyText = bodyWithoutFrontmatter
|
||||
.replace(/#{1,6} .+/g, '')
|
||||
.replace(/^\s*[\r\n]/gm, '')
|
||||
.trim();
|
||||
return { frontmatter, title, headings, content: bodyText };
|
||||
}
|
||||
}
|
||||
exports.VaultIndexer = VaultIndexer;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "commonjs",
|
||||
"outDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"types": ["node", "jest"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"typeRoots": ["node_modules/@types", "./src"],
|
||||
"exclude": ["node_modules"],
|
||||
"exclude": ["node_modules", "tests", "__mocks__", "dist"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user