roam-research-mcp 2.4.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +175 -667
  2. package/build/Roam_Markdown_Cheatsheet.md +138 -289
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +3 -8
  6. package/build/cli/commands/get.js +478 -60
  7. package/build/cli/commands/refs.js +51 -31
  8. package/build/cli/commands/save.js +61 -10
  9. package/build/cli/commands/search.js +63 -58
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/commands/update.js +71 -28
  12. package/build/cli/utils/graph.js +6 -2
  13. package/build/cli/utils/input.js +10 -0
  14. package/build/cli/utils/output.js +28 -5
  15. package/build/cli/utils/sort-group.js +110 -0
  16. package/build/config/graph-registry.js +31 -13
  17. package/build/config/graph-registry.test.js +42 -5
  18. package/build/markdown-utils.js +114 -4
  19. package/build/markdown-utils.test.js +125 -0
  20. package/build/query/generator.js +330 -0
  21. package/build/query/index.js +149 -0
  22. package/build/query/parser.js +319 -0
  23. package/build/query/parser.test.js +389 -0
  24. package/build/query/types.js +4 -0
  25. package/build/search/ancestor-rule.js +14 -0
  26. package/build/search/block-ref-search.js +1 -5
  27. package/build/search/hierarchy-search.js +5 -12
  28. package/build/search/index.js +1 -0
  29. package/build/search/status-search.js +10 -9
  30. package/build/search/tag-search.js +8 -24
  31. package/build/search/text-search.js +70 -27
  32. package/build/search/types.js +13 -0
  33. package/build/search/utils.js +71 -2
  34. package/build/server/roam-server.js +4 -3
  35. package/build/shared/index.js +2 -0
  36. package/build/shared/page-validator.js +233 -0
  37. package/build/shared/page-validator.test.js +128 -0
  38. package/build/shared/staged-batch.js +144 -0
  39. package/build/tools/helpers/batch-utils.js +57 -0
  40. package/build/tools/helpers/page-resolution.js +136 -0
  41. package/build/tools/helpers/refs.js +68 -0
  42. package/build/tools/operations/batch.js +75 -3
  43. package/build/tools/operations/block-retrieval.js +15 -4
  44. package/build/tools/operations/block-retrieval.test.js +87 -0
  45. package/build/tools/operations/blocks.js +1 -288
  46. package/build/tools/operations/memory.js +32 -90
  47. package/build/tools/operations/outline.js +38 -156
  48. package/build/tools/operations/pages.js +169 -122
  49. package/build/tools/operations/todos.js +5 -37
  50. package/build/tools/schemas.js +20 -9
  51. package/build/tools/tool-handlers.js +4 -4
  52. package/build/utils/helpers.js +27 -0
  53. package/package.json +1 -1
@@ -1,8 +1,11 @@
1
- import { q, createPage as createRoamPage, batchActions, updatePage } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage as createRoamPage, updatePage } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
- import { capitalizeWords } from '../helpers/text.js';
4
- import { resolveRefs } from '../helpers/refs.js';
3
+ import { ANCESTOR_RULE } from '../../search/ancestor-rule.js';
4
+ import { getPageUid as getPageUidHelper } from '../helpers/page-resolution.js';
5
+ import { resolveRefs, resolveBlockRefs } from '../helpers/refs.js';
6
+ import { executeBatch, executeBatchSafe } from '../helpers/batch-utils.js';
5
7
  import { convertToRoamMarkdown, generateBlockUid } from '../../markdown-utils.js';
8
+ import { executeStagedBatch } from '../../shared/staged-batch.js';
6
9
  import { pageUidCache } from '../../cache/page-uid-cache.js';
7
10
  import { buildTableActions } from './table.js';
8
11
  import { BatchOperations } from './batch.js';
@@ -24,14 +27,6 @@ export class PageOperations {
24
27
  this.batchOps = new BatchOperations(graph);
25
28
  }
26
29
  async findPagesModifiedToday(limit = 50, offset = 0, sort_order = 'desc') {
27
- // Define ancestor rule for traversing block hierarchy
28
- const ancestorRule = `[
29
- [ (ancestor ?b ?a)
30
- [?a :block/children ?b] ]
31
- [ (ancestor ?b ?a)
32
- [?parent :block/children ?b]
33
- (ancestor ?parent ?a) ]
34
- ]`;
35
30
  // Get start of today
36
31
  const startOfDay = new Date();
37
32
  startOfDay.setHours(0, 0, 0, 0);
@@ -50,7 +45,7 @@ export class PageOperations {
50
45
  if (offset > 0) {
51
46
  query += ` :offset ${offset}`;
52
47
  }
53
- const results = await q(this.graph, query, [startOfDay.getTime(), ancestorRule]);
48
+ const results = await q(this.graph, query, [startOfDay.getTime(), ANCESTOR_RULE]);
54
49
  if (!results || results.length === 0) {
55
50
  return {
56
51
  success: true,
@@ -98,22 +93,48 @@ export class PageOperations {
98
93
  pageUidCache.set(pageTitle, pageUid);
99
94
  }
100
95
  else {
101
- // Create new page
96
+ // Create new page by adding a page reference to today's daily page
97
+ // This leverages Roam's native behavior: [[Page Title]] creates the page instantly
102
98
  try {
103
- await createRoamPage(this.graph, {
104
- action: 'create-page',
105
- page: {
106
- title: pageTitle
107
- }
108
- });
109
- // Get the new page's UID
99
+ // Get today's daily page title
100
+ const today = new Date();
101
+ const day = today.getDate();
102
+ const month = today.toLocaleString('en-US', { month: 'long' });
103
+ const year = today.getFullYear();
104
+ const dailyPageTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
105
+ // Get or create daily page UID
106
+ const dailyPageQuery = `[:find ?uid . :where [?e :node/title "${dailyPageTitle}"] [?e :block/uid ?uid]]`;
107
+ let dailyPageUid = await q(this.graph, dailyPageQuery, []);
108
+ if (!dailyPageUid) {
109
+ // Create daily page first
110
+ await createRoamPage(this.graph, {
111
+ action: 'create-page',
112
+ page: { title: dailyPageTitle }
113
+ });
114
+ // Small delay for daily page creation to be available as parent
115
+ await new Promise(resolve => setTimeout(resolve, 400));
116
+ dailyPageUid = await q(this.graph, dailyPageQuery, []);
117
+ }
118
+ if (!dailyPageUid) {
119
+ throw new Error(`Could not resolve daily page "${dailyPageTitle}"`);
120
+ }
121
+ // Create block with page reference - this instantly creates the target page
122
+ await executeBatch(this.graph, [{
123
+ action: 'create-block',
124
+ location: { 'parent-uid': dailyPageUid, order: 'last' },
125
+ block: { string: `Created page: [[${pageTitle}]]` }
126
+ }], 'create page reference block');
127
+ // Now query for the page UID - should exist immediately
110
128
  const results = await q(this.graph, findQuery, [pageTitle]);
111
129
  if (!results || results.length === 0) {
112
- throw new Error('Could not find created page');
130
+ throw new Error(`Could not find created page "${pageTitle}"`);
113
131
  }
114
132
  pageUid = results[0][0];
115
133
  // Cache the newly created page
116
134
  pageUidCache.onPageCreated(pageTitle, pageUid);
135
+ // Small delay for new page to be fully available as parent in Roam
136
+ // (fixes "Parent entity doesn't exist" error when adding content immediately)
137
+ await new Promise(resolve => setTimeout(resolve, 400));
117
138
  }
118
139
  catch (error) {
119
140
  throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
@@ -230,13 +251,11 @@ export class PageOperations {
230
251
  // Build batch actions from nodes with UIDs
231
252
  const textActions = buildActionsFromNodes(nodesWithUids, pageUid, startOrder);
232
253
  if (textActions.length > 0) {
233
- const batchResult = await batchActions(this.graph, {
234
- action: 'batch-actions',
235
- actions: textActions
254
+ // Use staged batch to ensure parent blocks exist before children
255
+ await executeStagedBatch(this.graph, textActions, {
256
+ context: 'page content creation',
257
+ delayBetweenLevels: 100
236
258
  });
237
- if (!batchResult) {
238
- throw new Error('Failed to create text blocks');
239
- }
240
259
  }
241
260
  // Return the next order position (number of root-level blocks added)
242
261
  const nextOrder = startOrder + rootNodes.length;
@@ -291,94 +310,144 @@ export class PageOperations {
291
310
  }
292
311
  }
293
312
  // Add a "Processed: [[date]]" block as the last block of the newly created page
294
- try {
295
- const today = new Date();
296
- const day = today.getDate();
297
- const month = today.toLocaleString('en-US', { month: 'long' });
298
- const year = today.getFullYear();
299
- const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
300
- await batchActions(this.graph, {
301
- action: 'batch-actions',
302
- actions: [{
303
- action: 'create-block',
304
- location: { 'parent-uid': pageUid, order: 'last' },
305
- block: { string: `Processed: [[${formattedTodayTitle}]]` }
306
- }]
307
- });
308
- }
309
- catch (error) {
310
- console.error(`Failed to add Processed block: ${error instanceof Error ? error.message : String(error)}`);
311
- }
313
+ const today = new Date();
314
+ const day = today.getDate();
315
+ const month = today.toLocaleString('en-US', { month: 'long' });
316
+ const year = today.getFullYear();
317
+ const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
318
+ await executeBatchSafe(this.graph, [{
319
+ action: 'create-block',
320
+ location: { 'parent-uid': pageUid, order: 'last' },
321
+ block: { string: `Processed: [[${formattedTodayTitle}]]` }
322
+ }], 'add Processed block');
312
323
  return { success: true, uid: pageUid };
313
324
  }
314
- async fetchPageByTitle(title, format = 'raw') {
325
+ /**
326
+ * Get the UID for a page by its title.
327
+ * Tries different case variations (original, capitalized, lowercase).
328
+ * Returns null if not found.
329
+ */
330
+ async getPageUid(title) {
331
+ return getPageUidHelper(this.graph, title);
332
+ }
333
+ /**
334
+ * Fetch a page by its UID.
335
+ * Returns the page title and blocks, or null if not found.
336
+ */
337
+ async fetchPageByUid(uid) {
338
+ if (!uid) {
339
+ return null;
340
+ }
341
+ // First get the page title
342
+ const titleQuery = `[:find ?title . :where [?e :block/uid "${uid}"] [?e :node/title ?title]]`;
343
+ const title = await q(this.graph, titleQuery, []);
315
344
  if (!title) {
316
- throw new McpError(ErrorCode.InvalidRequest, 'title is required');
345
+ return null;
317
346
  }
318
- // Try different case variations
319
- const variations = [
320
- title, // Original
321
- capitalizeWords(title), // Each word capitalized
322
- title.toLowerCase() // All lowercase
323
- ];
324
- // Check cache first for any variation
325
- let uid = null;
326
- for (const variation of variations) {
327
- const cachedUid = pageUidCache.get(variation);
328
- if (cachedUid) {
329
- uid = cachedUid;
330
- break;
347
+ // Get all blocks under this page using ancestor rule
348
+ const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
349
+ :in $ % ?page-uid
350
+ :where [?page :block/uid ?page-uid]
351
+ [?block :block/string ?block-str]
352
+ [?block :block/uid ?block-uid]
353
+ [?block :block/order ?order]
354
+ (ancestor ?block ?page)
355
+ [?parent :block/children ?block]
356
+ [?parent :block/uid ?parent-uid]]`;
357
+ const blocks = await q(this.graph, blocksQuery, [ANCESTOR_RULE, uid]);
358
+ if (!blocks || blocks.length === 0) {
359
+ return { title, blocks: [] };
360
+ }
361
+ // Get heading information for blocks that have it
362
+ const headingsQuery = `[:find ?block-uid ?heading
363
+ :in $ % ?page-uid
364
+ :where [?page :block/uid ?page-uid]
365
+ [?block :block/uid ?block-uid]
366
+ [?block :block/heading ?heading]
367
+ (ancestor ?block ?page)]`;
368
+ const headings = await q(this.graph, headingsQuery, [ANCESTOR_RULE, uid]);
369
+ // Create a map of block UIDs to heading levels
370
+ const headingMap = new Map();
371
+ if (headings) {
372
+ for (const [blockUid, heading] of headings) {
373
+ headingMap.set(blockUid, heading);
331
374
  }
332
375
  }
333
- // If not cached, query the database
334
- if (!uid) {
335
- const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
336
- const searchQuery = `[:find ?uid .
337
- :where [?e :block/uid ?uid]
338
- (or ${orClause})]`;
339
- const result = await q(this.graph, searchQuery, []);
340
- uid = (result === null || result === undefined) ? null : String(result);
341
- // Cache the result for the original title
342
- if (uid) {
343
- pageUidCache.set(title, uid);
376
+ // Create a map of all blocks
377
+ const blockMap = new Map();
378
+ const rootBlocks = [];
379
+ // First pass: Create all block objects
380
+ for (const [blockUid, blockStr, order, parentUid] of blocks) {
381
+ const block = {
382
+ uid: blockUid,
383
+ string: blockStr,
384
+ order: order,
385
+ heading: headingMap.get(blockUid) || null,
386
+ children: []
387
+ };
388
+ blockMap.set(blockUid, block);
389
+ // If no parent or parent is the page itself, it's a root block
390
+ if (!parentUid || parentUid === uid) {
391
+ rootBlocks.push(block);
344
392
  }
345
393
  }
394
+ // Second pass: Build parent-child relationships
395
+ for (const [blockUid, _, __, parentUid] of blocks) {
396
+ if (parentUid && parentUid !== uid) {
397
+ const child = blockMap.get(blockUid);
398
+ const parent = blockMap.get(parentUid);
399
+ if (child && parent && !parent.children.includes(child)) {
400
+ parent.children.push(child);
401
+ }
402
+ }
403
+ }
404
+ // Sort blocks recursively
405
+ const sortBlocks = (blocks) => {
406
+ blocks.sort((a, b) => a.order - b.order);
407
+ blocks.forEach(block => {
408
+ if (block.children.length > 0) {
409
+ sortBlocks(block.children);
410
+ }
411
+ });
412
+ };
413
+ sortBlocks(rootBlocks);
414
+ return { title, blocks: rootBlocks };
415
+ }
416
+ async fetchPageByTitle(title, format = 'raw') {
417
+ if (!title) {
418
+ throw new McpError(ErrorCode.InvalidRequest, 'title is required');
419
+ }
420
+ // Use getPageUid which handles caching and case variations
421
+ const uid = await this.getPageUid(title);
346
422
  if (!uid) {
347
423
  throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
348
424
  }
349
- // Define ancestor rule for traversing block hierarchy
350
- const ancestorRule = `[
351
- [ (ancestor ?b ?a)
352
- [?a :block/children ?b] ]
353
- [ (ancestor ?b ?a)
354
- [?parent :block/children ?b]
355
- (ancestor ?parent ?a) ]
356
- ]`;
357
425
  // Get all blocks under this page using ancestor rule
426
+ // Use UID to avoid case-sensitivity issues (getPageUid handles case variations)
358
427
  const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
359
- :in $ % ?page-title
360
- :where [?page :node/title ?page-title]
428
+ :in $ % ?page-uid
429
+ :where [?page :block/uid ?page-uid]
361
430
  [?block :block/string ?block-str]
362
431
  [?block :block/uid ?block-uid]
363
432
  [?block :block/order ?order]
364
433
  (ancestor ?block ?page)
365
434
  [?parent :block/children ?block]
366
435
  [?parent :block/uid ?parent-uid]]`;
367
- const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]);
436
+ const blocks = await q(this.graph, blocksQuery, [ANCESTOR_RULE, uid]);
368
437
  if (!blocks || blocks.length === 0) {
369
438
  if (format === 'raw') {
370
- return [];
439
+ return '[]'; // Return JSON string, not array (MCP text field requires string)
371
440
  }
372
441
  return `${title} (no content found)`;
373
442
  }
374
443
  // Get heading information for blocks that have it
375
444
  const headingsQuery = `[:find ?block-uid ?heading
376
- :in $ % ?page-title
377
- :where [?page :node/title ?page-title]
445
+ :in $ % ?page-uid
446
+ :where [?page :block/uid ?page-uid]
378
447
  [?block :block/uid ?block-uid]
379
448
  [?block :block/heading ?heading]
380
449
  (ancestor ?block ?page)]`;
381
- const headings = await q(this.graph, headingsQuery, [ancestorRule, title]);
450
+ const headings = await q(this.graph, headingsQuery, [ANCESTOR_RULE, uid]);
382
451
  // Create a map of block UIDs to heading levels
383
452
  const headingMap = new Map();
384
453
  if (headings) {
@@ -389,17 +458,18 @@ export class PageOperations {
389
458
  // Create a map of all blocks
390
459
  const blockMap = new Map();
391
460
  const rootBlocks = [];
461
+ const allBlocks = [];
392
462
  // First pass: Create all block objects
393
463
  for (const [blockUid, blockStr, order, parentUid] of blocks) {
394
- const resolvedString = await resolveRefs(this.graph, blockStr);
395
464
  const block = {
396
465
  uid: blockUid,
397
- string: resolvedString,
466
+ string: blockStr,
398
467
  order: order,
399
468
  heading: headingMap.get(blockUid) || null,
400
469
  children: []
401
470
  };
402
471
  blockMap.set(blockUid, block);
472
+ allBlocks.push(block);
403
473
  // If no parent or parent is the page itself, it's a root block
404
474
  if (!parentUid || parentUid === uid) {
405
475
  rootBlocks.push(block);
@@ -426,8 +496,14 @@ export class PageOperations {
426
496
  };
427
497
  sortBlocks(rootBlocks);
428
498
  if (format === 'raw') {
499
+ // Resolve structured references for raw JSON output
500
+ await resolveBlockRefs(this.graph, allBlocks, 2);
429
501
  return JSON.stringify(rootBlocks);
430
502
  }
503
+ // For markdown, resolve references inline
504
+ await Promise.all(allBlocks.map(async (b) => {
505
+ b.string = await resolveRefs(this.graph, b.string);
506
+ }));
431
507
  // Convert to markdown with proper nesting
432
508
  const toMarkdown = (blocks, level = 0) => {
433
509
  return blocks
@@ -470,34 +546,7 @@ export class PageOperations {
470
546
  throw new McpError(ErrorCode.InvalidRequest, 'markdown is required');
471
547
  }
472
548
  // 1. Fetch existing page with raw block data
473
- const pageTitle = String(title).trim();
474
- // Try different case variations
475
- const variations = [
476
- pageTitle,
477
- capitalizeWords(pageTitle),
478
- pageTitle.toLowerCase()
479
- ];
480
- let pageUid = null;
481
- // Check cache first
482
- for (const variation of variations) {
483
- const cachedUid = pageUidCache.get(variation);
484
- if (cachedUid) {
485
- pageUid = cachedUid;
486
- break;
487
- }
488
- }
489
- // If not cached, query the database
490
- if (!pageUid) {
491
- const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
492
- const searchQuery = `[:find ?uid .
493
- :where [?e :block/uid ?uid]
494
- (or ${orClause})]`;
495
- const result = await q(this.graph, searchQuery, []);
496
- pageUid = (result === null || result === undefined) ? null : String(result);
497
- if (pageUid) {
498
- pageUidCache.set(pageTitle, pageUid);
499
- }
500
- }
549
+ const pageUid = await getPageUidHelper(this.graph, String(title).trim());
501
550
  if (!pageUid) {
502
551
  throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found`);
503
552
  }
@@ -527,13 +576,11 @@ export class PageOperations {
527
576
  // 7. Execute if not dry run and there are actions
528
577
  if (!dryRun && actions.length > 0) {
529
578
  try {
530
- const batchResult = await batchActions(this.graph, {
531
- action: 'batch-actions',
532
- actions: actions
579
+ // Use staged batch to ensure parent blocks exist before children
580
+ await executeStagedBatch(this.graph, actions, {
581
+ context: 'page update',
582
+ delayBetweenLevels: 100
533
583
  });
534
- if (!batchResult) {
535
- throw new Error('Batch actions returned no result');
536
- }
537
584
  }
538
585
  catch (error) {
539
586
  throw new McpError(ErrorCode.InternalError, `Failed to apply changes: ${error instanceof Error ? error.message : String(error)}`);
@@ -1,6 +1,6 @@
1
- import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
2
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
- import { formatRoamDate } from '../../utils/helpers.js';
2
+ import { getOrCreateTodayPage } from '../helpers/page-resolution.js';
3
+ import { executeBatch } from '../helpers/batch-utils.js';
4
4
  export class TodoOperations {
5
5
  constructor(graph) {
6
6
  this.graph = graph;
@@ -9,34 +9,8 @@ export class TodoOperations {
9
9
  if (!Array.isArray(todos) || todos.length === 0) {
10
10
  throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
11
11
  }
12
- // Get today's date
13
- const today = new Date();
14
- const dateStr = formatRoamDate(today);
15
- // Try to find today's page
16
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
17
- const findResults = await q(this.graph, findQuery, [dateStr]);
18
- let targetPageUid;
19
- if (findResults && findResults.length > 0) {
20
- targetPageUid = findResults[0][0];
21
- }
22
- else {
23
- // Create today's page if it doesn't exist
24
- try {
25
- await createPage(this.graph, {
26
- action: 'create-page',
27
- page: { title: dateStr }
28
- });
29
- // Get the new page's UID
30
- const results = await q(this.graph, findQuery, [dateStr]);
31
- if (!results || results.length === 0) {
32
- throw new Error('Could not find created today\'s page');
33
- }
34
- targetPageUid = results[0][0];
35
- }
36
- catch (error) {
37
- throw new Error('Failed to create today\'s page');
38
- }
39
- }
12
+ // Get or create today's daily page
13
+ const targetPageUid = await getOrCreateTodayPage(this.graph);
40
14
  const todo_tag = "{{[[TODO]]}}";
41
15
  const actions = todos.map((todo, index) => ({
42
16
  action: 'create-block',
@@ -48,13 +22,7 @@ export class TodoOperations {
48
22
  string: `${todo_tag} ${todo}`
49
23
  }
50
24
  }));
51
- const result = await batchActions(this.graph, {
52
- action: 'batch-actions',
53
- actions
54
- });
55
- if (!result) {
56
- throw new Error('Failed to create todo blocks');
57
- }
25
+ await executeBatch(this.graph, actions, 'create todo blocks');
58
26
  return { success: true };
59
27
  }
60
28
  }
@@ -218,7 +218,7 @@ export const toolSchemas = {
218
218
  },
219
219
  roam_search_for_tag: {
220
220
  name: 'roam_search_for_tag',
221
- description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag. This tool supports pagination via the `limit` and `offset` parameters. Use this tool to search for memories tagged with the MEMORIES_TAG.',
221
+ description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag. This tool supports pagination via the `limit` and `offset` parameters. Use this tool to search for memories tagged with the ROAM_MEMORIES_TAG.',
222
222
  inputSchema: {
223
223
  type: 'object',
224
224
  properties: withMultiGraphParams({
@@ -356,21 +356,27 @@ export const toolSchemas = {
356
356
  },
357
357
  roam_search_by_text: {
358
358
  name: 'roam_search_by_text',
359
- 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.',
359
+ 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.',
360
360
  inputSchema: {
361
361
  type: 'object',
362
362
  properties: withMultiGraphParams({
363
363
  text: {
364
364
  type: 'string',
365
- description: 'The text to search for'
365
+ description: 'The text to search for. When scope is "page_titles", this is the namespace prefix (trailing slash optional).'
366
+ },
367
+ scope: {
368
+ type: 'string',
369
+ enum: ['blocks', 'page_titles'],
370
+ default: 'blocks',
371
+ description: 'Search scope: "blocks" for block content (default), "page_titles" for page title namespace prefix matching.'
366
372
  },
367
373
  page_title_uid: {
368
374
  type: 'string',
369
- description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
375
+ 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".'
370
376
  },
371
377
  case_sensitive: {
372
378
  type: 'boolean',
373
- 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.',
379
+ 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".',
374
380
  default: false
375
381
  },
376
382
  limit: {
@@ -431,20 +437,20 @@ export const toolSchemas = {
431
437
  },
432
438
  roam_remember: {
433
439
  name: 'roam_remember',
434
- description: 'Add a memory or piece of information to remember, stored on the daily page with MEMORIES_TAG tag and optional categories. \nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
440
+ description: 'Add a memory or piece of information to remember, stored on the daily page with ROAM_MEMORIES_TAG tag and optional categories (unless include_memories_tag is false). \nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
435
441
  inputSchema: {
436
442
  type: 'object',
437
443
  properties: withMultiGraphParams({
438
444
  memory: {
439
445
  type: 'string',
440
- description: 'The memory detail or information to remember'
446
+ description: 'The memory detail or information to remember. Add tags in `categories`.'
441
447
  },
442
448
  categories: {
443
449
  type: 'array',
444
450
  items: {
445
451
  type: 'string'
446
452
  },
447
- description: 'Optional categories to tag the memory with (will be converted to Roam tags)'
453
+ description: 'Optional categories to tag the memory with (will be converted to Roam tags). Do not duplicate tags added in `memory` parameter.'
448
454
  },
449
455
  heading: {
450
456
  type: 'string',
@@ -453,6 +459,11 @@ export const toolSchemas = {
453
459
  parent_uid: {
454
460
  type: 'string',
455
461
  description: 'Optional UID of a specific block to nest the memory under. Takes precedence over heading parameter.'
462
+ },
463
+ include_memories_tag: {
464
+ type: 'boolean',
465
+ description: 'Whether to append the ROAM_MEMORIES_TAG tag to the memory block.',
466
+ default: true
456
467
  }
457
468
  }),
458
469
  required: ['memory']
@@ -460,7 +471,7 @@ export const toolSchemas = {
460
471
  },
461
472
  roam_recall: {
462
473
  name: 'roam_recall',
463
- description: 'Retrieve all stored memories on page titled MEMORIES_TAG, or tagged block content with the same name. Returns a combined, deduplicated list of memories. Optionally filter blcoks with a specific tag and sort by creation date.',
474
+ description: 'Retrieve all stored memories on page titled ROAM_MEMORIES_TAG, or tagged block content with the same name. Returns a combined, deduplicated list of memories. Optionally filter blcoks with a specific tag and sort by creation date.',
464
475
  inputSchema: {
465
476
  type: 'object',
466
477
  properties: withMultiGraphParams({
@@ -12,14 +12,14 @@ import { BatchOperations } from './operations/batch.js';
12
12
  import { TableOperations } from './operations/table.js';
13
13
  import { DatomicSearchHandlerImpl } from './operations/search/handlers.js';
14
14
  export class ToolHandlers {
15
- constructor(graph) {
15
+ constructor(graph, memoriesTag = 'Memories') {
16
16
  this.graph = graph;
17
17
  this.cachedCheatsheet = null;
18
18
  this.pageOps = new PageOperations(graph);
19
19
  this.blockOps = new BlockOperations(graph);
20
20
  this.blockRetrievalOps = new BlockRetrievalOperations(graph);
21
21
  this.searchOps = new SearchOperations(graph);
22
- this.memoryOps = new MemoryOperations(graph);
22
+ this.memoryOps = new MemoryOperations(graph, memoriesTag);
23
23
  this.todoOps = new TodoOperations(graph);
24
24
  this.outlineOps = new OutlineOperations(graph);
25
25
  this.batchOps = new BatchOperations(graph);
@@ -67,8 +67,8 @@ export class ToolHandlers {
67
67
  return handler.execute();
68
68
  }
69
69
  // Memory Operations
70
- async remember(memory, categories, heading, parent_uid) {
71
- return this.memoryOps.remember(memory, categories, heading, parent_uid);
70
+ async remember(memory, categories, heading, parent_uid, include_memories_tag) {
71
+ return this.memoryOps.remember(memory, categories, heading, parent_uid, include_memories_tag);
72
72
  }
73
73
  async recall(sort_by = 'newest', filter_tag) {
74
74
  return this.memoryOps.recall(sort_by, filter_tag);
@@ -17,6 +17,33 @@ export function formatRoamDate(date) {
17
17
  const year = date.getFullYear();
18
18
  return `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
19
19
  }
20
+ /**
21
+ * Parse a Roam Research URL and extract the page/block UID.
22
+ * Handles URLs like:
23
+ * - https://roamresearch.com/#/app/graph-name/page/page_uid
24
+ * - https://roamresearch.com/#/app/graph-name/page/page_uid?version=...
25
+ *
26
+ * Returns null if the URL doesn't match expected patterns.
27
+ */
28
+ export function parseRoamUrl(url) {
29
+ // Match Roam URL pattern: roamresearch.com/#/app/<graph>/page/<uid>
30
+ const pagePattern = /roamresearch\.com\/#\/app\/([^/]+)\/page\/([a-zA-Z0-9_-]{9})/;
31
+ const pageMatch = url.match(pagePattern);
32
+ if (pageMatch) {
33
+ return {
34
+ type: 'page',
35
+ uid: pageMatch[2],
36
+ graph: pageMatch[1]
37
+ };
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * Check if a string looks like a Roam UID (9 alphanumeric characters).
43
+ */
44
+ export function isRoamUid(str) {
45
+ return /^[a-zA-Z0-9_-]{9}$/.test(str);
46
+ }
20
47
  /**
21
48
  * Resolve relative date keywords to Roam date format.
22
49
  * Returns the original string if not a recognized keyword.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "2.4.0",
3
+ "version": "2.13.0",
4
4
  "description": "MCP server and CLI for Roam Research",
5
5
  "private": false,
6
6
  "repository": {