roam-research-mcp 1.6.0 → 2.4.3
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 +202 -13
- package/build/Roam_Markdown_Cheatsheet.md +116 -269
- package/build/cli/batch/resolver.js +138 -0
- package/build/cli/batch/translator.js +363 -0
- package/build/cli/batch/types.js +4 -0
- package/build/cli/commands/batch.js +345 -0
- package/build/cli/commands/get.js +156 -43
- package/build/cli/commands/refs.js +63 -32
- package/build/cli/commands/rename.js +58 -0
- package/build/cli/commands/save.js +436 -63
- package/build/cli/commands/search.js +152 -31
- package/build/cli/commands/status.js +91 -0
- package/build/cli/commands/update.js +194 -0
- package/build/cli/roam.js +18 -1
- package/build/cli/utils/graph.js +56 -0
- package/build/cli/utils/input.js +10 -0
- package/build/cli/utils/output.js +34 -0
- package/build/config/environment.js +70 -34
- package/build/config/graph-registry.js +221 -0
- package/build/config/graph-registry.test.js +30 -0
- package/build/search/status-search.js +5 -4
- package/build/server/roam-server.js +98 -53
- package/build/shared/validation.js +10 -5
- package/build/tools/helpers/refs.js +50 -31
- package/build/tools/operations/blocks.js +38 -1
- package/build/tools/operations/memory.js +59 -9
- package/build/tools/operations/pages.js +186 -111
- package/build/tools/operations/search/index.js +5 -1
- package/build/tools/operations/todos.js +1 -1
- package/build/tools/schemas.js +123 -42
- package/build/tools/tool-handlers.js +9 -2
- package/build/utils/helpers.js +22 -0
- package/package.json +8 -5
|
@@ -2,8 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
4
|
import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ListToolsRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { HTTP_STREAM_PORT, validateEnvironment } from '../config/environment.js';
|
|
6
|
+
import { createRegistryFromEnv } from '../config/graph-registry.js';
|
|
7
7
|
import { toolSchemas } from '../tools/schemas.js';
|
|
8
8
|
import { ToolHandlers } from '../tools/tool-handlers.js';
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
@@ -20,28 +20,34 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
|
20
20
|
const serverVersion = packageJson.version;
|
|
21
21
|
export class RoamServer {
|
|
22
22
|
constructor() {
|
|
23
|
+
this.toolHandlersCache = new Map();
|
|
24
|
+
// Validate environment first
|
|
25
|
+
validateEnvironment();
|
|
23
26
|
try {
|
|
24
|
-
this.
|
|
25
|
-
token: API_TOKEN,
|
|
26
|
-
graph: GRAPH_NAME,
|
|
27
|
-
});
|
|
27
|
+
this.registry = createRegistryFromEnv();
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|
|
30
30
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
31
|
-
throw new McpError(ErrorCode.InternalError, `Failed to initialize
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
this.toolHandlers = new ToolHandlers(this.graph);
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
38
|
-
throw new McpError(ErrorCode.InternalError, `Failed to initialize tool handlers: ${errorMessage}`);
|
|
31
|
+
throw new McpError(ErrorCode.InternalError, `Failed to initialize graph registry: ${errorMessage}`);
|
|
39
32
|
}
|
|
40
33
|
// Ensure toolSchemas is not empty before proceeding
|
|
41
34
|
if (Object.keys(toolSchemas).length === 0) {
|
|
42
35
|
throw new McpError(ErrorCode.InternalError, 'No tool schemas defined in src/tools/schemas.ts');
|
|
43
36
|
}
|
|
44
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Get or create a ToolHandlers instance for a specific graph
|
|
40
|
+
* Handlers are cached per-graph for efficiency
|
|
41
|
+
*/
|
|
42
|
+
getToolHandlers(graph, graphKey) {
|
|
43
|
+
const cached = this.toolHandlersCache.get(graphKey);
|
|
44
|
+
if (cached) {
|
|
45
|
+
return cached;
|
|
46
|
+
}
|
|
47
|
+
const handlers = new ToolHandlers(graph);
|
|
48
|
+
this.toolHandlersCache.set(graphKey, handlers);
|
|
49
|
+
return handlers;
|
|
50
|
+
}
|
|
45
51
|
// Helper to create and configure MCP server instance
|
|
46
52
|
createMcpServer(nameSuffix = '') {
|
|
47
53
|
const server = new Server({
|
|
@@ -59,6 +65,25 @@ export class RoamServer {
|
|
|
59
65
|
this.setupRequestHandlers(server);
|
|
60
66
|
return server;
|
|
61
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Extract graph and write_key from tool arguments
|
|
70
|
+
*/
|
|
71
|
+
extractGraphParams(args) {
|
|
72
|
+
const { graph, write_key, ...cleanedArgs } = args;
|
|
73
|
+
return {
|
|
74
|
+
graphKey: graph,
|
|
75
|
+
writeKey: write_key,
|
|
76
|
+
cleanedArgs,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve graph for a tool call with validation
|
|
81
|
+
*/
|
|
82
|
+
resolveGraph(toolName, graphKey, writeKey) {
|
|
83
|
+
const resolvedKey = graphKey ?? this.registry.defaultKey;
|
|
84
|
+
const graph = this.registry.resolveGraphForTool(toolName, graphKey, writeKey);
|
|
85
|
+
return { graph, resolvedKey };
|
|
86
|
+
}
|
|
62
87
|
// Refactored to accept a Server instance
|
|
63
88
|
setupRequestHandlers(mcpServer) {
|
|
64
89
|
// List available tools
|
|
@@ -81,139 +106,145 @@ export class RoamServer {
|
|
|
81
106
|
// Handle tool calls
|
|
82
107
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
83
108
|
try {
|
|
109
|
+
const args = (request.params.arguments ?? {});
|
|
110
|
+
const { graphKey, writeKey, cleanedArgs } = this.extractGraphParams(args);
|
|
111
|
+
const { graph, resolvedKey } = this.resolveGraph(request.params.name, graphKey, writeKey);
|
|
112
|
+
const toolHandlers = this.getToolHandlers(graph, resolvedKey);
|
|
84
113
|
switch (request.params.name) {
|
|
85
114
|
case 'roam_markdown_cheatsheet': {
|
|
86
|
-
const
|
|
115
|
+
const graphInfo = this.registry.getGraphInfoMarkdown();
|
|
116
|
+
const cheatsheet = await toolHandlers.getRoamMarkdownCheatsheet();
|
|
117
|
+
const content = graphInfo + cheatsheet;
|
|
87
118
|
return {
|
|
88
119
|
content: [{ type: 'text', text: content }],
|
|
89
120
|
};
|
|
90
121
|
}
|
|
91
122
|
case 'roam_remember': {
|
|
92
|
-
const { memory, categories } =
|
|
93
|
-
const result = await
|
|
123
|
+
const { memory, categories, heading, parent_uid, include_memories_tag } = cleanedArgs;
|
|
124
|
+
const result = await toolHandlers.remember(memory, categories, heading, parent_uid, include_memories_tag);
|
|
94
125
|
return {
|
|
95
126
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
96
127
|
};
|
|
97
128
|
}
|
|
98
129
|
case 'roam_fetch_page_by_title': {
|
|
99
|
-
const { title, format } =
|
|
100
|
-
const content = await
|
|
130
|
+
const { title, format } = cleanedArgs;
|
|
131
|
+
const content = await toolHandlers.fetchPageByTitle(title, format);
|
|
101
132
|
return {
|
|
102
133
|
content: [{ type: 'text', text: content }],
|
|
103
134
|
};
|
|
104
135
|
}
|
|
105
136
|
case 'roam_create_page': {
|
|
106
|
-
const { title, content } =
|
|
107
|
-
const result = await
|
|
137
|
+
const { title, content } = cleanedArgs;
|
|
138
|
+
const result = await toolHandlers.createPage(title, content);
|
|
108
139
|
return {
|
|
109
140
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
110
141
|
};
|
|
111
142
|
}
|
|
112
143
|
case 'roam_import_markdown': {
|
|
113
|
-
const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } =
|
|
114
|
-
const result = await
|
|
144
|
+
const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = cleanedArgs;
|
|
145
|
+
const result = await toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
|
|
115
146
|
return {
|
|
116
147
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
117
148
|
};
|
|
118
149
|
}
|
|
119
150
|
case 'roam_add_todo': {
|
|
120
|
-
const { todos } =
|
|
121
|
-
const result = await
|
|
151
|
+
const { todos } = cleanedArgs;
|
|
152
|
+
const result = await toolHandlers.addTodos(todos);
|
|
122
153
|
return {
|
|
123
154
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
124
155
|
};
|
|
125
156
|
}
|
|
126
157
|
case 'roam_create_outline': {
|
|
127
|
-
const { outline, page_title_uid, block_text_uid } =
|
|
128
|
-
const result = await
|
|
158
|
+
const { outline, page_title_uid, block_text_uid } = cleanedArgs;
|
|
159
|
+
const result = await toolHandlers.createOutline(outline, page_title_uid, block_text_uid);
|
|
129
160
|
return {
|
|
130
161
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
131
162
|
};
|
|
132
163
|
}
|
|
133
164
|
case 'roam_search_for_tag': {
|
|
134
|
-
const { primary_tag, page_title_uid, near_tag } =
|
|
135
|
-
const result = await
|
|
165
|
+
const { primary_tag, page_title_uid, near_tag } = cleanedArgs;
|
|
166
|
+
const result = await toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
|
|
136
167
|
return {
|
|
137
168
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
138
169
|
};
|
|
139
170
|
}
|
|
140
171
|
case 'roam_search_by_status': {
|
|
141
|
-
const { status, page_title_uid, include, exclude } =
|
|
142
|
-
const result = await
|
|
172
|
+
const { status, page_title_uid, include, exclude } = cleanedArgs;
|
|
173
|
+
const result = await toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
|
|
143
174
|
return {
|
|
144
175
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
145
176
|
};
|
|
146
177
|
}
|
|
147
178
|
case 'roam_search_block_refs': {
|
|
148
|
-
const params =
|
|
149
|
-
const result = await
|
|
179
|
+
const params = cleanedArgs;
|
|
180
|
+
const result = await toolHandlers.searchBlockRefs(params);
|
|
150
181
|
return {
|
|
151
182
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
152
183
|
};
|
|
153
184
|
}
|
|
154
185
|
case 'roam_search_hierarchy': {
|
|
155
|
-
const params =
|
|
186
|
+
const params = cleanedArgs;
|
|
156
187
|
// Validate that either parent_uid or child_uid is provided, but not both
|
|
157
188
|
if ((!params.parent_uid && !params.child_uid) || (params.parent_uid && params.child_uid)) {
|
|
158
189
|
throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
|
|
159
190
|
}
|
|
160
|
-
const result = await
|
|
191
|
+
const result = await toolHandlers.searchHierarchy(params);
|
|
161
192
|
return {
|
|
162
193
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
163
194
|
};
|
|
164
195
|
}
|
|
165
196
|
case 'roam_find_pages_modified_today': {
|
|
166
|
-
const { max_num_pages } =
|
|
167
|
-
const result = await
|
|
197
|
+
const { max_num_pages } = cleanedArgs;
|
|
198
|
+
const result = await toolHandlers.findPagesModifiedToday(max_num_pages || 50);
|
|
168
199
|
return {
|
|
169
200
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
170
201
|
};
|
|
171
202
|
}
|
|
172
203
|
case 'roam_search_by_text': {
|
|
173
|
-
const params =
|
|
174
|
-
const result = await
|
|
204
|
+
const params = cleanedArgs;
|
|
205
|
+
const result = await toolHandlers.searchByText(params);
|
|
175
206
|
return {
|
|
176
207
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
177
208
|
};
|
|
178
209
|
}
|
|
179
210
|
case 'roam_search_by_date': {
|
|
180
|
-
const params =
|
|
181
|
-
const result = await
|
|
211
|
+
const params = cleanedArgs;
|
|
212
|
+
const result = await toolHandlers.searchByDate(params);
|
|
182
213
|
return {
|
|
183
214
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
184
215
|
};
|
|
185
216
|
}
|
|
186
217
|
case 'roam_recall': {
|
|
187
|
-
const { sort_by = 'newest', filter_tag } =
|
|
188
|
-
const result = await
|
|
218
|
+
const { sort_by = 'newest', filter_tag } = cleanedArgs;
|
|
219
|
+
const result = await toolHandlers.recall(sort_by, filter_tag);
|
|
189
220
|
return {
|
|
190
221
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
191
222
|
};
|
|
192
223
|
}
|
|
193
224
|
case 'roam_datomic_query': {
|
|
194
|
-
const { query, inputs } =
|
|
195
|
-
const result = await
|
|
225
|
+
const { query, inputs } = cleanedArgs;
|
|
226
|
+
const result = await toolHandlers.executeDatomicQuery({ query, inputs });
|
|
196
227
|
return {
|
|
197
228
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
198
229
|
};
|
|
199
230
|
}
|
|
200
231
|
case 'roam_process_batch_actions': {
|
|
201
|
-
const { actions } =
|
|
202
|
-
const result = await
|
|
232
|
+
const { actions } = cleanedArgs;
|
|
233
|
+
const result = await toolHandlers.processBatch(actions);
|
|
203
234
|
return {
|
|
204
235
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
205
236
|
};
|
|
206
237
|
}
|
|
207
238
|
case 'roam_fetch_block_with_children': {
|
|
208
|
-
const { block_uid, depth } =
|
|
209
|
-
const result = await
|
|
239
|
+
const { block_uid, depth } = cleanedArgs;
|
|
240
|
+
const result = await toolHandlers.fetchBlockWithChildren(block_uid, depth);
|
|
210
241
|
return {
|
|
211
242
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
212
243
|
};
|
|
213
244
|
}
|
|
214
245
|
case 'roam_create_table': {
|
|
215
|
-
const { parent_uid, order, headers, rows } =
|
|
216
|
-
const result = await
|
|
246
|
+
const { parent_uid, order, headers, rows } = cleanedArgs;
|
|
247
|
+
const result = await toolHandlers.createTable({
|
|
217
248
|
parent_uid,
|
|
218
249
|
order,
|
|
219
250
|
headers,
|
|
@@ -223,9 +254,23 @@ export class RoamServer {
|
|
|
223
254
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
224
255
|
};
|
|
225
256
|
}
|
|
257
|
+
case 'roam_move_block': {
|
|
258
|
+
const { block_uid, parent_uid, order = 'last' } = cleanedArgs;
|
|
259
|
+
const result = await toolHandlers.moveBlock(block_uid, parent_uid, order);
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
226
264
|
case 'roam_update_page_markdown': {
|
|
227
|
-
const { title, markdown, dry_run = false } =
|
|
228
|
-
const result = await
|
|
265
|
+
const { title, markdown, dry_run = false } = cleanedArgs;
|
|
266
|
+
const result = await toolHandlers.updatePageMarkdown(title, markdown, dry_run);
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
case 'roam_rename_page': {
|
|
272
|
+
const { old_title, uid, new_title } = cleanedArgs;
|
|
273
|
+
const result = await toolHandlers.renamePage({ old_title, uid, new_title });
|
|
229
274
|
return {
|
|
230
275
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
231
276
|
};
|
|
@@ -24,7 +24,8 @@ export function validateBlockString(str, allowEmpty = false) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Validates a Roam UID.
|
|
26
26
|
* UIDs must be either:
|
|
27
|
-
* - 9 alphanumeric characters (standard Roam UID)
|
|
27
|
+
* - 9 alphanumeric characters (standard Roam block UID)
|
|
28
|
+
* - A date format like MM-DD-YYYY (daily page UID)
|
|
28
29
|
* - A placeholder like {{uid:name}}
|
|
29
30
|
* @param uid The UID to validate
|
|
30
31
|
* @param required If true, UID is required
|
|
@@ -38,11 +39,15 @@ export function validateUid(uid, required = true) {
|
|
|
38
39
|
if (UID_PLACEHOLDER_REGEX.test(uid)) {
|
|
39
40
|
return null;
|
|
40
41
|
}
|
|
41
|
-
// Check if it's a valid Roam UID (9 alphanumeric characters)
|
|
42
|
-
if (
|
|
43
|
-
return
|
|
42
|
+
// Check if it's a valid Roam block UID (9 alphanumeric characters)
|
|
43
|
+
if (/^[a-zA-Z0-9_-]{9}$/.test(uid)) {
|
|
44
|
+
return null;
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
// Check if it's a daily page UID (MM-DD-YYYY format)
|
|
47
|
+
if (/^\d{2}-\d{2}-\d{4}$/.test(uid)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return 'uid must be 9 alphanumeric characters, MM-DD-YYYY date, or a {{uid:name}} placeholder';
|
|
46
51
|
}
|
|
47
52
|
/**
|
|
48
53
|
* Validates an outline level.
|
|
@@ -1,47 +1,66 @@
|
|
|
1
1
|
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
// Roam block UIDs are 9 alphanumeric characters (with _ and -)
|
|
3
|
+
const REF_PATTERN = /\(\(([a-zA-Z0-9_-]{9})\)\)/g;
|
|
2
4
|
/**
|
|
3
|
-
*
|
|
5
|
+
* Extract all block UIDs referenced in text
|
|
4
6
|
*/
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
return refs; // Max recursion depth
|
|
8
|
-
const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
|
|
7
|
+
export function collectRefs(text) {
|
|
8
|
+
const refs = new Set();
|
|
9
9
|
let match;
|
|
10
|
-
while ((match =
|
|
11
|
-
|
|
12
|
-
refs.add(uid);
|
|
10
|
+
while ((match = REF_PATTERN.exec(text)) !== null) {
|
|
11
|
+
refs.add(match[1]);
|
|
13
12
|
}
|
|
13
|
+
REF_PATTERN.lastIndex = 0; // Reset for reuse
|
|
14
14
|
return refs;
|
|
15
|
-
}
|
|
15
|
+
}
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Resolve block references in text by replacing ((uid)) with block content.
|
|
18
|
+
* Handles nested refs up to maxDepth, with circular reference protection.
|
|
19
|
+
*
|
|
20
|
+
* @param graph - Roam graph connection
|
|
21
|
+
* @param text - Text containing ((uid)) references
|
|
22
|
+
* @param depth - Current recursion depth (internal)
|
|
23
|
+
* @param maxDepth - Maximum depth to resolve nested refs (default: 4)
|
|
24
|
+
* @param seen - UIDs already resolved in this chain (circular protection)
|
|
18
25
|
*/
|
|
19
|
-
export
|
|
20
|
-
if (depth >=
|
|
21
|
-
return text;
|
|
22
|
-
const refs = collectRefs(text
|
|
26
|
+
export async function resolveRefs(graph, text, depth = 0, maxDepth = 4, seen = new Set()) {
|
|
27
|
+
if (depth >= maxDepth)
|
|
28
|
+
return text;
|
|
29
|
+
const refs = collectRefs(text);
|
|
23
30
|
if (refs.size === 0)
|
|
24
31
|
return text;
|
|
25
|
-
//
|
|
32
|
+
// Filter out already-seen refs to prevent circular resolution
|
|
33
|
+
const newRefs = Array.from(refs).filter(uid => !seen.has(uid));
|
|
34
|
+
if (newRefs.length === 0)
|
|
35
|
+
return text;
|
|
36
|
+
// Track these refs as seen
|
|
37
|
+
newRefs.forEach(uid => seen.add(uid));
|
|
38
|
+
// Batch fetch all referenced blocks
|
|
26
39
|
const refQuery = `[:find ?uid ?string
|
|
27
40
|
:in $ [?uid ...]
|
|
28
41
|
:where [?b :block/uid ?uid]
|
|
29
42
|
[?b :block/string ?string]]`;
|
|
30
|
-
const
|
|
31
|
-
//
|
|
32
|
-
const refMap = new Map();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const refContent = refMap.get(uid);
|
|
40
|
-
if (refContent) {
|
|
41
|
-
// Recursively resolve nested references
|
|
42
|
-
const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
|
|
43
|
-
resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
|
|
43
|
+
const results = await q(graph, refQuery, [newRefs]);
|
|
44
|
+
// Build lookup map
|
|
45
|
+
const refMap = new Map(results);
|
|
46
|
+
// Collect refs that have nested refs needing resolution
|
|
47
|
+
const nestedTexts = new Map();
|
|
48
|
+
for (const uid of newRefs) {
|
|
49
|
+
const content = refMap.get(uid);
|
|
50
|
+
if (content && collectRefs(content).size > 0) {
|
|
51
|
+
nestedTexts.set(uid, content);
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
// Resolve nested refs in parallel
|
|
55
|
+
if (nestedTexts.size > 0) {
|
|
56
|
+
const resolvedEntries = await Promise.all(Array.from(nestedTexts.entries()).map(async ([uid, content]) => {
|
|
57
|
+
const resolved = await resolveRefs(graph, content, depth + 1, maxDepth, seen);
|
|
58
|
+
return [uid, resolved];
|
|
59
|
+
}));
|
|
60
|
+
resolvedEntries.forEach(([uid, resolved]) => refMap.set(uid, resolved));
|
|
61
|
+
}
|
|
62
|
+
// Replace all refs in a single pass
|
|
63
|
+
return text.replace(REF_PATTERN, (match, uid) => {
|
|
64
|
+
return refMap.get(uid) ?? match;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { q, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
|
|
1
|
+
import { q, updateBlock as updateRoamBlock, moveBlock as moveRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
4
|
import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
@@ -189,6 +189,43 @@ export class BlockOperations {
|
|
|
189
189
|
throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
+
async moveBlock(block_uid, parent_uid, order = 'last') {
|
|
193
|
+
if (!block_uid) {
|
|
194
|
+
throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
|
|
195
|
+
}
|
|
196
|
+
if (!parent_uid) {
|
|
197
|
+
throw new McpError(ErrorCode.InvalidRequest, 'parent_uid is required');
|
|
198
|
+
}
|
|
199
|
+
// Verify the block exists
|
|
200
|
+
const blockQuery = `[:find ?uid .
|
|
201
|
+
:where [?b :block/uid "${block_uid}"]
|
|
202
|
+
[?b :block/uid ?uid]]`;
|
|
203
|
+
const blockExists = await q(this.graph, blockQuery, []);
|
|
204
|
+
if (!blockExists) {
|
|
205
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
await moveRoamBlock(this.graph, {
|
|
209
|
+
action: 'move-block',
|
|
210
|
+
location: {
|
|
211
|
+
'parent-uid': parent_uid,
|
|
212
|
+
order: order
|
|
213
|
+
},
|
|
214
|
+
block: {
|
|
215
|
+
uid: block_uid
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
success: true,
|
|
220
|
+
block_uid,
|
|
221
|
+
new_parent_uid: parent_uid,
|
|
222
|
+
order
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw new McpError(ErrorCode.InternalError, `Failed to move block: ${error.message}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
192
229
|
async updateBlocks(updates) {
|
|
193
230
|
if (!Array.isArray(updates) || updates.length === 0) {
|
|
194
231
|
throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { q, createPage, batchActions } from '@roam-research/roam-api-sdk';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
|
+
import { generateBlockUid } from '../../markdown-utils.js';
|
|
4
5
|
import { resolveRefs } from '../helpers/refs.js';
|
|
5
6
|
import { SearchOperations } from './search/index.js';
|
|
6
7
|
import { pageUidCache } from '../../cache/page-uid-cache.js';
|
|
@@ -9,7 +10,7 @@ export class MemoryOperations {
|
|
|
9
10
|
this.graph = graph;
|
|
10
11
|
this.searchOps = new SearchOperations(graph);
|
|
11
12
|
}
|
|
12
|
-
async remember(memory, categories) {
|
|
13
|
+
async remember(memory, categories, heading, parent_uid, include_memories_tag = true) {
|
|
13
14
|
// Get today's date
|
|
14
15
|
const today = new Date();
|
|
15
16
|
const dateStr = formatRoamDate(today);
|
|
@@ -47,25 +48,74 @@ export class MemoryOperations {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
// Determine parent block for the memory
|
|
52
|
+
let targetParentUid;
|
|
53
|
+
if (parent_uid) {
|
|
54
|
+
// Use provided parent_uid directly
|
|
55
|
+
targetParentUid = parent_uid;
|
|
56
|
+
}
|
|
57
|
+
else if (heading) {
|
|
58
|
+
// Search for heading block on today's page, create if not found
|
|
59
|
+
const headingQuery = `[:find ?uid
|
|
60
|
+
:in $ ?page-uid ?text
|
|
61
|
+
:where
|
|
62
|
+
[?page :block/uid ?page-uid]
|
|
63
|
+
[?page :block/children ?block]
|
|
64
|
+
[?block :block/string ?text]
|
|
65
|
+
[?block :block/uid ?uid]]`;
|
|
66
|
+
const headingResults = await q(this.graph, headingQuery, [pageUid, heading]);
|
|
67
|
+
if (headingResults && headingResults.length > 0) {
|
|
68
|
+
targetParentUid = headingResults[0][0];
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Create the heading block
|
|
72
|
+
const headingBlockUid = generateBlockUid();
|
|
73
|
+
try {
|
|
74
|
+
await batchActions(this.graph, {
|
|
75
|
+
action: 'batch-actions',
|
|
76
|
+
actions: [{
|
|
77
|
+
action: 'create-block',
|
|
78
|
+
location: { 'parent-uid': pageUid, order: 'last' },
|
|
79
|
+
block: { uid: headingBlockUid, string: heading }
|
|
80
|
+
}]
|
|
81
|
+
});
|
|
82
|
+
targetParentUid = headingBlockUid;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create heading block: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Default: use daily page root
|
|
91
|
+
targetParentUid = pageUid;
|
|
92
|
+
}
|
|
50
93
|
// Get memories tag from environment
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
94
|
+
let memoriesTag;
|
|
95
|
+
if (include_memories_tag) {
|
|
96
|
+
memoriesTag = process.env.MEMORIES_TAG;
|
|
97
|
+
if (!memoriesTag) {
|
|
98
|
+
throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
|
|
99
|
+
}
|
|
54
100
|
}
|
|
55
101
|
// Format categories as Roam tags if provided
|
|
56
102
|
const categoryTags = categories?.map(cat => {
|
|
57
103
|
// Handle multi-word categories
|
|
58
104
|
return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
|
|
59
|
-
})
|
|
60
|
-
// Create block with memory,
|
|
61
|
-
const
|
|
105
|
+
}) ?? [];
|
|
106
|
+
// Create block with memory, then all tags together at the end
|
|
107
|
+
const tags = memoriesTag ? [...categoryTags, memoriesTag] : categoryTags;
|
|
108
|
+
const blockContent = [memory, ...tags].join(' ').trim();
|
|
109
|
+
// Pre-generate UID so we can return it
|
|
110
|
+
const blockUid = generateBlockUid();
|
|
62
111
|
const actions = [{
|
|
63
112
|
action: 'create-block',
|
|
64
113
|
location: {
|
|
65
|
-
'parent-uid':
|
|
114
|
+
'parent-uid': targetParentUid,
|
|
66
115
|
order: 'last'
|
|
67
116
|
},
|
|
68
117
|
block: {
|
|
118
|
+
uid: blockUid,
|
|
69
119
|
string: blockContent
|
|
70
120
|
}
|
|
71
121
|
}];
|
|
@@ -81,7 +131,7 @@ export class MemoryOperations {
|
|
|
81
131
|
catch (error) {
|
|
82
132
|
throw new McpError(ErrorCode.InternalError, `Failed to create memory block: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
133
|
}
|
|
84
|
-
return { success: true };
|
|
134
|
+
return { success: true, block_uid: blockUid, parent_uid: targetParentUid };
|
|
85
135
|
}
|
|
86
136
|
async recall(sort_by = 'newest', filter_tag) {
|
|
87
137
|
// Get memories tag from environment
|