roam-research-mcp 2.4.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.
@@ -1,8 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { BatchOperations } from '../../tools/operations/batch.js';
3
+ import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
3
4
  import { parseMarkdownHeadingLevel } from '../../markdown-utils.js';
4
5
  import { printDebug, exitWithError } from '../utils/output.js';
5
6
  import { resolveGraph } from '../utils/graph.js';
7
+ import { readStdin } from '../utils/input.js';
6
8
  // Patterns for TODO/DONE markers (both {{TODO}} and {{[[TODO]]}} formats)
7
9
  const TODO_PATTERN = /\{\{(\[\[)?TODO(\]\])?\}\}\s*/g;
8
10
  const DONE_PATTERN = /\{\{(\[\[)?DONE(\]\])?\}\}\s*/g;
@@ -43,7 +45,7 @@ export function createUpdateCommand() {
43
45
  return new Command('update')
44
46
  .description('Update block content, heading, open/closed state, or TODO/DONE status')
45
47
  .argument('<uid>', 'Block UID to update (accepts ((uid)) wrapper)')
46
- .argument('<content>', 'New content. Use # prefix for heading: "# Title" sets H1')
48
+ .argument('[content]', 'New content. Use # prefix for heading: "# Title" sets H1. Reads from stdin if "-" or omitted (when piped).')
47
49
  .option('-H, --heading <level>', 'Set heading level (1-3), or 0 to remove')
48
50
  .option('-o, --open', 'Expand block (show children)')
49
51
  .option('-c, --closed', 'Collapse block (hide children)')
@@ -72,30 +74,82 @@ Examples:
72
74
  roam update abc123def "Task" -T # Set as TODO
73
75
  roam update abc123def "Task" -D # Mark as DONE
74
76
  roam update abc123def "Task" --clear-status # Remove status marker
77
+
78
+ # Stdin / Partial Updates
79
+ echo "New text" | roam update abc123def # Pipe content
80
+ roam update abc123def -T # Add TODO (fetches existing text)
81
+ roam update abc123def -o # Expand block (keeps text)
75
82
  `)
76
83
  .action(async (uid, content, options) => {
77
84
  try {
78
85
  // Strip (( )) wrapper if present
79
86
  const blockUid = uid.replace(/^\(\(|\)\)$/g, '');
80
- // Detect heading from content unless explicitly set
81
- let finalContent = content;
87
+ const graph = resolveGraph(options, true); // This is a write operation
88
+ let finalContent;
89
+ // 1. Determine new content from args or stdin
90
+ if (content && content !== '-') {
91
+ finalContent = content;
92
+ }
93
+ else if (content === '-' || (!content && !process.stdin.isTTY)) {
94
+ finalContent = await readStdin();
95
+ finalContent = finalContent.trim();
96
+ }
97
+ // 2. Identify if we need to fetch existing content
98
+ const isStatusUpdate = options.todo || options.done || options.clearStatus;
99
+ const isHeadingUpdate = options.heading !== undefined;
100
+ const isStateUpdate = options.open || options.closed;
101
+ if (finalContent === undefined) {
102
+ if (isStatusUpdate) {
103
+ // Must fetch to apply status safely
104
+ const blockRetrieval = new BlockRetrievalOperations(graph);
105
+ const block = await blockRetrieval.fetchBlockWithChildren(blockUid, 0);
106
+ if (!block) {
107
+ exitWithError(`Block ${blockUid} not found`);
108
+ }
109
+ finalContent = block.string;
110
+ }
111
+ else if (!isHeadingUpdate && !isStateUpdate) {
112
+ exitWithError('No content or update options provided.');
113
+ }
114
+ }
115
+ // 3. Process content if we have it
82
116
  let headingLevel;
83
- if (options.heading !== undefined) {
84
- // Explicit heading option takes precedence
85
- const level = parseInt(options.heading, 10);
86
- if (level >= 0 && level <= 3) {
87
- headingLevel = level === 0 ? undefined : level;
88
- // Still strip # prefix if present for consistency
89
- const { content: stripped } = parseMarkdownHeadingLevel(content);
90
- finalContent = stripped;
117
+ if (finalContent !== undefined) {
118
+ // Handle explicit heading option
119
+ if (options.heading !== undefined) {
120
+ const level = parseInt(options.heading, 10);
121
+ if (level >= 0 && level <= 3) {
122
+ headingLevel = level === 0 ? undefined : level;
123
+ const { content: stripped } = parseMarkdownHeadingLevel(finalContent);
124
+ finalContent = stripped;
125
+ }
126
+ }
127
+ else {
128
+ // Auto-detect heading from content
129
+ const { heading_level, content: stripped } = parseMarkdownHeadingLevel(finalContent);
130
+ if (heading_level > 0) {
131
+ headingLevel = heading_level;
132
+ finalContent = stripped;
133
+ }
134
+ }
135
+ // Handle TODO/DONE status
136
+ if (options.clearStatus) {
137
+ finalContent = clearStatus(finalContent);
138
+ }
139
+ else if (options.todo) {
140
+ finalContent = applyStatus(finalContent, 'TODO');
141
+ }
142
+ else if (options.done) {
143
+ finalContent = applyStatus(finalContent, 'DONE');
91
144
  }
92
145
  }
93
146
  else {
94
- // Auto-detect heading from content
95
- const { heading_level, content: stripped } = parseMarkdownHeadingLevel(content);
96
- if (heading_level > 0) {
97
- headingLevel = heading_level;
98
- finalContent = stripped;
147
+ // No content update, just metadata
148
+ if (options.heading !== undefined) {
149
+ const level = parseInt(options.heading, 10);
150
+ if (level >= 0 && level <= 3) {
151
+ headingLevel = level === 0 ? undefined : level;
152
+ }
99
153
  }
100
154
  }
101
155
  // Handle open/closed state
@@ -106,25 +160,14 @@ Examples:
106
160
  else if (options.closed) {
107
161
  openState = false;
108
162
  }
109
- // Handle TODO/DONE status
110
- if (options.clearStatus) {
111
- finalContent = clearStatus(finalContent);
112
- }
113
- else if (options.todo) {
114
- finalContent = applyStatus(finalContent, 'TODO');
115
- }
116
- else if (options.done) {
117
- finalContent = applyStatus(finalContent, 'DONE');
118
- }
119
163
  if (options.debug) {
120
164
  printDebug('Block UID', blockUid);
121
165
  printDebug('Graph', options.graph || 'default');
122
- printDebug('Content', finalContent);
166
+ printDebug('Content', finalContent !== undefined ? finalContent : '(no change)');
123
167
  printDebug('Heading level', headingLevel ?? 'none');
124
168
  printDebug('Open state', openState ?? 'unchanged');
125
169
  printDebug('Status', options.todo ? 'TODO' : options.done ? 'DONE' : options.clearStatus ? 'cleared' : 'unchanged');
126
170
  }
127
- const graph = resolveGraph(options, true); // This is a write operation
128
171
  const batchOps = new BatchOperations(graph);
129
172
  const result = await batchOps.processBatch([{
130
173
  action: 'update-block',
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Read all input from stdin
3
+ */
4
+ export async function readStdin() {
5
+ const chunks = [];
6
+ for await (const chunk of process.stdin) {
7
+ chunks.push(chunk);
8
+ }
9
+ return Buffer.concat(chunks).toString('utf-8');
10
+ }
@@ -120,8 +120,8 @@ export class RoamServer {
120
120
  };
121
121
  }
122
122
  case 'roam_remember': {
123
- const { memory, categories, heading, parent_uid } = cleanedArgs;
124
- const result = await toolHandlers.remember(memory, categories, heading, parent_uid);
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);
125
125
  return {
126
126
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
127
127
  };
@@ -10,7 +10,7 @@ export class MemoryOperations {
10
10
  this.graph = graph;
11
11
  this.searchOps = new SearchOperations(graph);
12
12
  }
13
- async remember(memory, categories, heading, parent_uid) {
13
+ async remember(memory, categories, heading, parent_uid, include_memories_tag = true) {
14
14
  // Get today's date
15
15
  const today = new Date();
16
16
  const dateStr = formatRoamDate(today);
@@ -91,17 +91,21 @@ export class MemoryOperations {
91
91
  targetParentUid = pageUid;
92
92
  }
93
93
  // Get memories tag from environment
94
- const memoriesTag = process.env.MEMORIES_TAG;
95
- if (!memoriesTag) {
96
- throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
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
+ }
97
100
  }
98
101
  // Format categories as Roam tags if provided
99
102
  const categoryTags = categories?.map(cat => {
100
103
  // Handle multi-word categories
101
104
  return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
102
- }).join(' ') || '';
105
+ }) ?? [];
103
106
  // Create block with memory, then all tags together at the end
104
- const blockContent = `${memory} ${categoryTags} ${memoriesTag}`.trim();
107
+ const tags = memoriesTag ? [...categoryTags, memoriesTag] : categoryTags;
108
+ const blockContent = [memory, ...tags].join(' ').trim();
105
109
  // Pre-generate UID so we can return it
106
110
  const blockUid = generateBlockUid();
107
111
  const actions = [{
@@ -431,20 +431,20 @@ export const toolSchemas = {
431
431
  },
432
432
  roam_remember: {
433
433
  name: 'roam_remember',
434
- description: 'Add a memory or piece of information to remember, stored on the daily page with MEMORIES_TAG tag and optional categories. \nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
434
+ description: 'Add a memory or piece of information to remember, stored on the daily page with MEMORIES_TAG tag and optional categories (unless include_memories_tag is false). \nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
435
435
  inputSchema: {
436
436
  type: 'object',
437
437
  properties: withMultiGraphParams({
438
438
  memory: {
439
439
  type: 'string',
440
- description: 'The memory detail or information to remember'
440
+ description: 'The memory detail or information to remember. Add tags in `categories`.'
441
441
  },
442
442
  categories: {
443
443
  type: 'array',
444
444
  items: {
445
445
  type: 'string'
446
446
  },
447
- description: 'Optional categories to tag the memory with (will be converted to Roam tags)'
447
+ description: 'Optional categories to tag the memory with (will be converted to Roam tags). Do not duplicate tags added in `memory` parameter.'
448
448
  },
449
449
  heading: {
450
450
  type: 'string',
@@ -453,6 +453,11 @@ export const toolSchemas = {
453
453
  parent_uid: {
454
454
  type: 'string',
455
455
  description: 'Optional UID of a specific block to nest the memory under. Takes precedence over heading parameter.'
456
+ },
457
+ include_memories_tag: {
458
+ type: 'boolean',
459
+ description: 'Whether to append the MEMORIES_TAG tag to the memory block.',
460
+ default: true
456
461
  }
457
462
  }),
458
463
  required: ['memory']
@@ -67,8 +67,8 @@ export class ToolHandlers {
67
67
  return handler.execute();
68
68
  }
69
69
  // Memory Operations
70
- async remember(memory, categories, heading, parent_uid) {
71
- return this.memoryOps.remember(memory, categories, heading, parent_uid);
70
+ async remember(memory, categories, heading, parent_uid, include_memories_tag) {
71
+ return this.memoryOps.remember(memory, categories, heading, parent_uid, include_memories_tag);
72
72
  }
73
73
  async recall(sort_by = 'newest', filter_tag) {
74
74
  return this.memoryOps.recall(sort_by, filter_tag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "2.4.0",
3
+ "version": "2.4.3",
4
4
  "description": "MCP server and CLI for Roam Research",
5
5
  "private": false,
6
6
  "repository": {