Fix semantic cache distance calculation, tool path validation, and add deferred settings persistence

- Convert Chroma cosine distance to similarity (1 - distance) for correct threshold comparison
- Simplify forbidden directory check to match path segments instead of string prefixes
- Add debounced settings save via onPersist callback in ChatView to prevent data loss
- Fix workflow engine to pass availableTools to LLM when includeToolCalls is enabled
- Add targetFolder support to AutoLinker config and update tests
- Harden vault file indexing to await background indexing before processing create/modify/rename events
This commit is contained in:
2026-05-21 20:41:58 +02:00
parent b7b3a185a0
commit 3ab8542cf4
10 changed files with 197 additions and 66 deletions
+54 -29
View File
@@ -8158,7 +8158,9 @@ var SemanticCacheService = class _SemanticCacheService {
where: { source: "ollama" } where: { source: "ollama" }
}); });
if (results.ids[0] && results.ids[0].length > 0) { if (results.ids[0] && results.ids[0].length > 0) {
if (results.distances && results.distances[0] && results.distances[0][0] > this.config.similarityThreshold) { const distance = results.distances?.[0]?.[0];
const similarity = typeof distance === "number" ? 1 - distance : 0;
if (similarity >= this.config.similarityThreshold) {
return results.documents[0][0]; return results.documents[0][0];
} }
} }
@@ -8940,14 +8942,12 @@ var ToolExecutor = class {
return false; return false;
} }
const normalized = path.replace(/^(\.\/)+/, ""); const normalized = path.replace(/^(\.\/)+/, "");
if (normalized.split("/").includes("..")) { const segments = normalized.split("/").filter((segment) => segment.length > 0);
if (segments.includes("..")) {
return false; return false;
} }
for (const dir of FORBIDDEN_DIRS) { for (const segment of segments) {
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) { if (FORBIDDEN_DIRS.includes(segment)) {
return false;
}
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
return false; return false;
} }
} }
@@ -9930,6 +9930,7 @@ ${queryResult}`
var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g; var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
var WorkflowEngine = class _WorkflowEngine { var WorkflowEngine = class _WorkflowEngine {
constructor(vault, app, ollamaUrl, model, options) { constructor(vault, app, ollamaUrl, model, options) {
this.availableTools = [];
this.vaultIndexer = new VaultIndexer(vault); this.vaultIndexer = new VaultIndexer(vault);
this.vaultIndexer.setApp(app); this.vaultIndexer.setApp(app);
this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer); this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer);
@@ -10041,6 +10042,7 @@ var WorkflowEngine = class _WorkflowEngine {
*/ */
async executeWorkflowFromQuery(userQuery, availableTools) { async executeWorkflowFromQuery(userQuery, availableTools) {
Logger.info(`Generating workflow from query: ${userQuery}`, "workflow-engine"); Logger.info(`Generating workflow from query: ${userQuery}`, "workflow-engine");
this.availableTools = availableTools ?? [];
const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools); const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools);
if (!workflow) { if (!workflow) {
return { return {
@@ -10203,7 +10205,7 @@ Rules:
role: "user", role: "user",
content: config.userPrompt content: config.userPrompt
}); });
const tools = config.includeToolCalls ? [] : []; const tools = config.includeToolCalls ? this.availableTools : [];
const response = await this.ollamaClient.chat(messages, tools); const response = await this.ollamaClient.chat(messages, tools);
return response.content ?? ""; return response.content ?? "";
} }
@@ -10934,7 +10936,7 @@ var ErrorHandler = class {
// src/chat-view.ts // src/chat-view.ts
var ChatView = class extends import_obsidian5.ItemView { var ChatView = class extends import_obsidian5.ItemView {
constructor(leaf, settings, vectorStore, structuredMemoryManager, telemetryManager, chatHistoryManager, onModelChange) { constructor(leaf, settings, vectorStore, structuredMemoryManager, telemetryManager, chatHistoryManager, onModelChange, onPersist) {
super(leaf); super(leaf);
// State // State
this.messages = []; this.messages = [];
@@ -11003,6 +11005,7 @@ var ChatView = class extends import_obsidian5.ItemView {
this.telemetryManager = telemetryManager; this.telemetryManager = telemetryManager;
this.chatHistoryManager = chatHistoryManager; this.chatHistoryManager = chatHistoryManager;
this.onModelChange = onModelChange; this.onModelChange = onModelChange;
this.onPersist = onPersist;
this.workflowEngine = new WorkflowEngine( this.workflowEngine = new WorkflowEngine(
this.app.vault, this.app.vault,
this.app, this.app,
@@ -11399,6 +11402,7 @@ var ChatView = class extends import_obsidian5.ItemView {
const activeId = this.chatHistoryManager?.getActiveSessionId(); const activeId = this.chatHistoryManager?.getActiveSessionId();
if (activeId) { if (activeId) {
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode }); this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
this.onPersist?.();
} }
} }
clearConversation() { clearConversation() {
@@ -11446,6 +11450,7 @@ var ChatView = class extends import_obsidian5.ItemView {
agentMode: this.currentAgentMode, agentMode: this.currentAgentMode,
title: this.deriveSessionTitle(nonStreamingMessages) title: this.deriveSessionTitle(nonStreamingMessages)
}); });
this.onPersist?.();
} }
syncMessagesToSession() { syncMessagesToSession() {
if (!this.chatHistoryManager) return; if (!this.chatHistoryManager) return;
@@ -11457,6 +11462,7 @@ var ChatView = class extends import_obsidian5.ItemView {
agentMode: this.currentAgentMode, agentMode: this.currentAgentMode,
title: this.deriveSessionTitle(nonStreamingMessages) title: this.deriveSessionTitle(nonStreamingMessages)
}); });
this.onPersist?.();
} }
deriveSessionTitle(messages) { deriveSessionTitle(messages) {
const firstUser = messages.find((m) => m.role === "user"); const firstUser = messages.find((m) => m.role === "user");
@@ -13359,6 +13365,9 @@ var AutoLinker = class {
} }
updateConfig(config) { updateConfig(config) {
this.config = config; this.config = config;
if (typeof config.targetFolder === "string") {
this.targetFolder = config.targetFolder;
}
} }
setTargetFolder(folder) { setTargetFolder(folder) {
this.targetFolder = folder; this.targetFolder = folder;
@@ -13902,7 +13911,8 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
this.settings.model = model; this.settings.model = model;
void this.saveSettings(); void this.saveSettings();
this.notifyChatViews(); this.notifyChatViews();
} },
() => this.scheduleSaveSettings()
) )
); );
this.addRibbonIcon("bot", "Open Ollama Chat", async () => { this.addRibbonIcon("bot", "Open Ollama Chat", async () => {
@@ -13958,7 +13968,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
this.initializeAutoOrganizer(); this.initializeAutoOrganizer();
if (this.autoLinker) { if (this.autoLinker) {
new import_obsidian7.Notice("Auto-linking related notes..."); new import_obsidian7.Notice("Auto-linking related notes...");
void this.autoLinker.run(); void this.autoLinker.run(this.settings.autoLinkConfig.dryRun);
} }
} }
}); });
@@ -14007,9 +14017,11 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
onunload() { onunload() {
this.cancelBackgroundIndexing(); this.cancelBackgroundIndexing();
if (this.semanticCache) { if (this.saveSettingsTimer) {
void this.semanticCache.clearCache(); clearTimeout(this.saveSettingsTimer);
this.saveSettingsTimer = void 0;
} }
void this.saveSettings();
} }
async loadSettings() { async loadSettings() {
const data = await this.loadData() ?? {}; const data = await this.loadData() ?? {};
@@ -14037,6 +14049,17 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
chatHistory: this.chatHistoryManager?.getData() ?? createDefaultChatHistoryData() chatHistory: this.chatHistoryManager?.getData() ?? createDefaultChatHistoryData()
}); });
} }
scheduleSaveSettings(delayMs = 500) {
if (this.saveSettingsTimer) {
clearTimeout(this.saveSettingsTimer);
}
this.saveSettingsTimer = setTimeout(() => {
this.saveSettingsTimer = void 0;
void this.saveSettings().catch((error) => {
Logger.warn(`Failed to persist plugin data: ${String(error)}`, "main");
});
}, delayMs);
}
initializeAutoOrganizer() { initializeAutoOrganizer() {
this.autoTagger = new AutoTagger( this.autoTagger = new AutoTagger(
this.app.vault, this.app.vault,
@@ -14150,26 +14173,32 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
await this.vaultVectorStore.clearIndex(); await this.vaultVectorStore.clearIndex();
} }
} }
indexVaultFileWhenReady(file) {
void (async () => {
await this.awaitBackgroundIndexing();
if (!this.vaultVectorStore) return;
const currentFile = this.app.vault.getAbstractFileByPath(file.path);
if (!(currentFile instanceof import_obsidian7.TFile) || currentFile.extension !== "md") return;
const content = await this.app.vault.read(currentFile);
const cache = this.app.metadataCache.getFileCache(currentFile);
await this.vaultVectorStore.indexFile(currentFile, content, cache ?? void 0);
})().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to update vault index for ${file.path}: ${errorMessage}`, "main");
});
}
registerVaultEventListeners() { registerVaultEventListeners() {
this.registerEvent( this.registerEvent(
this.app.vault.on("create", (file) => { this.app.vault.on("create", (file) => {
if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { this.indexVaultFileWhenReady(file);
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
});
} }
}) })
); );
this.registerEvent( this.registerEvent(
this.app.vault.on("modify", (file) => { this.app.vault.on("modify", (file) => {
if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { this.indexVaultFileWhenReady(file);
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
});
} }
}) })
); );
@@ -14184,11 +14213,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
this.app.vault.on("rename", (file, oldPath) => { this.app.vault.on("rename", (file, oldPath) => {
if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) { if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.vaultVectorStore.deleteFile(oldPath); void this.vaultVectorStore.deleteFile(oldPath);
void this.app.vault.read(file).then((content) => { this.indexVaultFileWhenReady(file);
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
});
} }
}) })
); );
@@ -14538,7 +14563,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab {
try { try {
this.plugin.initializeAutoOrganizer(); this.plugin.initializeAutoOrganizer();
if (this.plugin.autoLinker) { if (this.plugin.autoLinker) {
await this.plugin.autoLinker.run(); await this.plugin.autoLinker.run(this.plugin.settings.autoLinkConfig.dryRun);
} }
} catch { } catch {
new import_obsidian7.Notice("Auto-linking failed. Check console for details."); new import_obsidian7.Notice("Auto-linking failed. Check console for details.");
+4
View File
@@ -362,8 +362,12 @@ export class AutoLinker {
enabled: boolean; enabled: boolean;
maxLinksPerNote: number; maxLinksPerNote: number;
similarityThreshold: number; similarityThreshold: number;
targetFolder?: string;
}): void { }): void {
this.config = config; this.config = config;
if (typeof config.targetFolder === 'string') {
this.targetFolder = config.targetFolder;
}
} }
setTargetFolder(folder: string): void { setTargetFolder(folder: string): void {
+7 -1
View File
@@ -58,7 +58,8 @@ export class ChatView extends ItemView {
structuredMemoryManager?: StructuredMemoryManager, structuredMemoryManager?: StructuredMemoryManager,
telemetryManager?: TelemetryManager, telemetryManager?: TelemetryManager,
chatHistoryManager?: ChatHistoryManager, chatHistoryManager?: ChatHistoryManager,
onModelChange?: (model: string) => void onModelChange?: (model: string) => void,
onPersist?: () => void
) { ) {
super(leaf); super(leaf);
this.messages = []; this.messages = [];
@@ -98,6 +99,7 @@ export class ChatView extends ItemView {
this.telemetryManager = telemetryManager; this.telemetryManager = telemetryManager;
this.chatHistoryManager = chatHistoryManager; this.chatHistoryManager = chatHistoryManager;
this.onModelChange = onModelChange; this.onModelChange = onModelChange;
this.onPersist = onPersist;
this.workflowEngine = new WorkflowEngine( this.workflowEngine = new WorkflowEngine(
this.app.vault, this.app.vault,
this.app, this.app,
@@ -565,6 +567,7 @@ export class ChatView extends ItemView {
const activeId = this.chatHistoryManager?.getActiveSessionId(); const activeId = this.chatHistoryManager?.getActiveSessionId();
if (activeId) { if (activeId) {
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode }); this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
this.onPersist?.();
} }
} }
@@ -620,6 +623,7 @@ export class ChatView extends ItemView {
agentMode: this.currentAgentMode, agentMode: this.currentAgentMode,
title: this.deriveSessionTitle(nonStreamingMessages), title: this.deriveSessionTitle(nonStreamingMessages),
}); });
this.onPersist?.();
} }
private syncMessagesToSession(): void { private syncMessagesToSession(): void {
@@ -632,6 +636,7 @@ export class ChatView extends ItemView {
agentMode: this.currentAgentMode, agentMode: this.currentAgentMode,
title: this.deriveSessionTitle(nonStreamingMessages), title: this.deriveSessionTitle(nonStreamingMessages),
}); });
this.onPersist?.();
} }
private deriveSessionTitle(messages: ChatMessage[]): string { private deriveSessionTitle(messages: ChatMessage[]): string {
@@ -1885,6 +1890,7 @@ export class ChatView extends ItemView {
private chatHistoryManager?: ChatHistoryManager; private chatHistoryManager?: ChatHistoryManager;
private vectorStore?: VaultVectorStore; private vectorStore?: VaultVectorStore;
private onModelChange?: (model: string) => void; private onModelChange?: (model: string) => void;
private onPersist?: () => void;
private modeSelectorEl: HTMLSelectElement | null = null; private modeSelectorEl: HTMLSelectElement | null = null;
private modelSelectorEl: HTMLSelectElement | null = null; private modelSelectorEl: HTMLSelectElement | null = null;
+42 -23
View File
@@ -24,6 +24,7 @@ export default class OllamaPlugin extends Plugin {
chatHistoryManager?: ChatHistoryManager; chatHistoryManager?: ChatHistoryManager;
private indexingAbortController?: AbortController; private indexingAbortController?: AbortController;
private currentIndexingPromise?: Promise<void>; private currentIndexingPromise?: Promise<void>;
private saveSettingsTimer?: ReturnType<typeof setTimeout>;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
@@ -50,7 +51,8 @@ export default class OllamaPlugin extends Plugin {
this.settings.model = model; this.settings.model = model;
void this.saveSettings(); void this.saveSettings();
this.notifyChatViews(); this.notifyChatViews();
} },
() => this.scheduleSaveSettings()
) )
); );
@@ -120,7 +122,7 @@ export default class OllamaPlugin extends Plugin {
this.initializeAutoOrganizer(); this.initializeAutoOrganizer();
if (this.autoLinker) { if (this.autoLinker) {
new Notice('Auto-linking related notes...'); new Notice('Auto-linking related notes...');
void this.autoLinker.run(); void this.autoLinker.run(this.settings.autoLinkConfig.dryRun);
} }
}, },
}); });
@@ -182,11 +184,11 @@ export default class OllamaPlugin extends Plugin {
// Cancel any ongoing indexing // Cancel any ongoing indexing
this.cancelBackgroundIndexing(); this.cancelBackgroundIndexing();
// Clean up any active semantic cache resources on plugin unload if (this.saveSettingsTimer) {
// Using fire-and-forget pattern since onunload cannot be async per Obsidian API clearTimeout(this.saveSettingsTimer);
if (this.semanticCache) { this.saveSettingsTimer = undefined;
void this.semanticCache.clearCache();
} }
void this.saveSettings();
// No explicit unregisterView needed; relying on Obsidian lifecycle management. // No explicit unregisterView needed; relying on Obsidian lifecycle management.
} }
@@ -227,6 +229,18 @@ export default class OllamaPlugin extends Plugin {
}); });
} }
private scheduleSaveSettings(delayMs = 500): void {
if (this.saveSettingsTimer) {
clearTimeout(this.saveSettingsTimer);
}
this.saveSettingsTimer = setTimeout(() => {
this.saveSettingsTimer = undefined;
void this.saveSettings().catch((error) => {
Logger.warn(`Failed to persist plugin data: ${String(error)}`, 'main');
});
}, delayMs);
}
initializeAutoOrganizer(): void { initializeAutoOrganizer(): void {
this.autoTagger = new AutoTagger( this.autoTagger = new AutoTagger(
this.app.vault, this.app.vault,
@@ -368,17 +382,30 @@ export default class OllamaPlugin extends Plugin {
} }
} }
private indexVaultFileWhenReady(file: TFile): void {
void (async () => {
await this.awaitBackgroundIndexing();
if (!this.vaultVectorStore) return;
const currentFile = this.app.vault.getAbstractFileByPath(file.path);
if (!(currentFile instanceof TFile) || currentFile.extension !== 'md') return;
const content = await this.app.vault.read(currentFile);
const cache = this.app.metadataCache.getFileCache(currentFile);
await this.vaultVectorStore.indexFile(currentFile, content, cache ?? undefined);
})().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to update vault index for ${file.path}: ${errorMessage}`, 'main');
});
}
registerVaultEventListeners(): void { registerVaultEventListeners(): void {
// Listen for file creation // Listen for file creation
this.registerEvent( this.registerEvent(
this.app.vault.on('create', (file) => { this.app.vault.on('create', (file) => {
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { this.indexVaultFileWhenReady(file);
// Skip if a full rebuild is in progress to avoid race conditions
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
});
} }
}) })
); );
@@ -387,11 +414,7 @@ export default class OllamaPlugin extends Plugin {
this.registerEvent( this.registerEvent(
this.app.vault.on('modify', (file) => { this.app.vault.on('modify', (file) => {
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { this.indexVaultFileWhenReady(file);
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
});
} }
}) })
); );
@@ -410,11 +433,7 @@ export default class OllamaPlugin extends Plugin {
this.app.vault.on('rename', (file, oldPath) => { this.app.vault.on('rename', (file, oldPath) => {
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
void this.vaultVectorStore.deleteFile(oldPath); void this.vaultVectorStore.deleteFile(oldPath);
void this.app.vault.read(file).then((content) => { this.indexVaultFileWhenReady(file);
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
});
} }
}) })
); );
@@ -938,7 +957,7 @@ class OllamaSettingTab extends PluginSettingTab {
try { try {
this.plugin.initializeAutoOrganizer(); this.plugin.initializeAutoOrganizer();
if (this.plugin.autoLinker) { if (this.plugin.autoLinker) {
await this.plugin.autoLinker.run(); await this.plugin.autoLinker.run(this.plugin.settings.autoLinkConfig.dryRun);
} }
} catch { } catch {
new Notice('Auto-linking failed. Check console for details.'); new Notice('Auto-linking failed. Check console for details.');
+3 -5
View File
@@ -47,11 +47,9 @@ export class SemanticCacheService {
}); });
if (results.ids[0] && results.ids[0].length > 0) { if (results.ids[0] && results.ids[0].length > 0) {
if ( const distance = results.distances?.[0]?.[0];
results.distances && const similarity = typeof distance === 'number' ? 1 - distance : 0;
results.distances[0] && if (similarity >= this.config.similarityThreshold) {
results.distances[0][0] > this.config.similarityThreshold
) {
return results.documents[0][0]; return results.documents[0][0];
} }
} }
+4 -6
View File
@@ -62,16 +62,14 @@ export class ToolExecutor {
// Reject paths that traverse to parent directories // Reject paths that traverse to parent directories
const normalized = path.replace(/^(\.\/)+/, ''); const normalized = path.replace(/^(\.\/)+/, '');
if (normalized.split('/').includes('..')) { const segments = normalized.split('/').filter((segment) => segment.length > 0);
if (segments.includes('..')) {
return false; return false;
} }
// Reject forbidden directories // Reject forbidden directories
for (const dir of FORBIDDEN_DIRS) { for (const segment of segments) {
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) { if (FORBIDDEN_DIRS.includes(segment)) {
return false;
}
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
return false; return false;
} }
} }
+3 -1
View File
@@ -45,6 +45,7 @@ export class WorkflowEngine {
private conversationStateManager: ConversationStateManager; private conversationStateManager: ConversationStateManager;
private maxSteps: number; private maxSteps: number;
private maxWorkflowDuration: number; private maxWorkflowDuration: number;
private availableTools: OllamaTool[] = [];
constructor( constructor(
vault: Vault, vault: Vault,
@@ -200,6 +201,7 @@ export class WorkflowEngine {
availableTools?: OllamaTool[] availableTools?: OllamaTool[]
): Promise<WorkflowExecutionResult> { ): Promise<WorkflowExecutionResult> {
Logger.info(`Generating workflow from query: ${userQuery}`, 'workflow-engine'); Logger.info(`Generating workflow from query: ${userQuery}`, 'workflow-engine');
this.availableTools = availableTools ?? [];
// First, ask the LLM to generate a workflow plan // First, ask the LLM to generate a workflow plan
const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools); const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools);
@@ -407,7 +409,7 @@ Rules:
}); });
// Determine if we should include tools // Determine if we should include tools
const tools = config.includeToolCalls ? [] : []; const tools = config.includeToolCalls ? this.availableTools : [];
const response = await this.ollamaClient.chat(messages, tools); const response = await this.ollamaClient.chat(messages, tools);
return response.content ?? ''; return response.content ?? '';
+19
View File
@@ -366,6 +366,25 @@ describe('AutoLinker', () => {
expect(mockVault.read).toHaveBeenCalledWith(files[0]); expect(mockVault.read).toHaveBeenCalledWith(files[0]);
}); });
it('should update targetFolder through updateConfig', async () => {
linker.setTargetFolder('Projects');
linker.updateConfig({
enabled: true,
maxLinksPerNote: 3,
similarityThreshold: 0.5,
targetFolder: 'Archive',
});
const files = [{ path: 'Projects/note1.md' }, { path: 'Archive/note2.md' }] as any[];
mockVault.getMarkdownFiles.mockReturnValue(files);
mockVault.read.mockResolvedValue('Content');
mockIndexer.searchVault!.mockResolvedValue([]);
await linker.run();
expect(mockVault.read).toHaveBeenCalledTimes(1);
expect(mockVault.read).toHaveBeenCalledWith(files[1]);
});
it('should return dry-run proposals when dryRun is true', async () => { it('should return dry-run proposals when dryRun is true', async () => {
const files = [{ path: 'note.md' }] as any[]; const files = [{ path: 'note.md' }] as any[];
mockVault.getMarkdownFiles.mockReturnValue(files); mockVault.getMarkdownFiles.mockReturnValue(files);
+13 -1
View File
@@ -119,7 +119,7 @@ describe('SemanticCacheService', () => {
mockCollection.query.mockResolvedValue({ mockCollection.query.mockResolvedValue({
ids: [['test-id']], ids: [['test-id']],
documents: [[cachedContent]], documents: [[cachedContent]],
distances: [[0.9]], // Above threshold distances: [[0.1]], // Similarity 0.9, above threshold
}); });
const result = await cacheService.getCache('test query'); const result = await cacheService.getCache('test query');
@@ -127,6 +127,18 @@ describe('SemanticCacheService', () => {
expect(result).toBe(cachedContent); expect(result).toBe(cachedContent);
expect(mockCollection.query).toHaveBeenCalled(); expect(mockCollection.query).toHaveBeenCalled();
}); });
it('should return null when cosine distance is too high', async () => {
mockCollection.query.mockResolvedValue({
ids: [['test-id']],
documents: [['unrelated cached response']],
distances: [[0.9]], // Similarity 0.1, below threshold
});
const result = await cacheService.getCache('test query');
expect(result).toBeNull();
});
}); });
describe('setCache', () => { describe('setCache', () => {
+48
View File
@@ -213,6 +213,38 @@ describe('ToolExecutor', () => {
expect(mockVault.create).not.toHaveBeenCalled(); expect(mockVault.create).not.toHaveBeenCalled();
}); });
it('should reject exact forbidden directory paths', async () => {
const call: ToolCall = {
id: 'call_forbidden_exact',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: '.obsidian',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject nested forbidden directory paths', async () => {
const call: ToolCall = {
id: 'call_forbidden_nested',
type: 'function',
function: {
name: 'create_file',
arguments: JSON.stringify({
path: 'Notes/.git',
content: 'Test content',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
expect(mockVault.create).not.toHaveBeenCalled();
});
it('should reject path traversal attempts with .\\', async () => { it('should reject path traversal attempts with .\\', async () => {
const call: ToolCall = { const call: ToolCall = {
id: 'call_8', id: 'call_8',
@@ -1214,6 +1246,22 @@ describe('ToolExecutor', () => {
expect(mockVault.createFolder).toHaveBeenCalledWith('Archive'); expect(mockVault.createFolder).toHaveBeenCalledWith('Archive');
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md'); expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
}); });
it('should reject moving a note into a forbidden folder', async () => {
const call: ToolCall = {
id: 'call_mn_forbidden',
type: 'function',
function: {
name: 'move_note',
arguments: JSON.stringify({
path: 'Projects/old.md',
folder: '.obsidian',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow('Invalid folder path detected');
expect(mockVault.rename).not.toHaveBeenCalled();
});
}); });
describe('delete_note tool', () => { describe('delete_note tool', () => {