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
+19
View File
@@ -366,6 +366,25 @@ describe('AutoLinker', () => {
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 () => {
const files = [{ path: 'note.md' }] as any[];
mockVault.getMarkdownFiles.mockReturnValue(files);
+13 -1
View File
@@ -119,7 +119,7 @@ describe('SemanticCacheService', () => {
mockCollection.query.mockResolvedValue({
ids: [['test-id']],
documents: [[cachedContent]],
distances: [[0.9]], // Above threshold
distances: [[0.1]], // Similarity 0.9, above threshold
});
const result = await cacheService.getCache('test query');
@@ -127,6 +127,18 @@ describe('SemanticCacheService', () => {
expect(result).toBe(cachedContent);
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', () => {
+48
View File
@@ -213,6 +213,38 @@ describe('ToolExecutor', () => {
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 () => {
const call: ToolCall = {
id: 'call_8',
@@ -1214,6 +1246,22 @@ describe('ToolExecutor', () => {
expect(mockVault.createFolder).toHaveBeenCalledWith('Archive');
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', () => {