roam-research-mcp 0.18.0 → 0.20.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ npm run build
28
28
 
29
29
  ## Features
30
30
 
31
- The server provides twelve powerful tools for interacting with Roam Research:
31
+ The server provides fourteen powerful tools for interacting with Roam Research:
32
32
 
33
33
  1. `roam_fetch_page_by_title`: Fetch and read a page's content by title, recursively resolving block references up to 4 levels deep
34
34
  2. `roam_create_page`: Create new pages with optional content
@@ -42,6 +42,8 @@ The server provides twelve powerful tools for interacting with Roam Research:
42
42
  10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
43
43
  11. `roam_update_block`: Update block content with direct text or pattern-based transformations
44
44
  12. `roam_search_by_date`: Search for blocks and pages based on creation or modification dates
45
+ 13. `roam_search_for_tag`: Search for blocks containing specific tags with optional filtering by nearby tags
46
+ 14. `roam_remember`: Store and categorize memories or information with automatic tagging
45
47
 
46
48
  ## Setup
47
49
 
@@ -60,28 +62,14 @@ The server provides twelve powerful tools for interacting with Roam Research:
60
62
  ```
61
63
  ROAM_API_TOKEN=your-api-token
62
64
  ROAM_GRAPH_NAME=your-graph-name
65
+ MEMORIES_TAG='#[[LLM/Memories]]'
66
+ PROFILE_PAGE='LLM/Profile' (not yet implemented)
63
67
  ```
64
68
 
65
69
  Option 2: Using MCP settings (Alternative method)
66
70
  Add the configuration to your MCP settings file:
67
71
 
68
72
  - For Cline (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`):
69
-
70
- ```json
71
- {
72
- "mcpServers": {
73
- "roam-research": {
74
- "command": "node",
75
- "args": ["/path/to/roam-research/build/index.js"],
76
- "env": {
77
- "ROAM_API_TOKEN": "your-api-token",
78
- "ROAM_GRAPH_NAME": "your-graph-name"
79
- }
80
- }
81
- }
82
- }
83
- ```
84
-
85
73
  - For Claude desktop app (`~/Library/Application Support/Claude/claude_desktop_config.json`):
86
74
 
87
75
  ```json
@@ -92,7 +80,9 @@ The server provides twelve powerful tools for interacting with Roam Research:
92
80
  "args": ["/path/to/roam-research/build/index.js"],
93
81
  "env": {
94
82
  "ROAM_API_TOKEN": "your-api-token",
95
- "ROAM_GRAPH_NAME": "your-graph-name"
83
+ "ROAM_GRAPH_NAME": "your-graph-name",
84
+ "MEMORIES_TAG": "#[[LLM/Memories]]",
85
+ "PROFILE_PAGE": "LLM/Profile"
96
86
  }
97
87
  }
98
88
  }
@@ -335,7 +325,7 @@ Search for blocks containing specific text across all pages or within a specific
335
325
  use_mcp_tool roam-research roam_search_by_text {
336
326
  "text": "search text",
337
327
  "page_title_uid": "optional-page-title-or-uid",
338
- "case_sensitive": false
328
+ "case_sensitive": true
339
329
  }
340
330
  ```
341
331
 
@@ -351,7 +341,7 @@ Parameters:
351
341
 
352
342
  - `text`: The text to search for (required)
353
343
  - `page_title_uid`: Title or UID of the page to search in (optional)
354
- - `case_sensitive`: Whether to perform a case-sensitive search (optional, default: false)
344
+ - `case_sensitive`: Whether to perform a case-sensitive search (optional, default: true to match Roam's native behavior)
355
345
 
356
346
  Returns:
357
347
 
@@ -421,6 +411,86 @@ Returns:
421
411
  }
422
412
  ```
423
413
 
414
+ ### Search For Tags
415
+
416
+ Search for blocks containing specific tags with optional filtering by nearby tags:
417
+
418
+ ```typescript
419
+ use_mcp_tool roam-research roam_search_for_tag {
420
+ "primary_tag": "Project/Tasks",
421
+ "page_title_uid": "optional-page-title-or-uid",
422
+ "near_tag": "optional-secondary-tag",
423
+ "case_sensitive": true
424
+ }
425
+ ```
426
+
427
+ Features:
428
+
429
+ - Search for blocks containing specific tags
430
+ - Optional filtering by presence of another tag
431
+ - Page-scoped or graph-wide search
432
+ - Case-sensitive or case-insensitive search
433
+ - Returns block content with page context
434
+ - Efficient tag matching using Datalog queries
435
+
436
+ Parameters:
437
+
438
+ - `primary_tag`: The main tag to search for (required)
439
+ - `page_title_uid`: Title or UID of the page to search in (optional)
440
+ - `near_tag`: Another tag to filter results by (optional)
441
+ - `case_sensitive`: Whether to perform case-sensitive search (optional, default: true to match Roam's native behavior)
442
+
443
+ Returns:
444
+
445
+ ```json
446
+ {
447
+ "success": true,
448
+ "matches": [
449
+ {
450
+ "block_uid": "matching-block-uid",
451
+ "content": "Block content containing #[[primary_tag]]",
452
+ "page_title": "Page containing block"
453
+ }
454
+ ],
455
+ "message": "Found N block(s) referencing \"primary_tag\""
456
+ }
457
+ ```
458
+
459
+ ### Remember Information
460
+
461
+ Store memories or important information with automatic tagging and categorization:
462
+
463
+ ```typescript
464
+ use_mcp_tool roam-research roam_remember {
465
+ "memory": "Important information to remember",
466
+ "categories": ["Work", "Project/Alpha"]
467
+ }
468
+ ```
469
+
470
+ Features:
471
+
472
+ - Store information with #[[LLM/Memories]] tag
473
+ - Add optional category tags for organization
474
+ - Automatically adds to today's daily page
475
+ - Supports multiple categories per memory
476
+ - Easy retrieval using roam_search_for_tag
477
+ - Maintains chronological order of memories
478
+
479
+ Parameters:
480
+
481
+ - `memory`: The information to remember (required)
482
+ - `categories`: Optional array of categories to tag the memory with
483
+
484
+ Returns:
485
+
486
+ ```json
487
+ {
488
+ "success": true,
489
+ "block_uid": "created-block-uid",
490
+ "content": "Memory content with tags"
491
+ }
492
+ ```
493
+
424
494
  ### Search By Date
425
495
 
426
496
  Search for blocks and pages based on creation or modification dates:
@@ -22,6 +22,7 @@ export class RoamServer {
22
22
  }, {
23
23
  capabilities: {
24
24
  tools: {
25
+ roam_remember: {},
25
26
  roam_add_todo: {},
26
27
  roam_fetch_page_by_title: {},
27
28
  roam_create_page: {},
@@ -35,6 +36,7 @@ export class RoamServer {
35
36
  find_pages_modified_today: {},
36
37
  roam_search_by_text: {},
37
38
  roam_update_block: {},
39
+ roam_update_blocks: {},
38
40
  roam_search_by_date: {}
39
41
  },
40
42
  },
@@ -56,6 +58,13 @@ export class RoamServer {
56
58
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
57
59
  try {
58
60
  switch (request.params.name) {
61
+ case 'roam_remember': {
62
+ const { memory, categories } = request.params.arguments;
63
+ const result = await this.toolHandlers.remember(memory, categories);
64
+ return {
65
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
66
+ };
67
+ }
59
68
  case 'roam_fetch_page_by_title': {
60
69
  const { title } = request.params.arguments;
61
70
  const content = await this.toolHandlers.fetchPageByTitle(title);
@@ -166,6 +175,13 @@ export class RoamServer {
166
175
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
167
176
  };
168
177
  }
178
+ case 'roam_update_blocks': {
179
+ const { updates } = request.params.arguments;
180
+ const result = await this.toolHandlers.updateBlocks(updates);
181
+ return {
182
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
183
+ };
184
+ }
169
185
  default:
170
186
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
171
187
  }
@@ -2,7 +2,7 @@
2
2
  export const toolSchemas = {
3
3
  roam_add_todo: {
4
4
  name: 'roam_add_todo',
5
- description: 'Add a list of todo items as individual blocks to today\'s daily page in Roam. Each item becomes its own actionable block with todo status.',
5
+ description: 'Add a list of todo items as individual blocks to today\'s daily page in Roam. Each item becomes its own actionable block with todo status.\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).',
6
6
  inputSchema: {
7
7
  type: 'object',
8
8
  properties: {
@@ -52,7 +52,7 @@ export const toolSchemas = {
52
52
  },
53
53
  roam_create_block: {
54
54
  name: 'roam_create_block',
55
- description: 'Add a new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.',
55
+ description: 'Add a new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\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).',
56
56
  inputSchema: {
57
57
  type: 'object',
58
58
  properties: {
@@ -74,7 +74,7 @@ export const toolSchemas = {
74
74
  },
75
75
  roam_create_outline: {
76
76
  name: 'roam_create_output_with_nested_structure',
77
- description: 'Create a structured outline or output with nested structure in Roam from an array of items with explicit levels. Can be added on a specific page or under a specific block.',
77
+ description: 'Create a structured outline or output with nested structure in Roam from an array of items with explicit levels. Can be added on a specific page or under a specific block. Ideal for saving a conversation with an LLM response, research, or organizing thoughts.',
78
78
  inputSchema: {
79
79
  type: 'object',
80
80
  properties: {
@@ -146,7 +146,7 @@ export const toolSchemas = {
146
146
  },
147
147
  roam_search_for_tag: {
148
148
  name: 'roam_search_for_tag',
149
- description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.',
149
+ description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby. Example: Use this to search for memories that are tagged with the MEMORIES_TAG.',
150
150
  inputSchema: {
151
151
  type: 'object',
152
152
  properties: {
@@ -161,6 +161,11 @@ export const toolSchemas = {
161
161
  near_tag: {
162
162
  type: 'string',
163
163
  description: 'Optional: Another tag to filter results by - will only return blocks where both tags appear',
164
+ },
165
+ case_sensitive: {
166
+ type: 'boolean',
167
+ description: 'Optional: Whether to perform case-sensitive matching (default: true, matching Roam\'s native behavior)',
168
+ default: true
164
169
  }
165
170
  },
166
171
  required: ['primary_tag']
@@ -188,6 +193,11 @@ export const toolSchemas = {
188
193
  exclude: {
189
194
  type: 'string',
190
195
  description: 'Optional: Comma-separated list of terms to filter results by exclusion (matches content or page title)'
196
+ },
197
+ case_sensitive: {
198
+ type: 'boolean',
199
+ description: 'Optional: Whether to perform case-sensitive matching (default: true, matching Roam\'s native behavior)',
200
+ default: true
191
201
  }
192
202
  },
193
203
  required: ['status']
@@ -266,7 +276,8 @@ export const toolSchemas = {
266
276
  },
267
277
  case_sensitive: {
268
278
  type: 'boolean',
269
- description: 'Optional: Whether to perform a case-sensitive search (default: false)'
279
+ description: 'Optional: Whether to perform case-sensitive search (default: true, matching Roam\'s native behavior)',
280
+ default: true
270
281
  }
271
282
  },
272
283
  required: ['text']
@@ -274,7 +285,7 @@ export const toolSchemas = {
274
285
  },
275
286
  roam_update_block: {
276
287
  name: 'roam_update_block',
277
- description: 'Update the content of an existing block identified by its UID. Can either provide new content directly or use a transform pattern to modify existing content.',
288
+ description: 'Update the content of an existing block identified by its UID. Can either provide new content directly or use a transform pattern to modify existing content.\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).',
278
289
  inputSchema: {
279
290
  type: 'object',
280
291
  properties: {
@@ -314,6 +325,58 @@ export const toolSchemas = {
314
325
  ]
315
326
  }
316
327
  },
328
+ roam_update_blocks: {
329
+ name: 'roam_update_blocks',
330
+ description: 'Update multiple blocks in a single batch operation. Each update can provide either new content directly or a transform pattern.\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).',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ updates: {
335
+ type: 'array',
336
+ description: 'Array of block updates to perform',
337
+ items: {
338
+ type: 'object',
339
+ properties: {
340
+ block_uid: {
341
+ type: 'string',
342
+ description: 'UID of the block to update'
343
+ },
344
+ content: {
345
+ type: 'string',
346
+ description: 'New content for the block. If not provided, transform will be used.'
347
+ },
348
+ transform: {
349
+ type: 'object',
350
+ description: 'Pattern to transform the current content. Used if content is not provided.',
351
+ properties: {
352
+ find: {
353
+ type: 'string',
354
+ description: 'Text or regex pattern to find'
355
+ },
356
+ replace: {
357
+ type: 'string',
358
+ description: 'Text to replace with'
359
+ },
360
+ global: {
361
+ type: 'boolean',
362
+ description: 'Whether to replace all occurrences',
363
+ default: true
364
+ }
365
+ },
366
+ required: ['find', 'replace']
367
+ }
368
+ },
369
+ required: ['block_uid'],
370
+ oneOf: [
371
+ { required: ['content'] },
372
+ { required: ['transform'] }
373
+ ]
374
+ }
375
+ }
376
+ },
377
+ required: ['updates']
378
+ }
379
+ },
317
380
  roam_search_by_date: {
318
381
  name: 'roam_search_by_date',
319
382
  description: 'Search for blocks or pages based on creation or modification dates',
@@ -342,9 +405,35 @@ export const toolSchemas = {
342
405
  type: 'boolean',
343
406
  description: 'Whether to include the content of matching blocks/pages',
344
407
  default: true,
408
+ },
409
+ case_sensitive: {
410
+ type: 'boolean',
411
+ description: 'Optional: Whether to perform case-sensitive matching (default: true, matching Roam\'s native behavior)',
412
+ default: true
345
413
  }
346
414
  },
347
415
  required: ['start_date', 'type', 'scope']
348
416
  }
417
+ },
418
+ roam_remember: {
419
+ name: 'roam_remember',
420
+ description: 'Add a memory or piece of information to remember, stored on the daily page with #[[LLM/Memories]] tag and optional categories. Use roam_search_for_tag with "LLM/Memories" to find stored memories.\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).',
421
+ inputSchema: {
422
+ type: 'object',
423
+ properties: {
424
+ memory: {
425
+ type: 'string',
426
+ description: 'The memory or information to remember'
427
+ },
428
+ categories: {
429
+ type: 'array',
430
+ items: {
431
+ type: 'string'
432
+ },
433
+ description: 'Optional categories to tag the memory with (will be converted to Roam tags)'
434
+ }
435
+ },
436
+ required: ['memory']
437
+ }
349
438
  }
350
439
  };
@@ -157,21 +157,23 @@ export class ToolHandlers {
157
157
  // Get or create the target page
158
158
  const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
159
159
  // Helper function to find block with improved relationship checks
160
- const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
160
+ const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000, case_sensitive = false) => {
161
161
  // Try multiple query strategies
162
162
  const queries = [
163
163
  // Strategy 1: Direct page and string match
164
164
  `[:find ?b-uid ?order
165
165
  :where [?p :block/uid "${pageUid}"]
166
166
  [?b :block/page ?p]
167
- [?b :block/string "${blockString}"]
167
+ [?b :block/string ?block-str]
168
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
168
169
  [?b :block/order ?order]
169
170
  [?b :block/uid ?b-uid]]`,
170
171
  // Strategy 2: Parent-child relationship
171
172
  `[:find ?b-uid ?order
172
173
  :where [?p :block/uid "${pageUid}"]
173
174
  [?b :block/parents ?p]
174
- [?b :block/string "${blockString}"]
175
+ [?b :block/string ?block-str]
176
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
175
177
  [?b :block/order ?order]
176
178
  [?b :block/uid ?b-uid]]`,
177
179
  // Strategy 3: Broader page relationship
@@ -179,7 +181,8 @@ export class ToolHandlers {
179
181
  :where [?p :block/uid "${pageUid}"]
180
182
  [?b :block/page ?page]
181
183
  [?p :block/page ?page]
182
- [?b :block/string "${blockString}"]
184
+ [?b :block/string ?block-str]
185
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
183
186
  [?b :block/order ?order]
184
187
  [?b :block/uid ?b-uid]]`
185
188
  ];
@@ -201,7 +204,7 @@ export class ToolHandlers {
201
204
  throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
202
205
  };
203
206
  // Helper function to create and verify block with improved error handling
204
- const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
207
+ const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false, case_sensitive = false) => {
205
208
  try {
206
209
  // Initial delay before any operations
207
210
  if (!isRetry) {
@@ -223,7 +226,7 @@ export class ToolHandlers {
223
226
  await new Promise(resolve => setTimeout(resolve, delay));
224
227
  try {
225
228
  // Try to find the block using our improved findBlockWithRetry
226
- return await findBlockWithRetry(parentUid, content);
229
+ return await findBlockWithRetry(parentUid, content, maxRetries, initialDelay, case_sensitive);
227
230
  }
228
231
  catch (error) {
229
232
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -241,7 +244,7 @@ export class ToolHandlers {
241
244
  // Otherwise, try one more time with a clean slate
242
245
  console.log(`Retrying block creation for "${content}" with fresh attempt`);
243
246
  await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
244
- return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
247
+ return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true, case_sensitive);
245
248
  }
246
249
  };
247
250
  // Get or create the parent block
@@ -728,7 +731,110 @@ export class ToolHandlers {
728
731
  throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
729
732
  }
730
733
  }
731
- async searchByStatus(status, page_title_uid, include, exclude) {
734
+ async updateBlocks(updates) {
735
+ if (!Array.isArray(updates) || updates.length === 0) {
736
+ throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
737
+ }
738
+ // Validate each update has required fields
739
+ updates.forEach((update, index) => {
740
+ if (!update.block_uid) {
741
+ throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
742
+ }
743
+ if (!update.content && !update.transform) {
744
+ throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
745
+ }
746
+ });
747
+ // Get current content for all blocks
748
+ const blockUids = updates.map(u => u.block_uid);
749
+ const blockQuery = `[:find ?uid ?string
750
+ :in $ [?uid ...]
751
+ :where [?b :block/uid ?uid]
752
+ [?b :block/string ?string]]`;
753
+ const blockResults = await q(this.graph, blockQuery, [blockUids]);
754
+ // Create map of uid -> current content
755
+ const contentMap = new Map();
756
+ blockResults.forEach(([uid, string]) => {
757
+ contentMap.set(uid, string);
758
+ });
759
+ // Prepare batch actions
760
+ const actions = [];
761
+ const results = [];
762
+ for (const update of updates) {
763
+ try {
764
+ const currentContent = contentMap.get(update.block_uid);
765
+ if (!currentContent) {
766
+ results.push({
767
+ block_uid: update.block_uid,
768
+ content: '',
769
+ success: false,
770
+ error: `Block with UID "${update.block_uid}" not found`
771
+ });
772
+ continue;
773
+ }
774
+ // Determine new content
775
+ let newContent;
776
+ if (update.content) {
777
+ newContent = update.content;
778
+ }
779
+ else if (update.transform) {
780
+ const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
781
+ newContent = currentContent.replace(regex, update.transform.replace);
782
+ }
783
+ else {
784
+ // This shouldn't happen due to earlier validation
785
+ throw new Error('Invalid update configuration');
786
+ }
787
+ // Add to batch actions
788
+ actions.push({
789
+ action: 'update-block',
790
+ block: {
791
+ uid: update.block_uid,
792
+ string: newContent
793
+ }
794
+ });
795
+ results.push({
796
+ block_uid: update.block_uid,
797
+ content: newContent,
798
+ success: true
799
+ });
800
+ }
801
+ catch (error) {
802
+ results.push({
803
+ block_uid: update.block_uid,
804
+ content: contentMap.get(update.block_uid) || '',
805
+ success: false,
806
+ error: error.message
807
+ });
808
+ }
809
+ }
810
+ // Execute batch update if we have any valid actions
811
+ if (actions.length > 0) {
812
+ try {
813
+ const batchResult = await batchActions(this.graph, {
814
+ action: 'batch-actions',
815
+ actions
816
+ });
817
+ if (!batchResult) {
818
+ throw new Error('Batch update failed');
819
+ }
820
+ }
821
+ catch (error) {
822
+ // Mark all previously successful results as failed
823
+ results.forEach(result => {
824
+ if (result.success) {
825
+ result.success = false;
826
+ result.error = `Batch update failed: ${error.message}`;
827
+ }
828
+ });
829
+ }
830
+ }
831
+ return {
832
+ success: results.every(r => r.success),
833
+ results
834
+ };
835
+ }
836
+ async searchByStatus(status, page_title_uid, include, exclude, case_sensitive = true // Changed to true to match Roam's behavior
837
+ ) {
732
838
  // Get target page UID if provided
733
839
  let targetPageUid;
734
840
  if (page_title_uid) {
@@ -789,16 +895,26 @@ export class ToolHandlers {
789
895
  ...(pageTitle && { page_title: pageTitle })
790
896
  };
791
897
  });
792
- // Post-query filtering
898
+ // Post-query filtering with case sensitivity option
793
899
  if (include) {
794
- const includeTerms = include.toLowerCase().split(',').map(term => term.trim());
795
- matches = matches.filter(match => includeTerms.some(term => match.content.toLowerCase().includes(term) ||
796
- (match.page_title && match.page_title.toLowerCase().includes(term))));
900
+ const includeTerms = include.split(',').map(term => term.trim());
901
+ matches = matches.filter(match => {
902
+ const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
903
+ const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
904
+ const terms = case_sensitive ? includeTerms : includeTerms.map(t => t.toLowerCase());
905
+ return terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
906
+ (matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
907
+ });
797
908
  }
798
909
  if (exclude) {
799
- const excludeTerms = exclude.toLowerCase().split(',').map(term => term.trim());
800
- matches = matches.filter(match => !excludeTerms.some(term => match.content.toLowerCase().includes(term) ||
801
- (match.page_title && match.page_title.toLowerCase().includes(term))));
910
+ const excludeTerms = exclude.split(',').map(term => term.trim());
911
+ matches = matches.filter(match => {
912
+ const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
913
+ const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
914
+ const terms = case_sensitive ? excludeTerms : excludeTerms.map(t => t.toLowerCase());
915
+ return !terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
916
+ (matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
917
+ });
802
918
  }
803
919
  return {
804
920
  success: true,
@@ -806,7 +922,8 @@ export class ToolHandlers {
806
922
  message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
807
923
  };
808
924
  }
809
- async searchForTag(primary_tag, page_title_uid, near_tag) {
925
+ async searchForTag(primary_tag, page_title_uid, near_tag, case_sensitive = true // Changed to true to match Roam's behavior
926
+ ) {
810
927
  // Ensure tags are properly formatted with #
811
928
  const formatTag = (tag) => tag.startsWith('#') ? tag : `#${tag}`;
812
929
  const primaryTagFormatted = formatTag(primary_tag);
@@ -841,8 +958,12 @@ export class ToolHandlers {
841
958
  [?b :block/page ?p]
842
959
  [?b :block/string ?block-str]
843
960
  [?b :block/uid ?block-uid]
844
- [(clojure.string/includes? ?block-str ?primary-tag)]
845
- [(clojure.string/includes? ?block-str ?near-tag)]]`;
961
+ [(clojure.string/includes?
962
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
963
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
964
+ [(clojure.string/includes?
965
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
966
+ ${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
846
967
  queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
847
968
  }
848
969
  else {
@@ -852,7 +973,9 @@ export class ToolHandlers {
852
973
  [?b :block/page ?p]
853
974
  [?b :block/string ?block-str]
854
975
  [?b :block/uid ?block-uid]
855
- [(clojure.string/includes? ?block-str ?primary-tag)]]`;
976
+ [(clojure.string/includes?
977
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
978
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
856
979
  queryParams = [primaryTagFormatted, targetPageUid];
857
980
  }
858
981
  }
@@ -865,8 +988,12 @@ export class ToolHandlers {
865
988
  [?b :block/uid ?block-uid]
866
989
  [?b :block/page ?p]
867
990
  [?p :node/title ?page-title]
868
- [(clojure.string/includes? ?block-str ?primary-tag)]
869
- [(clojure.string/includes? ?block-str ?near-tag)]]`;
991
+ [(clojure.string/includes?
992
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
993
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
994
+ [(clojure.string/includes?
995
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
996
+ ${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
870
997
  queryParams = [primaryTagFormatted, nearTagFormatted];
871
998
  }
872
999
  else {
@@ -876,7 +1003,9 @@ export class ToolHandlers {
876
1003
  [?b :block/uid ?block-uid]
877
1004
  [?b :block/page ?p]
878
1005
  [?p :node/title ?page-title]
879
- [(clojure.string/includes? ?block-str ?primary-tag)]]`;
1006
+ [(clojure.string/includes?
1007
+ ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
1008
+ ${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
880
1009
  queryParams = [primaryTagFormatted];
881
1010
  }
882
1011
  }
@@ -945,6 +1074,20 @@ export class ToolHandlers {
945
1074
  ...(params.include_content && { content }),
946
1075
  page_title: pageTitle
947
1076
  }));
1077
+ // Apply case sensitivity if content is included
1078
+ if (params.include_content) {
1079
+ const case_sensitive = params.case_sensitive ?? true; // Default to true to match Roam's behavior
1080
+ if (!case_sensitive) {
1081
+ matches.forEach(match => {
1082
+ if (match.content) {
1083
+ match.content = match.content.toLowerCase();
1084
+ }
1085
+ if (match.page_title) {
1086
+ match.page_title = match.page_title.toLowerCase();
1087
+ }
1088
+ });
1089
+ }
1090
+ }
948
1091
  // Sort by time
949
1092
  const sortedMatches = matches.sort((a, b) => b.time - a.time);
950
1093
  return {
@@ -953,6 +1096,58 @@ export class ToolHandlers {
953
1096
  message: `Found ${sortedMatches.length} matches for the given date range and criteria`
954
1097
  };
955
1098
  }
1099
+ async remember(memory, categories) {
1100
+ // Get today's date
1101
+ const today = new Date();
1102
+ const dateStr = formatRoamDate(today);
1103
+ // Try to find today's page
1104
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
1105
+ const findResults = await q(this.graph, findQuery, [dateStr]);
1106
+ let pageUid;
1107
+ if (findResults && findResults.length > 0) {
1108
+ pageUid = findResults[0][0];
1109
+ }
1110
+ else {
1111
+ // Create today's page if it doesn't exist
1112
+ const success = await createPage(this.graph, {
1113
+ action: 'create-page',
1114
+ page: { title: dateStr }
1115
+ });
1116
+ if (!success) {
1117
+ throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
1118
+ }
1119
+ // Get the new page's UID
1120
+ const results = await q(this.graph, findQuery, [dateStr]);
1121
+ if (!results || results.length === 0) {
1122
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
1123
+ }
1124
+ pageUid = results[0][0];
1125
+ }
1126
+ // Get memories tag from environment
1127
+ const memoriesTag = process.env.MEMORIES_TAG;
1128
+ if (!memoriesTag) {
1129
+ throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
1130
+ }
1131
+ // Format categories as Roam tags if provided
1132
+ const categoryTags = categories?.map(cat => {
1133
+ // Handle multi-word categories
1134
+ return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
1135
+ }).join(' ') || '';
1136
+ // Create block with memory, memories tag, and optional categories
1137
+ const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
1138
+ const success = await createBlock(this.graph, {
1139
+ action: 'create-block',
1140
+ location: {
1141
+ "parent-uid": pageUid,
1142
+ "order": "last"
1143
+ },
1144
+ block: { string: blockContent }
1145
+ });
1146
+ if (!success) {
1147
+ throw new McpError(ErrorCode.InternalError, 'Failed to create memory block');
1148
+ }
1149
+ return { success: true };
1150
+ }
956
1151
  async addTodos(todos) {
957
1152
  if (!Array.isArray(todos) || todos.length === 0) {
958
1153
  throw new McpError(ErrorCode.InvalidRequest, 'todos must be a non-empty array');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "A Model Context Protocol (MCP) server for Roam Research API integration",
5
5
  "private": false,
6
6
  "repository": {