roam-research-mcp 0.36.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,8 +20,9 @@ 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
- async findPagesModifiedToday(max_num_pages = 50) {
25
+ async findPagesModifiedToday(limit = 50, offset = 0, sort_order = 'desc') {
22
26
  // Define ancestor rule for traversing block hierarchy
23
27
  const ancestorRule = `[
24
28
  [ (ancestor ?b ?a)
@@ -31,15 +35,21 @@ export class PageOperations {
31
35
  const startOfDay = new Date();
32
36
  startOfDay.setHours(0, 0, 0, 0);
33
37
  try {
34
- // Query for pages modified today
35
- const results = await q(this.graph, `[:find ?title
38
+ // Query for pages modified today, including modification time for sorting
39
+ let query = `[:find ?title ?time
36
40
  :in $ ?start_of_day %
37
41
  :where
38
42
  [?page :node/title ?title]
39
43
  (ancestor ?block ?page)
40
44
  [?block :edit/time ?time]
41
- [(> ?time ?start_of_day)]]
42
- :limit ${max_num_pages}`, [startOfDay.getTime(), ancestorRule]);
45
+ [(> ?time ?start_of_day)]]`;
46
+ if (limit !== -1) {
47
+ query += ` :limit ${limit}`;
48
+ }
49
+ if (offset > 0) {
50
+ query += ` :offset ${offset}`;
51
+ }
52
+ const results = await q(this.graph, query, [startOfDay.getTime(), ancestorRule]);
43
53
  if (!results || results.length === 0) {
44
54
  return {
45
55
  success: true,
@@ -47,7 +57,16 @@ export class PageOperations {
47
57
  message: 'No pages have been modified today'
48
58
  };
49
59
  }
50
- // Extract unique page titles
60
+ // Sort results by modification time
61
+ results.sort((a, b) => {
62
+ if (sort_order === 'desc') {
63
+ return b[1] - a[1]; // Newest first
64
+ }
65
+ else {
66
+ return a[1] - b[1]; // Oldest first
67
+ }
68
+ });
69
+ // Extract unique page titles from sorted results
51
70
  const uniquePages = Array.from(new Set(results.map(([title]) => title)));
52
71
  return {
53
72
  success: true,
@@ -62,72 +81,146 @@ export class PageOperations {
62
81
  async createPage(title, content) {
63
82
  // Ensure title is properly formatted
64
83
  const pageTitle = String(title).trim();
65
- // First try to find if the page exists
66
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
67
- const findResults = await q(this.graph, findQuery, [pageTitle]);
68
84
  let pageUid;
69
- if (findResults && findResults.length > 0) {
70
- // Page exists, use its UID
71
- pageUid = findResults[0][0];
85
+ // Check cache first to avoid unnecessary query
86
+ const cachedUid = pageUidCache.get(pageTitle);
87
+ if (cachedUid) {
88
+ pageUid = cachedUid;
72
89
  }
73
90
  else {
74
- // Create new page
75
- try {
76
- await createRoamPage(this.graph, {
77
- action: 'create-page',
78
- page: {
79
- 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');
80
112
  }
81
- });
82
- // Get the new page's UID
83
- const results = await q(this.graph, findQuery, [pageTitle]);
84
- if (!results || results.length === 0) {
85
- 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)}`);
86
119
  }
87
- pageUid = results[0][0];
88
- }
89
- catch (error) {
90
- throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
91
120
  }
92
121
  }
93
122
  // If content is provided, create blocks using batch operations
94
123
  if (content && content.length > 0) {
95
124
  try {
96
- // Convert content array to MarkdownNode format expected by convertToRoamActions
97
- const nodes = content.map(block => ({
98
- content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
99
- level: block.level,
100
- ...(block.heading && { heading_level: block.heading }),
101
- children: []
102
- }));
103
- // Create hierarchical structure based on levels
104
- const rootNodes = [];
105
- const levelMap = {};
106
- for (const node of nodes) {
107
- if (node.level === 1) {
108
- rootNodes.push(node);
109
- 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 });
110
144
  }
111
145
  else {
112
- const parentLevel = node.level - 1;
113
- const parent = levelMap[parentLevel];
114
- if (!parent) {
115
- 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
+ }
116
171
  }
117
- parent.children.push(node);
118
- 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);
119
200
  }
120
201
  }
121
- // Generate batch actions for all blocks
122
- const actions = convertToRoamActions(rootNodes, pageUid, 'last');
123
- // Execute batch operation
124
- if (actions.length > 0) {
202
+ // Execute text block actions first (no placeholders, use SDK directly)
203
+ if (allActions.length > 0) {
125
204
  const batchResult = await batchActions(this.graph, {
126
205
  action: 'batch-actions',
127
- actions
206
+ actions: allActions
128
207
  });
129
208
  if (!batchResult) {
130
- 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}`);
131
224
  }
132
225
  }
133
226
  }
@@ -142,11 +235,18 @@ export class PageOperations {
142
235
  const month = today.toLocaleString('en-US', { month: 'long' });
143
236
  const year = today.getFullYear();
144
237
  const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
145
- const dailyPageQuery = `[:find ?uid .
146
- :where [?e :node/title "${formattedTodayTitle}"]
147
- [?e :block/uid ?uid]]`;
148
- const dailyPageResult = await q(this.graph, dailyPageQuery, []);
149
- 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
+ }
150
250
  if (dailyPageUid) {
151
251
  await createBlock(this.graph, {
152
252
  action: 'create-block',
@@ -178,15 +278,27 @@ export class PageOperations {
178
278
  capitalizeWords(title), // Each word capitalized
179
279
  title.toLowerCase() // All lowercase
180
280
  ];
281
+ // Check cache first for any variation
181
282
  let uid = null;
182
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(' ');
183
293
  const searchQuery = `[:find ?uid .
184
- :where [?e :node/title "${variation}"]
185
- [?e :block/uid ?uid]]`;
294
+ :where [?e :block/uid ?uid]
295
+ (or ${orClause})]`;
186
296
  const result = await q(this.graph, searchQuery, []);
187
297
  uid = (result === null || result === undefined) ? null : String(result);
188
- if (uid)
189
- break;
298
+ // Cache the result for the original title
299
+ if (uid) {
300
+ pageUidCache.set(title, uid);
301
+ }
190
302
  }
191
303
  if (!uid) {
192
304
  throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);