roam-research-mcp 0.19.0 → 0.22.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.
@@ -0,0 +1,102 @@
1
+ import { q, createBlock, createPage } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { formatRoamDate } from '../../utils/helpers.js';
4
+ import { resolveRefs } from '../helpers/refs.js';
5
+ import { SearchOperations } from './search/index.js';
6
+ export class MemoryOperations {
7
+ graph;
8
+ searchOps;
9
+ constructor(graph) {
10
+ this.graph = graph;
11
+ this.searchOps = new SearchOperations(graph);
12
+ }
13
+ async remember(memory, categories) {
14
+ // Get today's date
15
+ const today = new Date();
16
+ const dateStr = formatRoamDate(today);
17
+ // Try to find today's page
18
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
19
+ const findResults = await q(this.graph, findQuery, [dateStr]);
20
+ let pageUid;
21
+ if (findResults && findResults.length > 0) {
22
+ pageUid = findResults[0][0];
23
+ }
24
+ else {
25
+ // Create today's page if it doesn't exist
26
+ try {
27
+ await createPage(this.graph, {
28
+ action: 'create-page',
29
+ page: { title: dateStr }
30
+ });
31
+ // Get the new page's UID
32
+ const results = await q(this.graph, findQuery, [dateStr]);
33
+ if (!results || results.length === 0) {
34
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
35
+ }
36
+ pageUid = results[0][0];
37
+ }
38
+ catch (error) {
39
+ throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
40
+ }
41
+ }
42
+ // Get memories tag from environment
43
+ const memoriesTag = process.env.MEMORIES_TAG;
44
+ if (!memoriesTag) {
45
+ throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
46
+ }
47
+ // Format categories as Roam tags if provided
48
+ const categoryTags = categories?.map(cat => {
49
+ // Handle multi-word categories
50
+ return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
51
+ }).join(' ') || '';
52
+ // Create block with memory, memories tag, and optional categories
53
+ const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
54
+ try {
55
+ await createBlock(this.graph, {
56
+ action: 'create-block',
57
+ location: {
58
+ "parent-uid": pageUid,
59
+ "order": "last"
60
+ },
61
+ block: { string: blockContent }
62
+ });
63
+ }
64
+ catch (error) {
65
+ throw new McpError(ErrorCode.InternalError, 'Failed to create memory block');
66
+ }
67
+ return { success: true };
68
+ }
69
+ async recall() {
70
+ // Get memories tag from environment
71
+ const memoriesTag = process.env.MEMORIES_TAG;
72
+ if (!memoriesTag) {
73
+ throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
74
+ }
75
+ // Extract the tag text, removing any formatting
76
+ const tagText = memoriesTag
77
+ .replace(/^#/, '') // Remove leading #
78
+ .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]]
79
+ // Get results from tag search
80
+ const tagResults = await this.searchOps.searchForTag(tagText);
81
+ // Get blocks from the memories page
82
+ const pageQuery = `[:find ?string
83
+ :in $ ?title
84
+ :where [?p :node/title ?title]
85
+ [?b :block/page ?p]
86
+ [?b :block/string ?string]]`;
87
+ const pageResults = await q(this.graph, pageQuery, [tagText]);
88
+ // Combine both sets of results and remove the memories tag
89
+ const allMemories = [
90
+ ...tagResults.matches.map((match) => match.content),
91
+ ...pageResults.map(([content]) => content)
92
+ ].map(content => content.replace(`${memoriesTag} `, ''));
93
+ // Resolve any block references in the combined memories
94
+ const resolvedMemories = await Promise.all(allMemories.map(async (content) => resolveRefs(this.graph, content)));
95
+ // Remove duplicates
96
+ const uniqueMemories = [...new Set(resolvedMemories)];
97
+ return {
98
+ success: true,
99
+ memories: uniqueMemories
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,347 @@
1
+ import { q, createPage, createBlock, batchActions } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { formatRoamDate } from '../../utils/helpers.js';
4
+ import { capitalizeWords } from '../helpers/text.js';
5
+ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
6
+ export class OutlineOperations {
7
+ graph;
8
+ constructor(graph) {
9
+ this.graph = graph;
10
+ }
11
+ async createOutline(outline, page_title_uid, block_text_uid) {
12
+ // Validate input
13
+ if (!Array.isArray(outline) || outline.length === 0) {
14
+ throw new McpError(ErrorCode.InvalidRequest, 'outline must be a non-empty array');
15
+ }
16
+ // Filter out items with undefined text
17
+ const validOutline = outline.filter(item => item.text !== undefined);
18
+ if (validOutline.length === 0) {
19
+ throw new McpError(ErrorCode.InvalidRequest, 'outline must contain at least one item with text');
20
+ }
21
+ // Validate outline structure
22
+ const invalidItems = validOutline.filter(item => typeof item.level !== 'number' ||
23
+ item.level < 1 ||
24
+ item.level > 10 ||
25
+ typeof item.text !== 'string' ||
26
+ item.text.trim().length === 0);
27
+ if (invalidItems.length > 0) {
28
+ throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
29
+ }
30
+ // Helper function to find or create page with retries
31
+ const findOrCreatePage = async (titleOrUid, maxRetries = 3, delayMs = 500) => {
32
+ // First try to find by title
33
+ const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
34
+ const variations = [
35
+ titleOrUid, // Original
36
+ capitalizeWords(titleOrUid), // Each word capitalized
37
+ titleOrUid.toLowerCase() // All lowercase
38
+ ];
39
+ for (let retry = 0; retry < maxRetries; retry++) {
40
+ // Try each case variation
41
+ for (const variation of variations) {
42
+ const findResults = await q(this.graph, titleQuery, [variation]);
43
+ if (findResults && findResults.length > 0) {
44
+ return findResults[0][0];
45
+ }
46
+ }
47
+ // If not found as title, try as UID
48
+ const uidQuery = `[:find ?uid
49
+ :where [?e :block/uid "${titleOrUid}"]
50
+ [?e :block/uid ?uid]]`;
51
+ const uidResult = await q(this.graph, uidQuery, []);
52
+ if (uidResult && uidResult.length > 0) {
53
+ return uidResult[0][0];
54
+ }
55
+ // If still not found and this is the first retry, try to create the page
56
+ if (retry === 0) {
57
+ try {
58
+ await createPage(this.graph, {
59
+ action: 'create-page',
60
+ page: { title: titleOrUid }
61
+ });
62
+ // Wait a bit and continue to next retry to check if page was created
63
+ await new Promise(resolve => setTimeout(resolve, delayMs));
64
+ continue;
65
+ }
66
+ catch (error) {
67
+ console.error('Error creating page:', error);
68
+ // Continue to next retry
69
+ continue;
70
+ }
71
+ }
72
+ if (retry < maxRetries - 1) {
73
+ await new Promise(resolve => setTimeout(resolve, delayMs));
74
+ }
75
+ }
76
+ throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
77
+ };
78
+ // Get or create the target page
79
+ const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
80
+ // Helper function to find block with improved relationship checks
81
+ const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000, case_sensitive = false) => {
82
+ // Try multiple query strategies
83
+ const queries = [
84
+ // Strategy 1: Direct page and string match
85
+ `[:find ?b-uid ?order
86
+ :where [?p :block/uid "${pageUid}"]
87
+ [?b :block/page ?p]
88
+ [?b :block/string ?block-str]
89
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
90
+ [?b :block/order ?order]
91
+ [?b :block/uid ?b-uid]]`,
92
+ // Strategy 2: Parent-child relationship
93
+ `[:find ?b-uid ?order
94
+ :where [?p :block/uid "${pageUid}"]
95
+ [?b :block/parents ?p]
96
+ [?b :block/string ?block-str]
97
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
98
+ [?b :block/order ?order]
99
+ [?b :block/uid ?b-uid]]`,
100
+ // Strategy 3: Broader page relationship
101
+ `[:find ?b-uid ?order
102
+ :where [?p :block/uid "${pageUid}"]
103
+ [?b :block/page ?page]
104
+ [?p :block/page ?page]
105
+ [?b :block/string ?block-str]
106
+ [(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
107
+ [?b :block/order ?order]
108
+ [?b :block/uid ?b-uid]]`
109
+ ];
110
+ for (let retry = 0; retry < maxRetries; retry++) {
111
+ // Try each query strategy
112
+ for (const queryStr of queries) {
113
+ const blockResults = await q(this.graph, queryStr, []);
114
+ if (blockResults && blockResults.length > 0) {
115
+ // Use the most recently created block
116
+ const sorted = blockResults.sort((a, b) => b[1] - a[1]);
117
+ return sorted[0][0];
118
+ }
119
+ }
120
+ // Exponential backoff
121
+ const delay = initialDelay * Math.pow(2, retry);
122
+ await new Promise(resolve => setTimeout(resolve, delay));
123
+ console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
124
+ }
125
+ throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
126
+ };
127
+ // Helper function to create and verify block with improved error handling
128
+ const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false, case_sensitive = false) => {
129
+ try {
130
+ // Initial delay before any operations
131
+ if (!isRetry) {
132
+ await new Promise(resolve => setTimeout(resolve, initialDelay));
133
+ }
134
+ for (let retry = 0; retry < maxRetries; retry++) {
135
+ console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
136
+ try {
137
+ // Create block
138
+ await createBlock(this.graph, {
139
+ action: 'create-block',
140
+ location: {
141
+ 'parent-uid': parentUid,
142
+ order: 'first'
143
+ },
144
+ block: { string: content }
145
+ });
146
+ // Wait with exponential backoff
147
+ const delay = initialDelay * Math.pow(2, retry);
148
+ await new Promise(resolve => setTimeout(resolve, delay));
149
+ // Try to find the block using our improved findBlockWithRetry
150
+ return await findBlockWithRetry(parentUid, content, maxRetries, initialDelay, case_sensitive);
151
+ }
152
+ catch (error) {
153
+ const errorMessage = error instanceof Error ? error.message : String(error);
154
+ console.log(`Failed to create/find block on attempt ${retry + 1}: ${errorMessage}`);
155
+ if (retry === maxRetries - 1)
156
+ throw error;
157
+ }
158
+ }
159
+ throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
160
+ }
161
+ catch (error) {
162
+ // If this is already a retry, throw the error
163
+ if (isRetry)
164
+ throw error;
165
+ // Otherwise, try one more time with a clean slate
166
+ console.log(`Retrying block creation for "${content}" with fresh attempt`);
167
+ await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
168
+ return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true, case_sensitive);
169
+ }
170
+ };
171
+ // Get or create the parent block
172
+ let targetParentUid;
173
+ if (!block_text_uid) {
174
+ targetParentUid = targetPageUid;
175
+ }
176
+ else {
177
+ try {
178
+ // Create header block and get its UID
179
+ targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
180
+ }
181
+ catch (error) {
182
+ const errorMessage = error instanceof Error ? error.message : String(error);
183
+ throw new McpError(ErrorCode.InternalError, `Failed to create header block "${block_text_uid}": ${errorMessage}`);
184
+ }
185
+ }
186
+ // Initialize result variable
187
+ let result;
188
+ try {
189
+ // Validate level sequence
190
+ let prevLevel = 0;
191
+ for (const item of validOutline) {
192
+ // Level should not increase by more than 1 at a time
193
+ if (item.level > prevLevel + 1) {
194
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}`);
195
+ }
196
+ prevLevel = item.level;
197
+ }
198
+ // Convert outline items to markdown-like structure
199
+ const markdownContent = validOutline
200
+ .map(item => {
201
+ const indent = ' '.repeat(item.level - 1);
202
+ return `${indent}- ${item.text?.trim()}`;
203
+ })
204
+ .join('\n');
205
+ // Convert to Roam markdown format
206
+ const convertedContent = convertToRoamMarkdown(markdownContent);
207
+ // Parse markdown into hierarchical structure
208
+ const nodes = parseMarkdown(convertedContent);
209
+ // Convert nodes to batch actions
210
+ const actions = convertToRoamActions(nodes, targetParentUid, 'first');
211
+ if (actions.length === 0) {
212
+ throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
213
+ }
214
+ // Execute batch actions to create the outline
215
+ result = await batchActions(this.graph, {
216
+ action: 'batch-actions',
217
+ actions
218
+ }).catch(error => {
219
+ throw new McpError(ErrorCode.InternalError, `Failed to create outline blocks: ${error.message}`);
220
+ });
221
+ if (!result) {
222
+ throw new McpError(ErrorCode.InternalError, 'Failed to create outline blocks - no result returned');
223
+ }
224
+ }
225
+ catch (error) {
226
+ if (error instanceof McpError)
227
+ throw error;
228
+ throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
229
+ }
230
+ // Get the created block UIDs
231
+ const createdUids = result?.created_uids || [];
232
+ return {
233
+ success: true,
234
+ page_uid: targetPageUid,
235
+ parent_uid: targetParentUid,
236
+ created_uids: createdUids
237
+ };
238
+ }
239
+ async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
240
+ // First get the page UID
241
+ let targetPageUid = page_uid;
242
+ if (!targetPageUid && page_title) {
243
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
244
+ const findResults = await q(this.graph, findQuery, [page_title]);
245
+ if (findResults && findResults.length > 0) {
246
+ targetPageUid = findResults[0][0];
247
+ }
248
+ else {
249
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
250
+ }
251
+ }
252
+ // If no page specified, use today's date page
253
+ if (!targetPageUid) {
254
+ const today = new Date();
255
+ const dateStr = formatRoamDate(today);
256
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
257
+ const findResults = await q(this.graph, findQuery, [dateStr]);
258
+ if (findResults && findResults.length > 0) {
259
+ targetPageUid = findResults[0][0];
260
+ }
261
+ else {
262
+ // Create today's page
263
+ try {
264
+ await createPage(this.graph, {
265
+ action: 'create-page',
266
+ page: { title: dateStr }
267
+ });
268
+ const results = await q(this.graph, findQuery, [dateStr]);
269
+ if (!results || results.length === 0) {
270
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
271
+ }
272
+ targetPageUid = results[0][0];
273
+ }
274
+ catch (error) {
275
+ throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
276
+ }
277
+ }
278
+ }
279
+ // Now get the parent block UID
280
+ let targetParentUid = parent_uid;
281
+ if (!targetParentUid && parent_string) {
282
+ if (!targetPageUid) {
283
+ throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
284
+ }
285
+ // Find block by exact string match within the page
286
+ const findBlockQuery = `[:find ?uid
287
+ :where [?p :block/uid "${targetPageUid}"]
288
+ [?b :block/page ?p]
289
+ [?b :block/string "${parent_string}"]]`;
290
+ const blockResults = await q(this.graph, findBlockQuery, []);
291
+ if (!blockResults || blockResults.length === 0) {
292
+ throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
293
+ }
294
+ targetParentUid = blockResults[0][0];
295
+ }
296
+ // If no parent specified, use page as parent
297
+ if (!targetParentUid) {
298
+ targetParentUid = targetPageUid;
299
+ }
300
+ // Always use parseMarkdown for content with multiple lines or any markdown formatting
301
+ const isMultilined = content.includes('\n');
302
+ if (isMultilined) {
303
+ // Parse markdown into hierarchical structure
304
+ const convertedContent = convertToRoamMarkdown(content);
305
+ const nodes = parseMarkdown(convertedContent);
306
+ // Convert markdown nodes to batch actions
307
+ const actions = convertToRoamActions(nodes, targetParentUid, order);
308
+ // Execute batch actions to add content
309
+ const result = await batchActions(this.graph, {
310
+ action: 'batch-actions',
311
+ actions
312
+ });
313
+ if (!result) {
314
+ throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
315
+ }
316
+ // Get the created block UIDs
317
+ const createdUids = result.created_uids || [];
318
+ return {
319
+ success: true,
320
+ page_uid: targetPageUid,
321
+ parent_uid: targetParentUid,
322
+ created_uids: createdUids
323
+ };
324
+ }
325
+ else {
326
+ // Create a simple block for non-nested content
327
+ try {
328
+ await createBlock(this.graph, {
329
+ action: 'create-block',
330
+ location: {
331
+ "parent-uid": targetParentUid,
332
+ order
333
+ },
334
+ block: { string: content }
335
+ });
336
+ }
337
+ catch (error) {
338
+ throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
339
+ }
340
+ return {
341
+ success: true,
342
+ page_uid: targetPageUid,
343
+ parent_uid: targetParentUid
344
+ };
345
+ }
346
+ }
347
+ }
@@ -0,0 +1,213 @@
1
+ import { q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk';
2
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
+ import { capitalizeWords } from '../helpers/text.js';
4
+ import { resolveRefs } from '../helpers/refs.js';
5
+ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable } from '../../markdown-utils.js';
6
+ export class PageOperations {
7
+ graph;
8
+ constructor(graph) {
9
+ this.graph = graph;
10
+ }
11
+ async findPagesModifiedToday() {
12
+ // Define ancestor rule for traversing block hierarchy
13
+ const ancestorRule = `[
14
+ [ (ancestor ?b ?a)
15
+ [?a :block/children ?b] ]
16
+ [ (ancestor ?b ?a)
17
+ [?parent :block/children ?b]
18
+ (ancestor ?parent ?a) ]
19
+ ]`;
20
+ // Get start of today
21
+ const startOfDay = new Date();
22
+ startOfDay.setHours(0, 0, 0, 0);
23
+ try {
24
+ // Query for pages modified today
25
+ const results = await q(this.graph, `[:find ?title
26
+ :in $ ?start_of_day %
27
+ :where
28
+ [?page :node/title ?title]
29
+ (ancestor ?block ?page)
30
+ [?block :edit/time ?time]
31
+ [(> ?time ?start_of_day)]]`, [startOfDay.getTime(), ancestorRule]);
32
+ if (!results || results.length === 0) {
33
+ return {
34
+ success: true,
35
+ pages: [],
36
+ message: 'No pages have been modified today'
37
+ };
38
+ }
39
+ // Extract unique page titles
40
+ const uniquePages = [...new Set(results.map(([title]) => title))];
41
+ return {
42
+ success: true,
43
+ pages: uniquePages,
44
+ message: `Found ${uniquePages.length} page(s) modified today`
45
+ };
46
+ }
47
+ catch (error) {
48
+ throw new McpError(ErrorCode.InternalError, `Failed to find modified pages: ${error.message}`);
49
+ }
50
+ }
51
+ async createPage(title, content) {
52
+ // Ensure title is properly formatted
53
+ const pageTitle = String(title).trim();
54
+ // First try to find if the page exists
55
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
56
+ const findResults = await q(this.graph, findQuery, [pageTitle]);
57
+ let pageUid;
58
+ if (findResults && findResults.length > 0) {
59
+ // Page exists, use its UID
60
+ pageUid = findResults[0][0];
61
+ }
62
+ else {
63
+ // Create new page
64
+ try {
65
+ await createRoamPage(this.graph, {
66
+ action: 'create-page',
67
+ page: {
68
+ title: pageTitle
69
+ }
70
+ });
71
+ // Get the new page's UID
72
+ const results = await q(this.graph, findQuery, [pageTitle]);
73
+ if (!results || results.length === 0) {
74
+ throw new Error('Could not find created page');
75
+ }
76
+ pageUid = results[0][0];
77
+ }
78
+ catch (error) {
79
+ throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ }
82
+ // If content is provided, check if it looks like nested markdown
83
+ if (content) {
84
+ try {
85
+ const isMultilined = content.includes('\n') || hasMarkdownTable(content);
86
+ if (isMultilined) {
87
+ // Use import_nested_markdown functionality
88
+ const convertedContent = convertToRoamMarkdown(content);
89
+ const nodes = parseMarkdown(convertedContent);
90
+ const actions = convertToRoamActions(nodes, pageUid, 'last');
91
+ const result = await batchActions(this.graph, {
92
+ action: 'batch-actions',
93
+ actions
94
+ });
95
+ if (!result) {
96
+ throw new Error('Failed to import nested markdown content');
97
+ }
98
+ }
99
+ else {
100
+ // Create a simple block for non-nested content
101
+ await createBlock(this.graph, {
102
+ action: 'create-block',
103
+ location: {
104
+ "parent-uid": pageUid,
105
+ "order": "last"
106
+ },
107
+ block: { string: content }
108
+ });
109
+ }
110
+ }
111
+ catch (error) {
112
+ throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
113
+ }
114
+ }
115
+ return { success: true, uid: pageUid };
116
+ }
117
+ async fetchPageByTitle(title) {
118
+ if (!title) {
119
+ throw new McpError(ErrorCode.InvalidRequest, 'title is required');
120
+ }
121
+ // Try different case variations
122
+ const variations = [
123
+ title, // Original
124
+ capitalizeWords(title), // Each word capitalized
125
+ title.toLowerCase() // All lowercase
126
+ ];
127
+ let uid = null;
128
+ for (const variation of variations) {
129
+ const searchQuery = `[:find ?uid .
130
+ :where [?e :node/title "${variation}"]
131
+ [?e :block/uid ?uid]]`;
132
+ const result = await q(this.graph, searchQuery, []);
133
+ uid = (result === null || result === undefined) ? null : String(result);
134
+ if (uid)
135
+ break;
136
+ }
137
+ if (!uid) {
138
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
139
+ }
140
+ // Define ancestor rule for traversing block hierarchy
141
+ const ancestorRule = `[
142
+ [ (ancestor ?b ?a)
143
+ [?a :block/children ?b] ]
144
+ [ (ancestor ?b ?a)
145
+ [?parent :block/children ?b]
146
+ (ancestor ?parent ?a) ]
147
+ ]`;
148
+ // Get all blocks under this page using ancestor rule
149
+ const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
150
+ :in $ % ?page-title
151
+ :where [?page :node/title ?page-title]
152
+ [?block :block/string ?block-str]
153
+ [?block :block/uid ?block-uid]
154
+ [?block :block/order ?order]
155
+ (ancestor ?block ?page)
156
+ [?parent :block/children ?block]
157
+ [?parent :block/uid ?parent-uid]]`;
158
+ const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]);
159
+ if (!blocks || blocks.length === 0) {
160
+ return `${title} (no content found)`;
161
+ }
162
+ // Create a map of all blocks
163
+ const blockMap = new Map();
164
+ const rootBlocks = [];
165
+ // First pass: Create all block objects
166
+ for (const [blockUid, blockStr, order, parentUid] of blocks) {
167
+ const resolvedString = await resolveRefs(this.graph, blockStr);
168
+ const block = {
169
+ uid: blockUid,
170
+ string: resolvedString,
171
+ order: order,
172
+ children: []
173
+ };
174
+ blockMap.set(blockUid, block);
175
+ // If no parent or parent is the page itself, it's a root block
176
+ if (!parentUid || parentUid === uid) {
177
+ rootBlocks.push(block);
178
+ }
179
+ }
180
+ // Second pass: Build parent-child relationships
181
+ for (const [blockUid, _, __, parentUid] of blocks) {
182
+ if (parentUid && parentUid !== uid) {
183
+ const child = blockMap.get(blockUid);
184
+ const parent = blockMap.get(parentUid);
185
+ if (child && parent && !parent.children.includes(child)) {
186
+ parent.children.push(child);
187
+ }
188
+ }
189
+ }
190
+ // Sort blocks recursively
191
+ const sortBlocks = (blocks) => {
192
+ blocks.sort((a, b) => a.order - b.order);
193
+ blocks.forEach(block => {
194
+ if (block.children.length > 0) {
195
+ sortBlocks(block.children);
196
+ }
197
+ });
198
+ };
199
+ sortBlocks(rootBlocks);
200
+ // Convert to markdown with proper nesting
201
+ const toMarkdown = (blocks, level = 0) => {
202
+ return blocks.map(block => {
203
+ const indent = ' '.repeat(level);
204
+ let md = `${indent}- ${block.string}`;
205
+ if (block.children.length > 0) {
206
+ md += '\n' + toMarkdown(block.children, level + 1);
207
+ }
208
+ return md;
209
+ }).join('\n');
210
+ };
211
+ return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
212
+ }
213
+ }