roam-research-mcp 2.4.0 → 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 (53) hide show
  1. package/README.md +175 -667
  2. package/build/Roam_Markdown_Cheatsheet.md +138 -289
  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 +3 -8
  6. package/build/cli/commands/get.js +478 -60
  7. package/build/cli/commands/refs.js +51 -31
  8. package/build/cli/commands/save.js +61 -10
  9. package/build/cli/commands/search.js +63 -58
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/commands/update.js +71 -28
  12. package/build/cli/utils/graph.js +6 -2
  13. package/build/cli/utils/input.js +10 -0
  14. package/build/cli/utils/output.js +28 -5
  15. package/build/cli/utils/sort-group.js +110 -0
  16. package/build/config/graph-registry.js +31 -13
  17. package/build/config/graph-registry.test.js +42 -5
  18. package/build/markdown-utils.js +114 -4
  19. package/build/markdown-utils.test.js +125 -0
  20. package/build/query/generator.js +330 -0
  21. package/build/query/index.js +149 -0
  22. package/build/query/parser.js +319 -0
  23. package/build/query/parser.test.js +389 -0
  24. package/build/query/types.js +4 -0
  25. package/build/search/ancestor-rule.js +14 -0
  26. package/build/search/block-ref-search.js +1 -5
  27. package/build/search/hierarchy-search.js +5 -12
  28. package/build/search/index.js +1 -0
  29. package/build/search/status-search.js +10 -9
  30. package/build/search/tag-search.js +8 -24
  31. package/build/search/text-search.js +70 -27
  32. package/build/search/types.js +13 -0
  33. package/build/search/utils.js +71 -2
  34. package/build/server/roam-server.js +4 -3
  35. package/build/shared/index.js +2 -0
  36. package/build/shared/page-validator.js +233 -0
  37. package/build/shared/page-validator.test.js +128 -0
  38. package/build/shared/staged-batch.js +144 -0
  39. package/build/tools/helpers/batch-utils.js +57 -0
  40. package/build/tools/helpers/page-resolution.js +136 -0
  41. package/build/tools/helpers/refs.js +68 -0
  42. package/build/tools/operations/batch.js +75 -3
  43. package/build/tools/operations/block-retrieval.js +15 -4
  44. package/build/tools/operations/block-retrieval.test.js +87 -0
  45. package/build/tools/operations/blocks.js +1 -288
  46. package/build/tools/operations/memory.js +32 -90
  47. package/build/tools/operations/outline.js +38 -156
  48. package/build/tools/operations/pages.js +169 -122
  49. package/build/tools/operations/todos.js +5 -37
  50. package/build/tools/schemas.js +20 -9
  51. package/build/tools/tool-handlers.js +4 -4
  52. package/build/utils/helpers.js +27 -0
  53. package/package.json +1 -1
@@ -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
+ }