roam-research-mcp 1.0.0 → 1.4.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 +104 -17
- package/build/Roam_Markdown_Cheatsheet.md +528 -175
- package/build/cache/page-uid-cache.js +55 -0
- package/build/cli/import-markdown.js +98 -0
- package/build/config/environment.js +4 -2
- package/build/diff/actions.js +93 -0
- package/build/diff/actions.test.js +125 -0
- package/build/diff/diff.js +155 -0
- package/build/diff/diff.test.js +202 -0
- package/build/diff/index.js +43 -0
- package/build/diff/matcher.js +118 -0
- package/build/diff/matcher.test.js +198 -0
- package/build/diff/parser.js +114 -0
- package/build/diff/parser.test.js +281 -0
- package/build/diff/types.js +27 -0
- package/build/diff/types.test.js +57 -0
- package/build/markdown-utils.js +51 -5
- package/build/server/roam-server.js +76 -10
- package/build/shared/errors.js +84 -0
- package/build/shared/index.js +5 -0
- package/build/shared/validation.js +268 -0
- package/build/tools/operations/batch.js +165 -3
- package/build/tools/operations/memory.js +29 -19
- package/build/tools/operations/outline.js +110 -70
- package/build/tools/operations/pages.js +254 -60
- package/build/tools/operations/table.js +142 -0
- package/build/tools/schemas.js +110 -9
- package/build/tools/tool-handlers.js +12 -2
- package/package.json +9 -5
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared validation functions for Roam MCP tools.
|
|
3
|
+
* Provides consistent validation across all write operations.
|
|
4
|
+
*/
|
|
5
|
+
// Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
|
|
6
|
+
const UID_PLACEHOLDER_REGEX = /^\{\{uid:[^}]+\}\}$/;
|
|
7
|
+
/**
|
|
8
|
+
* Validates a block string content.
|
|
9
|
+
* @param str The string to validate
|
|
10
|
+
* @param allowEmpty If true, allows empty strings (for intentional blank blocks)
|
|
11
|
+
* @returns Error message if invalid, null if valid
|
|
12
|
+
*/
|
|
13
|
+
export function validateBlockString(str, allowEmpty = false) {
|
|
14
|
+
if (str === undefined || str === null) {
|
|
15
|
+
return 'string is required';
|
|
16
|
+
}
|
|
17
|
+
// Only reject truly empty strings '', not whitespace-only strings like ' '
|
|
18
|
+
// Whitespace-only strings are valid in Roam (e.g., empty table cells)
|
|
19
|
+
if (!allowEmpty && typeof str === 'string' && str === '') {
|
|
20
|
+
return 'string cannot be empty (use " " for intentional whitespace)';
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validates a Roam UID.
|
|
26
|
+
* UIDs must be either:
|
|
27
|
+
* - 9 alphanumeric characters (standard Roam UID)
|
|
28
|
+
* - A placeholder like {{uid:name}}
|
|
29
|
+
* @param uid The UID to validate
|
|
30
|
+
* @param required If true, UID is required
|
|
31
|
+
* @returns Error message if invalid, null if valid
|
|
32
|
+
*/
|
|
33
|
+
export function validateUid(uid, required = true) {
|
|
34
|
+
if (!uid) {
|
|
35
|
+
return required ? 'uid is required' : null;
|
|
36
|
+
}
|
|
37
|
+
// Check if it's a placeholder
|
|
38
|
+
if (UID_PLACEHOLDER_REGEX.test(uid)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Check if it's a valid Roam UID (9 alphanumeric characters)
|
|
42
|
+
if (!/^[a-zA-Z0-9_-]{9}$/.test(uid)) {
|
|
43
|
+
return 'uid must be 9 alphanumeric characters or a {{uid:name}} placeholder';
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validates an outline level.
|
|
49
|
+
* @param level The level to validate
|
|
50
|
+
* @returns Error message if invalid, null if valid
|
|
51
|
+
*/
|
|
52
|
+
export function validateOutlineLevel(level) {
|
|
53
|
+
if (level === undefined || level === null) {
|
|
54
|
+
return 'level is required';
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isInteger(level) || level < 1 || level > 10) {
|
|
57
|
+
return 'level must be an integer between 1 and 10';
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validates a location object for block creation/movement.
|
|
63
|
+
* @param location The location object to validate
|
|
64
|
+
* @returns Error message if invalid, null if valid
|
|
65
|
+
*/
|
|
66
|
+
export function validateLocation(location) {
|
|
67
|
+
if (!location) {
|
|
68
|
+
return 'location is required';
|
|
69
|
+
}
|
|
70
|
+
if (!location['parent-uid']) {
|
|
71
|
+
return 'location.parent-uid is required';
|
|
72
|
+
}
|
|
73
|
+
const uidError = validateUid(location['parent-uid'], true);
|
|
74
|
+
if (uidError) {
|
|
75
|
+
return `location.parent-uid: ${uidError}`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Validates a heading level.
|
|
81
|
+
* @param heading The heading level to validate
|
|
82
|
+
* @returns Error message if invalid, null if valid
|
|
83
|
+
*/
|
|
84
|
+
export function validateHeading(heading) {
|
|
85
|
+
if (heading === undefined || heading === null || heading === 0) {
|
|
86
|
+
return null; // Heading is optional
|
|
87
|
+
}
|
|
88
|
+
if (!Number.isInteger(heading) || heading < 1 || heading > 3) {
|
|
89
|
+
return 'heading must be 1, 2, or 3';
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Validates all batch actions before execution.
|
|
95
|
+
* @param actions Array of batch actions to validate
|
|
96
|
+
* @returns ValidationResult with errors if any
|
|
97
|
+
*/
|
|
98
|
+
export function validateBatchActions(actions) {
|
|
99
|
+
const errors = [];
|
|
100
|
+
if (!Array.isArray(actions)) {
|
|
101
|
+
errors.push({
|
|
102
|
+
field: 'actions',
|
|
103
|
+
message: 'actions must be an array'
|
|
104
|
+
});
|
|
105
|
+
return { valid: false, errors };
|
|
106
|
+
}
|
|
107
|
+
if (actions.length === 0) {
|
|
108
|
+
errors.push({
|
|
109
|
+
field: 'actions',
|
|
110
|
+
message: 'actions array cannot be empty'
|
|
111
|
+
});
|
|
112
|
+
return { valid: false, errors };
|
|
113
|
+
}
|
|
114
|
+
// Track defined placeholders for forward-reference validation
|
|
115
|
+
const definedPlaceholders = new Set();
|
|
116
|
+
for (let i = 0; i < actions.length; i++) {
|
|
117
|
+
const action = actions[i];
|
|
118
|
+
// Validate action type
|
|
119
|
+
if (!action.action) {
|
|
120
|
+
errors.push({
|
|
121
|
+
actionIndex: i,
|
|
122
|
+
field: 'action',
|
|
123
|
+
message: 'action type is required',
|
|
124
|
+
expected: 'create-block | update-block | move-block | delete-block'
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const validActions = ['create-block', 'update-block', 'move-block', 'delete-block'];
|
|
129
|
+
if (!validActions.includes(action.action)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
actionIndex: i,
|
|
132
|
+
field: 'action',
|
|
133
|
+
message: `invalid action type: ${action.action}`,
|
|
134
|
+
expected: 'create-block | update-block | move-block | delete-block',
|
|
135
|
+
received: action.action
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// Track placeholder definitions
|
|
140
|
+
if (action.uid && UID_PLACEHOLDER_REGEX.test(action.uid)) {
|
|
141
|
+
const placeholderMatch = action.uid.match(/\{\{uid:([^}]+)\}\}/);
|
|
142
|
+
if (placeholderMatch) {
|
|
143
|
+
definedPlaceholders.add(placeholderMatch[1]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Validate based on action type
|
|
147
|
+
switch (action.action) {
|
|
148
|
+
case 'create-block': {
|
|
149
|
+
// create-block requires string and location
|
|
150
|
+
const stringError = validateBlockString(action.string);
|
|
151
|
+
if (stringError) {
|
|
152
|
+
errors.push({
|
|
153
|
+
actionIndex: i,
|
|
154
|
+
field: 'string',
|
|
155
|
+
message: stringError
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const locationError = validateLocation(action.location);
|
|
159
|
+
if (locationError) {
|
|
160
|
+
errors.push({
|
|
161
|
+
actionIndex: i,
|
|
162
|
+
field: 'location',
|
|
163
|
+
message: locationError
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// Check for forward-reference to undefined placeholder
|
|
167
|
+
if (action.location?.['parent-uid']) {
|
|
168
|
+
const parentUid = action.location['parent-uid'];
|
|
169
|
+
const placeholderMatch = parentUid.match(/\{\{uid:([^}]+)\}\}/);
|
|
170
|
+
if (placeholderMatch && !definedPlaceholders.has(placeholderMatch[1])) {
|
|
171
|
+
errors.push({
|
|
172
|
+
actionIndex: i,
|
|
173
|
+
field: 'location.parent-uid',
|
|
174
|
+
message: `Placeholder {{uid:${placeholderMatch[1]}}} referenced before definition`
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const headingError = validateHeading(action.heading);
|
|
179
|
+
if (headingError) {
|
|
180
|
+
errors.push({
|
|
181
|
+
actionIndex: i,
|
|
182
|
+
field: 'heading',
|
|
183
|
+
message: headingError
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'update-block': {
|
|
189
|
+
// update-block requires uid
|
|
190
|
+
const uidError = validateUid(action.uid);
|
|
191
|
+
if (uidError) {
|
|
192
|
+
errors.push({
|
|
193
|
+
actionIndex: i,
|
|
194
|
+
field: 'uid',
|
|
195
|
+
message: uidError
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
// string is optional for update but if provided, validate it
|
|
199
|
+
if (action.string !== undefined) {
|
|
200
|
+
const stringError = validateBlockString(action.string);
|
|
201
|
+
if (stringError) {
|
|
202
|
+
errors.push({
|
|
203
|
+
actionIndex: i,
|
|
204
|
+
field: 'string',
|
|
205
|
+
message: stringError
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const headingError = validateHeading(action.heading);
|
|
210
|
+
if (headingError) {
|
|
211
|
+
errors.push({
|
|
212
|
+
actionIndex: i,
|
|
213
|
+
field: 'heading',
|
|
214
|
+
message: headingError
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'move-block': {
|
|
220
|
+
// move-block requires uid and location
|
|
221
|
+
const uidError = validateUid(action.uid);
|
|
222
|
+
if (uidError) {
|
|
223
|
+
errors.push({
|
|
224
|
+
actionIndex: i,
|
|
225
|
+
field: 'uid',
|
|
226
|
+
message: uidError
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const locationError = validateLocation(action.location);
|
|
230
|
+
if (locationError) {
|
|
231
|
+
errors.push({
|
|
232
|
+
actionIndex: i,
|
|
233
|
+
field: 'location',
|
|
234
|
+
message: locationError
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'delete-block': {
|
|
240
|
+
// delete-block requires uid
|
|
241
|
+
const uidError = validateUid(action.uid);
|
|
242
|
+
if (uidError) {
|
|
243
|
+
errors.push({
|
|
244
|
+
actionIndex: i,
|
|
245
|
+
field: 'uid',
|
|
246
|
+
message: uidError
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
valid: errors.length === 0,
|
|
255
|
+
errors
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Formats validation errors into a human-readable string.
|
|
260
|
+
*/
|
|
261
|
+
export function formatValidationErrors(errors) {
|
|
262
|
+
return errors
|
|
263
|
+
.map(err => {
|
|
264
|
+
const prefix = err.actionIndex !== undefined ? `Action ${err.actionIndex}: ` : '';
|
|
265
|
+
return `${prefix}[${err.field}] ${err.message}`;
|
|
266
|
+
})
|
|
267
|
+
.join('; ');
|
|
268
|
+
}
|
|
@@ -1,10 +1,133 @@
|
|
|
1
1
|
import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { generateBlockUid } from '../../markdown-utils.js';
|
|
3
|
+
import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js';
|
|
4
|
+
import { isRateLimitError, createRateLimitError } from '../../shared/errors.js';
|
|
5
|
+
// Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
|
|
6
|
+
const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g;
|
|
7
|
+
const DEFAULT_RATE_LIMIT_CONFIG = {
|
|
8
|
+
maxRetries: 3,
|
|
9
|
+
initialDelayMs: 1000,
|
|
10
|
+
maxDelayMs: 60000,
|
|
11
|
+
backoffMultiplier: 2
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Sleep for a specified number of milliseconds.
|
|
15
|
+
*/
|
|
16
|
+
function sleep(ms) {
|
|
17
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
18
|
+
}
|
|
2
19
|
export class BatchOperations {
|
|
3
|
-
constructor(graph) {
|
|
20
|
+
constructor(graph, rateLimitConfig) {
|
|
4
21
|
this.graph = graph;
|
|
22
|
+
this.rateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG, ...rateLimitConfig };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Finds all unique UID placeholders in the actions and generates real UIDs for them.
|
|
26
|
+
* Returns a map of placeholder name → generated UID.
|
|
27
|
+
*/
|
|
28
|
+
generateUidMap(actions) {
|
|
29
|
+
const placeholders = new Set();
|
|
30
|
+
const actionsJson = JSON.stringify(actions);
|
|
31
|
+
let match;
|
|
32
|
+
// Reset regex lastIndex to ensure fresh matching
|
|
33
|
+
UID_PLACEHOLDER_REGEX.lastIndex = 0;
|
|
34
|
+
while ((match = UID_PLACEHOLDER_REGEX.exec(actionsJson)) !== null) {
|
|
35
|
+
placeholders.add(match[1]); // The placeholder name (e.g., "parent1")
|
|
36
|
+
}
|
|
37
|
+
const uidMap = {};
|
|
38
|
+
for (const placeholder of placeholders) {
|
|
39
|
+
uidMap[placeholder] = generateBlockUid();
|
|
40
|
+
}
|
|
41
|
+
return uidMap;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Replaces all {{uid:*}} placeholders in a string with their generated UIDs.
|
|
45
|
+
*/
|
|
46
|
+
replacePlaceholders(value, uidMap) {
|
|
47
|
+
return value.replace(UID_PLACEHOLDER_REGEX, (_, name) => {
|
|
48
|
+
return uidMap[name] || _; // Return original if not found (shouldn't happen)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Recursively replaces placeholders in an object/array.
|
|
53
|
+
*/
|
|
54
|
+
replacePlaceholdersInObject(obj, uidMap) {
|
|
55
|
+
if (typeof obj === 'string') {
|
|
56
|
+
return this.replacePlaceholders(obj, uidMap);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(obj)) {
|
|
59
|
+
return obj.map(item => this.replacePlaceholdersInObject(item, uidMap));
|
|
60
|
+
}
|
|
61
|
+
if (obj && typeof obj === 'object') {
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const key of Object.keys(obj)) {
|
|
64
|
+
result[key] = this.replacePlaceholdersInObject(obj[key], uidMap);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
return obj;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Executes the batch operation with retry logic for rate limiting.
|
|
72
|
+
*/
|
|
73
|
+
async executeWithRetry(batchActions) {
|
|
74
|
+
let lastError;
|
|
75
|
+
let delay = this.rateLimitConfig.initialDelayMs;
|
|
76
|
+
for (let attempt = 0; attempt <= this.rateLimitConfig.maxRetries; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
await roamBatchActions(this.graph, { actions: batchActions });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!isRateLimitError(error)) {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
lastError = error;
|
|
86
|
+
if (attempt < this.rateLimitConfig.maxRetries) {
|
|
87
|
+
const waitTime = Math.min(delay, this.rateLimitConfig.maxDelayMs);
|
|
88
|
+
console.log(`[batch] Rate limited, retrying in ${waitTime}ms (attempt ${attempt + 1}/${this.rateLimitConfig.maxRetries})`);
|
|
89
|
+
await sleep(waitTime);
|
|
90
|
+
delay *= this.rateLimitConfig.backoffMultiplier;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Throw with rate limit context after all retries exhausted
|
|
95
|
+
const rateLimitError = new Error(`Rate limit exceeded after ${this.rateLimitConfig.maxRetries} retries. ` +
|
|
96
|
+
`Last error: ${lastError?.message || 'Unknown error'}. ` +
|
|
97
|
+
`Retry after ${this.rateLimitConfig.maxDelayMs}ms.`);
|
|
98
|
+
rateLimitError.isRateLimit = true;
|
|
99
|
+
rateLimitError.retryAfterMs = this.rateLimitConfig.maxDelayMs;
|
|
100
|
+
throw rateLimitError;
|
|
5
101
|
}
|
|
6
102
|
async processBatch(actions) {
|
|
7
|
-
|
|
103
|
+
// Step 0: Pre-validate all actions before any execution
|
|
104
|
+
const validationResult = validateBatchActions(actions);
|
|
105
|
+
if (!validationResult.valid) {
|
|
106
|
+
return {
|
|
107
|
+
success: false,
|
|
108
|
+
error: {
|
|
109
|
+
code: 'VALIDATION_ERROR',
|
|
110
|
+
message: formatValidationErrors(validationResult.errors),
|
|
111
|
+
details: validationResult.errors.length > 0 ? {
|
|
112
|
+
action_index: validationResult.errors[0].actionIndex,
|
|
113
|
+
field: validationResult.errors[0].field,
|
|
114
|
+
expected: validationResult.errors[0].expected,
|
|
115
|
+
received: validationResult.errors[0].received
|
|
116
|
+
} : undefined
|
|
117
|
+
},
|
|
118
|
+
validation_passed: false,
|
|
119
|
+
actions_attempted: 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Step 1: Generate UIDs for all placeholders
|
|
123
|
+
const uidMap = this.generateUidMap(actions);
|
|
124
|
+
const hasPlaceholders = Object.keys(uidMap).length > 0;
|
|
125
|
+
// Step 2: Replace placeholders with real UIDs
|
|
126
|
+
const processedActions = hasPlaceholders
|
|
127
|
+
? this.replacePlaceholdersInObject(actions, uidMap)
|
|
128
|
+
: actions;
|
|
129
|
+
// Step 3: Convert to Roam batch actions format
|
|
130
|
+
const batchActions = processedActions.map((action) => {
|
|
8
131
|
const { action: actionType, ...rest } = action;
|
|
9
132
|
const roamAction = { action: actionType };
|
|
10
133
|
if (rest.location) {
|
|
@@ -32,6 +155,45 @@ export class BatchOperations {
|
|
|
32
155
|
}
|
|
33
156
|
return roamAction;
|
|
34
157
|
});
|
|
35
|
-
|
|
158
|
+
try {
|
|
159
|
+
await this.executeWithRetry(batchActions);
|
|
160
|
+
// SUCCESS: Return uid_map only on success
|
|
161
|
+
const result = {
|
|
162
|
+
success: true,
|
|
163
|
+
validation_passed: true,
|
|
164
|
+
actions_attempted: batchActions.length
|
|
165
|
+
};
|
|
166
|
+
if (hasPlaceholders) {
|
|
167
|
+
result.uid_map = uidMap;
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// FAILURE: Do NOT return uid_map - blocks don't exist
|
|
173
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
174
|
+
// Check if it's a rate limit error
|
|
175
|
+
if (isRateLimitError(error) || error.isRateLimit) {
|
|
176
|
+
return {
|
|
177
|
+
success: false,
|
|
178
|
+
error: createRateLimitError(error.retryAfterMs),
|
|
179
|
+
validation_passed: true,
|
|
180
|
+
actions_attempted: batchActions.length
|
|
181
|
+
// No uid_map - nothing was committed
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
error: {
|
|
187
|
+
code: 'TRANSACTION_FAILED',
|
|
188
|
+
message: errorMessage,
|
|
189
|
+
recovery: {
|
|
190
|
+
suggestion: 'Check the error message and retry with corrected actions'
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
validation_passed: true,
|
|
194
|
+
actions_attempted: batchActions.length
|
|
195
|
+
// No uid_map - nothing was committed (or we can't verify what was)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
36
198
|
}
|
|
37
199
|
}
|
|
@@ -3,6 +3,7 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
|
3
3
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
4
|
import { resolveRefs } from '../helpers/refs.js';
|
|
5
5
|
import { SearchOperations } from './search/index.js';
|
|
6
|
+
import { pageUidCache } from '../../cache/page-uid-cache.js';
|
|
6
7
|
export class MemoryOperations {
|
|
7
8
|
constructor(graph) {
|
|
8
9
|
this.graph = graph;
|
|
@@ -12,29 +13,38 @@ export class MemoryOperations {
|
|
|
12
13
|
// Get today's date
|
|
13
14
|
const today = new Date();
|
|
14
15
|
const dateStr = formatRoamDate(today);
|
|
15
|
-
// Try to find today's page
|
|
16
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
17
|
-
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
18
16
|
let pageUid;
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
// Check cache first for today's page
|
|
18
|
+
const cachedUid = pageUidCache.get(dateStr);
|
|
19
|
+
if (cachedUid) {
|
|
20
|
+
pageUid = cachedUid;
|
|
21
21
|
}
|
|
22
22
|
else {
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Get the new page's UID
|
|
30
|
-
const results = await q(this.graph, findQuery, [dateStr]);
|
|
31
|
-
if (!results || results.length === 0) {
|
|
32
|
-
throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
|
|
33
|
-
}
|
|
34
|
-
pageUid = results[0][0];
|
|
23
|
+
// Try to find today's page
|
|
24
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
25
|
+
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
26
|
+
if (findResults && findResults.length > 0) {
|
|
27
|
+
pageUid = findResults[0][0];
|
|
28
|
+
pageUidCache.set(dateStr, pageUid);
|
|
35
29
|
}
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
else {
|
|
31
|
+
// Create today's page if it doesn't exist
|
|
32
|
+
try {
|
|
33
|
+
await createPage(this.graph, {
|
|
34
|
+
action: 'create-page',
|
|
35
|
+
page: { title: dateStr }
|
|
36
|
+
});
|
|
37
|
+
// Get the new page's UID
|
|
38
|
+
const results = await q(this.graph, findQuery, [dateStr]);
|
|
39
|
+
if (!results || results.length === 0) {
|
|
40
|
+
throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
|
|
41
|
+
}
|
|
42
|
+
pageUid = results[0][0];
|
|
43
|
+
pageUidCache.onPageCreated(dateStr, pageUid);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
|
|
47
|
+
}
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
// Get memories tag from environment
|