diff --git a/src/action-preview-builder.ts b/src/action-preview-builder.ts index 29eb8ff..3fe05b8 100644 --- a/src/action-preview-builder.ts +++ b/src/action-preview-builder.ts @@ -36,7 +36,7 @@ export class ActionPreviewBuilder { return {}; } } else if (rawArgs && typeof rawArgs === 'object') { - return rawArgs as Record; + return rawArgs; } return {}; } @@ -75,12 +75,13 @@ export class ActionPreviewBuilder { } } - private buildCreatePreview( - toolCall: ToolCall, - args: Record - ): ProposedAction { - const path = String(args.path ?? ''); - const content = String(args.content ?? ''); + private strArg(value: unknown): string { + return typeof value === 'string' ? value : ''; + } + + private buildCreatePreview(toolCall: ToolCall, args: Record): ProposedAction { + const path = this.strArg(args.path); + const content = this.strArg(args.content); return { id: toolCall.id, toolCall, @@ -99,8 +100,8 @@ export class ActionPreviewBuilder { toolCall: ToolCall, args: Record ): Promise { - const path = String(args.path ?? ''); - const content = String(args.content ?? ''); + const path = this.strArg(args.path); + const content = this.strArg(args.content); const before = await this.readFileSafe(path); const separator = before && before.endsWith('\n') ? '' : '\n'; return { @@ -121,9 +122,9 @@ export class ActionPreviewBuilder { toolCall: ToolCall, args: Record ): Promise { - const path = String(args.path ?? ''); - const heading = String(args.heading ?? ''); - const content = String(args.content ?? ''); + const path = this.strArg(args.path); + const heading = this.strArg(args.heading); + const content = this.strArg(args.content); const before = await this.readFileSafe(path); let after = before ?? ''; @@ -137,7 +138,7 @@ export class ActionPreviewBuilder { const afterHeading = headingIndex + match[0].length; const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}}\\s)`, 'm'); const nextMatch = nextHeadingRegex.exec(before.slice(afterHeading)); - const sectionEnd = nextMatch ? afterHeading + nextMatch.index! : before.length; + const sectionEnd = nextMatch ? afterHeading + nextMatch.index : before.length; after = before.slice(0, headingIndex) + match[0] + @@ -166,7 +167,7 @@ export class ActionPreviewBuilder { toolCall: ToolCall, args: Record ): Promise { - const path = String(args.path ?? ''); + const path = this.strArg(args.path); const fields = args.fields as Record | undefined; const before = await this.readFileSafe(path); let after = before ?? ''; @@ -182,11 +183,13 @@ export class ActionPreviewBuilder { } else if (Array.isArray(value)) { newFields[key] = value.join(', '); } else { - newFields[key] = String(value); + newFields[key] = this.strArg(value); } } const newFrontmatter = this.serializeFrontmatter(newFields); - const body = parsed.exists ? (before ?? '').replace(/^---\n[\s\S]*?\n---\n/, '') : before ?? ''; + const body = parsed.exists + ? (before ?? '').replace(/^---\n[\s\S]*?\n---\n/, '') + : (before ?? ''); after = newFrontmatter + body; } @@ -205,8 +208,8 @@ export class ActionPreviewBuilder { } private buildRenamePreview(toolCall: ToolCall, args: Record): ProposedAction { - const oldPath = String(args.oldPath ?? ''); - const newPath = String(args.newPath ?? ''); + const oldPath = this.strArg(args.oldPath); + const newPath = this.strArg(args.newPath); return { id: toolCall.id, toolCall, @@ -222,8 +225,8 @@ export class ActionPreviewBuilder { } private buildMovePreview(toolCall: ToolCall, args: Record): ProposedAction { - const path = String(args.path ?? ''); - const folder = String(args.folder ?? ''); + const path = this.strArg(args.path); + const folder = this.strArg(args.folder); const fileName = path.split('/').pop() ?? path; const newPath = folder ? `${folder}/${fileName}` : fileName; return { @@ -244,7 +247,7 @@ export class ActionPreviewBuilder { toolCall: ToolCall, args: Record ): Promise { - const path = String(args.path ?? ''); + const path = this.strArg(args.path); const before = await this.readFileSafe(path); return { id: toolCall.id, @@ -264,8 +267,8 @@ export class ActionPreviewBuilder { toolCall: ToolCall, args: Record ): Promise { - const sourcePath = String(args.sourcePath ?? ''); - const targetPath = String(args.targetPath ?? ''); + const sourcePath = this.strArg(args.sourcePath); + const targetPath = this.strArg(args.targetPath); const anchorText = args.anchorText; const before = await this.readFileSafe(sourcePath); const linkText = diff --git a/src/auto-organizer.ts b/src/auto-organizer.ts index ec44fa5..7b2695c 100644 --- a/src/auto-organizer.ts +++ b/src/auto-organizer.ts @@ -1,7 +1,6 @@ import { Vault, TFile, Notice } from 'obsidian'; import { OllamaClient } from './ollama-client'; import { VaultIndexer } from './vault-indexer'; -import { VaultVectorStore } from './vault-vector-store'; import { Logger } from './utils'; export interface AutoOrganizeConfig { diff --git a/src/chat-view.ts b/src/chat-view.ts index 9fda8d4..2006896 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -5,6 +5,7 @@ import { VaultVectorStore } from './vault-vector-store'; import { ToolExecutor } from './tool-executor'; import { ActionPreviewBuilder, isWriteTool } from './action-preview-builder'; import { WorkflowEngine } from './workflow-engine'; +import { NoteContextBuilder } from './note-context-builder'; import { PluginSettings, OllamaMessage, @@ -58,6 +59,7 @@ export class ChatView extends ItemView { this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore); this.toolExecutor = new ToolExecutor(this.app.vault, this.app); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault); + this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); this.conversationStateManager = new ConversationStateManager(); this.workflowEngine = new WorkflowEngine( this.app.vault, @@ -930,22 +932,19 @@ export class ChatView extends ItemView { } try { - const entries = await this.vaultIndexer.searchVault( + const noteContext = await this.noteContextBuilder.buildContext( actualMessage, - this.settings.vaultSearchLimit + this.settings.vaultSearchLimit, + { + includeOpenNote: true, + includeSelectedText: true, + includeBacklinks: true, + includeOutlinks: true, + includeRelated: true, + maxRelatedNotes: 10, + } ); - const context = entries - .map((entry) => { - const parts: string[] = []; - if (entry.tags) { - parts.push(`Tags: ${entry.tags}`); - } - parts.push(entry.title); - parts.push(entry.content); - return parts.join('\n'); - }) - .join('\n\n') - .slice(0, maxContextLength); + const context = this.noteContextBuilder.formatContext(noteContext, maxContextLength); const userMessageWithContext = context ? `Relevant vault context:\n${context}\n\nUser question:\n${actualMessage}` : actualMessage; @@ -1038,6 +1037,7 @@ export class ChatView extends ItemView { private vaultIndexer: VaultIndexer; private toolExecutor: ToolExecutor; private actionPreviewBuilder: ActionPreviewBuilder; + private noteContextBuilder: NoteContextBuilder; private workflowEngine: WorkflowEngine; private conversationStateManager: ConversationStateManager; private vectorStore?: VaultVectorStore; diff --git a/src/main.ts b/src/main.ts index 207e62c..b3b6719 100755 --- a/src/main.ts +++ b/src/main.ts @@ -80,11 +80,11 @@ export default class OllamaPlugin extends Plugin { this.addCommand({ id: 'auto-tag-notes', name: 'Auto-Tag Untagged Notes', - callback: async () => { - await this.initializeAutoOrganizer(); + callback: () => { + this.initializeAutoOrganizer(); if (this.autoTagger) { new Notice('Auto-tagging untagged notes...'); - await this.autoTagger.run(); + void this.autoTagger.run(); } }, }); @@ -93,11 +93,11 @@ export default class OllamaPlugin extends Plugin { this.addCommand({ id: 'auto-link-notes', name: 'Auto-Link Related Notes', - callback: async () => { - await this.initializeAutoOrganizer(); + callback: () => { + this.initializeAutoOrganizer(); if (this.autoLinker) { new Notice('Auto-linking related notes...'); - await this.autoLinker.run(); + void this.autoLinker.run(); } }, }); @@ -144,7 +144,7 @@ export default class OllamaPlugin extends Plugin { await this.saveData(this.settings); } - async initializeAutoOrganizer(): Promise { + initializeAutoOrganizer(): void { if (!this.autoTagger) { this.autoTagger = new AutoTagger( this.app.vault, @@ -232,7 +232,7 @@ export default class OllamaPlugin extends Plugin { try { const content = await this.app.vault.read(file); if (signal.aborted) break; - await this.vaultVectorStore!.indexFile(file, content); + await this.vaultVectorStore.indexFile(file, content); indexed++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -706,7 +706,7 @@ class OllamaSettingTab extends PluginSettingTab { .addButton((button) => button.setButtonText('Auto-Tag Notes').onClick(async () => { try { - await this.plugin.initializeAutoOrganizer(); + this.plugin.initializeAutoOrganizer(); if (this.plugin.autoTagger) { await this.plugin.autoTagger.run(); } @@ -769,7 +769,7 @@ class OllamaSettingTab extends PluginSettingTab { .addButton((button) => button.setButtonText('Auto-Link Notes').onClick(async () => { try { - await this.plugin.initializeAutoOrganizer(); + this.plugin.initializeAutoOrganizer(); if (this.plugin.autoLinker) { await this.plugin.autoLinker.run(); } diff --git a/src/note-context-builder.ts b/src/note-context-builder.ts new file mode 100644 index 0000000..76b06ee --- /dev/null +++ b/src/note-context-builder.ts @@ -0,0 +1,364 @@ +// src/note-context-builder.ts + +import { Vault, TFile, App, MarkdownView } from 'obsidian'; +import { VaultIndexer } from './vault-indexer'; +import { VaultIndexEntry } from './types'; +import { Logger } from './utils'; + +export interface NoteContextOptions { + includeOpenNote?: boolean; + includeSelectedText?: boolean; + includeBacklinks?: boolean; + includeOutlinks?: boolean; + includeRelated?: boolean; + maxRelatedNotes?: number; + scopeToExplicitNotes?: boolean; +} + +export interface NoteContext { + explicitMentions: VaultIndexEntry[]; + openNote?: VaultIndexEntry; + selectedText?: string; + backlinks: VaultIndexEntry[]; + outlinks: VaultIndexEntry[]; + relatedNotes: VaultIndexEntry[]; + searchResults: VaultIndexEntry[]; +} + +export class NoteContextBuilder { + private vault: Vault; + private app: App; + private vaultIndexer: VaultIndexer; + + constructor(vault: Vault, app: App, vaultIndexer: VaultIndexer) { + this.vault = vault; + this.app = app; + this.vaultIndexer = vaultIndexer; + } + + /** + * Extracts wikilink mentions like [[Note Title]] from a message. + */ + extractExplicitMentions(message: string): string[] { + const mentions: string[] = []; + const wikiLinkRegex = /\[\[(.+?)\]\]/g; + let match; + while ((match = wikiLinkRegex.exec(message)) !== null) { + const title = match[1].split('|')[0].trim(); // Strip alias + mentions.push(title); + } + return [...new Set(mentions)]; + } + + /** + * Detects scope commands in the user message. + * Returns 'explicit' if user says "use only this note" or similar. + * Returns 'related' if user says "include related notes" or similar. + * Returns 'default' otherwise. + */ + detectScopeIntent(message: string): 'explicit' | 'related' | 'default' { + const lower = message.toLowerCase(); + if ( + lower.includes('use only this note') || + lower.includes('only this note') || + lower.includes('just this note') || + lower.includes('use only the current note') + ) { + return 'explicit'; + } + if ( + lower.includes('include related notes') || + lower.includes('include related') || + lower.includes('neighboring notes') || + lower.includes('linked notes') || + lower.includes('context around') + ) { + return 'related'; + } + return 'default'; + } + + /** + * Gets the currently active note entry. + */ + private async getOpenNote(): Promise { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + return undefined; + } + return this.fileToIndexEntry(activeFile); + } + + /** + * Gets selected text from the active markdown editor. + */ + private getSelectedText(): string | undefined { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) { + return undefined; + } + const editor = activeView.editor; + if (!editor) { + return undefined; + } + const selection = editor.getSelection().trim(); + return selection.length > 0 ? selection : undefined; + } + + /** + * Resolves a note title or path to a TFile. + */ + private resolveNote(titleOrPath: string): TFile | null { + const isFile = (f: unknown): f is TFile => + !!f && typeof f === 'object' && 'path' in f && 'basename' in f; + + // Try exact path first + const byPath = this.vault.getAbstractFileByPath(titleOrPath); + if (isFile(byPath)) { + return byPath; + } + + // Try with .md extension + const withExtension = titleOrPath.endsWith('.md') ? titleOrPath : `${titleOrPath}.md`; + const byPathExt = this.vault.getAbstractFileByPath(withExtension); + if (isFile(byPathExt)) { + return byPathExt; + } + + // Try by basename + const files = this.vault.getMarkdownFiles(); + return files.find((f) => f.basename === titleOrPath) ?? null; + } + + /** + * Reads file content and builds a VaultIndexEntry. + */ + private async fileToIndexEntry(file: TFile): Promise { + try { + const content = await this.vault.cachedRead(file); + // Simple frontmatter + title parsing + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; + const fmMatch = content.match(frontmatterRegex); + let tags: string | undefined; + if (fmMatch) { + const tagMatch = fmMatch[1].match(/^tags:\s*(.+)$/m); + if (tagMatch) { + tags = tagMatch[1].trim(); + } + } + const titleMatch = content.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : file.basename; + const body = content.replace(frontmatterRegex, '').slice(0, 500); + return { + path: file.path, + title, + content: body, + score: 1, + tags, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.warn(`Failed to read ${file.path}: ${errorMessage}`, 'note-context'); + return { + path: file.path, + title: file.basename, + content: '', + score: 1, + }; + } + } + + /** + * Gets backlinks for a file using Obsidian's metadataCache. + */ + private getBacklinks(file: TFile): TFile[] { + interface CacheWithResolvedLinks { + resolvedLinks?: Record>; + } + const metadataCache = this.app.metadataCache as CacheWithResolvedLinks; + const resolvedLinks = metadataCache.resolvedLinks ?? {}; + const backlinks: TFile[] = []; + for (const sourcePath of Object.keys(resolvedLinks)) { + const targets = resolvedLinks[sourcePath]; + if (targets && targets[file.path]) { + const sourceFile = this.vault.getAbstractFileByPath(sourcePath); + if (sourceFile && typeof sourceFile === 'object' && 'path' in sourceFile) { + backlinks.push(sourceFile as TFile); + } + } + } + return backlinks; + } + + /** + * Gets outlinks (forward links) for a file using Obsidian's metadataCache. + */ + private getOutlinks(file: TFile): TFile[] { + const cache = this.app.metadataCache.getCache(file.path); + if (!cache?.links) { + return []; + } + const outlinks: TFile[] = []; + for (const link of cache.links) { + const targetPath = link.link; + // Resolve relative or bare links + const resolved = this.resolveNote(targetPath); + if (resolved) { + outlinks.push(resolved); + } + } + return [...new Set(outlinks.map((f) => f.path))] + .map((p) => this.vault.getAbstractFileByPath(p)) + .filter((f): f is TFile => !!f && typeof f === 'object' && 'path' in f); + } + + /** + * Builds the full note context for a user message. + */ + async buildContext( + message: string, + searchLimit: number, + options: NoteContextOptions = {} + ): Promise { + const scope = this.detectScopeIntent(message); + + const explicitTitles = this.extractExplicitMentions(message); + const explicitNotes: VaultIndexEntry[] = []; + for (const title of explicitTitles) { + const file = this.resolveNote(title); + if (file) { + explicitNotes.push(await this.fileToIndexEntry(file)); + } + } + + let openNote: VaultIndexEntry | undefined; + let selectedText: string | undefined; + const backlinks: VaultIndexEntry[] = []; + const outlinks: VaultIndexEntry[] = []; + const relatedNotes: VaultIndexEntry[] = []; + let searchResults: VaultIndexEntry[] = []; + + // Get open note and selected text + if (scope !== 'explicit' || explicitNotes.length === 0) { + openNote = await this.getOpenNote(); + if (options.includeSelectedText !== false) { + selectedText = this.getSelectedText(); + } + } + + // Get backlinks / outlinks for open note + const activeFile = this.app.workspace.getActiveFile(); + if ( + activeFile && + (scope === 'related' || options.includeBacklinks || options.includeOutlinks) + ) { + if (options.includeBacklinks !== false) { + const backFiles = this.getBacklinks(activeFile); + for (const f of backFiles.slice(0, options.maxRelatedNotes ?? 10)) { + backlinks.push(await this.fileToIndexEntry(f)); + } + } + if (options.includeOutlinks !== false) { + const outFiles = this.getOutlinks(activeFile); + for (const f of outFiles.slice(0, options.maxRelatedNotes ?? 10)) { + outlinks.push(await this.fileToIndexEntry(f)); + } + } + } + + // Combine backlinks + outlinks into related + if (scope === 'related' || options.includeRelated) { + const relatedPaths = new Set(); + for (const n of [...backlinks, ...outlinks]) { + if (!relatedPaths.has(n.path)) { + relatedPaths.add(n.path); + relatedNotes.push(n); + } + } + } + + // Vault search + if (scope !== 'explicit') { + const searchQuery = this.sanitizeSearchQuery(message); + searchResults = await this.vaultIndexer.searchVault(searchQuery, searchLimit); + } else if (explicitNotes.length > 0) { + // If explicit scope and we have explicit notes, just use those + searchResults = explicitNotes; + } + + return { + explicitMentions: explicitNotes, + openNote, + selectedText, + backlinks, + outlinks, + relatedNotes, + searchResults, + }; + } + + /** + * Formats a NoteContext into a string for the LLM prompt. + */ + formatContext(context: NoteContext, maxLength: number): string { + const parts: string[] = []; + + if (context.selectedText) { + parts.push(`Selected text from current note:\n${context.selectedText}`); + } + + if (context.openNote) { + parts.push(`Current open note: ${context.openNote.title} (${context.openNote.path})`); + if (context.openNote.tags) { + parts.push(`Tags: ${context.openNote.tags}`); + } + parts.push(context.openNote.content); + } + + if (context.explicitMentions.length > 0) { + parts.push('Explicitly mentioned notes:'); + for (const note of context.explicitMentions) { + parts.push(`- ${note.title} (${note.path})`); + if (note.tags) parts.push(` Tags: ${note.tags}`); + parts.push(note.content.slice(0, 300)); + } + } + + if (context.relatedNotes.length > 0) { + parts.push('Related notes (backlinks + outlinks):'); + for (const note of context.relatedNotes) { + parts.push(`- ${note.title} (${note.path})`); + } + } + + if (context.searchResults.length > 0) { + parts.push('Vault search results:'); + for (const note of context.searchResults) { + parts.push(`- ${note.title} (${note.path})`); + if (note.tags) parts.push(` Tags: ${note.tags}`); + parts.push(note.content.slice(0, 300)); + } + } + + let result = parts.join('\n\n'); + if (result.length > maxLength) { + result = result.slice(0, maxLength) + '\n... [truncated]'; + } + return result; + } + + /** + * Removes wikilinks and command phrases to get a clean search query. + */ + private sanitizeSearchQuery(message: string): string { + return message + .replace(/\[\[.+?\]\]/g, '') + .replace(/use only this note/gi, '') + .replace(/include related notes/gi, '') + .replace(/include related/gi, '') + .replace(/neighboring notes/gi, '') + .replace(/linked notes/gi, '') + .replace(/context around/gi, '') + .trim(); + } +} diff --git a/src/tool-executor.ts b/src/tool-executor.ts index 7459b0f..6d74212 100644 --- a/src/tool-executor.ts +++ b/src/tool-executor.ts @@ -277,7 +277,7 @@ export class ToolExecutor { const nextMatch = nextHeadingRegex.exec(fileContent.slice(afterHeading)); const sectionStart = headingIndex; - const sectionEnd = nextMatch ? afterHeading + nextMatch.index! : fileContent.length; + const sectionEnd = nextMatch ? afterHeading + nextMatch.index : fileContent.length; const newFileContent = fileContent.slice(0, sectionStart) + @@ -348,8 +348,10 @@ export class ToolExecutor { newFields[key] = value; } else if (Array.isArray(value)) { newFields[key] = value.join(', '); - } else { + } else if (typeof value === 'number' || typeof value === 'boolean') { newFields[key] = String(value); + } else { + newFields[key] = JSON.stringify(value); } } diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 646d51a..0aa26c5 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -117,6 +117,18 @@ describe('ChatView', () => { contentDiv.createEl = createElementWithCreateEl(contentDiv); view.contentEl = contentDiv; + + // Mock NoteContextBuilder to avoid needing full Obsidian API mocks + jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({ + explicitMentions: [], + openNote: undefined, + selectedText: undefined, + backlinks: [], + outlinks: [], + relatedNotes: [], + searchResults: [], + }); + jest.spyOn(view['noteContextBuilder'], 'formatContext').mockReturnValue(''); }); describe('getViewType', () => { @@ -331,12 +343,12 @@ describe('ChatView', () => { expect((view as any).messages.length).toBeLessThanOrEqual(50); }); - it('should call vaultIndexer.searchVault with user input', async () => { + it('should call noteContextBuilder with user input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'search query'; - const searchSpy = jest.spyOn(view['vaultIndexer'], 'searchVault').mockResolvedValue([]); + const searchSpy = jest.spyOn(view["noteContextBuilder"], "buildContext").mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [] }); const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; @@ -345,7 +357,7 @@ describe('ChatView', () => { await (view as any).handleUserInput('search query'); - expect(searchSpy).toHaveBeenCalledWith('search query', 3); // Should use DEFAULT_VAULT_SEARCH_LIMIT + expect(searchSpy).toHaveBeenCalledWith("search query", 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT expect(chatSpy).toHaveBeenCalled(); }); @@ -772,12 +784,12 @@ describe('ChatView', () => { expect((view as any).messages.length).toBeLessThanOrEqual(50); }); - it('should call vaultIndexer.searchVault with user input', async () => { + it('should call noteContextBuilder with user input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'search query'; - const searchSpy = jest.spyOn(view['vaultIndexer'], 'searchVault').mockResolvedValue([]); + const searchSpy = jest.spyOn(view["noteContextBuilder"], "buildContext").mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [] }); const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; @@ -786,7 +798,7 @@ describe('ChatView', () => { await (view as any).handleUserInput('search query'); - expect(searchSpy).toHaveBeenCalledWith('search query', 3); // Should use DEFAULT_VAULT_SEARCH_LIMIT + expect(searchSpy).toHaveBeenCalledWith("search query", 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT expect(chatSpy).toHaveBeenCalled(); }); diff --git a/tests/note-context-builder.test.ts b/tests/note-context-builder.test.ts new file mode 100644 index 0000000..a77ed14 --- /dev/null +++ b/tests/note-context-builder.test.ts @@ -0,0 +1,199 @@ +import { NoteContextBuilder } from '../src/note-context-builder'; +import { VaultIndexer } from '../src/vault-indexer'; +import { TFile } from 'obsidian'; + +describe('NoteContextBuilder', () => { + let builder: NoteContextBuilder; + let mockVault: any; + let mockApp: any; + let mockVaultIndexer: jest.Mocked; + + beforeEach(() => { + mockVault = { + getAbstractFileByPath: jest.fn(), + getMarkdownFiles: jest.fn().mockReturnValue([]), + cachedRead: jest.fn().mockResolvedValue(''), + }; + + mockApp = { + workspace: { + getActiveFile: jest.fn().mockReturnValue(null), + getActiveViewOfType: jest.fn().mockReturnValue(null), + }, + metadataCache: { + getCache: jest.fn().mockReturnValue(null), + resolvedLinks: {}, + }, + }; + + mockVaultIndexer = { + searchVault: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + + builder = new NoteContextBuilder(mockVault, mockApp, mockVaultIndexer); + }); + + describe('extractExplicitMentions', () => { + it('should extract simple wikilinks', () => { + const result = builder.extractExplicitMentions('What about [[My Note]]?'); + expect(result).toEqual(['My Note']); + }); + + it('should extract multiple wikilinks', () => { + const result = builder.extractExplicitMentions('See [[Note A]] and [[Note B]]'); + expect(result).toEqual(['Note A', 'Note B']); + }); + + it('should strip aliases', () => { + const result = builder.extractExplicitMentions('[[Real Name|Display Name]]'); + expect(result).toEqual(['Real Name']); + }); + + it('should deduplicate mentions', () => { + const result = builder.extractExplicitMentions('[[Note]] [[Note]]'); + expect(result).toEqual(['Note']); + }); + + it('should return empty array when no wikilinks', () => { + const result = builder.extractExplicitMentions('Just plain text'); + expect(result).toEqual([]); + }); + }); + + describe('detectScopeIntent', () => { + it('should detect explicit scope', () => { + expect(builder.detectScopeIntent('use only this note')).toBe('explicit'); + expect(builder.detectScopeIntent('Just this note please')).toBe('explicit'); + }); + + it('should detect related scope', () => { + expect(builder.detectScopeIntent('include related notes')).toBe('related'); + expect(builder.detectScopeIntent('show me linked notes')).toBe('related'); + }); + + it('should default to default', () => { + expect(builder.detectScopeIntent('hello world')).toBe('default'); + }); + }); + + describe('buildContext', () => { + it('should include explicit mentions', async () => { + mockVault.getAbstractFileByPath.mockReturnValue({ path: 'Note.md', basename: 'Note' }); + mockVault.getMarkdownFiles.mockReturnValue([{ path: 'Note.md', basename: 'Note' }]); + mockVault.cachedRead.mockResolvedValue('# Note\nContent'); + + const ctx = await builder.buildContext('What about [[Note]]?', 5); + expect(ctx.explicitMentions.length).toBe(1); + expect(ctx.explicitMentions[0].title).toBe('Note'); + }); + + it('should include open note when available', async () => { + const activeFile = { path: 'Open.md', basename: 'Open' }; + mockApp.workspace.getActiveFile.mockReturnValue(activeFile); + mockVault.getAbstractFileByPath.mockReturnValue(activeFile); + mockVault.cachedRead.mockResolvedValue('# Open\nBody'); + + const ctx = await builder.buildContext('hello', 5); + expect(ctx.openNote).toBeDefined(); + expect(ctx.openNote?.title).toBe('Open'); + }); + + it('should include selected text when available', async () => { + const activeFile = { path: 'Open.md', basename: 'Open' }; + mockApp.workspace.getActiveFile.mockReturnValue(activeFile); + mockVault.getAbstractFileByPath.mockReturnValue(activeFile); + mockVault.cachedRead.mockResolvedValue('Body'); + + const mockEditor = { getSelection: jest.fn().mockReturnValue('Selected passage') }; + const mockView = { editor: mockEditor }; + mockApp.workspace.getActiveViewOfType.mockReturnValue(mockView); + + const ctx = await builder.buildContext('hello', 5); + expect(ctx.selectedText).toBe('Selected passage'); + }); + + it('should call vaultIndexer.searchVault for default scope', async () => { + await builder.buildContext('search term', 5); + expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('search term', 5); + }); + + it('should skip vault search for explicit scope', async () => { + mockVault.getAbstractFileByPath.mockReturnValue({ path: 'N.md', basename: 'N' }); + mockVault.getMarkdownFiles.mockReturnValue([{ path: 'N.md', basename: 'N' }]); + mockVault.cachedRead.mockResolvedValue(''); + + await builder.buildContext('use only this note [[N]]', 5); + expect(mockVaultIndexer.searchVault).not.toHaveBeenCalled(); + }); + + it('should gather backlinks and outlinks in related mode', async () => { + const activeFile = { path: 'A.md', basename: 'A' }; + const backFile = { path: 'B.md', basename: 'B' }; + const outFile = { path: 'C.md', basename: 'C' }; + + mockApp.workspace.getActiveFile.mockReturnValue(activeFile); + mockApp.metadataCache.resolvedLinks = { 'B.md': { 'A.md': 1 } }; + mockApp.metadataCache.getCache.mockReturnValue({ links: [{ link: 'C' }] }); + + mockVault.getAbstractFileByPath.mockImplementation((p: string) => { + if (p === 'A.md') return activeFile; + if (p === 'B.md') return backFile; + if (p === 'C.md') return outFile; + return null; + }); + mockVault.getMarkdownFiles.mockReturnValue([backFile, outFile]); + mockVault.cachedRead.mockResolvedValue(''); + + const ctx = await builder.buildContext('include related notes', 5); + expect(ctx.backlinks.length).toBe(1); + expect(ctx.backlinks[0].path).toBe('B.md'); + expect(ctx.outlinks.length).toBe(1); + expect(ctx.outlinks[0].path).toBe('C.md'); + expect(ctx.relatedNotes.length).toBe(2); + }); + }); + + describe('formatContext', () => { + it('should include selected text, open note, mentions, and search results', () => { + const ctx = { + explicitMentions: [ + { path: 'M.md', title: 'Mention', content: 'Mention body', score: 1 }, + ], + openNote: { path: 'O.md', title: 'Open', content: 'Open body', score: 1 }, + selectedText: 'Selected text', + backlinks: [], + outlinks: [], + relatedNotes: [], + searchResults: [ + { path: 'S.md', title: 'Search', content: 'Search body', score: 1 }, + ], + }; + + const formatted = builder.formatContext(ctx as any, 2000); + expect(formatted).toContain('Selected text from current note:'); + expect(formatted).toContain('Selected text'); + expect(formatted).toContain('Current open note: Open (O.md)'); + expect(formatted).toContain('Explicitly mentioned notes:'); + expect(formatted).toContain('Mention (M.md)'); + expect(formatted).toContain('Vault search results:'); + expect(formatted).toContain('Search (S.md)'); + }); + + it('should truncate long context', () => { + const ctx = { + explicitMentions: [], + openNote: undefined, + selectedText: undefined, + backlinks: [], + outlinks: [], + relatedNotes: [], + searchResults: [ + { path: 'S.md', title: 'Search', content: 'A'.repeat(500), score: 1 }, + ], + }; + + const formatted = builder.formatContext(ctx as any, 50); + expect(formatted).toContain('... [truncated]'); + }); + }); +});