feat: Phase 6 - Powerful Search with regex and waypoint support
- Add enhanced 'search' tool with regex support, case sensitivity control, and advanced filtering - Add 'search_waypoints' tool for finding Waypoint plugin markers - Implement SearchUtils with regex/literal search, snippet extraction, and match highlighting - Add WaypointResult and WaypointSearchResult types - Update SearchResult type to include isRegex field - Remove deprecated search_notes tool (breaking change) - Support glob filtering (includes/excludes) and folder scoping - Configurable snippet length and result limiting - Extract wikilinks from waypoint content Breaking Changes: - search_notes tool removed, use 'search' tool instead
This commit is contained in:
@@ -95,19 +95,66 @@ export class ToolRegistry {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "search_notes",
|
||||
description: "Search for notes in the Obsidian vault by content or filename. Returns structured JSON with detailed search results including file paths, line numbers, column positions, snippets with context, and match ranges for highlighting. Searches are case-insensitive and match against both file names and file contents. Use this to find notes containing specific text or with specific names.",
|
||||
name: "search",
|
||||
description: "Search vault with advanced filtering, regex support, and snippet extraction. Returns structured JSON with detailed search results including file paths, line numbers, column positions, snippets with context, and match ranges for highlighting. Supports both literal and regex search patterns, case sensitivity control, glob filtering, folder scoping, and result limiting. Use this for powerful content search across your vault.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Text to search for in note names and contents (e.g., 'TODO', 'meeting notes', 'project'). Search is case-insensitive."
|
||||
description: "Text or regex pattern to search for (e.g., 'TODO', 'meeting.*notes', '^# Heading'). Interpretation depends on isRegex parameter."
|
||||
},
|
||||
isRegex: {
|
||||
type: "boolean",
|
||||
description: "If true, treat query as a regular expression pattern. If false (default), treat as literal text. Regex supports full JavaScript regex syntax."
|
||||
},
|
||||
caseSensitive: {
|
||||
type: "boolean",
|
||||
description: "If true, search is case-sensitive. If false (default), search is case-insensitive. Applies to both literal and regex searches."
|
||||
},
|
||||
includes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Glob patterns to include (e.g., ['*.md', 'projects/**']). Only files matching these patterns will be searched. If empty, all files are included."
|
||||
},
|
||||
excludes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Glob patterns to exclude (e.g., ['.obsidian/**', '*.tmp']). Files matching these patterns will be skipped. Takes precedence over includes."
|
||||
},
|
||||
folder: {
|
||||
type: "string",
|
||||
description: "Optional vault-relative folder path to limit search scope (e.g., 'projects' or 'daily/2024'). Only files within this folder will be searched."
|
||||
},
|
||||
returnSnippets: {
|
||||
type: "boolean",
|
||||
description: "If true (default), include surrounding context snippets for each match. If false, only return match locations without snippets."
|
||||
},
|
||||
snippetLength: {
|
||||
type: "number",
|
||||
description: "Maximum length of context snippets in characters. Default: 100. Only applies when returnSnippets is true."
|
||||
},
|
||||
maxResults: {
|
||||
type: "number",
|
||||
description: "Maximum number of matches to return. Default: 100. Use to limit results for broad searches."
|
||||
}
|
||||
},
|
||||
required: ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "search_waypoints",
|
||||
description: "Find all Waypoint plugin markers in the vault. Waypoints are special comment blocks (%% Begin Waypoint %% ... %% End Waypoint %%) used by the Waypoint plugin to auto-generate folder indexes. Returns structured JSON with waypoint locations, content, and extracted wikilinks. Useful for discovering folder notes and navigation structures.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
folder: {
|
||||
type: "string",
|
||||
description: "Optional vault-relative folder path to limit search scope (e.g., 'projects'). If omitted, searches entire vault."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_vault_info",
|
||||
description: "Get information about the Obsidian vault. Returns structured JSON with vault name, path, total file count, total folder count, markdown file count, and total size in bytes. Use this to understand the vault structure and get an overview of available content. No parameters required.",
|
||||
@@ -228,8 +275,20 @@ export class ToolRegistry {
|
||||
return await this.noteTools.updateNote(args.path, args.content);
|
||||
case "delete_note":
|
||||
return await this.noteTools.deleteNote(args.path);
|
||||
case "search_notes":
|
||||
return await this.vaultTools.searchNotes(args.query);
|
||||
case "search":
|
||||
return await this.vaultTools.search({
|
||||
query: args.query,
|
||||
isRegex: args.isRegex,
|
||||
caseSensitive: args.caseSensitive,
|
||||
includes: args.includes,
|
||||
excludes: args.excludes,
|
||||
folder: args.folder,
|
||||
returnSnippets: args.returnSnippets,
|
||||
snippetLength: args.snippetLength,
|
||||
maxResults: args.maxResults
|
||||
});
|
||||
case "search_waypoints":
|
||||
return await this.vaultTools.searchWaypoints(args.folder);
|
||||
case "get_vault_info":
|
||||
return await this.vaultTools.getVaultInfo();
|
||||
case "list":
|
||||
|
||||
@@ -1,89 +1,13 @@
|
||||
import { App, TFile, TFolder } from 'obsidian';
|
||||
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary } from '../types/mcp-types';
|
||||
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult } from '../types/mcp-types';
|
||||
import { PathUtils } from '../utils/path-utils';
|
||||
import { ErrorMessages } from '../utils/error-messages';
|
||||
import { GlobUtils } from '../utils/glob-utils';
|
||||
import { SearchUtils } from '../utils/search-utils';
|
||||
|
||||
export class VaultTools {
|
||||
constructor(private app: App) {}
|
||||
|
||||
async searchNotes(query: string): Promise<CallToolResult> {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const matches: SearchMatch[] = [];
|
||||
let filesSearched = 0;
|
||||
const filesWithMatches = new Set<string>();
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
for (const file of files) {
|
||||
filesSearched++;
|
||||
const content = await this.app.vault.read(file);
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Search in content
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||
const line = lines[lineIndex];
|
||||
const lineLower = line.toLowerCase();
|
||||
let columnIndex = lineLower.indexOf(queryLower);
|
||||
|
||||
while (columnIndex !== -1) {
|
||||
filesWithMatches.add(file.path);
|
||||
|
||||
// Extract snippet (50 chars before and after match)
|
||||
const snippetStart = Math.max(0, columnIndex - 50);
|
||||
const snippetEnd = Math.min(line.length, columnIndex + query.length + 50);
|
||||
const snippet = line.substring(snippetStart, snippetEnd);
|
||||
|
||||
matches.push({
|
||||
path: file.path,
|
||||
line: lineIndex + 1, // 1-indexed
|
||||
column: columnIndex + 1, // 1-indexed
|
||||
snippet: snippet,
|
||||
matchRanges: [{
|
||||
start: columnIndex - snippetStart,
|
||||
end: columnIndex - snippetStart + query.length
|
||||
}]
|
||||
});
|
||||
|
||||
// Find next occurrence in the same line
|
||||
columnIndex = lineLower.indexOf(queryLower, columnIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check filename
|
||||
if (file.basename.toLowerCase().includes(queryLower)) {
|
||||
filesWithMatches.add(file.path);
|
||||
// Add a match for the filename itself
|
||||
const nameIndex = file.basename.toLowerCase().indexOf(queryLower);
|
||||
matches.push({
|
||||
path: file.path,
|
||||
line: 0, // 0 indicates filename match
|
||||
column: nameIndex + 1,
|
||||
snippet: file.basename,
|
||||
matchRanges: [{
|
||||
start: nameIndex,
|
||||
end: nameIndex + query.length
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: SearchResult = {
|
||||
query: query,
|
||||
matches: matches,
|
||||
totalMatches: matches.length,
|
||||
filesSearched: filesSearched,
|
||||
filesWithMatches: filesWithMatches.size
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
async getVaultInfo(): Promise<CallToolResult> {
|
||||
const files = this.app.vault.getFiles();
|
||||
const markdownFiles = this.app.vault.getMarkdownFiles();
|
||||
@@ -578,4 +502,98 @@ export class VaultTools {
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 6: Powerful Search
|
||||
async search(options: {
|
||||
query: string;
|
||||
isRegex?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
includes?: string[];
|
||||
excludes?: string[];
|
||||
folder?: string;
|
||||
returnSnippets?: boolean;
|
||||
snippetLength?: number;
|
||||
maxResults?: number;
|
||||
}): Promise<CallToolResult> {
|
||||
const {
|
||||
query,
|
||||
isRegex = false,
|
||||
caseSensitive = false,
|
||||
includes,
|
||||
excludes,
|
||||
folder,
|
||||
returnSnippets = true,
|
||||
snippetLength = 100,
|
||||
maxResults = 100
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const { matches, stats } = await SearchUtils.search(this.app, {
|
||||
query,
|
||||
isRegex,
|
||||
caseSensitive,
|
||||
includes,
|
||||
excludes,
|
||||
folder,
|
||||
returnSnippets,
|
||||
snippetLength,
|
||||
maxResults
|
||||
});
|
||||
|
||||
const result: SearchResult = {
|
||||
query,
|
||||
isRegex,
|
||||
matches,
|
||||
totalMatches: stats.totalMatches,
|
||||
filesSearched: stats.filesSearched,
|
||||
filesWithMatches: stats.filesWithMatches
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Search error: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async searchWaypoints(folder?: string): Promise<CallToolResult> {
|
||||
try {
|
||||
const waypoints = await SearchUtils.searchWaypoints(this.app, folder);
|
||||
|
||||
const result: WaypointSearchResult = {
|
||||
waypoints,
|
||||
totalWaypoints: waypoints.length,
|
||||
filesSearched: this.app.vault.getMarkdownFiles().filter(file => {
|
||||
if (!folder) return true;
|
||||
const folderPath = folder.endsWith('/') ? folder : folder + '/';
|
||||
return file.path.startsWith(folderPath) || file.path === folder;
|
||||
}).length
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Waypoint search error: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user