Skip to content

Commit 73c0523

Browse files
2b3proclaude
andcommitted
v2.10.0 feat(search): add namespace prefix search for page titles
Add scope parameter to roam_search_by_text that enables searching for pages by namespace prefix (e.g., "Convention/" finds all Convention/* pages). Includes CLI --namespace flag and documentation updates. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 473bc3c commit 73c0523

10 files changed

Lines changed: 115 additions & 11 deletions

File tree

.mcp.json

Lines changed: 0 additions & 3 deletions
This file was deleted.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ echo "Buy milk" | roam save --todo
3737
# Search your graph and pipe results to another tool
3838
roam search "important" --json | jq .
3939

40+
# Search for pages by namespace prefix
41+
roam search --namespace "Convention" # Finds all Convention/* pages
42+
4043
# Fetch a page by title
4144
roam get "Roam Research"
4245

@@ -84,7 +87,7 @@ The MCP server exposes these tools to AI assistants (like Claude), enabling them
8487
| `roam_fetch_block_with_children` | Fetch a block and its nested children by UID (resolves refs). |
8588
| `roam_create_page` | Create new pages, optionally with mixed text and table content. |
8689
| `roam_update_page_markdown` | Update a page using smart diff (preserves block UIDs). |
87-
| `roam_search_by_text` | Full-text search across the graph or within specific pages. |
90+
| `roam_search_by_text` | Full-text search across the graph or within specific pages. Supports namespace prefix search for page titles. |
8891
| `roam_search_block_refs` | Find blocks that reference a page, tag, or block UID. |
8992
| `roam_search_by_status` | Find TODO or DONE items. |
9093
| `roam_search_for_tag` | Find blocks containing specific tags (supports exclusion). |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "roam-research-mcp",
3-
"version": "2.9.1",
3+
"version": "2.10.0",
44
"description": "MCP server and CLI for Roam Research",
55
"private": false,
66
"repository": {

src/cli/commands/search.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface SearchOptions extends GraphOptions {
4141
regex?: string;
4242
regexFlags?: string;
4343
any?: boolean;
44+
namespace?: string;
4445
}
4546

4647
export function createSearchCommand(): Command {
@@ -67,12 +68,17 @@ export function createSearchCommand(): Command {
6768
.option('--inputs <json>', 'JSON array of inputs for Datalog query')
6869
.option('--regex <pattern>', 'Client-side regex filter on Datalog results')
6970
.option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)')
71+
.option('--namespace <prefix>', 'Search for pages by namespace prefix (e.g., "Convention" finds "Convention/*")')
7072
.addHelpText('after', `
7173
Examples:
7274
# Text search
7375
roam search "meeting notes" # Find blocks containing text
7476
roam search api integration # Multiple terms (AND logic)
7577
78+
# Namespace search (find pages by title prefix)
79+
roam search --namespace Convention # Find all Convention/* pages
80+
roam search --namespace "Convention/" # Same (trailing slash optional)
81+
7682
# Stdin search
7783
echo "urgent project" | roam search # Pipe terms
7884
roam get today | roam search TODO # Search within output
@@ -89,7 +95,7 @@ Examples:
8995
9096
Output format:
9197
Markdown: Flat results with UIDs and content (no hierarchy).
92-
JSON: [{ block_uid, content, page_title }]
98+
JSON: [{ block_uid, content, page_title }] or [{ page_uid, page_title }] for namespace
9399
94100
Note: For hierarchical output with children, use 'roam get --tag/--text' instead.
95101
`)
@@ -121,6 +127,39 @@ Note: For hierarchical output with children, use 'roam get --tag/--text' instead
121127

122128
const searchOps = new SearchOperations(graph);
123129

130+
// Namespace search mode (search page titles by prefix)
131+
if (options.namespace) {
132+
const result = await searchOps.searchByText({
133+
text: options.namespace,
134+
scope: 'page_titles'
135+
});
136+
137+
if (!result.success) {
138+
exitWithError(result.message || 'Namespace search failed');
139+
}
140+
141+
let matches = result.matches.slice(0, limit);
142+
143+
if (options.json) {
144+
// For JSON output, return page_uid and page_title
145+
const jsonMatches = matches.map(m => ({
146+
page_uid: m.block_uid,
147+
page_title: m.page_title
148+
}));
149+
console.log(JSON.stringify(jsonMatches, null, 2));
150+
} else {
151+
if (matches.length === 0) {
152+
console.log('No pages found.');
153+
} else {
154+
console.log(`Found ${result.matches.length} page(s)${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`);
155+
for (const match of matches) {
156+
console.log(`- ${match.page_title} (${match.block_uid})`);
157+
}
158+
}
159+
}
160+
return;
161+
}
162+
124163
// Datalog query mode (bypasses other search options)
125164
if (options.query) {
126165
// Parse inputs if provided

src/search/text-search.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ export class TextSearchHandler extends BaseSearchHandler {
1313
}
1414

1515
async execute(): Promise<SearchResult> {
16-
const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0 } = this.params;
16+
const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0, scope = 'blocks' } = this.params;
17+
18+
// Handle page_titles scope (namespace search)
19+
if (scope === 'page_titles') {
20+
return this.executePageTitleSearch();
21+
}
1722

1823
// Get target page UID if provided for scoped search
1924
let targetPageUid: string | undefined;
@@ -104,4 +109,54 @@ export class TextSearchHandler extends BaseSearchHandler {
104109
formattedResults.total_count = totalCount;
105110
return formattedResults;
106111
}
112+
113+
/**
114+
* Search for page titles matching a namespace prefix.
115+
* Normalizes the search text to ensure trailing slash for prefix matching.
116+
*/
117+
private async executePageTitleSearch(): Promise<SearchResult> {
118+
const { text, limit = -1, offset = 0 } = this.params;
119+
120+
// Normalize namespace: ensure trailing slash for prefix matching
121+
const namespace = text.endsWith('/') ? text : `${text}/`;
122+
123+
// Query for pages with titles starting with the namespace
124+
const queryLimit = limit === -1 ? '' : `:limit ${limit}`;
125+
const queryOffset = offset === 0 ? '' : `:offset ${offset}`;
126+
127+
const queryStr = `[:find ?title ?uid
128+
:in $ ${queryLimit} ${queryOffset}
129+
:where
130+
[?e :node/title ?title]
131+
[?e :block/uid ?uid]
132+
[(clojure.string/starts-with? ?title "${namespace}")]]`;
133+
134+
const rawResults = await q(this.graph, queryStr, []) as [string, string][];
135+
136+
// Get total count
137+
const countQueryStr = `[:find (count ?e)
138+
:in $
139+
:where
140+
[?e :node/title ?title]
141+
[(clojure.string/starts-with? ?title "${namespace}")]]`;
142+
const totalCountResults = await q(this.graph, countQueryStr, []) as number[][];
143+
const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
144+
145+
// Format results: page UID as block_uid, title as content
146+
const matches = rawResults.map(([title, uid]) => ({
147+
block_uid: uid,
148+
content: title,
149+
page_title: title
150+
}));
151+
152+
// Sort alphabetically by title
153+
matches.sort((a, b) => a.content.localeCompare(b.content));
154+
155+
return {
156+
success: true,
157+
matches,
158+
message: `Found ${matches.length} page(s) with namespace "${namespace}"`,
159+
total_count: totalCount
160+
};
161+
}
107162
}

src/search/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface TextSearchParams {
3939
case_sensitive?: boolean;
4040
limit?: number;
4141
offset?: number;
42+
scope?: 'blocks' | 'page_titles';
4243
}
4344

4445
// Base class for all search handlers

src/server/roam-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export class RoamServer {
333333
const params = cleanedArgs as {
334334
text: string;
335335
page_title_uid?: string;
336+
scope?: 'blocks' | 'page_titles';
336337
};
337338
const result = await toolHandlers.searchByText(params);
338339
return {

src/tools/operations/search/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface HierarchySearchParams extends BaseSearchParams {
3636
// Text search parameters
3737
export interface TextSearchParams extends BaseSearchParams {
3838
text: string;
39+
scope?: 'blocks' | 'page_titles';
3940
}
4041

4142
// Status search parameters

src/tools/schemas.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,21 +361,27 @@ export const toolSchemas = {
361361
},
362362
roam_search_by_text: {
363363
name: 'roam_search_by_text',
364-
description: 'Search for blocks containing specific text across all pages or within a specific page. This tool supports pagination via the `limit` and `offset` parameters.',
364+
description: 'Search for blocks containing specific text across all pages or within a specific page. Use `scope: "page_titles"` to search for pages by namespace prefix (e.g., "Convention/" finds all pages starting with that prefix). This tool supports pagination via the `limit` and `offset` parameters.',
365365
inputSchema: {
366366
type: 'object',
367367
properties: withMultiGraphParams({
368368
text: {
369369
type: 'string',
370-
description: 'The text to search for'
370+
description: 'The text to search for. When scope is "page_titles", this is the namespace prefix (trailing slash optional).'
371+
},
372+
scope: {
373+
type: 'string',
374+
enum: ['blocks', 'page_titles'],
375+
default: 'blocks',
376+
description: 'Search scope: "blocks" for block content (default), "page_titles" for page title namespace prefix matching.'
371377
},
372378
page_title_uid: {
373379
type: 'string',
374-
description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
380+
description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages. Only used when scope is "blocks".'
375381
},
376382
case_sensitive: {
377383
type: 'boolean',
378-
description: 'Optional: Whether the search should be case-sensitive. If false, it will search for the provided text, capitalized versions, and first word capitalized versions.',
384+
description: 'Optional: Whether the search should be case-sensitive. If false, it will search for the provided text, capitalized versions, and first word capitalized versions. Only used when scope is "blocks".',
379385
default: false
380386
},
381387
limit: {

src/tools/tool-handlers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export class ToolHandlers {
9393
async searchByText(params: {
9494
text: string;
9595
page_title_uid?: string;
96+
scope?: 'blocks' | 'page_titles';
9697
}) {
9798
return this.searchOps.searchByText(params);
9899
}

0 commit comments

Comments
 (0)