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:
@@ -8158,7 +8158,9 @@ var SemanticCacheService = class _SemanticCacheService {
|
||||
where: { source: "ollama" }
|
||||
});
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -8940,14 +8942,12 @@ var ToolExecutor = class {
|
||||
return false;
|
||||
}
|
||||
const normalized = path.replace(/^(\.\/)+/, "");
|
||||
if (normalized.split("/").includes("..")) {
|
||||
const segments = normalized.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.includes("..")) {
|
||||
return false;
|
||||
}
|
||||
for (const dir of FORBIDDEN_DIRS) {
|
||||
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
|
||||
for (const segment of segments) {
|
||||
if (FORBIDDEN_DIRS.includes(segment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9930,6 +9930,7 @@ ${queryResult}`
|
||||
var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
|
||||
var WorkflowEngine = class _WorkflowEngine {
|
||||
constructor(vault, app, ollamaUrl, model, options) {
|
||||
this.availableTools = [];
|
||||
this.vaultIndexer = new VaultIndexer(vault);
|
||||
this.vaultIndexer.setApp(app);
|
||||
this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer);
|
||||
@@ -10041,6 +10042,7 @@ var WorkflowEngine = class _WorkflowEngine {
|
||||
*/
|
||||
async executeWorkflowFromQuery(userQuery, availableTools) {
|
||||
Logger.info(`Generating workflow from query: ${userQuery}`, "workflow-engine");
|
||||
this.availableTools = availableTools ?? [];
|
||||
const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools);
|
||||
if (!workflow) {
|
||||
return {
|
||||
@@ -10203,7 +10205,7 @@ Rules:
|
||||
role: "user",
|
||||
content: config.userPrompt
|
||||
});
|
||||
const tools = config.includeToolCalls ? [] : [];
|
||||
const tools = config.includeToolCalls ? this.availableTools : [];
|
||||
const response = await this.ollamaClient.chat(messages, tools);
|
||||
return response.content ?? "";
|
||||
}
|
||||
@@ -10934,7 +10936,7 @@ var ErrorHandler = class {
|
||||
|
||||
// src/chat-view.ts
|
||||
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);
|
||||
// State
|
||||
this.messages = [];
|
||||
@@ -11003,6 +11005,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
this.telemetryManager = telemetryManager;
|
||||
this.chatHistoryManager = chatHistoryManager;
|
||||
this.onModelChange = onModelChange;
|
||||
this.onPersist = onPersist;
|
||||
this.workflowEngine = new WorkflowEngine(
|
||||
this.app.vault,
|
||||
this.app,
|
||||
@@ -11399,6 +11402,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
const activeId = this.chatHistoryManager?.getActiveSessionId();
|
||||
if (activeId) {
|
||||
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
|
||||
this.onPersist?.();
|
||||
}
|
||||
}
|
||||
clearConversation() {
|
||||
@@ -11446,6 +11450,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
agentMode: this.currentAgentMode,
|
||||
title: this.deriveSessionTitle(nonStreamingMessages)
|
||||
});
|
||||
this.onPersist?.();
|
||||
}
|
||||
syncMessagesToSession() {
|
||||
if (!this.chatHistoryManager) return;
|
||||
@@ -11457,6 +11462,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
agentMode: this.currentAgentMode,
|
||||
title: this.deriveSessionTitle(nonStreamingMessages)
|
||||
});
|
||||
this.onPersist?.();
|
||||
}
|
||||
deriveSessionTitle(messages) {
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
@@ -13359,6 +13365,9 @@ var AutoLinker = class {
|
||||
}
|
||||
updateConfig(config) {
|
||||
this.config = config;
|
||||
if (typeof config.targetFolder === "string") {
|
||||
this.targetFolder = config.targetFolder;
|
||||
}
|
||||
}
|
||||
setTargetFolder(folder) {
|
||||
this.targetFolder = folder;
|
||||
@@ -13902,7 +13911,8 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
|
||||
this.settings.model = model;
|
||||
void this.saveSettings();
|
||||
this.notifyChatViews();
|
||||
}
|
||||
},
|
||||
() => this.scheduleSaveSettings()
|
||||
)
|
||||
);
|
||||
this.addRibbonIcon("bot", "Open Ollama Chat", async () => {
|
||||
@@ -13958,7 +13968,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
|
||||
this.initializeAutoOrganizer();
|
||||
if (this.autoLinker) {
|
||||
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
|
||||
onunload() {
|
||||
this.cancelBackgroundIndexing();
|
||||
if (this.semanticCache) {
|
||||
void this.semanticCache.clearCache();
|
||||
if (this.saveSettingsTimer) {
|
||||
clearTimeout(this.saveSettingsTimer);
|
||||
this.saveSettingsTimer = void 0;
|
||||
}
|
||||
void this.saveSettings();
|
||||
}
|
||||
async loadSettings() {
|
||||
const data = await this.loadData() ?? {};
|
||||
@@ -14037,6 +14049,17 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
|
||||
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() {
|
||||
this.autoTagger = new AutoTagger(
|
||||
this.app.vault,
|
||||
@@ -14150,26 +14173,32 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
|
||||
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() {
|
||||
this.registerEvent(
|
||||
this.app.vault.on("create", (file) => {
|
||||
if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
}
|
||||
});
|
||||
this.indexVaultFileWhenReady(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("modify", (file) => {
|
||||
if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
}
|
||||
});
|
||||
this.indexVaultFileWhenReady(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -14184,11 +14213,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
|
||||
this.app.vault.on("rename", (file, oldPath) => {
|
||||
if (file instanceof import_obsidian7.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.vaultVectorStore.deleteFile(oldPath);
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
}
|
||||
});
|
||||
this.indexVaultFileWhenReady(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -14538,7 +14563,7 @@ var OllamaSettingTab = class extends import_obsidian7.PluginSettingTab {
|
||||
try {
|
||||
this.plugin.initializeAutoOrganizer();
|
||||
if (this.plugin.autoLinker) {
|
||||
await this.plugin.autoLinker.run();
|
||||
await this.plugin.autoLinker.run(this.plugin.settings.autoLinkConfig.dryRun);
|
||||
}
|
||||
} catch {
|
||||
new import_obsidian7.Notice("Auto-linking failed. Check console for details.");
|
||||
|
||||
@@ -362,8 +362,12 @@ export class AutoLinker {
|
||||
enabled: boolean;
|
||||
maxLinksPerNote: number;
|
||||
similarityThreshold: number;
|
||||
targetFolder?: string;
|
||||
}): void {
|
||||
this.config = config;
|
||||
if (typeof config.targetFolder === 'string') {
|
||||
this.targetFolder = config.targetFolder;
|
||||
}
|
||||
}
|
||||
|
||||
setTargetFolder(folder: string): void {
|
||||
|
||||
+7
-1
@@ -58,7 +58,8 @@ export class ChatView extends ItemView {
|
||||
structuredMemoryManager?: StructuredMemoryManager,
|
||||
telemetryManager?: TelemetryManager,
|
||||
chatHistoryManager?: ChatHistoryManager,
|
||||
onModelChange?: (model: string) => void
|
||||
onModelChange?: (model: string) => void,
|
||||
onPersist?: () => void
|
||||
) {
|
||||
super(leaf);
|
||||
this.messages = [];
|
||||
@@ -98,6 +99,7 @@ export class ChatView extends ItemView {
|
||||
this.telemetryManager = telemetryManager;
|
||||
this.chatHistoryManager = chatHistoryManager;
|
||||
this.onModelChange = onModelChange;
|
||||
this.onPersist = onPersist;
|
||||
this.workflowEngine = new WorkflowEngine(
|
||||
this.app.vault,
|
||||
this.app,
|
||||
@@ -565,6 +567,7 @@ export class ChatView extends ItemView {
|
||||
const activeId = this.chatHistoryManager?.getActiveSessionId();
|
||||
if (activeId) {
|
||||
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
|
||||
this.onPersist?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,6 +623,7 @@ export class ChatView extends ItemView {
|
||||
agentMode: this.currentAgentMode,
|
||||
title: this.deriveSessionTitle(nonStreamingMessages),
|
||||
});
|
||||
this.onPersist?.();
|
||||
}
|
||||
|
||||
private syncMessagesToSession(): void {
|
||||
@@ -632,6 +636,7 @@ export class ChatView extends ItemView {
|
||||
agentMode: this.currentAgentMode,
|
||||
title: this.deriveSessionTitle(nonStreamingMessages),
|
||||
});
|
||||
this.onPersist?.();
|
||||
}
|
||||
|
||||
private deriveSessionTitle(messages: ChatMessage[]): string {
|
||||
@@ -1885,6 +1890,7 @@ export class ChatView extends ItemView {
|
||||
private chatHistoryManager?: ChatHistoryManager;
|
||||
private vectorStore?: VaultVectorStore;
|
||||
private onModelChange?: (model: string) => void;
|
||||
private onPersist?: () => void;
|
||||
|
||||
private modeSelectorEl: HTMLSelectElement | null = null;
|
||||
private modelSelectorEl: HTMLSelectElement | null = null;
|
||||
|
||||
+42
-23
@@ -24,6 +24,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
chatHistoryManager?: ChatHistoryManager;
|
||||
private indexingAbortController?: AbortController;
|
||||
private currentIndexingPromise?: Promise<void>;
|
||||
private saveSettingsTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
@@ -50,7 +51,8 @@ export default class OllamaPlugin extends Plugin {
|
||||
this.settings.model = model;
|
||||
void this.saveSettings();
|
||||
this.notifyChatViews();
|
||||
}
|
||||
},
|
||||
() => this.scheduleSaveSettings()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -120,7 +122,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
this.initializeAutoOrganizer();
|
||||
if (this.autoLinker) {
|
||||
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
|
||||
this.cancelBackgroundIndexing();
|
||||
|
||||
// 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();
|
||||
if (this.saveSettingsTimer) {
|
||||
clearTimeout(this.saveSettingsTimer);
|
||||
this.saveSettingsTimer = undefined;
|
||||
}
|
||||
void this.saveSettings();
|
||||
// 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 {
|
||||
this.autoTagger = new AutoTagger(
|
||||
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 {
|
||||
// Listen for file creation
|
||||
this.registerEvent(
|
||||
this.app.vault.on('create', (file) => {
|
||||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
// Skip if a full rebuild is in progress to avoid race conditions
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
}
|
||||
});
|
||||
this.indexVaultFileWhenReady(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -387,11 +414,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
this.registerEvent(
|
||||
this.app.vault.on('modify', (file) => {
|
||||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
}
|
||||
});
|
||||
this.indexVaultFileWhenReady(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -410,11 +433,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
this.app.vault.on('rename', (file, oldPath) => {
|
||||
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
|
||||
void this.vaultVectorStore.deleteFile(oldPath);
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
}
|
||||
});
|
||||
this.indexVaultFileWhenReady(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -938,7 +957,7 @@ class OllamaSettingTab extends PluginSettingTab {
|
||||
try {
|
||||
this.plugin.initializeAutoOrganizer();
|
||||
if (this.plugin.autoLinker) {
|
||||
await this.plugin.autoLinker.run();
|
||||
await this.plugin.autoLinker.run(this.plugin.settings.autoLinkConfig.dryRun);
|
||||
}
|
||||
} catch {
|
||||
new Notice('Auto-linking failed. Check console for details.');
|
||||
|
||||
@@ -47,11 +47,9 @@ export class SemanticCacheService {
|
||||
});
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +62,14 @@ export class ToolExecutor {
|
||||
|
||||
// Reject paths that traverse to parent directories
|
||||
const normalized = path.replace(/^(\.\/)+/, '');
|
||||
if (normalized.split('/').includes('..')) {
|
||||
const segments = normalized.split('/').filter((segment) => segment.length > 0);
|
||||
if (segments.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}\\`)) {
|
||||
for (const segment of segments) {
|
||||
if (FORBIDDEN_DIRS.includes(segment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export class WorkflowEngine {
|
||||
private conversationStateManager: ConversationStateManager;
|
||||
private maxSteps: number;
|
||||
private maxWorkflowDuration: number;
|
||||
private availableTools: OllamaTool[] = [];
|
||||
|
||||
constructor(
|
||||
vault: Vault,
|
||||
@@ -200,6 +201,7 @@ export class WorkflowEngine {
|
||||
availableTools?: OllamaTool[]
|
||||
): Promise<WorkflowExecutionResult> {
|
||||
Logger.info(`Generating workflow from query: ${userQuery}`, 'workflow-engine');
|
||||
this.availableTools = availableTools ?? [];
|
||||
|
||||
// First, ask the LLM to generate a workflow plan
|
||||
const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools);
|
||||
@@ -407,7 +409,7 @@ Rules:
|
||||
});
|
||||
|
||||
// Determine if we should include tools
|
||||
const tools = config.includeToolCalls ? [] : [];
|
||||
const tools = config.includeToolCalls ? this.availableTools : [];
|
||||
|
||||
const response = await this.ollamaClient.chat(messages, tools);
|
||||
return response.content ?? '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user