roam-research-mcp 1.4.0 → 2.4.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 (34) hide show
  1. package/README.md +360 -31
  2. package/build/Roam_Markdown_Cheatsheet.md +30 -12
  3. package/build/cli/batch/resolver.js +138 -0
  4. package/build/cli/batch/translator.js +363 -0
  5. package/build/cli/batch/types.js +4 -0
  6. package/build/cli/commands/batch.js +352 -0
  7. package/build/cli/commands/get.js +161 -0
  8. package/build/cli/commands/refs.js +135 -0
  9. package/build/cli/commands/rename.js +58 -0
  10. package/build/cli/commands/save.js +498 -0
  11. package/build/cli/commands/search.js +240 -0
  12. package/build/cli/commands/status.js +91 -0
  13. package/build/cli/commands/update.js +151 -0
  14. package/build/cli/roam.js +35 -0
  15. package/build/cli/utils/graph.js +56 -0
  16. package/build/cli/utils/output.js +122 -0
  17. package/build/config/environment.js +70 -34
  18. package/build/config/graph-registry.js +221 -0
  19. package/build/config/graph-registry.test.js +30 -0
  20. package/build/search/block-ref-search.js +34 -7
  21. package/build/search/status-search.js +5 -4
  22. package/build/server/roam-server.js +98 -53
  23. package/build/shared/validation.js +10 -5
  24. package/build/tools/helpers/refs.js +50 -31
  25. package/build/tools/operations/blocks.js +38 -1
  26. package/build/tools/operations/memory.js +51 -5
  27. package/build/tools/operations/pages.js +186 -111
  28. package/build/tools/operations/search/index.js +5 -1
  29. package/build/tools/operations/todos.js +1 -1
  30. package/build/tools/schemas.js +121 -41
  31. package/build/tools/tool-handlers.js +9 -2
  32. package/build/utils/helpers.js +22 -0
  33. package/package.json +11 -7
  34. package/build/cli/import-markdown.js +0 -98
@@ -0,0 +1,58 @@
1
+ import { Command } from 'commander';
2
+ import { updatePage } from '@roam-research/roam-api-sdk';
3
+ import { printDebug, exitWithError } from '../utils/output.js';
4
+ import { resolveGraph } from '../utils/graph.js';
5
+ export function createRenameCommand() {
6
+ return new Command('rename')
7
+ .description('Rename a page')
8
+ .argument('<old-title>', 'Current page title (or use --uid for UID)')
9
+ .argument('<new-title>', 'New page title')
10
+ .option('-u, --uid <uid>', 'Use page UID instead of title')
11
+ .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
12
+ .option('--write-key <key>', 'Write confirmation key (non-default graphs)')
13
+ .option('--debug', 'Show debug information')
14
+ .addHelpText('after', `
15
+ Examples:
16
+ # Rename by title
17
+ roam rename "Old Page Name" "New Page Name"
18
+
19
+ # Rename by UID
20
+ roam rename --uid abc123def "New Page Name"
21
+
22
+ # Multi-graph
23
+ roam rename "Draft" "Published" -g work --write-key confirm
24
+ `)
25
+ .action(async (oldTitle, newTitle, options) => {
26
+ try {
27
+ if (options.debug) {
28
+ printDebug('Old title', oldTitle);
29
+ printDebug('New title', newTitle);
30
+ printDebug('UID', options.uid || 'none (using title)');
31
+ printDebug('Graph', options.graph || 'default');
32
+ }
33
+ const graph = resolveGraph(options, true); // Write operation
34
+ // Build the page identifier
35
+ const pageIdentifier = options.uid
36
+ ? { uid: options.uid }
37
+ : { title: oldTitle };
38
+ if (options.debug) {
39
+ printDebug('Page identifier', pageIdentifier);
40
+ }
41
+ const success = await updatePage(graph, {
42
+ page: pageIdentifier,
43
+ title: newTitle
44
+ });
45
+ if (success) {
46
+ const identifier = options.uid ? `((${options.uid}))` : `"${oldTitle}"`;
47
+ console.log(`Renamed ${identifier} → "${newTitle}"`);
48
+ }
49
+ else {
50
+ exitWithError('Failed to rename page (API returned false)');
51
+ }
52
+ }
53
+ catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ exitWithError(message);
56
+ }
57
+ });
58
+ }
@@ -0,0 +1,498 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { basename } from 'path';
4
+ import { PageOperations } from '../../tools/operations/pages.js';
5
+ import { TodoOperations } from '../../tools/operations/todos.js';
6
+ import { BatchOperations } from '../../tools/operations/batch.js';
7
+ import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
8
+ import { printDebug, exitWithError } from '../utils/output.js';
9
+ import { resolveGraph } from '../utils/graph.js';
10
+ import { formatRoamDate } from '../../utils/helpers.js';
11
+ import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
12
+ /**
13
+ * Flatten nested MarkdownNode[] to flat array with absolute levels
14
+ */
15
+ function flattenNodes(nodes, baseLevel = 1) {
16
+ const result = [];
17
+ for (const node of nodes) {
18
+ result.push({
19
+ text: node.content,
20
+ level: baseLevel,
21
+ ...(node.heading_level && { heading: node.heading_level })
22
+ });
23
+ if (node.children.length > 0) {
24
+ result.push(...flattenNodes(node.children, baseLevel + 1));
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+ /**
30
+ * Read all input from stdin
31
+ */
32
+ async function readStdin() {
33
+ const chunks = [];
34
+ for await (const chunk of process.stdin) {
35
+ chunks.push(chunk);
36
+ }
37
+ return Buffer.concat(chunks).toString('utf-8');
38
+ }
39
+ /**
40
+ * Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
41
+ */
42
+ function isBlockUid(value) {
43
+ // Strip (( )) wrapper if present
44
+ const cleaned = value.replace(/^\(\(|\)\)$/g, '');
45
+ return /^[a-zA-Z0-9_-]{9}$/.test(cleaned);
46
+ }
47
+ /**
48
+ * Check if content looks like a JSON array
49
+ */
50
+ function looksLikeJsonArray(content) {
51
+ const trimmed = content.trim();
52
+ return trimmed.startsWith('[') && trimmed.endsWith(']');
53
+ }
54
+ /**
55
+ * Find or create a page by title, returns UID
56
+ */
57
+ async function findOrCreatePage(graph, title) {
58
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
59
+ const findResults = await q(graph, findQuery, [title]);
60
+ if (findResults && findResults.length > 0) {
61
+ return findResults[0][0];
62
+ }
63
+ // Create the page if it doesn't exist
64
+ await roamCreatePage(graph, {
65
+ action: 'create-page',
66
+ page: { title }
67
+ });
68
+ const results = await q(graph, findQuery, [title]);
69
+ if (!results || results.length === 0) {
70
+ throw new Error(`Could not find created page: ${title}`);
71
+ }
72
+ return results[0][0];
73
+ }
74
+ /**
75
+ * Get or create today's daily page UID
76
+ */
77
+ async function getDailyPageUid(graph) {
78
+ const today = new Date();
79
+ const dateStr = formatRoamDate(today);
80
+ return findOrCreatePage(graph, dateStr);
81
+ }
82
+ /**
83
+ * Find or create a heading block on a page
84
+ */
85
+ async function findOrCreateHeading(graph, pageUid, heading, headingLevel) {
86
+ // Search for existing heading block
87
+ const headingQuery = `[:find ?uid
88
+ :in $ ?page-uid ?text
89
+ :where
90
+ [?page :block/uid ?page-uid]
91
+ [?page :block/children ?block]
92
+ [?block :block/string ?text]
93
+ [?block :block/uid ?uid]]`;
94
+ const headingResults = await q(graph, headingQuery, [pageUid, heading]);
95
+ if (headingResults && headingResults.length > 0) {
96
+ return headingResults[0][0];
97
+ }
98
+ // Create the heading block
99
+ const batchOps = new BatchOperations(graph);
100
+ const headingUid = generateBlockUid();
101
+ await batchOps.processBatch([{
102
+ action: 'create-block',
103
+ location: { 'parent-uid': pageUid, order: 'last' },
104
+ string: heading,
105
+ uid: headingUid,
106
+ ...(headingLevel && { heading: headingLevel })
107
+ }]);
108
+ return headingUid;
109
+ }
110
+ /**
111
+ * Parse JSON content blocks
112
+ */
113
+ function parseJsonContent(content) {
114
+ const parsed = JSON.parse(content);
115
+ if (!Array.isArray(parsed)) {
116
+ throw new Error('JSON content must be an array of {text, level, heading?} objects');
117
+ }
118
+ return parsed.map((item, index) => {
119
+ if (typeof item.text !== 'string' || typeof item.level !== 'number') {
120
+ throw new Error(`Invalid item at index ${index}: must have "text" (string) and "level" (number)`);
121
+ }
122
+ // If heading not explicitly provided, detect from text and strip hashes
123
+ if (item.heading) {
124
+ return {
125
+ text: item.text,
126
+ level: item.level,
127
+ heading: item.heading
128
+ };
129
+ }
130
+ const { heading_level, content: strippedText } = parseMarkdownHeadingLevel(item.text);
131
+ return {
132
+ text: strippedText,
133
+ level: item.level,
134
+ ...(heading_level > 0 && { heading: heading_level })
135
+ };
136
+ });
137
+ }
138
+ export function createSaveCommand() {
139
+ return new Command('save')
140
+ .description('Save text, files, or JSON to pages/blocks. Auto-detects format.')
141
+ .argument('[input]', 'Text, file path, or "-" for stdin (auto-detected)')
142
+ .option('--title <title>', 'Create a new page with this title')
143
+ .option('--update', 'Update existing page using smart diff (preserves block UIDs)')
144
+ .option('-p, --page <ref>', 'Target page by title or UID (default: daily page, creates if missing)')
145
+ .option('--parent <ref>', 'Nest under block UID ((uid)) or heading text (creates if missing). Use # prefix for heading level: "## Section"')
146
+ .option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
147
+ .option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
148
+ .option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
149
+ .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
150
+ .option('--write-key <key>', 'Write confirmation key (non-default graphs)')
151
+ .option('--debug', 'Show debug information')
152
+ .addHelpText('after', `
153
+ Examples:
154
+ # Quick saves to daily page
155
+ roam save "Quick note" # Single block
156
+ roam save "# Important" -c "work,urgent" # H1 heading with tags
157
+ roam save --todo "Buy groceries" # TODO item
158
+
159
+ # Save under heading (creates if missing)
160
+ roam save --parent "## Notes" "My note" # Under H2 "Notes" heading
161
+ roam save --parent "((blockUid9))" "Child" # Under specific block
162
+
163
+ # Target specific page
164
+ roam save -p "Project X" "Status update" # By title (creates if missing)
165
+ roam save -p "pageUid123" "Note" # By UID
166
+
167
+ # File operations
168
+ roam save notes.md --title "My Notes" # Create page from file
169
+ roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
170
+ cat data.json | roam save --json # Pipe JSON blocks
171
+
172
+ # Combine options
173
+ roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
174
+
175
+ JSON format (--json):
176
+ Array of blocks with text, level, and optional heading:
177
+ [
178
+ {"text": "# Main Title", "level": 1}, # Auto-detects H1
179
+ {"text": "Subheading", "level": 1, "heading": 2}, # Explicit H2
180
+ {"text": "Nested content", "level": 2}, # Child block
181
+ {"text": "Sibling", "level": 2}
182
+ ]
183
+ `)
184
+ .action(async (input, options) => {
185
+ try {
186
+ // TODO mode: add a TODO item to today's daily page
187
+ if (options.todo !== undefined) {
188
+ let todoText;
189
+ if (typeof options.todo === 'string' && options.todo.length > 0) {
190
+ todoText = options.todo;
191
+ }
192
+ else {
193
+ if (process.stdin.isTTY) {
194
+ exitWithError('No TODO text. Use: roam save --todo "text" or echo "text" | roam save --todo');
195
+ }
196
+ todoText = (await readStdin()).trim();
197
+ }
198
+ if (!todoText) {
199
+ exitWithError('Empty TODO text');
200
+ }
201
+ const todos = todoText.split('\n').map(t => t.trim()).filter(Boolean);
202
+ if (options.debug) {
203
+ printDebug('TODO mode', true);
204
+ printDebug('Graph', options.graph || 'default');
205
+ printDebug('TODO items', todos);
206
+ }
207
+ const graph = resolveGraph(options, true);
208
+ const todoOps = new TodoOperations(graph);
209
+ const result = await todoOps.addTodos(todos);
210
+ if (result.success) {
211
+ console.log(`Added ${todos.length} TODO item(s) to today's daily page`);
212
+ }
213
+ else {
214
+ exitWithError('Failed to save TODO');
215
+ }
216
+ return;
217
+ }
218
+ // Determine content source: file, text argument, or stdin
219
+ let content;
220
+ let isFile = false;
221
+ let sourceFilename;
222
+ if (input) {
223
+ // Check if input is a file path that exists
224
+ if (existsSync(input)) {
225
+ isFile = true;
226
+ sourceFilename = input;
227
+ try {
228
+ content = readFileSync(input, 'utf-8');
229
+ }
230
+ catch (err) {
231
+ exitWithError(`Could not read file: ${input}`);
232
+ }
233
+ }
234
+ else {
235
+ // Treat as text content
236
+ content = input;
237
+ }
238
+ }
239
+ else {
240
+ // Read from stdin
241
+ if (process.stdin.isTTY) {
242
+ exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
243
+ }
244
+ content = await readStdin();
245
+ }
246
+ content = content.trim();
247
+ if (!content) {
248
+ exitWithError('Empty content');
249
+ }
250
+ // Determine format: JSON or markdown/text
251
+ const isJson = options.json || (isFile && sourceFilename?.endsWith('.json')) || looksLikeJsonArray(content);
252
+ // Parse content into blocks
253
+ let contentBlocks;
254
+ if (isJson) {
255
+ try {
256
+ contentBlocks = parseJsonContent(content);
257
+ }
258
+ catch (err) {
259
+ exitWithError(err instanceof Error ? err.message : 'Invalid JSON');
260
+ }
261
+ }
262
+ else if (isFile || content.includes('\n')) {
263
+ // Multi-line content: parse as markdown
264
+ const nodes = parseMarkdown(content);
265
+ contentBlocks = flattenNodes(nodes);
266
+ }
267
+ else {
268
+ // Single line text: detect heading syntax and strip hashes
269
+ const { heading_level, content: strippedContent } = parseMarkdownHeadingLevel(content);
270
+ contentBlocks = [{
271
+ text: strippedContent,
272
+ level: 1,
273
+ ...(heading_level > 0 && { heading: heading_level })
274
+ }];
275
+ }
276
+ if (contentBlocks.length === 0) {
277
+ exitWithError('No content blocks parsed');
278
+ }
279
+ // Parse categories
280
+ const categories = options.categories
281
+ ? options.categories.split(',').map(c => c.trim()).filter(Boolean)
282
+ : undefined;
283
+ // Determine parent type if specified
284
+ let parentUid;
285
+ let parentHeading;
286
+ let parentHeadingLevel;
287
+ if (options.parent) {
288
+ const cleanedParent = options.parent.replace(/^\(\(|\)\)$/g, '');
289
+ if (isBlockUid(cleanedParent)) {
290
+ parentUid = cleanedParent;
291
+ }
292
+ else {
293
+ // Parse heading syntax from parent text
294
+ const { heading_level, content } = parseMarkdownHeadingLevel(options.parent);
295
+ parentHeading = content;
296
+ if (heading_level > 0) {
297
+ parentHeadingLevel = heading_level;
298
+ }
299
+ }
300
+ }
301
+ if (options.debug) {
302
+ printDebug('Input', input || 'stdin');
303
+ printDebug('Is file', isFile);
304
+ printDebug('Is JSON', isJson);
305
+ printDebug('Graph', options.graph || 'default');
306
+ printDebug('Content blocks', contentBlocks.length);
307
+ printDebug('Parent UID', parentUid || 'none');
308
+ printDebug('Parent heading', parentHeading || 'none');
309
+ printDebug('Target page', options.page || 'daily page');
310
+ printDebug('Categories', categories || 'none');
311
+ printDebug('Title', options.title || 'none');
312
+ }
313
+ const graph = resolveGraph(options, true);
314
+ // Determine operation mode based on options
315
+ const hasParent = parentUid || parentHeading;
316
+ const hasTitle = options.title;
317
+ const wantsPage = hasTitle && !hasParent;
318
+ if (wantsPage || (isFile && !hasParent)) {
319
+ // PAGE MODE: create a page
320
+ const pageTitle = options.title || (sourceFilename ? basename(sourceFilename, '.md').replace('.json', '') : undefined);
321
+ if (!pageTitle) {
322
+ exitWithError('--title required for page creation from stdin');
323
+ }
324
+ const pageOps = new PageOperations(graph);
325
+ if (options.update) {
326
+ if (isJson) {
327
+ exitWithError('--update is not supported with JSON content');
328
+ }
329
+ const result = await pageOps.updatePageMarkdown(pageTitle, content, false);
330
+ if (result.success) {
331
+ console.log(`Updated page '${pageTitle}'`);
332
+ console.log(` ${result.summary}`);
333
+ if (result.preservedUids.length > 0) {
334
+ console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
335
+ }
336
+ }
337
+ else {
338
+ exitWithError(`Failed to update page '${pageTitle}'`);
339
+ }
340
+ }
341
+ else {
342
+ const result = await pageOps.createPage(pageTitle, contentBlocks);
343
+ if (result.success) {
344
+ console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
345
+ }
346
+ else {
347
+ exitWithError(`Failed to create page '${pageTitle}'`);
348
+ }
349
+ }
350
+ return;
351
+ }
352
+ // BLOCK MODE: add content under parent or to daily page
353
+ if (parentUid) {
354
+ // Direct parent UID: use batch operations
355
+ const batchOps = new BatchOperations(graph);
356
+ // Build batch actions for all content blocks
357
+ const actions = [];
358
+ const uidMap = {};
359
+ for (let i = 0; i < contentBlocks.length; i++) {
360
+ const block = contentBlocks[i];
361
+ const uidPlaceholder = `block-${i}`;
362
+ uidMap[i] = uidPlaceholder;
363
+ // Determine parent for this block
364
+ let blockParent;
365
+ if (block.level === 1) {
366
+ blockParent = parentUid;
367
+ }
368
+ else {
369
+ // Find the closest ancestor at level - 1
370
+ let ancestorIndex = i - 1;
371
+ while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
372
+ ancestorIndex--;
373
+ }
374
+ if (ancestorIndex >= 0) {
375
+ blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
376
+ }
377
+ else {
378
+ blockParent = parentUid;
379
+ }
380
+ }
381
+ actions.push({
382
+ action: 'create-block',
383
+ location: {
384
+ 'parent-uid': blockParent,
385
+ order: 'last'
386
+ },
387
+ string: block.text,
388
+ uid: `{{uid:${uidPlaceholder}}}`,
389
+ ...(block.heading && { heading: block.heading })
390
+ });
391
+ }
392
+ const result = await batchOps.processBatch(actions);
393
+ if (result.success && result.uid_map) {
394
+ // Output the first block's UID
395
+ console.log(result.uid_map['block-0']);
396
+ }
397
+ else {
398
+ const errorMsg = typeof result.error === 'string'
399
+ ? result.error
400
+ : result.error?.message || 'Unknown error';
401
+ exitWithError(`Failed to save: ${errorMsg}`);
402
+ }
403
+ return;
404
+ }
405
+ // Parent heading or target page: get target page UID first
406
+ let pageUid;
407
+ if (options.page) {
408
+ // Strip (( )) wrapper if UID, but NOT [[ ]] (that's valid page title syntax)
409
+ const cleanedPage = options.page.replace(/^\(\(|\)\)$/g, '');
410
+ if (isBlockUid(cleanedPage)) {
411
+ pageUid = cleanedPage;
412
+ }
413
+ else {
414
+ pageUid = await findOrCreatePage(graph, options.page);
415
+ }
416
+ }
417
+ else {
418
+ pageUid = await getDailyPageUid(graph);
419
+ }
420
+ if (options.debug) {
421
+ printDebug('Target page UID', pageUid);
422
+ }
423
+ // Resolve heading to parent UID if specified
424
+ let targetParentUid;
425
+ if (parentHeading) {
426
+ targetParentUid = await findOrCreateHeading(graph, pageUid, parentHeading, parentHeadingLevel);
427
+ }
428
+ else {
429
+ targetParentUid = pageUid;
430
+ }
431
+ // Format categories as Roam tags if provided
432
+ const categoryTags = categories?.map(cat => {
433
+ return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
434
+ }).join(' ') || '';
435
+ // Create all blocks using batch operations
436
+ const batchOps = new BatchOperations(graph);
437
+ const actions = [];
438
+ const uidMap = {};
439
+ for (let i = 0; i < contentBlocks.length; i++) {
440
+ const block = contentBlocks[i];
441
+ const uidPlaceholder = `block-${i}`;
442
+ uidMap[i] = uidPlaceholder;
443
+ // Determine parent for this block
444
+ let blockParent;
445
+ if (block.level === 1) {
446
+ blockParent = targetParentUid;
447
+ }
448
+ else {
449
+ // Find the closest ancestor at level - 1
450
+ let ancestorIndex = i - 1;
451
+ while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
452
+ ancestorIndex--;
453
+ }
454
+ if (ancestorIndex >= 0) {
455
+ blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
456
+ }
457
+ else {
458
+ blockParent = targetParentUid;
459
+ }
460
+ }
461
+ // Append category tags to first block only
462
+ const blockText = i === 0 && categoryTags
463
+ ? `${block.text} ${categoryTags}`
464
+ : block.text;
465
+ actions.push({
466
+ action: 'create-block',
467
+ location: {
468
+ 'parent-uid': blockParent,
469
+ order: 'last'
470
+ },
471
+ string: blockText,
472
+ uid: `{{uid:${uidPlaceholder}}}`,
473
+ ...(block.heading && { heading: block.heading })
474
+ });
475
+ }
476
+ const result = await batchOps.processBatch(actions);
477
+ if (result.success && result.uid_map) {
478
+ // Output first block UID (and parent if heading was used)
479
+ if (parentHeading) {
480
+ console.log(`${result.uid_map['block-0']} ${targetParentUid}`);
481
+ }
482
+ else {
483
+ console.log(result.uid_map['block-0']);
484
+ }
485
+ }
486
+ else {
487
+ const errorMsg = typeof result.error === 'string'
488
+ ? result.error
489
+ : result.error?.message || 'Unknown error';
490
+ exitWithError(`Failed to save: ${errorMsg}`);
491
+ }
492
+ }
493
+ catch (error) {
494
+ const message = error instanceof Error ? error.message : String(error);
495
+ exitWithError(message);
496
+ }
497
+ });
498
+ }