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,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
- async remember(memory, categories, heading, parent_uid) {
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
- }
15
+ async remember(memory, categories, heading, parent_uid, include_memories_tag = true) {
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,38 +37,33 @@ 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
- const memoriesTag = process.env.MEMORIES_TAG;
95
- if (!memoriesTag) {
96
- throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
97
- }
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;
98
59
  // Format categories as Roam tags if provided
99
60
  const categoryTags = categories?.map(cat => {
100
61
  // Handle multi-word categories
101
62
  return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
102
- }).join(' ') || '';
63
+ }) ?? [];
103
64
  // Create block with memory, then all tags together at the end
104
- const blockContent = `${memory} ${categoryTags} ${memoriesTag}`.trim();
65
+ const tags = memoriesTagFormatted ? [...categoryTags, memoriesTagFormatted] : categoryTags;
66
+ const blockContent = [memory, ...tags].join(' ').trim();
105
67
  // Pre-generate UID so we can return it
106
68
  const blockUid = generateBlockUid();
107
69
  const actions = [{
@@ -115,49 +77,29 @@ export class MemoryOperations {
115
77
  string: blockContent
116
78
  }
117
79
  }];
118
- try {
119
- const result = await batchActions(this.graph, {
120
- action: 'batch-actions',
121
- actions
122
- });
123
- if (!result) {
124
- throw new McpError(ErrorCode.InternalError, 'Failed to create memory block via batch action');
125
- }
126
- }
127
- catch (error) {
128
- throw new McpError(ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}`);
129
- }
80
+ await executeBatch(this.graph, actions, 'create memory block');
130
81
  return { success: true, block_uid: blockUid, parent_uid: targetParentUid };
131
82
  }
132
83
  async recall(sort_by = 'newest', filter_tag) {
133
- // Get memories tag from environment
134
- var memoriesTag = process.env.MEMORIES_TAG;
135
- if (!memoriesTag) {
136
- memoriesTag = "Memories";
84
+ // If memories tag is disabled for this graph, return empty
85
+ if (!this.memoriesTag) {
86
+ return { success: true, memories: [] };
137
87
  }
138
88
  // Extract the tag text, removing any formatting
139
- const tagText = memoriesTag
89
+ const tagText = this.memoriesTag
140
90
  .replace(/^#/, '') // Remove leading #
141
91
  .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]]
142
92
  try {
143
- // Get page blocks using query to access actual block content
144
- const ancestorRule = `[
145
- [ (ancestor ?b ?a)
146
- [?a :block/children ?b] ]
147
- [ (ancestor ?b ?a)
148
- [?parent :block/children ?b]
149
- (ancestor ?parent ?a) ]
150
- ]`;
151
93
  // Query to find all blocks on the page
152
94
  const pageQuery = `[:find ?string ?time
153
95
  :in $ % ?title
154
- :where
96
+ :where
155
97
  [?page :node/title ?title]
156
98
  [?block :block/string ?string]
157
99
  [?block :create/time ?time]
158
100
  (ancestor ?block ?page)]`;
159
101
  // Execute query
160
- const pageResults = await q(this.graph, pageQuery, [ancestorRule, tagText]);
102
+ const pageResults = await q(this.graph, pageQuery, [ANCESTOR_RULE, tagText]);
161
103
  // Process page blocks with sorting
162
104
  let pageMemories = pageResults
163
105
  .sort(([_, aTime], [__, bTime]) => sort_by === 'newest' ? bTime - aTime : aTime - bTime)