roam-research-mcp 2.24.0 → 3.0.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 CHANGED
@@ -146,6 +146,24 @@ The MCP server exposes these tools to AI assistants (like Claude), enabling them
146
146
  | `roam_markdown_cheatsheet` | Retrieve the Roam-flavored markdown reference. |
147
147
  | `roam_get_guidelines` | Retrieve this graph's user-defined agent conventions. |
148
148
 
149
+ ### Structured results from write tools (v3.0.0+)
150
+
151
+ The ten write tools declare an `outputSchema` and return `structuredContent` — a validated object — alongside the usual text. A client can read `page_uid`, `uid_map` or `success` directly instead of hunting for JSON inside a string, which makes chaining calls more reliable:
152
+
153
+ ```jsonc
154
+ // roam_process_batch_actions
155
+ { "success": true, "uid_map": { "parent1": "Xk7mN2pQ9" },
156
+ "validation_passed": true, "actions_attempted": 4 }
157
+ ```
158
+
159
+ Three things worth knowing:
160
+
161
+ - **Nothing was taken away.** The text channel is unchanged, so a client that ignores `structuredContent` behaves exactly as before.
162
+ - **Read tools deliberately have neither.** They already serialise their whole result into the text channel, so a schema would just double the payload.
163
+ - **These fields are additive-only.** Some clients validate live responses against a cached tool list, so a field will be added or deprecated — never renamed or removed outside a major version.
164
+
165
+ > **Upgrading from 2.x:** three write-result fields were renamed — `uid` → `page_uid` (`roam_create_page`), `created_uids` → `created_blocks` (`roam_create_outline`, `roam_import_markdown`) and `preservedUids` → `preserved_uids` (`roam_update_page_markdown`). This only affects code that reads those names; if you use the server through an AI assistant, nothing changes. See the [changelog](CHANGELOG.md) for why.
166
+
149
167
  ---
150
168
 
151
169
  ## Agent guidelines (per-graph)
@@ -161,22 +179,24 @@ This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
161
179
  | To change it | edit the file, restart the server | edit the page |
162
180
  | Answers | how to write Roam markdown | how *this user* wants *this graph* handled |
163
181
 
164
- **Opt-in.** A graph with no `guidelinesPage` and no `ROAM_GUIDELINES_PAGE` fallback has guidelines **disabled** — the tool reports that and never touches the graph. Nothing is read until you name a page, so a graph that happens to contain a similarly-titled page won't start feeding it to agents.
182
+ **Just create the page.** With no configuration at all, `roam_get_guidelines` reads `[[roam/agent guidelines]]` — the same title Roam's own server reads, so writing it once makes both honour it. Creating a page with that exact namespaced title is the opt-in; nothing is read from the graph unless an agent explicitly calls the tool.
183
+
184
+ If the page doesn't exist, the tool returns `exists: false` rather than failing, so it is always safe to call.
165
185
 
166
- Each graph can point at a different page:
186
+ Each graph can point at a different page, or turn it off:
167
187
 
168
188
  ```bash
169
189
  ROAM_GRAPHS='{
170
- "personal": {"token": "...", "graph": "...", "guidelinesPage": "roam/agent guidelines"},
190
+ "personal": {"token": "...", "graph": "..."},
171
191
  "work": {"token": "...", "graph": "...", "guidelinesPage": "work/agent rules"},
172
- "archive": {"token": "...", "graph": "..."}
192
+ "private": {"token": "...", "graph": "...", "guidelinesPage": false}
173
193
  }'
174
- ROAM_GUIDELINES_PAGE='roam/agent guidelines' # fallback for graphs that name none
194
+ ROAM_GUIDELINES_PAGE='team/agent guidelines' # change the default for every graph
175
195
  ```
176
196
 
177
- Resolution order is **per-graph `guidelinesPage` → `ROAM_GUIDELINES_PAGE` → disabled**. Setting `guidelinesPage: false` disables it for one graph even when the env fallback is set. In the example above, `archive` uses the fallback; without that env var it would be disabled.
197
+ Resolution order is **per-graph `guidelinesPage` → `ROAM_GUIDELINES_PAGE` → `roam/agent guidelines`**. Above: `personal` uses the env override, `work` uses its own page, and `private` has guidelines off entirely. **Only an explicit `false` disables it** — an unset value never does.
178
198
 
179
- If the named page doesn't exist the tool returns `exists: false` rather than failing, so it is safe to call unconditionally. Results are cached for 30 seconds — an edit to the page takes effect without a restart. A starter template lives at [`.roam/agent-guidelines.template.md`](.roam/agent-guidelines.template.md).
199
+ Results are cached for 30 seconds — an edit to the page takes effect without a restart. A starter template lives at [`.roam/agent-guidelines.template.md`](.roam/agent-guidelines.template.md).
180
200
 
181
201
  Note that guidelines are read through the normal page path, so blocks tagged `#.rm-hide` / `#.rm-private` are withheld from them too — see below.
182
202
 
@@ -229,7 +249,7 @@ ROAM_SYSTEM_WRITE_KEY=your-secret-key
229
249
  |----------|----------|-------------|
230
250
  | `token` | Yes | Roam API token for this graph |
231
251
  | `graph` | Yes | Graph name/database identifier |
232
- | `protected` | No | If `true`, writes require `ROAM_SYSTEM_WRITE_KEY` confirmation |
252
+ | `protected` | No | If `true`, writes require `ROAM_SYSTEM_WRITE_KEY` confirmation — **except on the default graph**, see below |
233
253
  | `memoriesTag` | No | Tag for `roam_remember`/`roam_recall` (overrides global default) |
234
254
 
235
255
  **Two kinds of access control (and how they differ)**
@@ -249,6 +269,8 @@ Think of a house: the **bearer token locks the front door** (keeps strangers out
249
269
 
250
270
  So: to mark a graph as needing the write key, set `protected: true` on it and configure `ROAM_SYSTEM_WRITE_KEY`; callers then pass a matching `write_key` for any write to that graph.
251
271
 
272
+ > ⚠️ **`protected` does nothing on your default graph.** Writes to whichever graph `ROAM_DEFAULT_GRAPH` names are always allowed, before `protected` is ever consulted — the flag guards the graphs you have to *ask* for by name, on the reasoning that reaching for a non-default graph is the deliberate act worth confirming. If you want a graph write-guarded, it must not be your default.
273
+
252
274
  *Optional:*
253
275
  - `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
254
276
  - `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088).
@@ -348,6 +370,16 @@ docker run -p 8088:8088 --env-file .env roam-research-mcp --server
348
370
 
349
371
  Add to your MCP settings file (e.g., `~/Library/Application Support/Claude/claude_desktop_config.json`):
350
372
 
373
+ > **Pinning the version.** `npx -y roam-research-mcp` fetches the **latest** release every time your client starts the server, so a new major version arrives without warning. Pin the major to decide for yourself when to move:
374
+ >
375
+ > | `args` | You get |
376
+ > | :--- | :--- |
377
+ > | `["-y", "roam-research-mcp"]` | Latest, always — including the next major |
378
+ > | `["-y", "roam-research-mcp@3"]` | 3.x only; majors need an edit here |
379
+ > | `["-y", "roam-research-mcp@3.0.0"]` | Exactly this build |
380
+ >
381
+ > Pinning the major is the sensible default: you still get fixes and new tools, but a breaking change becomes something you opt into. The examples below stay unpinned to match what most people paste in first.
382
+
351
383
  *Single Graph:*
352
384
  ```json
353
385
  {
@@ -247,10 +247,10 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
247
247
  const pageOps = new PageOperations(graph);
248
248
  for (const title of unresolved) {
249
249
  const result = await pageOps.createPage(title);
250
- if (result.success && result.uid) {
251
- context.pageUids.set(title, result.uid);
250
+ if (result.success && result.page_uid) {
251
+ context.pageUids.set(title, result.page_uid);
252
252
  if (options.debug) {
253
- printDebug(`Auto-created "${title}"`, result.uid);
253
+ printDebug(`Auto-created "${title}"`, result.page_uid);
254
254
  }
255
255
  }
256
256
  else {
@@ -313,12 +313,12 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
313
313
  // Report partial results before exiting
314
314
  outputPartialResults(pageResults, pc.params.title);
315
315
  }
316
- pageResults.push({ title: pc.params.title, uid: result.uid });
316
+ pageResults.push({ title: pc.params.title, uid: result.page_uid });
317
317
  if (pc.params.as) {
318
- context.placeholders.set(pc.params.as, result.uid);
318
+ context.placeholders.set(pc.params.as, result.page_uid);
319
319
  }
320
320
  if (options.debug) {
321
- printDebug(`Created "${pc.params.title}"`, result.uid);
321
+ printDebug(`Created "${pc.params.title}"`, result.page_uid);
322
322
  }
323
323
  }
324
324
  }
@@ -430,8 +430,8 @@ JSON format (--json):
430
430
  if (result.success) {
431
431
  console.log(`Updated page '${pageTitle}'`);
432
432
  console.log(` ${result.summary}`);
433
- if (result.preservedUids.length > 0) {
434
- console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
433
+ if (result.preserved_uids.length > 0) {
434
+ console.log(` Preserved ${result.preserved_uids.length} block UID(s)`);
435
435
  }
436
436
  }
437
437
  else {
@@ -441,7 +441,7 @@ JSON format (--json):
441
441
  else {
442
442
  const result = await pageOps.createPage(pageTitle, contentBlocks);
443
443
  if (result.success) {
444
- console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
444
+ console.log(`Created page '${pageTitle}' (uid: ${result.page_uid})`);
445
445
  }
446
446
  else {
447
447
  exitWithError(`Failed to create page '${pageTitle}'`);
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { initializeGraph } from '@roam-research/roam-api-sdk';
11
11
  import { RoamError } from '../shared/errors.js';
12
+ import { DEFAULT_GUIDELINES_PAGE } from '../tools/operations/guidelines.js';
12
13
  /** List of tool names that perform write operations */
13
14
  export const WRITE_OPERATIONS = [
14
15
  'roam_create_page',
@@ -70,15 +71,21 @@ export class GraphRegistry {
70
71
  return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
71
72
  }
72
73
  /**
73
- * Page holding a graph's agent conventions, or null when disabled.
74
+ * Page holding a graph's agent conventions, or null when explicitly disabled.
74
75
  *
75
- * Precedence: per-graph config > ROAM_GUIDELINES_PAGE env var > **disabled**.
76
+ * Precedence: per-graph config > ROAM_GUIDELINES_PAGE env var >
77
+ * "roam/agent guidelines" (the shared convention).
76
78
  *
77
- * Unlike memoriesTag this is opt-in: a graph with no `guidelinesPage` and no
78
- * env fallback returns null rather than silently reading a conventional page
79
- * title. Reading a page nobody asked us to read is a surprise, and a graph
80
- * that happens to contain a similarly-named page should not start feeding it
81
- * to agents.
79
+ * Reading the conventional title by default is deliberate. It is the same
80
+ * page Roam's own MCP server reads unconditionally, so writing it once makes
81
+ * both servers honour it — which is the entire point of a convention, and it
82
+ * stops working the moment it needs private configuration. Creating a page
83
+ * with that exact namespaced title IS the opt-in; nobody makes one by
84
+ * accident. And nothing here reads the graph unprompted: the lookup only
85
+ * happens when an agent explicitly calls roam_get_guidelines, so the tool
86
+ * call is already the consent.
87
+ *
88
+ * Set `guidelinesPage: false` to turn it off for a graph.
82
89
  */
83
90
  getGuidelinesPage(key) {
84
91
  const resolvedKey = key ?? this.defaultKey;
@@ -86,7 +93,7 @@ export class GraphRegistry {
86
93
  if (config?.guidelinesPage === false) {
87
94
  return null;
88
95
  }
89
- return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? null;
96
+ return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? DEFAULT_GUIDELINES_PAGE;
90
97
  }
91
98
  /**
92
99
  * Get an initialized Graph instance, creating it lazily if needed
@@ -20,6 +20,29 @@ const __dirname = dirname(__filename);
20
20
  const packageJsonPath = join(__dirname, '../../package.json');
21
21
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
22
22
  const serverVersion = packageJson.version;
23
+ /**
24
+ * The single place a tool result is built, and the only place
25
+ * `structuredContent` may be attached.
26
+ *
27
+ * The wire invariant is **`structuredContent` is present iff the tool declares
28
+ * an `outputSchema`**. A schema-bearing tool that returns none, or a
29
+ * schema-less tool that returns some, is a protocol violation a strict client
30
+ * will reject. We use the low-level `Server` rather than `McpServer`, so the
31
+ * SDK does not enforce this for us — routing every result through here is what
32
+ * enforces it. Adding a schema in schemas.ts is therefore sufficient; no switch
33
+ * case needs touching.
34
+ *
35
+ * The text channel is unchanged either way, so clients that never look at
36
+ * structuredContent see exactly what they saw before.
37
+ */
38
+ function toolResult(toolName, result) {
39
+ const declaresSchema = Boolean(toolSchemas[toolName]?.outputSchema);
40
+ const canStructure = declaresSchema && result !== null && typeof result === 'object';
41
+ return {
42
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
43
+ ...(canStructure ? { structuredContent: result } : {}),
44
+ };
45
+ }
23
46
  export class RoamServer {
24
47
  constructor() {
25
48
  this.toolHandlersCache = new Map();
@@ -126,9 +149,7 @@ export class RoamServer {
126
149
  case 'roam_remember': {
127
150
  const { memory, categories, heading, parent_uid, include_memories_tag } = cleanedArgs;
128
151
  const result = await toolHandlers.remember(memory, categories, heading, parent_uid, include_memories_tag);
129
- return {
130
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
131
- };
152
+ return toolResult(request.params.name, result);
132
153
  }
133
154
  case 'roam_fetch_page_full_view': {
134
155
  const { title, children_depth, max_references } = cleanedArgs;
@@ -146,9 +167,7 @@ export class RoamServer {
146
167
  }
147
168
  case 'roam_get_guidelines': {
148
169
  const result = await toolHandlers.getGuidelines();
149
- return {
150
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
151
- };
170
+ return toolResult(request.params.name, result);
152
171
  }
153
172
  case 'roam_fetch_page_by_title': {
154
173
  const { title, format } = cleanedArgs;
@@ -160,30 +179,22 @@ export class RoamServer {
160
179
  case 'roam_create_page': {
161
180
  const { title, content } = cleanedArgs;
162
181
  const result = await toolHandlers.createPage(title, content);
163
- return {
164
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
165
- };
182
+ return toolResult(request.params.name, result);
166
183
  }
167
184
  case 'roam_import_markdown': {
168
185
  const { content, page_uid, page_title, parent_uid, parent_string, order = 'first' } = cleanedArgs;
169
186
  const result = await toolHandlers.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
170
- return {
171
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
172
- };
187
+ return toolResult(request.params.name, result);
173
188
  }
174
189
  case 'roam_add_todo': {
175
190
  const { todos } = cleanedArgs;
176
191
  const result = await toolHandlers.addTodos(todos);
177
- return {
178
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
179
- };
192
+ return toolResult(request.params.name, result);
180
193
  }
181
194
  case 'roam_create_outline': {
182
195
  const { outline, page_title_uid, block_text_uid, order } = cleanedArgs;
183
196
  const result = await toolHandlers.createOutline(outline, page_title_uid, block_text_uid, order);
184
- return {
185
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
186
- };
197
+ return toolResult(request.params.name, result);
187
198
  }
188
199
  case 'roam_search_for_tag': {
189
200
  const { primary_tag, page_title_uid, near_tag } = cleanedArgs;
@@ -191,23 +202,17 @@ export class RoamServer {
191
202
  throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: primary_tag (the tag to search for). Use page_title_uid to limit search to a specific page.');
192
203
  }
193
204
  const result = await toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
194
- return {
195
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
196
- };
205
+ return toolResult(request.params.name, result);
197
206
  }
198
207
  case 'roam_search_by_status': {
199
208
  const { status, page_title_uid, include, exclude } = cleanedArgs;
200
209
  const result = await toolHandlers.searchByStatus(status, page_title_uid, include, exclude);
201
- return {
202
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
203
- };
210
+ return toolResult(request.params.name, result);
204
211
  }
205
212
  case 'roam_search_block_refs': {
206
213
  const params = cleanedArgs;
207
214
  const result = await toolHandlers.searchBlockRefs(params);
208
- return {
209
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
210
- };
215
+ return toolResult(request.params.name, result);
211
216
  }
212
217
  case 'roam_search_hierarchy': {
213
218
  const params = cleanedArgs;
@@ -216,58 +221,42 @@ export class RoamServer {
216
221
  throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
217
222
  }
218
223
  const result = await toolHandlers.searchHierarchy(params);
219
- return {
220
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
221
- };
224
+ return toolResult(request.params.name, result);
222
225
  }
223
226
  case 'roam_find_pages_modified_today': {
224
227
  const { max_num_pages } = cleanedArgs;
225
228
  const result = await toolHandlers.findPagesModifiedToday(max_num_pages || 50);
226
- return {
227
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
228
- };
229
+ return toolResult(request.params.name, result);
229
230
  }
230
231
  case 'roam_search_by_text': {
231
232
  const params = cleanedArgs;
232
233
  const result = await toolHandlers.searchByText(params);
233
- return {
234
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
235
- };
234
+ return toolResult(request.params.name, result);
236
235
  }
237
236
  case 'roam_search_by_date': {
238
237
  const params = cleanedArgs;
239
238
  const result = await toolHandlers.searchByDate(params);
240
- return {
241
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
242
- };
239
+ return toolResult(request.params.name, result);
243
240
  }
244
241
  case 'roam_recall': {
245
242
  const { sort_by = 'newest', filter_tag } = cleanedArgs;
246
243
  const result = await toolHandlers.recall(sort_by, filter_tag);
247
- return {
248
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
249
- };
244
+ return toolResult(request.params.name, result);
250
245
  }
251
246
  case 'roam_datomic_query': {
252
247
  const { query, inputs } = cleanedArgs;
253
248
  const result = await toolHandlers.executeDatomicQuery({ query, inputs });
254
- return {
255
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
256
- };
249
+ return toolResult(request.params.name, result);
257
250
  }
258
251
  case 'roam_process_batch_actions': {
259
252
  const { actions } = cleanedArgs;
260
253
  const result = await toolHandlers.processBatch(actions);
261
- return {
262
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
263
- };
254
+ return toolResult(request.params.name, result);
264
255
  }
265
256
  case 'roam_fetch_block': {
266
257
  const { block_uid, depth, include_ancestors } = cleanedArgs;
267
258
  const result = await toolHandlers.fetchBlock(block_uid, depth, include_ancestors);
268
- return {
269
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
270
- };
259
+ return toolResult(request.params.name, result);
271
260
  }
272
261
  case 'roam_create_table': {
273
262
  const { parent_uid, order, headers, rows } = cleanedArgs;
@@ -277,30 +266,22 @@ export class RoamServer {
277
266
  headers,
278
267
  rows
279
268
  });
280
- return {
281
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
282
- };
269
+ return toolResult(request.params.name, result);
283
270
  }
284
271
  case 'roam_move_block': {
285
272
  const { block_uid, parent_uid, order = 'last' } = cleanedArgs;
286
273
  const result = await toolHandlers.moveBlock(block_uid, parent_uid, order);
287
- return {
288
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
289
- };
274
+ return toolResult(request.params.name, result);
290
275
  }
291
276
  case 'roam_update_page_markdown': {
292
277
  const { title, markdown, dry_run = false } = cleanedArgs;
293
278
  const result = await toolHandlers.updatePageMarkdown(title, markdown, dry_run);
294
- return {
295
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
296
- };
279
+ return toolResult(request.params.name, result);
297
280
  }
298
281
  case 'roam_rename_page': {
299
282
  const { old_title, uid, new_title } = cleanedArgs;
300
283
  const result = await toolHandlers.renamePage({ old_title, uid, new_title });
301
- return {
302
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
303
- };
284
+ return toolResult(request.params.name, result);
304
285
  }
305
286
  default:
306
287
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
@@ -3,6 +3,7 @@ import { generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-util
3
3
  import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js';
4
4
  import { isRateLimitError, createRateLimitError } from '../../shared/errors.js';
5
5
  import { ensurePagesExist } from '../../shared/page-validator.js';
6
+ import { withRateLimitRetry } from '../../shared/retry.js';
6
7
  // Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc.
7
8
  const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g;
8
9
  const DEFAULT_RATE_LIMIT_CONFIG = {
@@ -69,36 +70,22 @@ export class BatchOperations {
69
70
  return obj;
70
71
  }
71
72
  /**
72
- * Executes the batch operation with retry logic for rate limiting.
73
+ * Executes the batch operation, retrying while Roam throttles us.
74
+ *
75
+ * This used to be a hand-rolled loop identical to the one in
76
+ * `withRateLimitRetry`, which is precisely how `executeStagedBatch` came to
77
+ * be written with no retry at all — the logic was private here, so the next
78
+ * write path could not reuse it and simply went without. A half-written page
79
+ * and no undo was the cost.
80
+ *
81
+ * On exhaustion the shared helper rethrows Roam's original error rather than
82
+ * a synthesised one carrying `retryAfterMs`. `processBatch` never used that
83
+ * field's value for anything the caller saw except the backoff hint, which
84
+ * now comes from this instance's own config — a more honest source than
85
+ * whatever happened to be stapled to an error object.
73
86
  */
74
87
  async executeWithRetry(batchActions) {
75
- let lastError;
76
- let delay = this.rateLimitConfig.initialDelayMs;
77
- for (let attempt = 0; attempt <= this.rateLimitConfig.maxRetries; attempt++) {
78
- try {
79
- await roamBatchActions(this.graph, { actions: batchActions });
80
- return;
81
- }
82
- catch (error) {
83
- if (!isRateLimitError(error)) {
84
- throw error;
85
- }
86
- lastError = error;
87
- if (attempt < this.rateLimitConfig.maxRetries) {
88
- const waitTime = Math.min(delay, this.rateLimitConfig.maxDelayMs);
89
- console.log(`[batch] Rate limited, retrying in ${waitTime}ms (attempt ${attempt + 1}/${this.rateLimitConfig.maxRetries})`);
90
- await sleep(waitTime);
91
- delay *= this.rateLimitConfig.backoffMultiplier;
92
- }
93
- }
94
- }
95
- // Throw with rate limit context after all retries exhausted
96
- const rateLimitError = new Error(`Rate limit exceeded after ${this.rateLimitConfig.maxRetries} retries. ` +
97
- `Last error: ${lastError?.message || 'Unknown error'}. ` +
98
- `Retry after ${this.rateLimitConfig.maxDelayMs}ms.`);
99
- rateLimitError.isRateLimit = true;
100
- rateLimitError.retryAfterMs = this.rateLimitConfig.maxDelayMs;
101
- throw rateLimitError;
88
+ await withRateLimitRetry(() => roamBatchActions(this.graph, { actions: batchActions }), this.rateLimitConfig, (attempt, waitMs) => console.log(`[batch] Rate limited, retrying in ${waitMs}ms (attempt ${attempt}/${this.rateLimitConfig.maxRetries})`));
102
89
  }
103
90
  async processBatch(actions) {
104
91
  // Step 0: Pre-validate all actions before any execution
@@ -244,10 +231,10 @@ export class BatchOperations {
244
231
  }
245
232
  }
246
233
  // Check if it's a rate limit error
247
- if (isRateLimitError(error) || error.isRateLimit) {
234
+ if (isRateLimitError(error)) {
248
235
  return {
249
236
  success: false,
250
- error: createRateLimitError(error.retryAfterMs),
237
+ error: createRateLimitError(this.rateLimitConfig.maxDelayMs),
251
238
  validation_passed: true,
252
239
  actions_attempted: batchActions.length
253
240
  // No uid_map - nothing was committed
@@ -10,14 +10,11 @@
10
10
  * server's mechanics and lives in a file. Guidelines are per-graph, live-edited
11
11
  * from inside Roam, and answer "how does this user want their graph handled".
12
12
  *
13
- * Opt-in: a graph must name its page via `guidelinesPage` or ROAM_GUIDELINES_PAGE.
14
- * With neither set the tool reports disabled and never touches the graph.
13
+ * Read by default; set `guidelinesPage: false` on a graph to disable it there.
15
14
  */
16
15
  import { PageOperations } from './pages.js';
17
16
  import { formatRoamDate } from '../../utils/helpers.js';
18
- /** The conventional page title, matching Roam's own server. Not applied
19
- * automatically — a graph must opt in via `guidelinesPage` or
20
- * ROAM_GUIDELINES_PAGE. */
17
+ /** The shared convention, read by default and by Roam's own MCP server. */
21
18
  export const DEFAULT_GUIDELINES_PAGE = 'roam/agent guidelines';
22
19
  /**
23
20
  * Short TTL: the point of a page over a config file is that an edit takes
@@ -30,7 +27,7 @@ export class GuidelinesOperations {
30
27
  /**
31
28
  * @param guidelinesPage Page title to read, or null when disabled for this graph.
32
29
  */
33
- constructor(graph, guidelinesPage = null) {
30
+ constructor(graph, guidelinesPage = DEFAULT_GUIDELINES_PAGE) {
34
31
  this.graph = graph;
35
32
  this.guidelinesPage = guidelinesPage;
36
33
  this.pageOps = new PageOperations(graph);
@@ -313,7 +313,7 @@ export class OutlineOperations {
313
313
  success: true,
314
314
  page_uid: targetPageUid,
315
315
  parent_uid: targetParentUid,
316
- created_uids: createdBlocks
316
+ created_blocks: createdBlocks
317
317
  };
318
318
  }
319
319
  async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
@@ -377,7 +377,7 @@ export class OutlineOperations {
377
377
  // Roam's write API does not echo back transacted UIDs, so we report the
378
378
  // UIDs we generated client-side rather than issuing a fragile post-write
379
379
  // re-query (which was also skipped entirely past VERIFICATION_THRESHOLD,
380
- // leaving created_uids empty for larger imports).
380
+ // leaving created_blocks empty for larger imports).
381
381
  const { actions, blocks } = convertToRoamActionsWithBlocks(nodes, targetParentUid, order);
382
382
  // Execute batch actions to add content
383
383
  await executeBatch(this.graph, actions, 'import nested markdown content');
@@ -385,7 +385,7 @@ export class OutlineOperations {
385
385
  success: true,
386
386
  page_uid: targetPageUid,
387
387
  parent_uid: targetParentUid,
388
- created_uids: blockInfoToNestedBlocks(blocks)
388
+ created_blocks: blockInfoToNestedBlocks(blocks)
389
389
  };
390
390
  }
391
391
  else {
@@ -399,11 +399,11 @@ export class OutlineOperations {
399
399
  block: { string: content }
400
400
  }], 'create content block');
401
401
  // For single-line content, we still need to fetch the UID and construct a NestedBlock
402
- const createdUids = [];
402
+ const createdBlocks = [];
403
403
  try {
404
404
  const foundUid = await this.findBlockWithRetry(targetParentUid, content);
405
405
  if (foundUid) {
406
- createdUids.push({
406
+ createdBlocks.push({
407
407
  uid: foundUid,
408
408
  text: content,
409
409
  level: 0,
@@ -420,7 +420,7 @@ export class OutlineOperations {
420
420
  success: true,
421
421
  page_uid: targetPageUid,
422
422
  parent_uid: targetParentUid,
423
- created_uids: createdUids
423
+ created_blocks: createdBlocks
424
424
  };
425
425
  }
426
426
  }
@@ -155,7 +155,7 @@ export class PageOperations {
155
155
  if (existingBlockCount && existingBlockCount > 0) {
156
156
  // Page already has content - this might be a duplicate call
157
157
  // Return success without adding duplicate content
158
- return { success: true, uid: pageUid };
158
+ return { success: true, page_uid: pageUid };
159
159
  }
160
160
  // Process content items in order, tracking position for correct placement
161
161
  // Tables and text blocks are interleaved at their original positions
@@ -323,7 +323,7 @@ export class PageOperations {
323
323
  location: { 'parent-uid': pageUid, order: 'last' },
324
324
  block: { string: `Processed: [[${formattedTodayTitle}]]` }
325
325
  }], 'add Processed block');
326
- return { success: true, uid: pageUid };
326
+ return { success: true, page_uid: pageUid };
327
327
  }
328
328
  /**
329
329
  * Get the UID for a page by its title.
@@ -647,7 +647,7 @@ export class PageOperations {
647
647
  success: true,
648
648
  actions,
649
649
  stats,
650
- preservedUids: [...diff.preservedUids],
650
+ preserved_uids: [...diff.preservedUids],
651
651
  summary: dryRun ? `[DRY RUN] ${summary}` : summary
652
652
  };
653
653
  }