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