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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged batch executor for Roam API.
|
|
3
|
+
*
|
|
4
|
+
* Solves the race condition where child blocks reference parent UIDs
|
|
5
|
+
* that are also being created in the same batch. By analyzing dependencies
|
|
6
|
+
* and executing in topological order (by level), we ensure parents exist
|
|
7
|
+
* before their children are created.
|
|
8
|
+
*/
|
|
9
|
+
import { batchActions } from '@roam-research/roam-api-sdk';
|
|
10
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Groups batch actions by dependency level.
|
|
13
|
+
*
|
|
14
|
+
* Level 0: Actions whose parent-uid is NOT created by another action in the batch
|
|
15
|
+
* Level 1: Actions whose parent-uid is created by a Level 0 action
|
|
16
|
+
* Level N: Actions whose parent-uid is created by a Level N-1 action
|
|
17
|
+
*
|
|
18
|
+
* @param actions - Flat array of batch actions
|
|
19
|
+
* @returns Array of action arrays, grouped by level
|
|
20
|
+
*/
|
|
21
|
+
export function groupActionsByDependencyLevel(actions) {
|
|
22
|
+
if (actions.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
// Build set of UIDs being created in this batch
|
|
25
|
+
const createdUids = new Set();
|
|
26
|
+
for (const action of actions) {
|
|
27
|
+
if (action.action === 'create-block' && action.block?.uid) {
|
|
28
|
+
createdUids.add(action.block.uid);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Build dependency map: action index -> parent action index (or -1 if external parent)
|
|
32
|
+
const dependsOn = new Map();
|
|
33
|
+
const uidToIndex = new Map();
|
|
34
|
+
// First pass: map UIDs to action indices
|
|
35
|
+
for (let i = 0; i < actions.length; i++) {
|
|
36
|
+
const action = actions[i];
|
|
37
|
+
if (action.action === 'create-block' && action.block?.uid) {
|
|
38
|
+
uidToIndex.set(action.block.uid, i);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Second pass: determine dependencies
|
|
42
|
+
for (let i = 0; i < actions.length; i++) {
|
|
43
|
+
const action = actions[i];
|
|
44
|
+
const parentUid = action.location?.['parent-uid'];
|
|
45
|
+
if (parentUid && createdUids.has(parentUid)) {
|
|
46
|
+
// This action depends on another action in the batch
|
|
47
|
+
const parentIndex = uidToIndex.get(parentUid);
|
|
48
|
+
if (parentIndex !== undefined) {
|
|
49
|
+
dependsOn.set(i, parentIndex);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// External parent (already exists in Roam)
|
|
54
|
+
dependsOn.set(i, -1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Calculate levels using BFS-like approach
|
|
58
|
+
const levels = new Map(); // action index -> level
|
|
59
|
+
// Initialize: actions with external parents are level 0
|
|
60
|
+
for (let i = 0; i < actions.length; i++) {
|
|
61
|
+
if (dependsOn.get(i) === -1) {
|
|
62
|
+
levels.set(i, 0);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Propagate levels
|
|
66
|
+
let changed = true;
|
|
67
|
+
let iterations = 0;
|
|
68
|
+
const maxIterations = actions.length + 1; // Prevent infinite loops
|
|
69
|
+
while (changed && iterations < maxIterations) {
|
|
70
|
+
changed = false;
|
|
71
|
+
iterations++;
|
|
72
|
+
for (let i = 0; i < actions.length; i++) {
|
|
73
|
+
if (levels.has(i))
|
|
74
|
+
continue; // Already assigned
|
|
75
|
+
const parentIndex = dependsOn.get(i);
|
|
76
|
+
if (parentIndex !== undefined && parentIndex !== -1 && levels.has(parentIndex)) {
|
|
77
|
+
levels.set(i, levels.get(parentIndex) + 1);
|
|
78
|
+
changed = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Check for unassigned actions (circular dependencies or missing parents)
|
|
83
|
+
for (let i = 0; i < actions.length; i++) {
|
|
84
|
+
if (!levels.has(i)) {
|
|
85
|
+
// This shouldn't happen with valid input, but handle gracefully
|
|
86
|
+
// Assign to level 0 as fallback
|
|
87
|
+
levels.set(i, 0);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Group by level
|
|
91
|
+
const maxLevel = Math.max(...levels.values());
|
|
92
|
+
const grouped = [];
|
|
93
|
+
for (let level = 0; level <= maxLevel; level++) {
|
|
94
|
+
grouped[level] = [];
|
|
95
|
+
}
|
|
96
|
+
for (let i = 0; i < actions.length; i++) {
|
|
97
|
+
const level = levels.get(i) ?? 0;
|
|
98
|
+
grouped[level].push(actions[i]);
|
|
99
|
+
}
|
|
100
|
+
// Filter out empty levels
|
|
101
|
+
return grouped.filter(level => level.length > 0);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Executes batch actions in staged order, ensuring parent blocks exist
|
|
105
|
+
* before their children are created.
|
|
106
|
+
*
|
|
107
|
+
* @param graph - Roam graph connection
|
|
108
|
+
* @param actions - Flat array of batch actions
|
|
109
|
+
* @param options - Execution options
|
|
110
|
+
* @returns Promise that resolves when all actions are complete
|
|
111
|
+
*/
|
|
112
|
+
export async function executeStagedBatch(graph, actions, options = {}) {
|
|
113
|
+
const { delayBetweenLevels = 100, context = 'batch operation' } = options;
|
|
114
|
+
if (actions.length === 0) {
|
|
115
|
+
return { success: true, levelsExecuted: 0, totalActions: 0 };
|
|
116
|
+
}
|
|
117
|
+
const actionsByLevel = groupActionsByDependencyLevel(actions);
|
|
118
|
+
for (let level = 0; level < actionsByLevel.length; level++) {
|
|
119
|
+
const levelActions = actionsByLevel[level];
|
|
120
|
+
if (levelActions.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
try {
|
|
123
|
+
const result = await batchActions(graph, {
|
|
124
|
+
action: 'batch-actions',
|
|
125
|
+
actions: levelActions
|
|
126
|
+
});
|
|
127
|
+
if (!result) {
|
|
128
|
+
throw new McpError(ErrorCode.InternalError, `Failed to execute ${context} at level ${level} - no result returned`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw new McpError(ErrorCode.InternalError, `Failed to execute ${context} at level ${level}: ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
// Delay between levels to ensure parent blocks are committed
|
|
135
|
+
if (level < actionsByLevel.length - 1) {
|
|
136
|
+
await new Promise(resolve => setTimeout(resolve, delayBetweenLevels));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
success: true,
|
|
141
|
+
levelsExecuted: actionsByLevel.length,
|
|
142
|
+
totalActions: actions.length
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for batch operations with consistent error handling.
|
|
3
|
+
*/
|
|
4
|
+
import { batchActions } from '@roam-research/roam-api-sdk';
|
|
5
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Execute batch actions with consistent error handling.
|
|
8
|
+
* Wraps batchActions call in try-catch and throws McpError on failure.
|
|
9
|
+
*
|
|
10
|
+
* @param graph - The Roam graph instance
|
|
11
|
+
* @param actions - Array of batch actions to execute
|
|
12
|
+
* @param errorContext - Description of what operation failed (e.g., "create memory block")
|
|
13
|
+
* @returns The result from batchActions
|
|
14
|
+
* @throws McpError if the operation fails
|
|
15
|
+
*/
|
|
16
|
+
export async function executeBatch(graph, actions, errorContext) {
|
|
17
|
+
try {
|
|
18
|
+
const result = await batchActions(graph, {
|
|
19
|
+
action: 'batch-actions',
|
|
20
|
+
actions
|
|
21
|
+
});
|
|
22
|
+
if (!result) {
|
|
23
|
+
throw new McpError(ErrorCode.InternalError, `Failed to ${errorContext}`);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof McpError) {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
throw new McpError(ErrorCode.InternalError, `Failed to ${errorContext}: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Execute batch actions without throwing on failure (for non-critical operations).
|
|
36
|
+
* Returns null on failure instead of throwing.
|
|
37
|
+
*
|
|
38
|
+
* @param graph - The Roam graph instance
|
|
39
|
+
* @param actions - Array of batch actions to execute
|
|
40
|
+
* @param errorContext - Description for logging on failure
|
|
41
|
+
* @returns The result from batchActions, or null on failure
|
|
42
|
+
*/
|
|
43
|
+
export async function executeBatchSafe(graph, actions, errorContext) {
|
|
44
|
+
try {
|
|
45
|
+
const result = await batchActions(graph, {
|
|
46
|
+
action: 'batch-actions',
|
|
47
|
+
actions
|
|
48
|
+
});
|
|
49
|
+
return result || null;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (errorContext) {
|
|
53
|
+
console.error(`Failed to ${errorContext}: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for page UID resolution with caching and case-insensitive matching.
|
|
3
|
+
*/
|
|
4
|
+
import { q, createPage } from '@roam-research/roam-api-sdk';
|
|
5
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { formatRoamDate } from '../../utils/helpers.js';
|
|
7
|
+
import { pageUidCache } from '../../cache/page-uid-cache.js';
|
|
8
|
+
import { capitalizeWords } from './text.js';
|
|
9
|
+
/**
|
|
10
|
+
* Find a page UID by title with case variation matching and caching.
|
|
11
|
+
* Tries: original, capitalized words, lowercase.
|
|
12
|
+
* @returns The page UID or null if not found
|
|
13
|
+
*/
|
|
14
|
+
export async function getPageUid(graph, title) {
|
|
15
|
+
if (!title) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const variations = [
|
|
19
|
+
title,
|
|
20
|
+
capitalizeWords(title),
|
|
21
|
+
title.toLowerCase()
|
|
22
|
+
];
|
|
23
|
+
// Check cache first for any variation
|
|
24
|
+
for (const variation of variations) {
|
|
25
|
+
const cachedUid = pageUidCache.get(variation);
|
|
26
|
+
if (cachedUid) {
|
|
27
|
+
return cachedUid;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Query database with OR clause for all variations
|
|
31
|
+
const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
|
|
32
|
+
const searchQuery = `[:find ?uid .
|
|
33
|
+
:where [?e :block/uid ?uid]
|
|
34
|
+
(or ${orClause})]`;
|
|
35
|
+
const result = await q(graph, searchQuery, []);
|
|
36
|
+
const uid = (result === null || result === undefined) ? null : String(result);
|
|
37
|
+
if (uid) {
|
|
38
|
+
pageUidCache.set(title, uid);
|
|
39
|
+
}
|
|
40
|
+
return uid;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get or create today's daily page.
|
|
44
|
+
* @returns The page UID
|
|
45
|
+
*/
|
|
46
|
+
export async function getOrCreateTodayPage(graph) {
|
|
47
|
+
const dateStr = formatRoamDate(new Date());
|
|
48
|
+
// Check cache first
|
|
49
|
+
const cachedUid = pageUidCache.get(dateStr);
|
|
50
|
+
if (cachedUid) {
|
|
51
|
+
return cachedUid;
|
|
52
|
+
}
|
|
53
|
+
// Try to find today's page
|
|
54
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
55
|
+
const findResults = await q(graph, findQuery, [dateStr]);
|
|
56
|
+
if (findResults && findResults.length > 0) {
|
|
57
|
+
const uid = findResults[0][0];
|
|
58
|
+
pageUidCache.set(dateStr, uid);
|
|
59
|
+
return uid;
|
|
60
|
+
}
|
|
61
|
+
// Create today's page
|
|
62
|
+
await createPage(graph, {
|
|
63
|
+
action: 'create-page',
|
|
64
|
+
page: { title: dateStr }
|
|
65
|
+
});
|
|
66
|
+
// Fetch the newly created page UID
|
|
67
|
+
const results = await q(graph, findQuery, [dateStr]);
|
|
68
|
+
if (!results || results.length === 0) {
|
|
69
|
+
throw new McpError(ErrorCode.InternalError, "Could not find created today's page");
|
|
70
|
+
}
|
|
71
|
+
const uid = results[0][0];
|
|
72
|
+
pageUidCache.onPageCreated(dateStr, uid);
|
|
73
|
+
return uid;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Find or create a page by title or UID with retries and caching.
|
|
77
|
+
* Tries case variations, checks if input is a UID, creates page if not found.
|
|
78
|
+
* @returns The page UID
|
|
79
|
+
*/
|
|
80
|
+
export async function findOrCreatePage(graph, titleOrUid, options = {}) {
|
|
81
|
+
const { maxRetries = 3, delayMs = 500 } = options;
|
|
82
|
+
const variations = [
|
|
83
|
+
titleOrUid,
|
|
84
|
+
capitalizeWords(titleOrUid),
|
|
85
|
+
titleOrUid.toLowerCase()
|
|
86
|
+
];
|
|
87
|
+
// Check cache first for any variation
|
|
88
|
+
for (const variation of variations) {
|
|
89
|
+
const cachedUid = pageUidCache.get(variation);
|
|
90
|
+
if (cachedUid) {
|
|
91
|
+
return cachedUid;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
95
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
96
|
+
// Try each case variation
|
|
97
|
+
for (const variation of variations) {
|
|
98
|
+
const findResults = await q(graph, titleQuery, [variation]);
|
|
99
|
+
if (findResults && findResults.length > 0) {
|
|
100
|
+
const uid = findResults[0][0];
|
|
101
|
+
pageUidCache.set(titleOrUid, uid);
|
|
102
|
+
return uid;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// If not found as title, try as UID
|
|
106
|
+
const uidQuery = `[:find ?uid
|
|
107
|
+
:where [?e :block/uid "${titleOrUid}"]
|
|
108
|
+
[?e :block/uid ?uid]]`;
|
|
109
|
+
const uidResult = await q(graph, uidQuery, []);
|
|
110
|
+
if (uidResult && uidResult.length > 0) {
|
|
111
|
+
return uidResult[0][0];
|
|
112
|
+
}
|
|
113
|
+
// If still not found and this is the first retry, try to create the page
|
|
114
|
+
if (retry === 0) {
|
|
115
|
+
await createPage(graph, {
|
|
116
|
+
action: 'create-page',
|
|
117
|
+
page: { title: titleOrUid }
|
|
118
|
+
});
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (retry < maxRetries - 1) {
|
|
123
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// One more attempt to find after creation attempts
|
|
127
|
+
for (const variation of variations) {
|
|
128
|
+
const findResults = await q(graph, titleQuery, [variation]);
|
|
129
|
+
if (findResults && findResults.length > 0) {
|
|
130
|
+
const uid = findResults[0][0];
|
|
131
|
+
pageUidCache.onPageCreated(titleOrUid, uid);
|
|
132
|
+
return uid;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
|
|
136
|
+
}
|
|
@@ -64,3 +64,71 @@ export async function resolveRefs(graph, text, depth = 0, maxDepth = 4, seen = n
|
|
|
64
64
|
return refMap.get(uid) ?? match;
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Recursively resolves block references for a list of RoamBlocks.
|
|
69
|
+
* Attaches referenced blocks to the `refs` property of the referencing block.
|
|
70
|
+
*
|
|
71
|
+
* @param graph - Roam graph connection
|
|
72
|
+
* @param blocksToScan - List of blocks to scan for references
|
|
73
|
+
* @param remainingDepth - Maximum depth to resolve nested refs
|
|
74
|
+
*/
|
|
75
|
+
export async function resolveBlockRefs(graph, blocksToScan, remainingDepth = 2) {
|
|
76
|
+
if (remainingDepth <= 0 || blocksToScan.length === 0) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const refMap = {}; // uid -> list of blocks referencing it
|
|
80
|
+
const allRefUids = new Set();
|
|
81
|
+
for (const block of blocksToScan) {
|
|
82
|
+
// Skip blocks without valid string content
|
|
83
|
+
if (typeof block.string !== 'string') {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Reset lastIndex for REF_PATTERN reuse if using exec, or use matchAll
|
|
87
|
+
// matchAll is safer with global regex state
|
|
88
|
+
const matches = block.string.matchAll(REF_PATTERN);
|
|
89
|
+
for (const match of matches) {
|
|
90
|
+
const refUid = match[1];
|
|
91
|
+
if (!refMap[refUid]) {
|
|
92
|
+
refMap[refUid] = [];
|
|
93
|
+
}
|
|
94
|
+
refMap[refUid].push(block);
|
|
95
|
+
allRefUids.add(refUid);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (allRefUids.size === 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const uidsToFetch = Array.from(allRefUids);
|
|
102
|
+
// Fetch referenced blocks content
|
|
103
|
+
const refsQuery = `[:find ?uid ?string ?heading
|
|
104
|
+
:in $ [?uid ...]
|
|
105
|
+
:where [?b :block/uid ?uid]
|
|
106
|
+
[?b :block/string ?string]
|
|
107
|
+
[(get-else $ ?b :block/heading 0) ?heading]]`;
|
|
108
|
+
const results = await q(graph, refsQuery, [uidsToFetch]);
|
|
109
|
+
const fetchedBlocks = [];
|
|
110
|
+
for (const [uid, string, heading] of results) {
|
|
111
|
+
const newBlock = {
|
|
112
|
+
uid,
|
|
113
|
+
string,
|
|
114
|
+
order: 0,
|
|
115
|
+
heading: heading || undefined,
|
|
116
|
+
children: [],
|
|
117
|
+
refs: []
|
|
118
|
+
};
|
|
119
|
+
fetchedBlocks.push(newBlock);
|
|
120
|
+
// Attach to parents
|
|
121
|
+
if (refMap[uid]) {
|
|
122
|
+
for (const parentBlock of refMap[uid]) {
|
|
123
|
+
if (!parentBlock.refs)
|
|
124
|
+
parentBlock.refs = [];
|
|
125
|
+
// Avoid duplicates
|
|
126
|
+
if (!parentBlock.refs.some(r => r.uid === uid)) {
|
|
127
|
+
parentBlock.refs.push(newBlock);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Recurse for the next level of references
|
|
133
|
+
await resolveBlockRefs(graph, fetchedBlocks, remainingDepth - 1);
|
|
134
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk';
|
|
2
|
-
import { generateBlockUid } from '../../markdown-utils.js';
|
|
2
|
+
import { generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
|
|
3
3
|
import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js';
|
|
4
4
|
import { isRateLimitError, createRateLimitError } from '../../shared/errors.js';
|
|
5
|
+
import { ensurePagesExist } from '../../shared/page-validator.js';
|
|
5
6
|
// Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
|
|
6
7
|
const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g;
|
|
7
8
|
const DEFAULT_RATE_LIMIT_CONFIG = {
|
|
@@ -119,6 +120,35 @@ export class BatchOperations {
|
|
|
119
120
|
actions_attempted: 0
|
|
120
121
|
};
|
|
121
122
|
}
|
|
123
|
+
// Step 0.5: Validate parent pages exist (auto-creates daily pages)
|
|
124
|
+
// This uses batched queries and caching to minimize API calls
|
|
125
|
+
try {
|
|
126
|
+
const pageValidation = await ensurePagesExist(this.graph, actions, {
|
|
127
|
+
maxRetries: this.rateLimitConfig.maxRetries,
|
|
128
|
+
initialDelayMs: this.rateLimitConfig.initialDelayMs,
|
|
129
|
+
maxDelayMs: this.rateLimitConfig.maxDelayMs,
|
|
130
|
+
backoffMultiplier: this.rateLimitConfig.backoffMultiplier
|
|
131
|
+
});
|
|
132
|
+
if (pageValidation.created > 0) {
|
|
133
|
+
console.log(`[batch] Auto-created ${pageValidation.created} daily page(s), checked ${pageValidation.checked}, cached ${pageValidation.cached}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Page validation failed - return structured error
|
|
138
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
error: {
|
|
142
|
+
code: 'PAGE_NOT_FOUND',
|
|
143
|
+
message: errorMessage,
|
|
144
|
+
recovery: {
|
|
145
|
+
suggestion: 'Create the missing page(s) first with roam_create_page, or verify the parent-uid is correct'
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
validation_passed: true, // Syntax validation passed, page validation failed
|
|
149
|
+
actions_attempted: 0
|
|
150
|
+
};
|
|
151
|
+
}
|
|
122
152
|
// Step 1: Generate UIDs for all placeholders
|
|
123
153
|
const uidMap = this.generateUidMap(actions);
|
|
124
154
|
const hasPlaceholders = Object.keys(uidMap).length > 0;
|
|
@@ -137,12 +167,20 @@ export class BatchOperations {
|
|
|
137
167
|
};
|
|
138
168
|
}
|
|
139
169
|
const block = {};
|
|
140
|
-
if (rest.string)
|
|
141
|
-
|
|
170
|
+
if (rest.string) {
|
|
171
|
+
// Parse markdown heading syntax (e.g., "### Description" -> heading: 3, string: "Description")
|
|
172
|
+
const { heading_level, content } = parseMarkdownHeadingLevel(rest.string);
|
|
173
|
+
block.string = heading_level > 0 ? content : rest.string;
|
|
174
|
+
// Use parsed heading level if not explicitly overridden
|
|
175
|
+
if (heading_level > 0 && rest.heading === undefined) {
|
|
176
|
+
block.heading = heading_level;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
142
179
|
if (rest.uid)
|
|
143
180
|
block.uid = rest.uid;
|
|
144
181
|
if (rest.open !== undefined)
|
|
145
182
|
block.open = rest.open;
|
|
183
|
+
// Explicit heading parameter takes precedence over markdown syntax
|
|
146
184
|
if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) {
|
|
147
185
|
block.heading = rest.heading;
|
|
148
186
|
}
|
|
@@ -171,6 +209,40 @@ export class BatchOperations {
|
|
|
171
209
|
catch (error) {
|
|
172
210
|
// FAILURE: Do NOT return uid_map - blocks don't exist
|
|
173
211
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
212
|
+
// Check for parent entity error - retry once after delay (Roam eventual consistency)
|
|
213
|
+
if (errorMessage.includes("Parent entity doesn't exist")) {
|
|
214
|
+
console.log('[batch] Parent entity not found, retrying after 400ms...');
|
|
215
|
+
await sleep(400);
|
|
216
|
+
try {
|
|
217
|
+
await this.executeWithRetry(batchActions);
|
|
218
|
+
// SUCCESS on retry
|
|
219
|
+
const result = {
|
|
220
|
+
success: true,
|
|
221
|
+
validation_passed: true,
|
|
222
|
+
actions_attempted: batchActions.length
|
|
223
|
+
};
|
|
224
|
+
if (hasPlaceholders) {
|
|
225
|
+
result.uid_map = uidMap;
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
catch (retryError) {
|
|
230
|
+
// Still failed after retry
|
|
231
|
+
const retryErrorMessage = retryError instanceof Error ? retryError.message : String(retryError);
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
error: {
|
|
235
|
+
code: 'PARENT_ENTITY_NOT_FOUND',
|
|
236
|
+
message: `${retryErrorMessage} (retried once after 400ms delay)`,
|
|
237
|
+
recovery: {
|
|
238
|
+
suggestion: 'Verify the parent block/page UID exists and is spelled correctly'
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
validation_passed: true,
|
|
242
|
+
actions_attempted: batchActions.length
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
174
246
|
// Check if it's a rate limit error
|
|
175
247
|
if (isRateLimitError(error) || error.isRateLimit) {
|
|
176
248
|
return {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { q } from '@roam-research/roam-api-sdk';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { resolveBlockRefs } from '../helpers/refs.js';
|
|
3
4
|
export class BlockRetrievalOperations {
|
|
4
5
|
constructor(graph) {
|
|
5
6
|
this.graph = graph;
|
|
@@ -53,19 +54,29 @@ export class BlockRetrievalOperations {
|
|
|
53
54
|
[?b :block/string ?string]
|
|
54
55
|
[?b :block/order ?order]
|
|
55
56
|
[(get-else $ ?b :block/heading 0) ?heading]]`;
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
57
|
+
const rootBlockResults = await q(this.graph, rootBlockQuery, [block_uid]);
|
|
58
|
+
if (!rootBlockResults || rootBlockResults.length === 0) {
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
|
-
const [rootString, rootOrder, rootHeading] =
|
|
61
|
+
const [rootString, rootOrder, rootHeading] = rootBlockResults[0];
|
|
61
62
|
const childrenMap = await fetchChildren([block_uid], 0);
|
|
62
|
-
|
|
63
|
+
const rootBlock = {
|
|
63
64
|
uid: block_uid,
|
|
64
65
|
string: rootString,
|
|
65
66
|
order: rootOrder,
|
|
66
67
|
heading: rootHeading || undefined,
|
|
67
68
|
children: childrenMap[block_uid] || [],
|
|
68
69
|
};
|
|
70
|
+
// Gather all blocks in the tree to scan for references
|
|
71
|
+
const allBlocks = [];
|
|
72
|
+
const traverse = (b) => {
|
|
73
|
+
allBlocks.push(b);
|
|
74
|
+
b.children.forEach(traverse);
|
|
75
|
+
};
|
|
76
|
+
traverse(rootBlock);
|
|
77
|
+
// Resolve references (max depth 2)
|
|
78
|
+
await resolveBlockRefs(this.graph, allBlocks, 2);
|
|
79
|
+
return rootBlock;
|
|
69
80
|
}
|
|
70
81
|
catch (error) {
|
|
71
82
|
throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { BlockRetrievalOperations } from './block-retrieval.js';
|
|
3
|
+
// Mock roam-api-sdk
|
|
4
|
+
vi.mock('@roam-research/roam-api-sdk', () => ({
|
|
5
|
+
q: vi.fn(),
|
|
6
|
+
Graph: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
9
|
+
describe('BlockRetrievalOperations', () => {
|
|
10
|
+
let ops;
|
|
11
|
+
let mockGraph;
|
|
12
|
+
let qMock;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockGraph = {};
|
|
15
|
+
ops = new BlockRetrievalOperations(mockGraph);
|
|
16
|
+
qMock = q;
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
it('fetches a block with recursive reference resolution', async () => {
|
|
20
|
+
// ref UIDs must be 9 chars to match REF_PATTERN
|
|
21
|
+
const ref1Uid = 'ref1AAAAA';
|
|
22
|
+
const ref2Uid = 'ref2BBBBB';
|
|
23
|
+
// 1. Root block query: [:find ?string ?order ?heading ...]
|
|
24
|
+
qMock.mockResolvedValueOnce([[`Root Block with Ref ((${ref1Uid}))`, 0, 0]]);
|
|
25
|
+
// 2. Children query: [:find ?parentUid ?childUid ?childString ?childOrder ?childHeading ...]
|
|
26
|
+
qMock.mockResolvedValueOnce([]);
|
|
27
|
+
// 3. resolveBlockRefs query for ref1: [:find ?uid ?string ?heading ...]
|
|
28
|
+
qMock.mockResolvedValueOnce([[ref1Uid, `Ref 1 content with ((${ref2Uid}))`, 0]]);
|
|
29
|
+
// 4. resolveBlockRefs query for ref2 (depth 2)
|
|
30
|
+
qMock.mockResolvedValueOnce([[ref2Uid, 'Ref 2 content', 0]]);
|
|
31
|
+
const result = await ops.fetchBlockWithChildren('root-uid');
|
|
32
|
+
expect(result).toBeDefined();
|
|
33
|
+
expect(result?.uid).toBe('root-uid');
|
|
34
|
+
expect(result?.string).toBe(`Root Block with Ref ((${ref1Uid}))`);
|
|
35
|
+
expect(result?.refs).toBeDefined();
|
|
36
|
+
expect(result?.refs).toHaveLength(1);
|
|
37
|
+
const ref1 = result?.refs?.[0];
|
|
38
|
+
expect(ref1?.uid).toBe(ref1Uid);
|
|
39
|
+
expect(ref1?.string).toBe(`Ref 1 content with ((${ref2Uid}))`);
|
|
40
|
+
expect(ref1?.refs).toBeDefined();
|
|
41
|
+
expect(ref1?.refs).toHaveLength(1);
|
|
42
|
+
const ref2 = ref1?.refs?.[0];
|
|
43
|
+
expect(ref2?.uid).toBe(ref2Uid);
|
|
44
|
+
expect(ref2?.string).toBe('Ref 2 content');
|
|
45
|
+
// ref2 should have empty refs as it has no refs in string
|
|
46
|
+
expect(ref2?.refs).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('handles multiple references in same block', async () => {
|
|
49
|
+
const refAUid = 'refAAAAAA';
|
|
50
|
+
const refBUid = 'refBBBBBB';
|
|
51
|
+
// 1. Root block
|
|
52
|
+
qMock.mockResolvedValueOnce([[`Root ((${refAUid})) and ((${refBUid}))`, 0, 0]]);
|
|
53
|
+
// 2. Children
|
|
54
|
+
qMock.mockResolvedValueOnce([]);
|
|
55
|
+
// 3. resolveBlockRefs query for refA and refB
|
|
56
|
+
qMock.mockResolvedValueOnce([
|
|
57
|
+
[refAUid, 'Content A', 0],
|
|
58
|
+
[refBUid, 'Content B', 0]
|
|
59
|
+
]);
|
|
60
|
+
// No more queries - Content A and B have no refs
|
|
61
|
+
const result = await ops.fetchBlockWithChildren('root-uid');
|
|
62
|
+
expect(result?.refs).toHaveLength(2);
|
|
63
|
+
const uids = result?.refs?.map(r => r.uid).sort();
|
|
64
|
+
expect(uids).toEqual([refAUid, refBUid].sort());
|
|
65
|
+
});
|
|
66
|
+
it('handles shared references in tree', async () => {
|
|
67
|
+
const sharedUid = 'sharedUID';
|
|
68
|
+
// 1. Root block
|
|
69
|
+
qMock.mockResolvedValueOnce([['Root', 0, 0]]);
|
|
70
|
+
// 2. Children query: Child1 and Child2 both reference 'shared'
|
|
71
|
+
qMock.mockResolvedValueOnce([
|
|
72
|
+
['root-uid', 'child1uid', `Child 1 ((${sharedUid}))`, 0, 0],
|
|
73
|
+
['root-uid', 'child2uid', `Child 2 ((${sharedUid}))`, 1, 0]
|
|
74
|
+
]);
|
|
75
|
+
// 3. Children of Child1 and Child2 (empty - depth limit or no children)
|
|
76
|
+
qMock.mockResolvedValueOnce([]);
|
|
77
|
+
// 4. resolveBlockRefs query for 'shared' - fetched once, attached to both
|
|
78
|
+
qMock.mockResolvedValueOnce([[sharedUid, 'Shared Content', 0]]);
|
|
79
|
+
const result = await ops.fetchBlockWithChildren('root-uid');
|
|
80
|
+
const child1 = result?.children.find(c => c.uid === 'child1uid');
|
|
81
|
+
const child2 = result?.children.find(c => c.uid === 'child2uid');
|
|
82
|
+
expect(child1?.refs).toHaveLength(1);
|
|
83
|
+
expect(child1?.refs[0].uid).toBe(sharedUid);
|
|
84
|
+
expect(child2?.refs).toHaveLength(1);
|
|
85
|
+
expect(child2?.refs[0].uid).toBe(sharedUid);
|
|
86
|
+
});
|
|
87
|
+
});
|