roam-research-mcp 2.4.3 → 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 (51) hide show
  1. package/README.md +175 -669
  2. package/build/Roam_Markdown_Cheatsheet.md +24 -4
  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 +2 -0
  6. package/build/cli/commands/get.js +401 -14
  7. package/build/cli/commands/refs.js +2 -0
  8. package/build/cli/commands/save.js +56 -1
  9. package/build/cli/commands/search.js +45 -0
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/utils/graph.js +6 -2
  12. package/build/cli/utils/output.js +28 -5
  13. package/build/cli/utils/sort-group.js +110 -0
  14. package/build/config/graph-registry.js +31 -13
  15. package/build/config/graph-registry.test.js +42 -5
  16. package/build/markdown-utils.js +114 -4
  17. package/build/markdown-utils.test.js +125 -0
  18. package/build/query/generator.js +330 -0
  19. package/build/query/index.js +149 -0
  20. package/build/query/parser.js +319 -0
  21. package/build/query/parser.test.js +389 -0
  22. package/build/query/types.js +4 -0
  23. package/build/search/ancestor-rule.js +14 -0
  24. package/build/search/block-ref-search.js +1 -5
  25. package/build/search/hierarchy-search.js +5 -12
  26. package/build/search/index.js +1 -0
  27. package/build/search/status-search.js +10 -9
  28. package/build/search/tag-search.js +8 -24
  29. package/build/search/text-search.js +70 -27
  30. package/build/search/types.js +13 -0
  31. package/build/search/utils.js +71 -2
  32. package/build/server/roam-server.js +2 -1
  33. package/build/shared/index.js +2 -0
  34. package/build/shared/page-validator.js +233 -0
  35. package/build/shared/page-validator.test.js +128 -0
  36. package/build/shared/staged-batch.js +144 -0
  37. package/build/tools/helpers/batch-utils.js +57 -0
  38. package/build/tools/helpers/page-resolution.js +136 -0
  39. package/build/tools/helpers/refs.js +68 -0
  40. package/build/tools/operations/batch.js +75 -3
  41. package/build/tools/operations/block-retrieval.js +15 -4
  42. package/build/tools/operations/block-retrieval.test.js +87 -0
  43. package/build/tools/operations/blocks.js +1 -288
  44. package/build/tools/operations/memory.js +29 -91
  45. package/build/tools/operations/outline.js +38 -156
  46. package/build/tools/operations/pages.js +169 -122
  47. package/build/tools/operations/todos.js +5 -37
  48. package/build/tools/schemas.js +14 -8
  49. package/build/tools/tool-handlers.js +2 -2
  50. package/build/utils/helpers.js +27 -0
  51. package/package.json +1 -1
@@ -1,194 +1,9 @@
1
- import { q, updateBlock as updateRoamBlock, moveBlock as moveRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
1
+ import { q, moveBlock as moveRoamBlock } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
- import { formatRoamDate } from '../../utils/helpers.js';
4
- import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
5
3
  export class BlockOperations {
6
4
  constructor(graph) {
7
5
  this.graph = graph;
8
6
  }
9
- async createBlock(content, page_uid, title, heading) {
10
- // If page_uid provided, use it directly
11
- let targetPageUid = page_uid;
12
- // If no page_uid but title provided, search for page by title
13
- if (!targetPageUid && title) {
14
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
15
- const findResults = await q(this.graph, findQuery, [title]);
16
- if (findResults && findResults.length > 0) {
17
- targetPageUid = findResults[0][0];
18
- }
19
- else {
20
- // Create page with provided title if it doesn't exist
21
- try {
22
- await createPage(this.graph, {
23
- action: 'create-page',
24
- page: { title }
25
- });
26
- // Get the new page's UID
27
- const results = await q(this.graph, findQuery, [title]);
28
- if (!results || results.length === 0) {
29
- throw new Error('Could not find created page');
30
- }
31
- targetPageUid = results[0][0];
32
- }
33
- catch (error) {
34
- throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
35
- }
36
- }
37
- }
38
- // If neither page_uid nor title provided, use today's date page
39
- if (!targetPageUid) {
40
- const today = new Date();
41
- const dateStr = formatRoamDate(today);
42
- // Try to find today's page
43
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
44
- const findResults = await q(this.graph, findQuery, [dateStr]);
45
- if (findResults && findResults.length > 0) {
46
- targetPageUid = findResults[0][0];
47
- }
48
- else {
49
- // Create today's page if it doesn't exist
50
- try {
51
- await createPage(this.graph, {
52
- action: 'create-page',
53
- page: { title: dateStr }
54
- });
55
- // Get the new page's UID
56
- const results = await q(this.graph, findQuery, [dateStr]);
57
- if (!results || results.length === 0) {
58
- throw new Error('Could not find created today\'s page');
59
- }
60
- targetPageUid = results[0][0];
61
- }
62
- catch (error) {
63
- throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
64
- }
65
- }
66
- }
67
- try {
68
- // If the content has multiple lines or is a table, use nested import
69
- if (content.includes('\n')) {
70
- let nodes;
71
- // If heading parameter is provided, manually construct nodes to preserve heading
72
- if (heading) {
73
- const lines = content.split('\n');
74
- const firstLine = lines[0].trim();
75
- const remainingLines = lines.slice(1);
76
- // Create the first node with heading formatting
77
- const firstNode = {
78
- content: firstLine,
79
- level: 0,
80
- heading_level: heading,
81
- children: []
82
- };
83
- // If there are remaining lines, parse them as children or siblings
84
- if (remainingLines.length > 0 && remainingLines.some(line => line.trim())) {
85
- const remainingContent = remainingLines.join('\n');
86
- const convertedRemainingContent = convertToRoamMarkdown(remainingContent);
87
- const remainingNodes = parseMarkdown(convertedRemainingContent);
88
- // Add remaining nodes as siblings to the first node
89
- nodes = [firstNode, ...remainingNodes];
90
- }
91
- else {
92
- nodes = [firstNode];
93
- }
94
- }
95
- else {
96
- // No heading parameter, use original parsing logic
97
- const convertedContent = convertToRoamMarkdown(content);
98
- nodes = parseMarkdown(convertedContent);
99
- }
100
- const actions = convertToRoamActions(nodes, targetPageUid, 'last');
101
- // Execute batch actions to create the nested structure
102
- const result = await batchActions(this.graph, {
103
- action: 'batch-actions',
104
- actions
105
- });
106
- if (!result) {
107
- throw new Error('Failed to create nested blocks');
108
- }
109
- const blockUid = result.created_uids?.[0];
110
- return {
111
- success: true,
112
- block_uid: blockUid,
113
- parent_uid: targetPageUid
114
- };
115
- }
116
- else {
117
- // For single block content, use the same convertToRoamActions approach that works in roam_create_page
118
- const nodes = [{
119
- content: content,
120
- level: 0,
121
- ...(heading && typeof heading === 'number' && heading > 0 && { heading_level: heading }),
122
- children: []
123
- }];
124
- if (!targetPageUid) {
125
- throw new McpError(ErrorCode.InternalError, 'targetPageUid is undefined');
126
- }
127
- const actions = convertToRoamActions(nodes, targetPageUid, 'last');
128
- // Execute batch actions to create the block
129
- const result = await batchActions(this.graph, {
130
- action: 'batch-actions',
131
- actions
132
- });
133
- if (!result) {
134
- throw new Error('Failed to create block');
135
- }
136
- const blockUid = result.created_uids?.[0];
137
- return {
138
- success: true,
139
- block_uid: blockUid,
140
- parent_uid: targetPageUid
141
- };
142
- }
143
- }
144
- catch (error) {
145
- throw new McpError(ErrorCode.InternalError, `Failed to create block: ${error instanceof Error ? error.message : String(error)}`);
146
- }
147
- }
148
- async updateBlock(block_uid, content, transform) {
149
- if (!block_uid) {
150
- throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
151
- }
152
- // Get current block content
153
- const blockQuery = `[:find ?string .
154
- :where [?b :block/uid "${block_uid}"]
155
- [?b :block/string ?string]]`;
156
- const result = await q(this.graph, blockQuery, []);
157
- if (result === null || result === undefined) {
158
- throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
159
- }
160
- const currentContent = String(result);
161
- if (currentContent === null || currentContent === undefined) {
162
- throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
163
- }
164
- // Determine new content
165
- let newContent;
166
- if (content) {
167
- newContent = content;
168
- }
169
- else if (transform) {
170
- newContent = transform(currentContent);
171
- }
172
- else {
173
- throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
174
- }
175
- try {
176
- await updateRoamBlock(this.graph, {
177
- action: 'update-block',
178
- block: {
179
- uid: block_uid,
180
- string: newContent
181
- }
182
- });
183
- return {
184
- success: true,
185
- content: newContent
186
- };
187
- }
188
- catch (error) {
189
- throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
190
- }
191
- }
192
7
  async moveBlock(block_uid, parent_uid, order = 'last') {
193
8
  if (!block_uid) {
194
9
  throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
@@ -226,106 +41,4 @@ export class BlockOperations {
226
41
  throw new McpError(ErrorCode.InternalError, `Failed to move block: ${error.message}`);
227
42
  }
228
43
  }
229
- async updateBlocks(updates) {
230
- if (!Array.isArray(updates) || updates.length === 0) {
231
- throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
232
- }
233
- // Validate each update has required fields
234
- updates.forEach((update, index) => {
235
- if (!update.block_uid) {
236
- throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
237
- }
238
- if (!update.content && !update.transform) {
239
- throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
240
- }
241
- });
242
- // Get current content for all blocks
243
- const blockUids = updates.map(u => u.block_uid);
244
- const blockQuery = `[:find ?uid ?string
245
- :in $ [?uid ...]
246
- :where [?b :block/uid ?uid]
247
- [?b :block/string ?string]]`;
248
- const blockResults = await q(this.graph, blockQuery, [blockUids]);
249
- // Create map of uid -> current content
250
- const contentMap = new Map();
251
- blockResults.forEach(([uid, string]) => {
252
- contentMap.set(uid, string);
253
- });
254
- // Prepare batch actions
255
- const actions = [];
256
- const results = [];
257
- for (const update of updates) {
258
- try {
259
- const currentContent = contentMap.get(update.block_uid);
260
- if (!currentContent) {
261
- results.push({
262
- block_uid: update.block_uid,
263
- content: '',
264
- success: false,
265
- error: `Block with UID "${update.block_uid}" not found`
266
- });
267
- continue;
268
- }
269
- // Determine new content
270
- let newContent;
271
- if (update.content) {
272
- newContent = update.content;
273
- }
274
- else if (update.transform) {
275
- const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
276
- newContent = currentContent.replace(regex, update.transform.replace);
277
- }
278
- else {
279
- // This shouldn't happen due to earlier validation
280
- throw new Error('Invalid update configuration');
281
- }
282
- // Add to batch actions
283
- actions.push({
284
- action: 'update-block',
285
- block: {
286
- uid: update.block_uid,
287
- string: newContent
288
- }
289
- });
290
- results.push({
291
- block_uid: update.block_uid,
292
- content: newContent,
293
- success: true
294
- });
295
- }
296
- catch (error) {
297
- results.push({
298
- block_uid: update.block_uid,
299
- content: contentMap.get(update.block_uid) || '',
300
- success: false,
301
- error: error.message
302
- });
303
- }
304
- }
305
- // Execute batch update if we have any valid actions
306
- if (actions.length > 0) {
307
- try {
308
- const batchResult = await batchActions(this.graph, {
309
- action: 'batch-actions',
310
- actions
311
- });
312
- if (!batchResult) {
313
- throw new Error('Batch update failed');
314
- }
315
- }
316
- catch (error) {
317
- // Mark all previously successful results as failed
318
- results.forEach(result => {
319
- if (result.success) {
320
- result.success = false;
321
- result.error = `Batch update failed: ${error.message}`;
322
- }
323
- });
324
- }
325
- }
326
- return {
327
- success: results.every(r => r.success),
328
- results
329
- };
330
- }
331
44
  }
@@ -1,53 +1,20 @@
1
- import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
1
+ import { q } from '@roam-research/roam-api-sdk';
2
2
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
- import { formatRoamDate } from '../../utils/helpers.js';
4
3
  import { generateBlockUid } from '../../markdown-utils.js';
4
+ import { ANCESTOR_RULE } from '../../search/ancestor-rule.js';
5
5
  import { resolveRefs } from '../helpers/refs.js';
6
+ import { getOrCreateTodayPage } from '../helpers/page-resolution.js';
7
+ import { executeBatch } from '../helpers/batch-utils.js';
6
8
  import { SearchOperations } from './search/index.js';
7
- import { pageUidCache } from '../../cache/page-uid-cache.js';
8
9
  export class MemoryOperations {
9
- constructor(graph) {
10
+ constructor(graph, memoriesTag = 'Memories') {
10
11
  this.graph = graph;
11
12
  this.searchOps = new SearchOperations(graph);
13
+ this.memoriesTag = memoriesTag;
12
14
  }
13
15
  async remember(memory, categories, heading, parent_uid, include_memories_tag = true) {
14
- // Get today's date
15
- const today = new Date();
16
- const dateStr = formatRoamDate(today);
17
- let pageUid;
18
- // Check cache first for today's page
19
- const cachedUid = pageUidCache.get(dateStr);
20
- if (cachedUid) {
21
- pageUid = cachedUid;
22
- }
23
- else {
24
- // Try to find today's page
25
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
26
- const findResults = await q(this.graph, findQuery, [dateStr]);
27
- if (findResults && findResults.length > 0) {
28
- pageUid = findResults[0][0];
29
- pageUidCache.set(dateStr, pageUid);
30
- }
31
- else {
32
- // Create today's page if it doesn't exist
33
- try {
34
- await createPage(this.graph, {
35
- action: 'create-page',
36
- page: { title: dateStr }
37
- });
38
- // Get the new page's UID
39
- const results = await q(this.graph, findQuery, [dateStr]);
40
- if (!results || results.length === 0) {
41
- throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
42
- }
43
- pageUid = results[0][0];
44
- pageUidCache.onPageCreated(dateStr, pageUid);
45
- }
46
- catch (error) {
47
- throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
48
- }
49
- }
50
- }
16
+ // Get or create today's daily page
17
+ const pageUid = await getOrCreateTodayPage(this.graph);
51
18
  // Determine parent block for the memory
52
19
  let targetParentUid;
53
20
  if (parent_uid) {
@@ -70,41 +37,32 @@ export class MemoryOperations {
70
37
  else {
71
38
  // Create the heading block
72
39
  const headingBlockUid = generateBlockUid();
73
- try {
74
- await batchActions(this.graph, {
75
- action: 'batch-actions',
76
- actions: [{
77
- action: 'create-block',
78
- location: { 'parent-uid': pageUid, order: 'last' },
79
- block: { uid: headingBlockUid, string: heading }
80
- }]
81
- });
82
- targetParentUid = headingBlockUid;
83
- }
84
- catch (error) {
85
- throw new McpError(ErrorCode.InternalError, `Failed to create heading block: ${error instanceof Error ? error.message : String(error)}`);
86
- }
40
+ await executeBatch(this.graph, [{
41
+ action: 'create-block',
42
+ location: { 'parent-uid': pageUid, order: 'last' },
43
+ block: { uid: headingBlockUid, string: heading }
44
+ }], 'create heading block');
45
+ targetParentUid = headingBlockUid;
87
46
  }
88
47
  }
89
48
  else {
90
49
  // Default: use daily page root
91
50
  targetParentUid = pageUid;
92
51
  }
93
- // Get memories tag from environment
94
- let memoriesTag;
95
- if (include_memories_tag) {
96
- memoriesTag = process.env.MEMORIES_TAG;
97
- if (!memoriesTag) {
98
- throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
99
- }
100
- }
52
+ // Get memories tag (use instance property) and format as Roam tag
53
+ // If memoriesTag is null (disabled for this graph), treat as if include_memories_tag is false
54
+ const memoriesTagFormatted = (include_memories_tag && this.memoriesTag)
55
+ ? (this.memoriesTag.includes(' ') || this.memoriesTag.includes('/')
56
+ ? `#[[${this.memoriesTag}]]`
57
+ : `#${this.memoriesTag}`)
58
+ : undefined;
101
59
  // Format categories as Roam tags if provided
102
60
  const categoryTags = categories?.map(cat => {
103
61
  // Handle multi-word categories
104
62
  return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
105
63
  }) ?? [];
106
64
  // Create block with memory, then all tags together at the end
107
- const tags = memoriesTag ? [...categoryTags, memoriesTag] : categoryTags;
65
+ const tags = memoriesTagFormatted ? [...categoryTags, memoriesTagFormatted] : categoryTags;
108
66
  const blockContent = [memory, ...tags].join(' ').trim();
109
67
  // Pre-generate UID so we can return it
110
68
  const blockUid = generateBlockUid();
@@ -119,49 +77,29 @@ export class MemoryOperations {
119
77
  string: blockContent
120
78
  }
121
79
  }];
122
- try {
123
- const result = await batchActions(this.graph, {
124
- action: 'batch-actions',
125
- actions
126
- });
127
- if (!result) {
128
- throw new McpError(ErrorCode.InternalError, 'Failed to create memory block via batch action');
129
- }
130
- }
131
- catch (error) {
132
- throw new McpError(ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}`);
133
- }
80
+ await executeBatch(this.graph, actions, 'create memory block');
134
81
  return { success: true, block_uid: blockUid, parent_uid: targetParentUid };
135
82
  }
136
83
  async recall(sort_by = 'newest', filter_tag) {
137
- // Get memories tag from environment
138
- var memoriesTag = process.env.MEMORIES_TAG;
139
- if (!memoriesTag) {
140
- memoriesTag = "Memories";
84
+ // If memories tag is disabled for this graph, return empty
85
+ if (!this.memoriesTag) {
86
+ return { success: true, memories: [] };
141
87
  }
142
88
  // Extract the tag text, removing any formatting
143
- const tagText = memoriesTag
89
+ const tagText = this.memoriesTag
144
90
  .replace(/^#/, '') // Remove leading #
145
91
  .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]]
146
92
  try {
147
- // Get page blocks using query to access actual block content
148
- const ancestorRule = `[
149
- [ (ancestor ?b ?a)
150
- [?a :block/children ?b] ]
151
- [ (ancestor ?b ?a)
152
- [?parent :block/children ?b]
153
- (ancestor ?parent ?a) ]
154
- ]`;
155
93
  // Query to find all blocks on the page
156
94
  const pageQuery = `[:find ?string ?time
157
95
  :in $ % ?title
158
- :where
96
+ :where
159
97
  [?page :node/title ?title]
160
98
  [?block :block/string ?string]
161
99
  [?block :create/time ?time]
162
100
  (ancestor ?block ?page)]`;
163
101
  // Execute query
164
- const pageResults = await q(this.graph, pageQuery, [ancestorRule, tagText]);
102
+ const pageResults = await q(this.graph, pageQuery, [ANCESTOR_RULE, tagText]);
165
103
  // Process page blocks with sorting
166
104
  let pageMemories = pageResults
167
105
  .sort(([_, aTime], [__, bTime]) => sort_by === 'newest' ? bTime - aTime : aTime - bTime)