roam-research-mcp 1.0.0 → 1.3.2

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.
@@ -3,6 +3,9 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { capitalizeWords } from '../helpers/text.js';
5
5
  import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
6
+ import { pageUidCache } from '../../cache/page-uid-cache.js';
7
+ // Threshold for skipping child fetch during verification
8
+ const VERIFICATION_THRESHOLD = 5;
6
9
  export class OutlineOperations {
7
10
  constructor(graph) {
8
11
  this.graph = graph;
@@ -14,49 +17,31 @@ export class OutlineOperations {
14
17
  };
15
18
  }
16
19
  /**
17
- * Helper function to find block with improved relationship checks
20
+ * Helper function to find block with reduced retries for rate limit efficiency.
21
+ * Uses only the most reliable query strategy with 2 retries max.
18
22
  */
19
- async findBlockWithRetry(pageUid, blockString, maxRetries = 5, initialDelay = 1000) {
20
- // Try multiple query strategies
21
- const queries = [
22
- // Strategy 1: Direct page and string match
23
- `[:find ?b-uid ?order
24
- :where [?p :block/uid "${pageUid}"]
25
- [?b :block/page ?p]
26
- [?b :block/string "${blockString}"]
27
- [?b :block/order ?order]
28
- [?b :block/uid ?b-uid]]`,
29
- // Strategy 2: Parent-child relationship
30
- `[:find ?b-uid ?order
31
- :where [?p :block/uid "${pageUid}"]
32
- [?b :block/parents ?p]
33
- [?b :block/string "${blockString}"]
34
- [?b :block/order ?order]
35
- [?b :block/uid ?b-uid]]`,
36
- // Strategy 3: Broader page relationship
37
- `[:find ?b-uid ?order
38
- :where [?p :block/uid "${pageUid}"]
39
- [?b :block/page ?page]
40
- [?p :block/page ?page]
41
- [?b :block/string "${blockString}"]
42
- [?b :block/order ?order]
43
- [?b :block/uid ?b-uid]]`
44
- ];
23
+ async findBlockWithRetry(pageUid, blockString, maxRetries = 2, initialDelay = 1000) {
24
+ // Use only the most reliable query strategy (direct page and string match)
25
+ const query = `[:find ?b-uid ?order
26
+ :where [?p :block/uid "${pageUid}"]
27
+ [?b :block/page ?p]
28
+ [?b :block/string "${blockString}"]
29
+ [?b :block/order ?order]
30
+ [?b :block/uid ?b-uid]]`;
45
31
  for (let retry = 0; retry < maxRetries; retry++) {
46
- // Try each query strategy
47
- for (const queryStr of queries) {
48
- const blockResults = await q(this.graph, queryStr, []);
49
- if (blockResults && blockResults.length > 0) {
50
- // Use the most recently created block
51
- const sorted = blockResults.sort((a, b) => b[1] - a[1]);
52
- return sorted[0][0];
53
- }
32
+ const blockResults = await q(this.graph, query, []);
33
+ if (blockResults && blockResults.length > 0) {
34
+ // Use the most recently created block (highest order)
35
+ const sorted = blockResults.sort((a, b) => b[1] - a[1]);
36
+ return sorted[0][0];
37
+ }
38
+ // Exponential backoff between retries
39
+ if (retry < maxRetries - 1) {
40
+ const delay = initialDelay * Math.pow(2, retry);
41
+ await new Promise(resolve => setTimeout(resolve, delay));
54
42
  }
55
- // Exponential backoff
56
- const delay = initialDelay * Math.pow(2, retry);
57
- await new Promise(resolve => setTimeout(resolve, delay));
58
43
  }
59
- throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
44
+ throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after ${maxRetries} attempts`);
60
45
  }
61
46
  ;
62
47
  /**
@@ -224,21 +209,30 @@ export class OutlineOperations {
224
209
  if (invalidItems.length > 0) {
225
210
  throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
226
211
  }
227
- // Helper function to find or create page with retries
212
+ // Helper function to find or create page with retries and caching
228
213
  const findOrCreatePage = async (titleOrUid, maxRetries = 3, delayMs = 500) => {
229
- // First try to find by title
230
- const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
231
214
  const variations = [
232
215
  titleOrUid, // Original
233
216
  capitalizeWords(titleOrUid), // Each word capitalized
234
217
  titleOrUid.toLowerCase() // All lowercase
235
218
  ];
219
+ // Check cache first for any variation
220
+ for (const variation of variations) {
221
+ const cachedUid = pageUidCache.get(variation);
222
+ if (cachedUid) {
223
+ return cachedUid;
224
+ }
225
+ }
226
+ const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
236
227
  for (let retry = 0; retry < maxRetries; retry++) {
237
228
  // Try each case variation
238
229
  for (const variation of variations) {
239
230
  const findResults = await q(this.graph, titleQuery, [variation]);
240
231
  if (findResults && findResults.length > 0) {
241
- return findResults[0][0];
232
+ const uid = findResults[0][0];
233
+ // Cache the result
234
+ pageUidCache.set(titleOrUid, uid);
235
+ return uid;
242
236
  }
243
237
  }
244
238
  // If not found as title, try as UID
@@ -264,6 +258,15 @@ export class OutlineOperations {
264
258
  await new Promise(resolve => setTimeout(resolve, delayMs));
265
259
  }
266
260
  }
261
+ // One more attempt to find and cache after creation attempts
262
+ for (const variation of variations) {
263
+ const findResults = await q(this.graph, titleQuery, [variation]);
264
+ if (findResults && findResults.length > 0) {
265
+ const uid = findResults[0][0];
266
+ pageUidCache.onPageCreated(titleOrUid, uid);
267
+ return uid;
268
+ }
269
+ }
267
270
  throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
268
271
  };
269
272
  // Get or create the target page
@@ -359,18 +362,27 @@ export class OutlineOperations {
359
362
  throw error;
360
363
  throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
361
364
  }
362
- // Post-creation verification to get actual UIDs for top-level blocks and their children
365
+ // Post-creation verification to get actual UIDs for top-level blocks
363
366
  const createdBlocks = [];
364
367
  // Only query for top-level blocks (level 1) based on the original outline input
365
368
  const topLevelOutlineItems = validOutline.filter(item => item.level === 1);
369
+ // Skip recursive child fetching for large batches to reduce API calls
370
+ const skipChildFetch = topLevelOutlineItems.length > VERIFICATION_THRESHOLD;
366
371
  for (const item of topLevelOutlineItems) {
367
372
  try {
368
373
  // Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
369
374
  const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
370
375
  if (foundUid) {
371
- const nestedBlock = await this.fetchBlockWithChildren(foundUid);
372
- if (nestedBlock) {
373
- createdBlocks.push(nestedBlock);
376
+ if (skipChildFetch) {
377
+ // Large batch: just return parent UID, skip recursive child queries
378
+ createdBlocks.push({ uid: foundUid, text: item.text, level: 1, order: 0 });
379
+ }
380
+ else {
381
+ // Small batch: full verification with children (current behavior)
382
+ const nestedBlock = await this.fetchBlockWithChildren(foundUid);
383
+ if (nestedBlock) {
384
+ createdBlocks.push(nestedBlock);
385
+ }
374
386
  }
375
387
  }
376
388
  }
@@ -391,39 +403,56 @@ export class OutlineOperations {
391
403
  // First get the page UID
392
404
  let targetPageUid = page_uid;
393
405
  if (!targetPageUid && page_title) {
394
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
395
- const findResults = await q(this.graph, findQuery, [page_title]);
396
- if (findResults && findResults.length > 0) {
397
- targetPageUid = findResults[0][0];
406
+ // Check cache first
407
+ const cachedUid = pageUidCache.get(page_title);
408
+ if (cachedUid) {
409
+ targetPageUid = cachedUid;
398
410
  }
399
411
  else {
400
- throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
412
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
413
+ const findResults = await q(this.graph, findQuery, [page_title]);
414
+ if (findResults && findResults.length > 0) {
415
+ targetPageUid = findResults[0][0];
416
+ pageUidCache.set(page_title, targetPageUid);
417
+ }
418
+ else {
419
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
420
+ }
401
421
  }
402
422
  }
403
423
  // If no page specified, use today's date page
404
424
  if (!targetPageUid) {
405
425
  const today = new Date();
406
426
  const dateStr = formatRoamDate(today);
407
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
408
- const findResults = await q(this.graph, findQuery, [dateStr]);
409
- if (findResults && findResults.length > 0) {
410
- targetPageUid = findResults[0][0];
427
+ // Check cache for today's page
428
+ const cachedDailyUid = pageUidCache.get(dateStr);
429
+ if (cachedDailyUid) {
430
+ targetPageUid = cachedDailyUid;
411
431
  }
412
432
  else {
413
- // Create today's page
414
- try {
415
- await createPage(this.graph, {
416
- action: 'create-page',
417
- page: { title: dateStr }
418
- });
419
- const results = await q(this.graph, findQuery, [dateStr]);
420
- if (!results || results.length === 0) {
421
- throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
422
- }
423
- targetPageUid = results[0][0];
433
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
434
+ const findResults = await q(this.graph, findQuery, [dateStr]);
435
+ if (findResults && findResults.length > 0) {
436
+ targetPageUid = findResults[0][0];
437
+ pageUidCache.set(dateStr, targetPageUid);
424
438
  }
425
- catch (error) {
426
- throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
439
+ else {
440
+ // Create today's page
441
+ try {
442
+ await createPage(this.graph, {
443
+ action: 'create-page',
444
+ page: { title: dateStr }
445
+ });
446
+ const results = await q(this.graph, findQuery, [dateStr]);
447
+ if (!results || results.length === 0) {
448
+ throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
449
+ }
450
+ targetPageUid = results[0][0];
451
+ pageUidCache.onPageCreated(dateStr, targetPageUid);
452
+ }
453
+ catch (error) {
454
+ throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
455
+ }
427
456
  }
428
457
  }
429
458
  }
@@ -469,7 +498,18 @@ export class OutlineOperations {
469
498
  if (!result) {
470
499
  throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
471
500
  }
472
- // After successful batch action, get all nested UIDs under the parent
501
+ // Skip nested structure fetch for large imports to reduce API calls
502
+ const skipNestedFetch = actions.length > VERIFICATION_THRESHOLD;
503
+ if (skipNestedFetch) {
504
+ // Large import: return success with block count, skip recursive queries
505
+ return {
506
+ success: true,
507
+ page_uid: targetPageUid,
508
+ parent_uid: targetParentUid,
509
+ created_uids: []
510
+ };
511
+ }
512
+ // Small import: get all nested UIDs under the parent (current behavior)
473
513
  const createdUids = await this.fetchNestedStructure(targetParentUid);
474
514
  return {
475
515
  success: true,
@@ -3,6 +3,9 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { capitalizeWords } from '../helpers/text.js';
4
4
  import { resolveRefs } from '../helpers/refs.js';
5
5
  import { convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
6
+ import { pageUidCache } from '../../cache/page-uid-cache.js';
7
+ import { buildTableActions } from './table.js';
8
+ import { BatchOperations } from './batch.js';
6
9
  // Helper to get ordinal suffix for dates
7
10
  function getOrdinalSuffix(day) {
8
11
  if (day > 3 && day < 21)
@@ -17,6 +20,7 @@ function getOrdinalSuffix(day) {
17
20
  export class PageOperations {
18
21
  constructor(graph) {
19
22
  this.graph = graph;
23
+ this.batchOps = new BatchOperations(graph);
20
24
  }
21
25
  async findPagesModifiedToday(limit = 50, offset = 0, sort_order = 'desc') {
22
26
  // Define ancestor rule for traversing block hierarchy
@@ -77,72 +81,146 @@ export class PageOperations {
77
81
  async createPage(title, content) {
78
82
  // Ensure title is properly formatted
79
83
  const pageTitle = String(title).trim();
80
- // First try to find if the page exists
81
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
82
- const findResults = await q(this.graph, findQuery, [pageTitle]);
83
84
  let pageUid;
84
- if (findResults && findResults.length > 0) {
85
- // Page exists, use its UID
86
- pageUid = findResults[0][0];
85
+ // Check cache first to avoid unnecessary query
86
+ const cachedUid = pageUidCache.get(pageTitle);
87
+ if (cachedUid) {
88
+ pageUid = cachedUid;
87
89
  }
88
90
  else {
89
- // Create new page
90
- try {
91
- await createRoamPage(this.graph, {
92
- action: 'create-page',
93
- page: {
94
- title: pageTitle
91
+ // First try to find if the page exists
92
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
93
+ const findResults = await q(this.graph, findQuery, [pageTitle]);
94
+ if (findResults && findResults.length > 0) {
95
+ // Page exists, use its UID and cache it
96
+ pageUid = findResults[0][0];
97
+ pageUidCache.set(pageTitle, pageUid);
98
+ }
99
+ else {
100
+ // Create new page
101
+ try {
102
+ await createRoamPage(this.graph, {
103
+ action: 'create-page',
104
+ page: {
105
+ title: pageTitle
106
+ }
107
+ });
108
+ // Get the new page's UID
109
+ const results = await q(this.graph, findQuery, [pageTitle]);
110
+ if (!results || results.length === 0) {
111
+ throw new Error('Could not find created page');
95
112
  }
96
- });
97
- // Get the new page's UID
98
- const results = await q(this.graph, findQuery, [pageTitle]);
99
- if (!results || results.length === 0) {
100
- throw new Error('Could not find created page');
113
+ pageUid = results[0][0];
114
+ // Cache the newly created page
115
+ pageUidCache.onPageCreated(pageTitle, pageUid);
116
+ }
117
+ catch (error) {
118
+ throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
101
119
  }
102
- pageUid = results[0][0];
103
- }
104
- catch (error) {
105
- throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
106
120
  }
107
121
  }
108
122
  // If content is provided, create blocks using batch operations
109
123
  if (content && content.length > 0) {
110
124
  try {
111
- // Convert content array to MarkdownNode format expected by convertToRoamActions
112
- const nodes = content.map(block => ({
113
- content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
114
- level: block.level,
115
- ...(block.heading && { heading_level: block.heading }),
116
- children: []
117
- }));
118
- // Create hierarchical structure based on levels
119
- const rootNodes = [];
120
- const levelMap = {};
121
- for (const node of nodes) {
122
- if (node.level === 1) {
123
- rootNodes.push(node);
124
- levelMap[1] = node;
125
+ // Idempotency check: If page already has content, skip adding more
126
+ // This prevents duplicate content when the tool is called twice
127
+ const existingBlocksQuery = `[:find (count ?b) .
128
+ :where [?p :block/uid "${pageUid}"]
129
+ [?p :block/children ?b]]`;
130
+ const existingBlockCountResult = await q(this.graph, existingBlocksQuery, []);
131
+ const existingBlockCount = typeof existingBlockCountResult === 'number' ? existingBlockCountResult : 0;
132
+ if (existingBlockCount && existingBlockCount > 0) {
133
+ // Page already has content - this might be a duplicate call
134
+ // Return success without adding duplicate content
135
+ return { success: true, uid: pageUid };
136
+ }
137
+ // Separate text content from table content, maintaining order
138
+ const textItems = [];
139
+ const tableItems = [];
140
+ for (let i = 0; i < content.length; i++) {
141
+ const item = content[i];
142
+ if (item.type === 'table') {
143
+ tableItems.push({ index: i, item: item });
125
144
  }
126
145
  else {
127
- const parentLevel = node.level - 1;
128
- const parent = levelMap[parentLevel];
129
- if (!parent) {
130
- throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
146
+ // Default to text type
147
+ textItems.push(item);
148
+ }
149
+ }
150
+ // Process text blocks
151
+ const allActions = [];
152
+ if (textItems.length > 0) {
153
+ // Filter out empty blocks (empty or whitespace-only text) to prevent creating visual linebreaks
154
+ const nonEmptyContent = textItems.filter(block => block.text && block.text.trim().length > 0);
155
+ if (nonEmptyContent.length > 0) {
156
+ // Normalize levels to prevent gaps after filtering
157
+ const normalizedContent = [];
158
+ for (let i = 0; i < nonEmptyContent.length; i++) {
159
+ const block = nonEmptyContent[i];
160
+ if (i === 0) {
161
+ normalizedContent.push({ ...block, level: 1 });
162
+ }
163
+ else {
164
+ const prevLevel = normalizedContent[i - 1].level;
165
+ const maxAllowedLevel = prevLevel + 1;
166
+ normalizedContent.push({
167
+ ...block,
168
+ level: Math.min(block.level, maxAllowedLevel)
169
+ });
170
+ }
131
171
  }
132
- parent.children.push(node);
133
- levelMap[node.level] = node;
172
+ // Convert content array to MarkdownNode format expected by convertToRoamActions
173
+ const nodes = normalizedContent.map(block => ({
174
+ content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
175
+ level: block.level,
176
+ ...(block.heading && { heading_level: block.heading }),
177
+ children: []
178
+ }));
179
+ // Create hierarchical structure based on levels
180
+ const rootNodes = [];
181
+ const levelMap = {};
182
+ for (const node of nodes) {
183
+ if (node.level === 1) {
184
+ rootNodes.push(node);
185
+ levelMap[1] = node;
186
+ }
187
+ else {
188
+ const parentLevel = node.level - 1;
189
+ const parent = levelMap[parentLevel];
190
+ if (!parent) {
191
+ throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
192
+ }
193
+ parent.children.push(node);
194
+ levelMap[node.level] = node;
195
+ }
196
+ }
197
+ // Generate batch actions for text blocks
198
+ const textActions = convertToRoamActions(rootNodes, pageUid, 'last');
199
+ allActions.push(...textActions);
134
200
  }
135
201
  }
136
- // Generate batch actions for all blocks
137
- const actions = convertToRoamActions(rootNodes, pageUid, 'last');
138
- // Execute batch operation
139
- if (actions.length > 0) {
202
+ // Execute text block actions first (no placeholders, use SDK directly)
203
+ if (allActions.length > 0) {
140
204
  const batchResult = await batchActions(this.graph, {
141
205
  action: 'batch-actions',
142
- actions
206
+ actions: allActions
143
207
  });
144
208
  if (!batchResult) {
145
- throw new Error('Failed to create blocks');
209
+ throw new Error('Failed to create text blocks');
210
+ }
211
+ }
212
+ // Process table items separately (use BatchOperations to handle UID placeholders)
213
+ for (const { item } of tableItems) {
214
+ const tableActions = buildTableActions({
215
+ parent_uid: pageUid,
216
+ headers: item.headers,
217
+ rows: item.rows,
218
+ order: 'last'
219
+ });
220
+ // Use BatchOperations.processBatch to handle {{uid:*}} placeholders
221
+ const tableResult = await this.batchOps.processBatch(tableActions);
222
+ if (!tableResult.success) {
223
+ throw new Error(`Failed to create table: ${typeof tableResult.error === 'string' ? tableResult.error : tableResult.error?.message}`);
146
224
  }
147
225
  }
148
226
  }
@@ -157,11 +235,18 @@ export class PageOperations {
157
235
  const month = today.toLocaleString('en-US', { month: 'long' });
158
236
  const year = today.getFullYear();
159
237
  const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
160
- const dailyPageQuery = `[:find ?uid .
161
- :where [?e :node/title "${formattedTodayTitle}"]
162
- [?e :block/uid ?uid]]`;
163
- const dailyPageResult = await q(this.graph, dailyPageQuery, []);
164
- const dailyPageUid = dailyPageResult ? String(dailyPageResult) : null;
238
+ // Check cache for daily page
239
+ let dailyPageUid = pageUidCache.get(formattedTodayTitle);
240
+ if (!dailyPageUid) {
241
+ const dailyPageQuery = `[:find ?uid .
242
+ :where [?e :node/title "${formattedTodayTitle}"]
243
+ [?e :block/uid ?uid]]`;
244
+ const dailyPageResult = await q(this.graph, dailyPageQuery, []);
245
+ dailyPageUid = dailyPageResult ? String(dailyPageResult) : undefined;
246
+ if (dailyPageUid) {
247
+ pageUidCache.set(formattedTodayTitle, dailyPageUid);
248
+ }
249
+ }
165
250
  if (dailyPageUid) {
166
251
  await createBlock(this.graph, {
167
252
  action: 'create-block',
@@ -188,19 +273,33 @@ export class PageOperations {
188
273
  throw new McpError(ErrorCode.InvalidRequest, 'title is required');
189
274
  }
190
275
  // Try different case variations
191
- // Generate variations to check
192
276
  const variations = [
193
277
  title, // Original
194
278
  capitalizeWords(title), // Each word capitalized
195
279
  title.toLowerCase() // All lowercase
196
280
  ];
197
- // Create OR clause for query
198
- const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
199
- const searchQuery = `[:find ?uid .
200
- :where [?e :block/uid ?uid]
201
- (or ${orClause})]`;
202
- const result = await q(this.graph, searchQuery, []);
203
- const uid = (result === null || result === undefined) ? null : String(result);
281
+ // Check cache first for any variation
282
+ let uid = null;
283
+ for (const variation of variations) {
284
+ const cachedUid = pageUidCache.get(variation);
285
+ if (cachedUid) {
286
+ uid = cachedUid;
287
+ break;
288
+ }
289
+ }
290
+ // If not cached, query the database
291
+ if (!uid) {
292
+ const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
293
+ const searchQuery = `[:find ?uid .
294
+ :where [?e :block/uid ?uid]
295
+ (or ${orClause})]`;
296
+ const result = await q(this.graph, searchQuery, []);
297
+ uid = (result === null || result === undefined) ? null : String(result);
298
+ // Cache the result for the original title
299
+ if (uid) {
300
+ pageUidCache.set(title, uid);
301
+ }
302
+ }
204
303
  if (!uid) {
205
304
  throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
206
305
  }