UNPKG

roam-research-mcp

Version:
246 lines (238 loc) • 11.4 kB
import { Command } from 'commander'; import { SearchOperations } from '../../tools/operations/search/index.js'; import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js'; import { resolveGraph } from '../utils/graph.js'; import { readStdin } from '../utils/input.js'; /** * Normalize a tag by stripping #, [[, ]] wrappers */ function normalizeTag(tag) { return tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, ''); } /** * Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats) */ function contentHasTag(content, tag) { const normalized = normalizeTag(tag); return (content.includes(`[[${normalized}]]`) || content.includes(`#${normalized}`) || content.includes(`#[[${normalized}]]`)); } export function createSearchCommand() { return new Command('search') .description('Search blocks by text, tags, Datalog queries, or within specific pages') .argument('[terms...]', 'Search terms (multiple terms use AND logic). Reads from stdin if omitted.') .option('--tag <tag>', 'Filter by tag (repeatable, comma-separated). Default: AND logic', (val, prev) => { // Support both comma-separated and multiple flags const tags = val.split(',').map(t => t.trim()).filter(Boolean); return prev ? [...prev, ...tags] : tags; }, []) .option('--any', 'Use OR logic for multiple tags (default is AND)') .option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => { const tags = val.split(',').map(t => t.trim()).filter(Boolean); return prev ? [...prev, ...tags] : tags; }, []) .option('--page <title>', 'Scope search to a specific page') .option('-i, --case-insensitive', 'Case-insensitive search') .option('-n, --limit <n>', 'Limit number of results (default: 20)', '20') .option('--json', 'Output as JSON') .option('--debug', 'Show query metadata') .option('-g, --graph <name>', 'Target graph key (for multi-graph mode)') .option('-q, --query <datalog>', 'Raw Datalog query (bypasses other search options)') .option('--inputs <json>', 'JSON array of inputs for Datalog query') .option('--regex <pattern>', 'Client-side regex filter on Datalog results') .option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)') .option('--namespace <prefix>', 'Search for pages by namespace prefix (e.g., "Convention" finds "Convention/*")') .addHelpText('after', ` Examples: # Text search roam search "meeting notes" # Find blocks containing text roam search api integration # Multiple terms (AND logic) # Namespace search (find pages by title prefix) roam search --namespace Convention # Find all Convention/* pages roam search --namespace "Convention/" # Same (trailing slash optional) # Stdin search echo "urgent project" | roam search # Pipe terms roam get today | roam search TODO # Search within output # Tag search roam search --tag TODO # All blocks with #TODO roam search --tag "[[Project Alpha]]" # Blocks with page reference # Datalog queries (advanced) roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting" # Chaining with jq roam search TODO --json | jq '.[].block_uid' Output format: Markdown: Flat results with UIDs and content (no hierarchy). JSON: [{ block_uid, content, page_title }] or [{ page_uid, page_title }] for namespace Note: For hierarchical output with children, use 'roam get --tag/--text' instead. `) .action(async (terms, options) => { try { const graph = resolveGraph(options, false); const limit = parseInt(options.limit || '20', 10); const outputOptions = { json: options.json, debug: options.debug }; let searchTerms = terms; // If no terms provided as args, try stdin if (searchTerms.length === 0 && !process.stdin.isTTY && !options.query && (options.tag?.length === 0)) { const input = await readStdin(); if (input) { searchTerms = input.trim().split(/\s+/); } } if (options.debug) { printDebug('Search terms', searchTerms); printDebug('Graph', options.graph || 'default'); printDebug('Options', options); } const searchOps = new SearchOperations(graph); // Namespace search mode (search page titles by prefix) if (options.namespace) { const result = await searchOps.searchByText({ text: options.namespace, scope: 'page_titles' }); if (!result.success) { exitWithError(result.message || 'Namespace search failed'); } let matches = result.matches.slice(0, limit); if (options.json) { // For JSON output, return page_uid and page_title const jsonMatches = matches.map(m => ({ page_uid: m.block_uid, page_title: m.page_title })); console.log(JSON.stringify(jsonMatches, null, 2)); } else { if (matches.length === 0) { console.log('No pages found.'); } else { console.log(`Found ${result.matches.length} page(s)${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`); for (const match of matches) { console.log(`- ${match.page_title} (${match.block_uid})`); } } } return; } // Datalog query mode (bypasses other search options) if (options.query) { // Parse inputs if provided let inputs; if (options.inputs) { try { inputs = JSON.parse(options.inputs); if (!Array.isArray(inputs)) { exitWithError('--inputs must be a JSON array'); } } catch { exitWithError('Invalid JSON in --inputs'); } } const result = await searchOps.executeDatomicQuery({ query: options.query, inputs, regexFilter: options.regex, regexFlags: options.regexFlags }); if (!result.success) { exitWithError(result.message || 'Query failed'); } // Apply limit and format output const limitedMatches = result.matches.slice(0, limit); if (options.json) { const parsed = limitedMatches.map(m => { try { return JSON.parse(m.content); } catch { return m.content; } }); console.log(JSON.stringify(parsed, null, 2)); } else { if (limitedMatches.length === 0) { console.log('No results found.'); } else { console.log(`Found ${result.matches.length} results${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`); for (const match of limitedMatches) { console.log(match.content); } } } return; } // Determine search type based on options const tags = options.tag || []; if (tags.length > 0 && searchTerms.length === 0) { // Tag-only search const normalizedTags = tags.map(normalizeTag); const useOrLogic = options.any || false; const result = await searchOps.searchForTag(normalizedTags[0], options.page); let matches = result.matches; if (normalizedTags.length > 1) { matches = matches.filter(m => { if (useOrLogic) { return normalizedTags.some(tag => contentHasTag(m.content, tag)); } else { return normalizedTags.every(tag => contentHasTag(m.content, tag)); } }); } const negTags = options.negtag || []; if (negTags.length > 0) { const normalizedNegTags = negTags.map(normalizeTag); matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag))); } const limitedMatches = matches.slice(0, limit); console.log(formatSearchResults(limitedMatches, outputOptions)); } else if (searchTerms.length > 0) { // Text search const searchText = searchTerms.join(' '); const result = await searchOps.searchByText({ text: searchText, page_title_uid: options.page }); let matches = result.matches; if (options.caseInsensitive) { const lowerSearchText = searchText.toLowerCase(); matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText)); } if (tags.length > 0) { const normalizedTags = tags.map(normalizeTag); const useOrLogic = options.any || false; matches = matches.filter(m => { if (useOrLogic) { return normalizedTags.some(tag => contentHasTag(m.content, tag)); } else { return normalizedTags.every(tag => contentHasTag(m.content, tag)); } }); } const negTags = options.negtag || []; if (negTags.length > 0) { const normalizedNegTags = negTags.map(normalizeTag); matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag))); } console.log(formatSearchResults(matches.slice(0, limit), outputOptions)); } else { exitWithError('Please provide search terms or use --tag to search by tag'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); exitWithError(message); } }); }