roam-research-mcp 2.4.3 → 2.13.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.
Files changed (51) hide show
  1. package/README.md +175 -669
  2. package/build/Roam_Markdown_Cheatsheet.md +24 -4
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +2 -0
  6. package/build/cli/commands/get.js +401 -14
  7. package/build/cli/commands/refs.js +2 -0
  8. package/build/cli/commands/save.js +56 -1
  9. package/build/cli/commands/search.js +45 -0
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/utils/graph.js +6 -2
  12. package/build/cli/utils/output.js +28 -5
  13. package/build/cli/utils/sort-group.js +110 -0
  14. package/build/config/graph-registry.js +31 -13
  15. package/build/config/graph-registry.test.js +42 -5
  16. package/build/markdown-utils.js +114 -4
  17. package/build/markdown-utils.test.js +125 -0
  18. package/build/query/generator.js +330 -0
  19. package/build/query/index.js +149 -0
  20. package/build/query/parser.js +319 -0
  21. package/build/query/parser.test.js +389 -0
  22. package/build/query/types.js +4 -0
  23. package/build/search/ancestor-rule.js +14 -0
  24. package/build/search/block-ref-search.js +1 -5
  25. package/build/search/hierarchy-search.js +5 -12
  26. package/build/search/index.js +1 -0
  27. package/build/search/status-search.js +10 -9
  28. package/build/search/tag-search.js +8 -24
  29. package/build/search/text-search.js +70 -27
  30. package/build/search/types.js +13 -0
  31. package/build/search/utils.js +71 -2
  32. package/build/server/roam-server.js +2 -1
  33. package/build/shared/index.js +2 -0
  34. package/build/shared/page-validator.js +233 -0
  35. package/build/shared/page-validator.test.js +128 -0
  36. package/build/shared/staged-batch.js +144 -0
  37. package/build/tools/helpers/batch-utils.js +57 -0
  38. package/build/tools/helpers/page-resolution.js +136 -0
  39. package/build/tools/helpers/refs.js +68 -0
  40. package/build/tools/operations/batch.js +75 -3
  41. package/build/tools/operations/block-retrieval.js +15 -4
  42. package/build/tools/operations/block-retrieval.test.js +87 -0
  43. package/build/tools/operations/blocks.js +1 -288
  44. package/build/tools/operations/memory.js +29 -91
  45. package/build/tools/operations/outline.js +38 -156
  46. package/build/tools/operations/pages.js +169 -122
  47. package/build/tools/operations/todos.js +5 -37
  48. package/build/tools/schemas.js +14 -8
  49. package/build/tools/tool-handlers.js +2 -2
  50. package/build/utils/helpers.js +27 -0
  51. package/package.json +1 -1
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Roam Query Block Parser and Executor
3
+ *
4
+ * Parses Roam query block syntax and executes as Datalog queries
5
+ *
6
+ * Usage:
7
+ * import { QueryExecutor } from './query/index.js';
8
+ *
9
+ * const executor = new QueryExecutor(graph);
10
+ * const results = await executor.execute('{{[[query]]: {and: [[Project]] [[TODO]]}}}');
11
+ */
12
+ export { QueryParser, QueryParseError } from './parser.js';
13
+ export { DatalogGenerator, buildDatalogQuery } from './generator.js';
14
+ import { q } from '@roam-research/roam-api-sdk';
15
+ import { QueryParser } from './parser.js';
16
+ import { DatalogGenerator, buildDatalogQuery } from './generator.js';
17
+ import { resolveRefs } from '../tools/helpers/refs.js';
18
+ export class QueryExecutor {
19
+ constructor(graph) {
20
+ this.graph = graph;
21
+ this.generator = new DatalogGenerator();
22
+ }
23
+ /**
24
+ * Parse and execute a Roam query block
25
+ *
26
+ * @param queryBlock - The query block text, e.g., "{{[[query]]: {and: [[tag1]] [[tag2]]}}}"
27
+ * @param options - Execution options (limit, offset, pageUid)
28
+ * @returns Query results
29
+ */
30
+ async execute(queryBlock, options = {}) {
31
+ try {
32
+ // Parse the query
33
+ const ast = QueryParser.parse(queryBlock);
34
+ // Generate Datalog clauses
35
+ const clauses = this.generator.generate(ast);
36
+ // Build the full query
37
+ const { query, args } = buildDatalogQuery(clauses, {
38
+ limit: options.limit,
39
+ offset: options.offset,
40
+ pageUid: options.pageUid,
41
+ orderBy: options.orderBy || '?block-uid asc'
42
+ });
43
+ // Execute query
44
+ const rawResults = await q(this.graph, query, args);
45
+ // Resolve block references in content
46
+ const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
47
+ const resolvedContent = await resolveRefs(this.graph, content);
48
+ return {
49
+ block_uid: uid,
50
+ content: resolvedContent,
51
+ page_title: pageTitle
52
+ };
53
+ }));
54
+ // Get total count if pagination is used
55
+ let totalCount = resolvedResults.length;
56
+ if (options.limit !== undefined && options.limit !== -1) {
57
+ const countQuery = this.buildCountQuery(clauses, options.pageUid);
58
+ const countResults = await q(this.graph, countQuery.query, countQuery.args);
59
+ totalCount = countResults[0]?.[0] ?? 0;
60
+ }
61
+ return {
62
+ success: true,
63
+ matches: resolvedResults,
64
+ message: `Found ${resolvedResults.length} block(s) matching query`,
65
+ total_count: totalCount,
66
+ query // Include for debugging
67
+ };
68
+ }
69
+ catch (error) {
70
+ return {
71
+ success: false,
72
+ matches: [],
73
+ message: error instanceof Error ? error.message : String(error)
74
+ };
75
+ }
76
+ }
77
+ /**
78
+ * Parse a query block without executing (for validation/debugging)
79
+ */
80
+ parse(queryBlock) {
81
+ const ast = QueryParser.parse(queryBlock);
82
+ const clauses = this.generator.generate(ast);
83
+ const datalog = buildDatalogQuery(clauses);
84
+ return { ast, datalog };
85
+ }
86
+ buildCountQuery(clauses, pageUid) {
87
+ let inClause = ':in $';
88
+ if (clauses.inputs.length > 0) {
89
+ inClause += ' ' + clauses.inputs.join(' ');
90
+ }
91
+ if (pageUid) {
92
+ inClause += ' ?target-page-uid';
93
+ }
94
+ const baseClauses = [
95
+ '[?b :block/string ?block-str]',
96
+ '[?b :block/uid ?block-uid]',
97
+ '[?b :block/page ?p]'
98
+ ];
99
+ if (pageUid) {
100
+ baseClauses.push('[?p :block/uid ?target-page-uid]');
101
+ }
102
+ const allWhereClauses = [...baseClauses, ...clauses.where];
103
+ const query = `[:find (count ?b)
104
+ ${inClause}
105
+ :where
106
+ ${allWhereClauses.join('\n ')}]`;
107
+ const args = [...clauses.inputValues];
108
+ if (pageUid) {
109
+ args.push(pageUid);
110
+ }
111
+ return { query, args };
112
+ }
113
+ }
114
+ /**
115
+ * Detect if a block string contains a query block
116
+ */
117
+ export function isQueryBlock(text) {
118
+ return /^\s*\{\{\[\[query\]\]:/i.test(text);
119
+ }
120
+ /**
121
+ * Extract all query blocks from a string (handles nested braces)
122
+ */
123
+ export function extractQueryBlocks(text) {
124
+ const matches = [];
125
+ const prefix = '{{[[query]]:';
126
+ let startIdx = 0;
127
+ while (startIdx < text.length) {
128
+ const foundIdx = text.indexOf(prefix, startIdx);
129
+ if (foundIdx === -1)
130
+ break;
131
+ // Find matching closing }}
132
+ let depth = 2; // We've seen {{
133
+ let pos = foundIdx + prefix.length;
134
+ while (pos < text.length && depth > 0) {
135
+ if (text[pos] === '{') {
136
+ depth++;
137
+ }
138
+ else if (text[pos] === '}') {
139
+ depth--;
140
+ }
141
+ pos++;
142
+ }
143
+ if (depth === 0) {
144
+ matches.push(text.slice(foundIdx, pos));
145
+ }
146
+ startIdx = pos;
147
+ }
148
+ return matches;
149
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Parser for Roam Query Block syntax
3
+ *
4
+ * Parses queries like:
5
+ * {{[[query]]: {and: [[tag1]] [[tag2]]}}}
6
+ * {{[[query]]: {or: [[a]] {not: [[b]]}}}}
7
+ * {{[[query]]: {and: {between: [[January 1st, 2026]] [[January 31st, 2026]]} [[Project]]}}}
8
+ */
9
+ export class QueryParseError extends Error {
10
+ constructor(message, position) {
11
+ super(message);
12
+ this.position = position;
13
+ this.name = 'QueryParseError';
14
+ }
15
+ }
16
+ export class QueryParser {
17
+ constructor(input) {
18
+ this.input = input;
19
+ this.pos = 0;
20
+ }
21
+ /**
22
+ * Parse a Roam query block string into an AST
23
+ * Accepts either the full block syntax or just the query expression
24
+ */
25
+ static parse(input) {
26
+ const result = QueryParser.parseWithName(input);
27
+ return result.query;
28
+ }
29
+ /**
30
+ * Parse a Roam query block string into an AST with optional name
31
+ * Returns both the name (if present) and the query AST
32
+ */
33
+ static parseWithName(input) {
34
+ const parser = new QueryParser(input);
35
+ return parser.parseQueryWithName();
36
+ }
37
+ /**
38
+ * Extract query expression from a full query block
39
+ * {{[[query]]: expression}} -> expression
40
+ * {{[[query]]: "name" expression}} -> expression (name extracted separately)
41
+ */
42
+ extractQueryExpression() {
43
+ const trimmed = this.input.trim();
44
+ // Full query block format
45
+ const fullMatch = trimmed.match(/^\{\{\[\[query\]\]:\s*(.+)\}\}$/s);
46
+ if (fullMatch) {
47
+ return fullMatch[1].trim();
48
+ }
49
+ // Already just the expression
50
+ return trimmed;
51
+ }
52
+ parseQueryWithName() {
53
+ this.input = this.extractQueryExpression();
54
+ this.pos = 0;
55
+ this.skipWhitespace();
56
+ // Check for optional name prefix (quoted string)
57
+ let name;
58
+ if (this.peek() === '"') {
59
+ name = this.parseQuotedString();
60
+ this.skipWhitespace();
61
+ }
62
+ const query = this.parseExpression();
63
+ this.skipWhitespace();
64
+ if (this.pos < this.input.length) {
65
+ throw new QueryParseError(`Unexpected content after query: "${this.input.slice(this.pos)}"`, this.pos);
66
+ }
67
+ return { name, query };
68
+ }
69
+ parseQuery() {
70
+ return this.parseQueryWithName().query;
71
+ }
72
+ parseQuotedString() {
73
+ this.expect('"');
74
+ let value = '';
75
+ while (this.pos < this.input.length && this.peek() !== '"') {
76
+ if (this.peek() === '\\' && this.input[this.pos + 1] === '"') {
77
+ value += '"';
78
+ this.pos += 2;
79
+ }
80
+ else {
81
+ value += this.input[this.pos];
82
+ this.pos++;
83
+ }
84
+ }
85
+ if (this.peek() === '"') {
86
+ this.pos++; // Skip closing quote
87
+ }
88
+ else {
89
+ throw new QueryParseError('Unclosed quoted string', this.pos);
90
+ }
91
+ return value;
92
+ }
93
+ parseExpression() {
94
+ this.skipWhitespace();
95
+ if (this.peek() === '{') {
96
+ return this.parseOperator();
97
+ }
98
+ else if (this.input.slice(this.pos, this.pos + 2) === '[[') {
99
+ return this.parseTag();
100
+ }
101
+ else if (this.input.slice(this.pos, this.pos + 2) === '((') {
102
+ return this.parseBlockRef();
103
+ }
104
+ else {
105
+ throw new QueryParseError(`Expected '{', '[[', or '((' at position ${this.pos}, found: "${this.input.slice(this.pos, this.pos + 10)}..."`, this.pos);
106
+ }
107
+ }
108
+ parseOperator() {
109
+ this.expect('{');
110
+ this.skipWhitespace();
111
+ const operator = this.parseOperatorName();
112
+ this.skipWhitespace();
113
+ this.expect(':');
114
+ this.skipWhitespace();
115
+ let node;
116
+ switch (operator.toLowerCase()) {
117
+ case 'and':
118
+ node = this.parseAndOr('and');
119
+ break;
120
+ case 'or':
121
+ node = this.parseAndOr('or');
122
+ break;
123
+ case 'not':
124
+ node = this.parseNot();
125
+ break;
126
+ case 'between':
127
+ node = this.parseBetween();
128
+ break;
129
+ case 'search':
130
+ node = this.parseSearch();
131
+ break;
132
+ case 'daily notes':
133
+ node = this.parseDailyNotes();
134
+ break;
135
+ case 'by':
136
+ node = this.parseUserClause('by');
137
+ break;
138
+ case 'created by':
139
+ node = this.parseUserClause('created-by');
140
+ break;
141
+ case 'edited by':
142
+ node = this.parseUserClause('edited-by');
143
+ break;
144
+ default:
145
+ throw new QueryParseError(`Unknown operator: ${operator}`, this.pos);
146
+ }
147
+ this.skipWhitespace();
148
+ this.expect('}');
149
+ return node;
150
+ }
151
+ parseAndOr(type) {
152
+ const children = [];
153
+ this.skipWhitespace();
154
+ while (this.pos < this.input.length && this.peek() !== '}') {
155
+ children.push(this.parseExpression());
156
+ this.skipWhitespace();
157
+ }
158
+ if (children.length === 0) {
159
+ throw new QueryParseError(`${type} operator requires at least one child`, this.pos);
160
+ }
161
+ return { type, children };
162
+ }
163
+ parseNot() {
164
+ this.skipWhitespace();
165
+ const child = this.parseExpression();
166
+ return { type: 'not', child };
167
+ }
168
+ parseBetween() {
169
+ this.skipWhitespace();
170
+ // Parse first date
171
+ const startTag = this.parseTag();
172
+ this.skipWhitespace();
173
+ // Parse second date
174
+ const endTag = this.parseTag();
175
+ return {
176
+ type: 'between',
177
+ startDate: startTag.value,
178
+ endDate: endTag.value
179
+ };
180
+ }
181
+ parseTag() {
182
+ this.expect('[');
183
+ this.expect('[');
184
+ let value = '';
185
+ let depth = 1;
186
+ while (this.pos < this.input.length && depth > 0) {
187
+ if (this.input.slice(this.pos, this.pos + 2) === ']]') {
188
+ depth--;
189
+ if (depth === 0) {
190
+ this.pos += 2;
191
+ break;
192
+ }
193
+ value += ']]';
194
+ this.pos += 2;
195
+ }
196
+ else if (this.input.slice(this.pos, this.pos + 2) === '[[') {
197
+ depth++;
198
+ value += '[[';
199
+ this.pos += 2;
200
+ }
201
+ else {
202
+ value += this.input[this.pos];
203
+ this.pos++;
204
+ }
205
+ }
206
+ if (depth !== 0) {
207
+ throw new QueryParseError('Unclosed tag reference', this.pos);
208
+ }
209
+ return { type: 'tag', value: value.trim() };
210
+ }
211
+ parseBlockRef() {
212
+ this.expect('(');
213
+ this.expect('(');
214
+ let uid = '';
215
+ while (this.pos < this.input.length) {
216
+ if (this.input.slice(this.pos, this.pos + 2) === '))') {
217
+ this.pos += 2;
218
+ break;
219
+ }
220
+ uid += this.input[this.pos];
221
+ this.pos++;
222
+ }
223
+ if (!uid) {
224
+ throw new QueryParseError('Empty block reference', this.pos);
225
+ }
226
+ return { type: 'block-ref', uid: uid.trim() };
227
+ }
228
+ parseSearch() {
229
+ // Search text is typically in quotes or just plain text until }
230
+ this.skipWhitespace();
231
+ let text = '';
232
+ // Check if quoted
233
+ if (this.peek() === '"') {
234
+ this.pos++; // Skip opening quote
235
+ while (this.pos < this.input.length && this.peek() !== '"') {
236
+ if (this.peek() === '\\' && this.input[this.pos + 1] === '"') {
237
+ text += '"';
238
+ this.pos += 2;
239
+ }
240
+ else {
241
+ text += this.input[this.pos];
242
+ this.pos++;
243
+ }
244
+ }
245
+ if (this.peek() === '"') {
246
+ this.pos++; // Skip closing quote
247
+ }
248
+ }
249
+ else {
250
+ // Unquoted - read until }
251
+ while (this.pos < this.input.length && this.peek() !== '}') {
252
+ text += this.input[this.pos];
253
+ this.pos++;
254
+ }
255
+ text = text.trim();
256
+ }
257
+ return { type: 'search', text };
258
+ }
259
+ parseDailyNotes() {
260
+ // Daily notes clause has no arguments, just empty or whitespace until }
261
+ return { type: 'daily-notes' };
262
+ }
263
+ parseUserClause(type) {
264
+ this.skipWhitespace();
265
+ const user = this.parseUserIdentifier();
266
+ return { type, user };
267
+ }
268
+ parseUserIdentifier() {
269
+ // User can be in [[display name]] format or plain text
270
+ if (this.input.slice(this.pos, this.pos + 2) === '[[') {
271
+ const tag = this.parseTag();
272
+ return tag.value;
273
+ }
274
+ // Plain text until }
275
+ let user = '';
276
+ while (this.pos < this.input.length && this.peek() !== '}') {
277
+ user += this.input[this.pos];
278
+ this.pos++;
279
+ }
280
+ return user.trim();
281
+ }
282
+ /**
283
+ * Parse operator name, handling multi-word operators like "created by"
284
+ */
285
+ parseOperatorName() {
286
+ const knownMultiWord = ['created by', 'edited by', 'daily notes'];
287
+ const remaining = this.input.slice(this.pos).toLowerCase();
288
+ for (const op of knownMultiWord) {
289
+ if (remaining.startsWith(op)) {
290
+ this.pos += op.length;
291
+ return op;
292
+ }
293
+ }
294
+ // Single word operator
295
+ return this.parseIdentifier();
296
+ }
297
+ parseIdentifier() {
298
+ let id = '';
299
+ while (this.pos < this.input.length && /[a-zA-Z]/.test(this.input[this.pos])) {
300
+ id += this.input[this.pos];
301
+ this.pos++;
302
+ }
303
+ return id;
304
+ }
305
+ skipWhitespace() {
306
+ while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
307
+ this.pos++;
308
+ }
309
+ }
310
+ peek() {
311
+ return this.input[this.pos];
312
+ }
313
+ expect(char) {
314
+ if (this.input[this.pos] !== char) {
315
+ throw new QueryParseError(`Expected '${char}' at position ${this.pos}, found '${this.input[this.pos] || 'EOF'}'`, this.pos);
316
+ }
317
+ this.pos++;
318
+ }
319
+ }