Add NoteContextBuilder for enhanced chat context gathering
Introduces NoteContextBuilder to extract explicit wikilink mentions, detect scope intent, and gather contextual note data including backlinks, outlinks, and related notes. Integrates into ChatView and adds comprehensive unit tests. Also includes minor type fixes: removes unnecessary `as` cast in action-preview-builder, fixes non-null assertion in tool-executor, and cleans up unused import in auto-organizer. Simplifies auto-tag command callback by removing redundant async/await.
This commit is contained in:
@@ -36,7 +36,7 @@ export class ActionPreviewBuilder {
|
||||
return {};
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||
return rawArgs as Record<string, unknown>;
|
||||
return rawArgs;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -75,12 +75,13 @@ export class ActionPreviewBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private buildCreatePreview(
|
||||
toolCall: ToolCall,
|
||||
args: Record<string, unknown>
|
||||
): 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<string, unknown>): 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<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
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<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
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<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
const path = String(args.path ?? '');
|
||||
const path = this.strArg(args.path);
|
||||
const fields = args.fields as Record<string, unknown> | 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<string, unknown>): 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<string, unknown>): 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<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
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<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+14
-14
@@ -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;
|
||||
|
||||
+10
-10
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<VaultIndexEntry | undefined> {
|
||||
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<VaultIndexEntry> {
|
||||
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<string, Record<string, number>>;
|
||||
}
|
||||
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<NoteContext> {
|
||||
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<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-6
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<VaultIndexer>;
|
||||
|
||||
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<VaultIndexer>;
|
||||
|
||||
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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user