roam-research-mcp 3.1.0 → 3.2.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
@@ -183,6 +183,14 @@ This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
183
183
 
184
184
  If the page doesn't exist, the tool returns `exists: false` rather than failing, so it is always safe to call.
185
185
 
186
+ ### It also returns the rules that aren't yours to set
187
+
188
+ Alongside your conventions, every `roam_get_guidelines` response carries a `roamSyntax` field: the short list of things that *destroy* content — `roam_update_page_markdown` deleting every block your markdown omits, truncated `structure` previews written back as if they were content, block references retyped as plain text — plus a caution that reads silently exclude `#.rm-hide` subtrees, and the handful of places Roam's markdown inverts standard markdown.
189
+
190
+ Two reasons it rides here rather than in the cheatsheet. It reaches **every** client, including one that never calls `roam_markdown_cheatsheet`; and it is returned even when a graph has **no** guidelines page, which is exactly the case where an agent has least context. The layering is deliberate: **your conventions win on style, `roamSyntax` wins on data safety.** No convention can make a truncated preview complete.
191
+
192
+ The full syntax reference — components, queries, embeds, tool selection — stays in `roam_markdown_cheatsheet`. `roamSyntax` is ~800 tokens and deliberately capped.
193
+
186
194
  Each graph can point at a different page, or turn it off:
187
195
 
188
196
  ```bash
@@ -210,6 +218,8 @@ This follows the same convention as Roam's official MCP server, so a block tagge
210
218
 
211
219
  Applied to: `roam_fetch_page_by_title`, `roam_fetch_block`, `roam_fetch_page_full_view`, `roam_get_subpages`, `roam_search_by_text`, `roam_search_for_tag`, `roam_search_by_status`, `roam_search_block_refs`, `roam_search_hierarchy`, `roam_search_by_date`.
212
220
 
221
+ **Hidden blocks are also excluded from the page-rewrite diff**, which is what stops them being *deleted* for being absent from markdown the agent could not have written. `roam_update_page_markdown` (and `roam save --update`) replaces a page with what you give it, deleting whatever your markdown omits — so its baseline is pruned by this same filter, on the rule that **the baseline a diff deletes from must be the same page the caller was allowed to read.** It reports `preserved_hidden` when it protected anything. Content is preserved; exact ordering relative to visible siblings may shift. This was a real data-loss bug before the fix — see the [changelog](CHANGELOG.md).
222
+
213
223
  **This is a convenience filter, not a security guarantee.** `roam_datomic_query` reads the database directly and deliberately does **not** apply it, so a capable agent can still surface hidden blocks through raw Datalog. Treat these tags as "keep it out of the AI's way," not "keep it secret."
214
224
 
215
225
  Tag matching is case-insensitive, and only exact tags match — `#.rm-hidden` and `#.rm-highlight` are left alone. The set of hidden UIDs is cached for 30 seconds, so a block tagged just now may remain visible for up to that long.
@@ -1,4 +1,4 @@
1
- # Roam Markdown Cheatsheet v2.3.0
1
+ # Roam Markdown Cheatsheet v2.4.0
2
2
 
3
3
  ## Core Syntax
4
4
 
@@ -32,6 +32,36 @@ Always ordinal format: `[[January 1st, 2025]]`, `[[December 23rd, 2024]]`
32
32
  - Todo: `{{[[TODO]]}} task`
33
33
  - Done: `{{[[DONE]]}} task`
34
34
 
35
+ ### Callouts
36
+ Styled blockquotes with an icon and colour. Two page refs open the block: `[[>]]` marks it a callout, `[[!TYPE]]` picks the style.
37
+
38
+ ```
39
+ [[>]] [[!TIP]] Title text
40
+ Body on the next line
41
+ ```
42
+
43
+ The body is a **soft line break inside the same block** (Shift+Enter in the UI, `\n` in the block string) — not a child block. A child block renders as a nested bullet inside the callout instead, which is usually not what you want.
44
+
45
+ Types: `NOTE` `INFO` `SUMMARY` `TIP` `SUCCESS` `QUESTION` `WARNING` `FAILURE` `DANGER` `BUG` `EXAMPLE` `QUOTE`
46
+
47
+ Append `+` or `-` to make it foldable — `[[!TIP]]+` starts expanded, `[[!TIP]]-` starts collapsed.
48
+
49
+ ⚠️ `[[>]]` and `[[!TIP]]` are real page references, so every callout backlinks to those pages. That is normal and how the feature works — don't "clean it up."
50
+ ⚠️ A plain `> quote` is an ordinary blockquote, not a callout. The two are unrelated.
51
+
52
+ ### ⚠️ Multi-line blocks and page rewrites
53
+
54
+ A block can hold a **soft line break** (Shift+Enter, stored as `\n` in the block
55
+ string). Callout bodies require one; fenced code blocks are full of them.
56
+
57
+ **Do not run `roam_update_page_markdown` — or `roam save --update` — on a page
58
+ containing a callout, a fenced code block, or any Shift+Enter line break.** It
59
+ will split those blocks and **flatten the hierarchy of everything after them**,
60
+ reparenting blocks under the wrong ancestors.
61
+
62
+ Use `roam_process_batch_actions` for those pages. It writes block strings
63
+ literally, and is currently the only way to create a soft line break at all.
64
+
35
65
  ### Attributes
36
66
  ```
37
67
  Type:: Book
@@ -67,8 +97,32 @@ const x = 1;
67
97
  {{[[query]]: {or: [[A]] [[B]]}}}
68
98
  {{[[query]]: {not: [[exclude]]}}}
69
99
  {{[[query]]: {between: [[January 1st, 2025]] [[January 31st, 2025]]}}}
100
+ {{[[query]]: {and: [[Project]] {search: mobile}}}}
70
101
  ```
71
102
 
103
+ Clauses nest: `{and: [[Project]] {not: [[DONE]]}}`.
104
+
105
+ **Queries match REFERENCES, not text.** Operands must be `[[Page]]` or `((block-uid))` — bare or quoted words do not match. `{and: TODO}` and `{and: "project alpha"}` both find nothing; write `{and: [[TODO]]}` and `{and: [[project alpha]]}`. For free text use `roam_search_by_text`, or a `{search:}` clause.
106
+
107
+ **`{search:}` only works nested inside `{and:}` or `{or:}`** — never on its own, and it is the one clause that takes plain text rather than a reference.
108
+
109
+ **`{between:}` only works on Daily Notes pages.** It filters by the daily page a block lives on, so it does nothing for content on ordinary pages. It accepts shorthands: `[[today]]`, `[[yesterday]]`, `[[last week]]`, `[[next month]]`.
110
+
111
+ Also available: `{created-by: [[User]]}`, `{edited-by: [[User]]}`, `{by: [[User]]}`.
112
+
113
+ #### Page-ref inheritance — the non-obvious one
114
+ **A block inherits its parent's page refs for query matching.** So this TODO matches `{and: [[TODO]] [[Project Alpha]]}` even though it contains no reference to Project Alpha:
115
+
116
+ ```
117
+ - Notes on [[Project Alpha]]
118
+ - {{[[TODO]]}} Ship the thing
119
+ ```
120
+
121
+ Three consequences:
122
+ - **Don't tag every child with the parent's ref** — it is already inherited, and the duplication just clutters the backlinks.
123
+ - **Do tag a child explicitly** if you want it to match *independently* of where it sits. Move it later and inherited matching goes with the old parent.
124
+ - **Reading results:** a returned block may not visibly contain the thing you queried for. The matching ref can be on an ancestor. Don't report the result as wrong, and don't "fix" the block by adding the tag.
125
+
72
126
  ### Calculator
73
127
  `{{[[calc]]: 2 + 2}}`
74
128
 
@@ -170,6 +224,11 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
170
224
  | `- *bullet` | `- bullet` |
171
225
  | `* bullet` | `- bullet` |
172
226
  | `**Attr**:: val` | `Attr:: val` |
227
+ | `{and: TODO}` | `{and: [[TODO]]}` (queries match refs, not words) |
228
+ | `{and: "project alpha"}` | `{and: [[project alpha]]}` |
229
+ | `{{[[query]]: {search: text}}}` | `{{[[query]]: {and: {search: text}}}}` (never standalone) |
230
+ | `> [[!TIP]] Title` | `[[>]] [[!TIP]] Title` |
231
+ | callout body as a child block | body as `\n` in the same block |
173
232
 
174
233
  ## Tool Selection
175
234
 
@@ -34,7 +34,7 @@
34
34
  */
35
35
  export { getDiffStats, isDiffEmpty } from './types.js';
36
36
  // Parser
37
- export { parseExistingBlock, parseExistingBlocks, flattenExistingBlocks, markdownToBlocks, getBlockDepth, } from './parser.js';
37
+ export { parseExistingBlock, parseExistingBlocks, pruneHiddenExistingBlocks, countHiddenExistingBlocks, flattenExistingBlocks, markdownToBlocks, getBlockDepth, } from './parser.js';
38
38
  // Matcher
39
39
  export { normalizeText, normalizeForMatching, matchBlocks, groupByParent } from './matcher.js';
40
40
  // Diff
@@ -5,6 +5,7 @@
5
5
  * and provides utilities for flattening block trees.
6
6
  */
7
7
  import { generateBlockUid, parseMarkdown } from '../markdown-utils.js';
8
+ import { isHiddenBlockString } from '../tools/helpers/hidden.js';
8
9
  /**
9
10
  * Parse a raw Roam API block into an ExistingBlock structure.
10
11
  * Recursively processes children and sorts them by order.
@@ -38,6 +39,56 @@ export function parseExistingBlocks(pageData) {
38
39
  const childrenSorted = [...childrenRaw].sort((a, b) => (a[':block/order'] ?? 0) - (b[':block/order'] ?? 0));
39
40
  return childrenSorted.map((c) => parseExistingBlock(c, null));
40
41
  }
42
+ /**
43
+ * Drop `#.rm-hide` / `#.rm-private` subtrees from a diff baseline.
44
+ *
45
+ * THE RULE THIS ENFORCES: **the baseline a diff deletes from must be the same
46
+ * page the caller was allowed to read.**
47
+ *
48
+ * Every read path filters these subtrees out, so an agent composing replacement
49
+ * markdown cannot include what it was never shown. The diff then compared that
50
+ * markdown against the *unfiltered* page, found the hidden blocks unaccounted
51
+ * for, and deleted them — turning "hide this from the AI" into "let the AI
52
+ * delete this", with no undo. Pruning the baseline makes the two views agree:
53
+ * a block that is invisible is also unmatched-against, so it is never a
54
+ * deletion candidate.
55
+ *
56
+ * WHAT THIS DOES NOT PRESERVE: position. A surviving hidden block keeps its
57
+ * original `:block/order`, while the new blocks are numbered from the markdown,
58
+ * so a hidden block can end up sharing an order with a visible sibling and
59
+ * settle either side of it. Ordering is a sort key, not content — and the
60
+ * alternative, reserving slots for blocks the caller cannot see, would let
61
+ * hidden content dictate visible layout. Content survives; exact position may
62
+ * not.
63
+ *
64
+ * @param blocks - Parsed baseline, straight from `parseExistingBlocks`
65
+ * @returns The same trees with hidden blocks and their descendants removed
66
+ */
67
+ export function pruneHiddenExistingBlocks(blocks) {
68
+ const kept = [];
69
+ for (const block of blocks) {
70
+ // The whole subtree goes: a visible child of a hidden parent is still
71
+ // content the caller never saw, so it is equally undeletable.
72
+ if (isHiddenBlockString(block.text))
73
+ continue;
74
+ kept.push({ ...block, children: pruneHiddenExistingBlocks(block.children) });
75
+ }
76
+ return kept;
77
+ }
78
+ /** How many blocks `pruneHiddenExistingBlocks` would remove, counting subtrees. */
79
+ export function countHiddenExistingBlocks(blocks) {
80
+ let count = 0;
81
+ for (const block of blocks) {
82
+ if (isHiddenBlockString(block.text)) {
83
+ // The block plus everything under it, all of which is being protected.
84
+ count += 1 + flattenExistingBlocks(block.children).length;
85
+ }
86
+ else {
87
+ count += countHiddenExistingBlocks(block.children);
88
+ }
89
+ }
90
+ return count;
91
+ }
41
92
  /**
42
93
  * Flatten a tree of existing blocks into a single array.
43
94
  * Preserves parent-child relationships through parentUid property.
@@ -100,6 +100,31 @@ function convertToRoamMarkdown(text) {
100
100
  });
101
101
  return text;
102
102
  }
103
+ /**
104
+ * Is this line a bullet whose only content is a code-fence opener?
105
+ *
106
+ * Only such a line may be spliced into "bullet" + "fence" so the fence state
107
+ * machine can gather the following lines. Any line with content AFTER the
108
+ * fence is a block that merely CONTAINS backticks — splicing it opens a region
109
+ * that never closes, and the parser then consumes the rest of the document.
110
+ *
111
+ * That was a real, unrecoverable defect: `- wrap it in ``` to make code`
112
+ * followed by three blocks parsed to a single block "wrap it in", and
113
+ * roam_update_page_markdown deleted the other three. Roam has no undo.
114
+ */
115
+ function isBulletFenceOpener(trimmedLine) {
116
+ return /^\s*[-*+]\s+```[A-Za-z0-9_+-]*\s*$/.test(trimmedLine);
117
+ }
118
+ /**
119
+ * A fence line with content after its opening ``` is content, not a region
120
+ * opener. Guards the bare (non-bullet) case the splice rule cannot see.
121
+ */
122
+ function fenceHasTrailingContent(trimmedLine) {
123
+ const open = trimmedLine.indexOf('```');
124
+ if (open === -1)
125
+ return false;
126
+ return trimmedLine.slice(open + 3).replace(/^[A-Za-z0-9_+-]*/, '').trim().length > 0;
127
+ }
103
128
  function parseMarkdown(markdown) {
104
129
  markdown = convertToRoamMarkdown(markdown);
105
130
  const originalLines = markdown.split('\n');
@@ -108,9 +133,15 @@ function parseMarkdown(markdown) {
108
133
  for (const line of originalLines) {
109
134
  const trimmedLine = line.trimEnd();
110
135
  const codeStartIndex = trimmedLine.indexOf('```');
111
- if (codeStartIndex > 0) {
136
+ if (codeStartIndex > 0 && isBulletFenceOpener(trimmedLine)) {
137
+ // Under this rule the text before the fence is ALWAYS just the bullet
138
+ // marker, so there is no real content to preserve as its own node.
139
+ // Pushing it anyway left a bare "-" line that the parser could not
140
+ // recognise as a bullet once trimmed, so it emitted a spurious "-" block
141
+ // ahead of the code block it introduces. Dropping it loses nothing: the
142
+ // fence line below carries the same leading whitespace, so
143
+ // indentation-based nesting is unaffected.
112
144
  const indentationWhitespace = line.match(/^\s*/)?.[0] ?? '';
113
- processedLines.push(indentationWhitespace + trimmedLine.substring(0, codeStartIndex));
114
145
  processedLines.push(indentationWhitespace + trimmedLine.substring(codeStartIndex));
115
146
  }
116
147
  else {
@@ -123,7 +154,7 @@ function parseMarkdown(markdown) {
123
154
  let inCodeBlockFirstPass = false;
124
155
  for (const line of processedLines) {
125
156
  const trimmedLine = line.trimEnd();
126
- if (trimmedLine.match(/^(\s*)```/)) {
157
+ if (trimmedLine.match(/^(\s*)```/) && !fenceHasTrailingContent(trimmedLine)) {
127
158
  inCodeBlockFirstPass = !inCodeBlockFirstPass;
128
159
  if (!inCodeBlockFirstPass)
129
160
  continue; // Skip closing ```
@@ -176,7 +207,7 @@ function parseMarkdown(markdown) {
176
207
  for (let i = 0; i < processedLines.length; i++) {
177
208
  const line = processedLines[i];
178
209
  const trimmedLine = line.trimEnd();
179
- if (trimmedLine.match(/^(\s*)```/)) {
210
+ if (trimmedLine.match(/^(\s*)```/) && !fenceHasTrailingContent(trimmedLine)) {
180
211
  if (!inCodeBlock) {
181
212
  inCodeBlock = true;
182
213
  codeBlockContent = trimmedLine.trimStart() + '\n';
@@ -10,10 +10,16 @@
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
+ * The result also carries `roamSyntax` (see `../roam-syntax.ts`) on every path,
14
+ * whether or not a guidelines page exists. Conventions are the user's to supply
15
+ * and may be absent; the data-safety rules are the server's and never are.
16
+ *
13
17
  * Read by default; set `guidelinesPage: false` on a graph to disable it there.
18
+ * Disabling suppresses the user's conventions, not `roamSyntax`.
14
19
  */
15
20
  import { PageOperations } from './pages.js';
16
21
  import { formatRoamDate } from '../../utils/helpers.js';
22
+ import { ROAM_SYNTAX } from '../roam-syntax.js';
17
23
  /** The shared convention, read by default and by Roam's own MCP server. */
18
24
  export const DEFAULT_GUIDELINES_PAGE = 'roam/agent guidelines';
19
25
  /**
@@ -43,8 +49,9 @@ export class GuidelinesOperations {
43
49
  page: null,
44
50
  exists: false,
45
51
  guidelines: null,
52
+ roamSyntax: ROAM_SYNTAX,
46
53
  todaysDailyNote: today,
47
- nextSteps: 'Guidelines are disabled for this graph. Proceed using the Roam Markdown Cheatsheet for syntax.',
54
+ nextSteps: 'Guidelines are disabled for this graph. Follow `roamSyntax` below, and load the Roam Markdown Cheatsheet for anything it does not cover.',
48
55
  };
49
56
  }
50
57
  const perGraph = cache.get(this.graph) ?? new Map();
@@ -60,9 +67,11 @@ export class GuidelinesOperations {
60
67
  page: this.guidelinesPage,
61
68
  exists: false,
62
69
  guidelines: null,
70
+ roamSyntax: ROAM_SYNTAX,
63
71
  todaysDailyNote: today,
64
72
  nextSteps: `No "${this.guidelinesPage}" page exists in this graph, so there are no user conventions to follow. ` +
65
- `Do not call this tool again for this graph this session. Proceed using the Roam Markdown Cheatsheet for syntax. ` +
73
+ `Do not call this tool again for this graph this session. The \`roamSyntax\` rules below still apply they are about data safety, not convention. ` +
74
+ `Load the Roam Markdown Cheatsheet for syntax they do not cover. ` +
66
75
  `The user can create the page at any time to set conventions.`,
67
76
  };
68
77
  }
@@ -75,9 +84,11 @@ export class GuidelinesOperations {
75
84
  page: this.guidelinesPage,
76
85
  exists: true,
77
86
  guidelines,
87
+ roamSyntax: ROAM_SYNTAX,
78
88
  todaysDailyNote: today,
79
89
  nextSteps: `You now have this graph's conventions. Do not call this tool again for this graph this session — you already have what you need. ` +
80
90
  `Apply these conventions to reads as well as writes: they change how results should be interpreted and presented, not just how content is written. ` +
91
+ `Where they are silent, the \`roamSyntax\` rules govern; where they conflict, conventions win on style and \`roamSyntax\` wins on data safety. ` +
81
92
  `Today's daily note is "${today}".`,
82
93
  };
83
94
  }
@@ -88,9 +99,12 @@ export class GuidelinesOperations {
88
99
  page: this.guidelinesPage,
89
100
  exists: false,
90
101
  guidelines: null,
102
+ roamSyntax: ROAM_SYNTAX,
91
103
  todaysDailyNote: today,
92
104
  nextSteps: `Could not read "${this.guidelinesPage}" (${error instanceof Error ? error.message : String(error)}). ` +
93
- `Proceed using the Roam Markdown Cheatsheet for syntax.`,
105
+ `The graph's conventions are unavailable, so be conservative about placement and style. ` +
106
+ `The \`roamSyntax\` rules below are unaffected — they ship with the server. ` +
107
+ `Load the Roam Markdown Cheatsheet for syntax they do not cover.`,
94
108
  };
95
109
  }
96
110
  perGraph.set(this.guidelinesPage, { at: Date.now(), result });
@@ -10,7 +10,7 @@ import { executeStagedBatch } from '../../shared/staged-batch.js';
10
10
  import { pageUidCache } from '../../cache/page-uid-cache.js';
11
11
  import { buildTableActions } from './table.js';
12
12
  import { BatchOperations } from './batch.js';
13
- import { parseExistingBlocks, markdownToBlocks, diffBlockTrees, generateBatchActions, getDiffStats, isDiffEmpty, summarizeActions, } from '../../diff/index.js';
13
+ import { parseExistingBlocks, pruneHiddenExistingBlocks, countHiddenExistingBlocks, markdownToBlocks, diffBlockTrees, generateBatchActions, getDiffStats, isDiffEmpty, summarizeActions, } from '../../diff/index.js';
14
14
  // Helper to get ordinal suffix for dates
15
15
  function getOrdinalSuffix(day) {
16
16
  if (day > 3 && day < 21)
@@ -524,12 +524,30 @@ export class PageOperations {
524
524
  return JSON.stringify(visibleRoots);
525
525
  }
526
526
  if (format === 'structure') {
527
+ // Flatten the tree into a list optimized for surgical updates: this
528
+ // format exists to hand an agent the UIDs and shape of a page cheaply, so
529
+ // `text` is a PREVIEW, cut at PREVIEW_CHARS.
530
+ //
531
+ // That cut is the format's one sharp edge. The tool description sells the
532
+ // output as "optimized for surgical updates", and the obvious next move —
533
+ // feed these entries to roam_process_batch_actions as update-block
534
+ // strings — silently replaces every long block with its own first 80
535
+ // characters. The `...` suffix was the only signal, and an agent
536
+ // reassembling content does not reliably read punctuation as a warning.
537
+ //
538
+ // So a cut entry now says so in a field: `truncated: true`, plus
539
+ // `full_length` so the agent can see how much is missing. The payload
540
+ // also carries a one-line instruction, but ONLY when something was
541
+ // actually cut — a warning present on every response is a warning that
542
+ // gets skimmed. Widening the cut, or removing it, would change what an
543
+ // unchanged call returns; marking it does not.
544
+ const PREVIEW_CHARS = 80;
527
545
  const flattenBlocks = (blocks, depth, parentUid) => {
528
546
  const result = [];
529
547
  for (const block of blocks) {
530
- // Truncate text for preview (keep first 80 chars)
531
- const preview = block.string.length > 80
532
- ? block.string.substring(0, 80) + '...'
548
+ const isTruncated = block.string.length > PREVIEW_CHARS;
549
+ const preview = isTruncated
550
+ ? block.string.substring(0, PREVIEW_CHARS) + '...'
533
551
  : block.string;
534
552
  const entry = {
535
553
  uid: block.uid,
@@ -538,6 +556,10 @@ export class PageOperations {
538
556
  depth,
539
557
  parent_uid: parentUid
540
558
  };
559
+ if (isTruncated) {
560
+ entry.truncated = true;
561
+ entry.full_length = block.string.length;
562
+ }
541
563
  if (block.heading) {
542
564
  entry.heading = block.heading;
543
565
  }
@@ -550,10 +572,19 @@ export class PageOperations {
550
572
  return result;
551
573
  };
552
574
  const structureBlocks = flattenBlocks(visibleRoots, 0, uid);
575
+ const truncatedCount = structureBlocks.filter((b) => b.truncated).length;
553
576
  return JSON.stringify({
554
577
  page_uid: uid,
555
578
  title: title,
556
579
  block_count: structureBlocks.length,
580
+ ...(truncatedCount > 0 && {
581
+ truncated_count: truncatedCount,
582
+ warning: `${truncatedCount} block${truncatedCount === 1 ? '' : 's'} shown here ` +
583
+ `${truncatedCount === 1 ? 'is' : 'are'} cut off at ${PREVIEW_CHARS} characters ` +
584
+ `(marked \`truncated: true\`). Their \`text\` is a preview for orientation, not content. ` +
585
+ `Writing it back would replace the block with its opening fragment — ` +
586
+ `fetch the block with roam_fetch_block to get its full string before editing it.`
587
+ }),
557
588
  blocks: structureBlocks
558
589
  });
559
590
  }
@@ -621,7 +652,17 @@ export class PageOperations {
621
652
  throw new McpError(ErrorCode.InternalError, `Failed to fetch page data for "${title}"`);
622
653
  }
623
654
  // 3. Parse existing blocks into our format
624
- const existingBlocks = parseExistingBlocks(pageData);
655
+ const allExistingBlocks = parseExistingBlocks(pageData);
656
+ // 3a. Withhold #.rm-hide / #.rm-private subtrees from the BASELINE, not
657
+ // just from reads. The query above deliberately pulls the whole page —
658
+ // block UIDs and ordering have to be complete for the diff to preserve
659
+ // references — but diffing against blocks the caller was never shown is
660
+ // how the hide filter became a deletion mechanism: unseen content cannot
661
+ // appear in replacement markdown, and this diff deletes whatever the
662
+ // markdown does not account for. The baseline must match what could be
663
+ // read. See `pruneHiddenExistingBlocks`.
664
+ const hiddenCount = countHiddenExistingBlocks(allExistingBlocks);
665
+ const existingBlocks = pruneHiddenExistingBlocks(allExistingBlocks);
625
666
  // 4. Convert new markdown to block structure
626
667
  const newBlocks = markdownToBlocks(markdown, pageUid);
627
668
  // 5. Compute diff
@@ -643,12 +684,23 @@ export class PageOperations {
643
684
  throw new McpError(ErrorCode.InternalError, `Failed to apply changes: ${error instanceof Error ? error.message : String(error)}`);
644
685
  }
645
686
  }
687
+ // Report the protection rather than applying it silently. Without this, a
688
+ // caller told "3 blocks deleted, 5 created" has no way to explain the
689
+ // blocks still on the page afterwards, and a user debugging that has
690
+ // nothing to go on. It is a count, never content — and these tags are
691
+ // documented as "keep it out of the AI's way", not a secrecy boundary
692
+ // (`tools/helpers/hidden.ts`), so a count is well within their contract.
693
+ const preservationNote = hiddenCount > 0
694
+ ? ` ${hiddenCount} hidden block${hiddenCount === 1 ? '' : 's'} ` +
695
+ `(#.rm-hide / #.rm-private) ${hiddenCount === 1 ? 'was' : 'were'} excluded from the diff and left untouched.`
696
+ : '';
646
697
  return {
647
698
  success: true,
648
699
  actions,
649
700
  stats,
650
701
  preserved_uids: [...diff.preservedUids],
651
- summary: dryRun ? `[DRY RUN] ${summary}` : summary
702
+ ...(hiddenCount > 0 && { preserved_hidden: hiddenCount }),
703
+ summary: (dryRun ? `[DRY RUN] ${summary}` : summary) + preservationNote
652
704
  };
653
705
  }
654
706
  /**
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ROAM_SYNTAX — the data-safety and syntax rules an agent needs before its
3
+ * first write, returned by `roam_get_guidelines` as the `roamSyntax` field.
4
+ *
5
+ * WHY IT LIVES ON THE GUIDELINES RESULT. Every write tool's description already
6
+ * says "load the Roam Markdown Cheatsheet", but that is a second, voluntary
7
+ * tool call, and a model under budget pressure skips it. The guidelines call is
8
+ * the one we successfully insist on. Anything that must reach every client —
9
+ * including clients with no skill installed and no cheatsheet fetch — has to
10
+ * ride on a response the agent already asked for.
11
+ *
12
+ * SCOPE. This is not the cheatsheet. `Roam_Markdown_Cheatsheet.md` is the
13
+ * complete syntax reference (components, queries, embeds, CSS tags) and stays
14
+ * the place to look things up. This blob is only the subset where being wrong
15
+ * DESTROYS something — content, references, or the user's trust in the graph —
16
+ * and it stays short enough that its cost is never the reason to skip it.
17
+ *
18
+ * ORDERING IS DELIBERATE. Sections run in descending order of how much damage
19
+ * getting them wrong does, not in taxonomic order, and the closing line repeats
20
+ * the destructive cases. Models attend hardest to the beginning and end of a
21
+ * blob, so the irreversible things occupy both.
22
+ *
23
+ * LAYERING. The user's own `[[roam/agent guidelines]]` page governs style and
24
+ * convention and can override anything about voice, tagging, or placement. It
25
+ * cannot override the facts below: no convention makes a truncated preview
26
+ * complete, or brings back a block that a whole-page rewrite deleted.
27
+ *
28
+ * PRIOR ART. The idea of returning a compact syntax blob from the guidelines
29
+ * call, the damage-ranked ordering, and the closing checksum are borrowed from
30
+ * Roam Research's own MCP server (`@roam-research/roam-mcp`, the `roamSyntax`
31
+ * field of its `get_graph_guidelines`, added 2026-08). That project publishes
32
+ * no licence, so nothing here is copied from it: the text below is written for
33
+ * this server's tools and describes this server's failure modes, which are not
34
+ * the same ones — it talks to Roam's backend REST API, not Roam Desktop, and
35
+ * has no `<roam/>` wire format to teach.
36
+ *
37
+ * WHEN EDITING: this is the canonical home for the load-bearing warnings. If a
38
+ * fact here also appears in the cheatsheet, change it here first;
39
+ * `roam-syntax.test.ts` pins the invariants that must not quietly disappear.
40
+ */
41
+ /**
42
+ * The blob itself. Assembled from an array so each section is independently
43
+ * readable in source and the joins can't drift.
44
+ */
45
+ export const ROAM_SYNTAX = [
46
+ 'ROAM DATA SAFETY — read before writing. Roam is an outliner, its markdown is not standard markdown, and it has no undo for API writes. The graph guidelines above govern style and convention; the rules below govern data integrity and hold regardless of them.',
47
+ '',
48
+ 'DESTRUCTIVE: WHOLE-PAGE REWRITES DELETE. `roam_update_page_markdown` diffs the markdown you pass against the ENTIRE existing page and deletes every block your markdown does not account for. It is not an append. Pass the complete intended page, or use `roam_process_batch_actions` / `roam_create_outline` to touch only what you mean to touch. Use `dry_run: true` first when unsure — it returns the actions without applying them.',
49
+ '',
50
+ 'DESTRUCTIVE: PREVIEWS ARE NOT CONTENT. `roam_fetch_page_by_title` with `format: "structure"` cuts each block at 80 characters and marks the entry `truncated: true`. That text is for orientation only. Writing it back replaces the block with its own opening fragment. Re-read the block with `roam_fetch_block` before editing any text you got from a preview.',
51
+ '',
52
+ 'DESTRUCTIVE: REFERENCES ARE LIVE — DO NOT RETYPE THEM. `((block-uid))` renders the referenced block; replacing it with the text it displayed turns a live reference into a stale copy, and the link is not recoverable from the result. Preserve `((uid))` spans exactly as read, and never invent a uid — use only uids a tool actually returned to you.',
53
+ '',
54
+ 'YOUR READS ARE INCOMPLETE. Blocks tagged `#.rm-hide` or `#.rm-private`, and everything nested under them, are withheld from every read tool, and nothing in the output marks the gap — a page can look complete when it is not. This will not destroy anything: those subtrees are excluded from the `roam_update_page_markdown` diff too, so a rewrite leaves them alone (it reports `preserved_hidden` when it does). But do not claim a page contains only what you were shown, and be careful answering "is X here?" — you cannot tell absence from concealment.',
55
+ '',
56
+ 'FORMATTING (differs from standard markdown). Italics is `__text__`; bold is `**text**` — the two are swapped relative to standard markdown, so `__x__` written as bold silently renders italic. Highlight `^^text^^`, strikethrough `~~text~~`. A task is `{{[[TODO]]}}` at the START of the block, never `- [ ]`, or it will not toggle. Headings are `#`/`##`/`###` only, H1–H3. Nest by indenting: a heading does not pull the blocks after it underneath itself.',
57
+ '',
58
+ 'LINKS MINT PAGES. `[[Name]]` and `#Name` both create the page if it does not exist, so link deliberately rather than bracketing every noun. Multi-word tags need brackets: `#[[two words]]`, not `#twowords`. Because `#` always makes a page, a bare `#1` creates a page named "1" — write `Step 1` or quote it. Dates are ordinal page names: `[[January 3rd, 2026]]`.',
59
+ '',
60
+ 'ATTRIBUTES. `Key:: value` at the start of a block creates a queryable attribute. Roam bolds the key itself, so write `Type:: Book`, never `**Type**:: Book`. Use `::` only for values you would query across the graph; otherwise write a bold label.',
61
+ '',
62
+ 'ESCAPING. Markup you write goes live immediately. When your text DOCUMENTS syntax rather than using it, wrap it in backticks — an unescaped `[[example]]` in a note about linking creates a real page and a real backlink. This is how an agent quietly pollutes a graph.',
63
+ '',
64
+ 'BEFORE EVERY WRITE: is this a whole-page rewrite that will delete what I did not include; did I get this text from a truncated preview; am I retyping a `((uid))` reference instead of preserving it; is the syntax I am documenting inside backticks?',
65
+ ].join('\n');
@@ -163,7 +163,7 @@ export const toolSchemas = {
163
163
  roam_get_guidelines: {
164
164
  name: 'roam_get_guidelines',
165
165
  annotations: READ,
166
- description: 'Retrieve this graph\'s user-defined agent conventions, read from the `[[roam/agent guidelines]]` page inside the graph (configurable per graph). These are the user\'s own rules — how they tag, how they name and namespace pages, what to never do, how they want your voice attributed.\n\nCall this ONCE per graph per session, before other tools, INCLUDING for reads: conventions change how results should be interpreted and presented, not just how content is written. Returns today\'s daily note title as orientation.\n\nDistinct from `roam_markdown_cheatsheet`, which covers Roam syntax and this server\'s mechanics. Guidelines answer "how does this user want their graph handled". Returns exists:false rather than failing when no page has been created.',
166
+ description: 'Retrieve this graph\'s user-defined agent conventions, read from the `[[roam/agent guidelines]]` page inside the graph (configurable per graph). These are the user\'s own rules — how they tag, how they name and namespace pages, what to never do, how they want your voice attributed.\n\nAlso returns `roamSyntax`: the rules whose violation destroys content — whole-page rewrites that delete, truncated previews written back as content, retyped block references, and the syntax that differs from standard markdown. These are returned on every call, including when the graph has no guidelines page, and they hold regardless of what the conventions say.\n\nCall this ONCE per graph per session, before other tools, INCLUDING for reads: conventions change how results should be interpreted and presented, not just how content is written. Returns today\'s daily note title as orientation.\n\nDistinct from `roam_markdown_cheatsheet`, which is the complete syntax reference components, queries, embeds, tool selection. Call that when you need to look something up; this one you need before writing at all. Returns exists:false rather than failing when no page has been created.',
167
167
  inputSchema: {
168
168
  type: 'object',
169
169
  properties: withMultiGraphParams({}),
@@ -204,7 +204,7 @@ export const toolSchemas = {
204
204
  type: 'string',
205
205
  enum: ['markdown', 'raw', 'structure'],
206
206
  default: 'raw',
207
- description: "Format output as markdown, JSON, or structure. 'markdown' returns readable string; 'raw' returns full JSON with nested blocks; 'structure' returns flattened list optimized for surgical updates (uid, order, text preview, depth, parent_uid)"
207
+ description: "Format output as markdown, JSON, or structure. 'markdown' returns readable string; 'raw' returns full JSON with nested blocks; 'structure' returns a flattened list (uid, order, text, depth, parent_uid) for locating blocks to update. In 'structure', `text` is a PREVIEW cut at 80 characters — an entry marked `truncated: true` is a fragment, and writing it back would replace the block with its own opening. Use it to find the uid, then fetch that block with roam_fetch_block before editing its text."
208
208
  }
209
209
  }),
210
210
  required: ['title']
@@ -616,7 +616,7 @@ export const toolSchemas = {
616
616
  roam_markdown_cheatsheet: {
617
617
  name: 'roam_markdown_cheatsheet',
618
618
  annotations: READ,
619
- description: 'Provides the comprehensive Roam syntax reference. Covers: formatting, links & references (page refs, block refs, embeds including embed-children and embed-path), tags, dates, tasks, attributes, queries (native and :q Datalog tables with built-in rules), tables, kanban, mermaid diagrams (with theme support), advanced components (dropdowns, tooltips, templates, document mode, word-count), CSS tags (#.rm-E, #.rm-hide, etc.), anti-patterns, tool selection guide, and API efficiency tips.\n\n**IMPORTANT:** Always load this cheatsheet before creating or updating Roam content. It prevents common syntax errors and guides tool selection.\n\nIMPORTANT: call roam_get_guidelines for this graph once per session before using this tool, reads included — conventions change how results are read, not just written.',
619
+ description: 'Provides the comprehensive Roam syntax reference. Covers: formatting, links & references (page refs, block refs, embeds including embed-children and embed-path), tags, dates, tasks, callouts, attributes, queries (native `{{query}}` with its clause rules and page-ref inheritance, plus :q Datalog tables with built-in rules), tables, kanban, mermaid diagrams (with theme support), advanced components (dropdowns, tooltips, templates, document mode, word-count), CSS tags (#.rm-E, #.rm-hide, etc.), anti-patterns, tool selection guide, and API efficiency tips.\n\n**IMPORTANT:** Always load this cheatsheet before creating or updating Roam content. It prevents common syntax errors and guides tool selection.\n\nIMPORTANT: call roam_get_guidelines for this graph once per session before using this tool, reads included — conventions change how results are read, not just written.',
620
620
  inputSchema: {
621
621
  type: 'object',
622
622
  properties: withMultiGraphParams({}),
@@ -906,9 +906,13 @@ export const toolSchemas = {
906
906
  items: { type: 'string' },
907
907
  description: 'Blocks whose UIDs survived the diff, so refs to them still resolve'
908
908
  },
909
+ preserved_hidden: {
910
+ type: 'number',
911
+ description: 'Present only when non-zero: how many #.rm-hide / #.rm-private blocks were excluded from the diff and left on the page untouched'
912
+ },
909
913
  summary: { type: 'string' }
910
914
  }, ['success', 'actions', 'stats', 'preserved_uids', 'summary']),
911
- description: 'Update an existing page with new markdown content using smart diff. Preserves block UIDs where possible and generates minimal changes. This is ideal for:\n- Syncing external markdown files to Roam\n- AI-assisted content updates that preserve references\n- Batch content modifications without losing block references\n\n**How it works:**\n1. Fetches existing page blocks\n2. Matches new content to existing blocks by text similarity\n3. Generates minimal create/update/move/delete operations\n4. Preserves UIDs for matched blocks (keeping references intact)\n\n\nIMPORTANT: call roam_get_guidelines for this graph once per session, and load the Roam Markdown Cheatsheet, before using this tool.',
915
+ description: 'Update an existing page with new markdown content using smart diff. Preserves block UIDs where possible and generates minimal changes. This is ideal for:\n- Syncing external markdown files to Roam\n- AI-assisted content updates that preserve references\n- Batch content modifications without losing block references\n\n**⚠️ This REPLACES the page, it does not append.** Any block your markdown does not account for is deleted. Pass the complete intended page, or use `roam_process_batch_actions` / `roam_create_outline` to change only part of one. Use `dry_run: true` to see the actions first.\n\n**How it works:**\n1. Fetches existing page blocks\n2. Matches new content to existing blocks by text similarity\n3. Generates minimal create/update/move/delete operations\n4. Preserves UIDs for matched blocks (keeping references intact)\n\n`#.rm-hide` / `#.rm-private` subtrees are excluded from the diff and left untouched — you cannot see them, so you cannot be asked to account for them. `preserved_hidden` reports how many, when any.\n\nIMPORTANT: call roam_get_guidelines for this graph once per session, and load the Roam Markdown Cheatsheet, before using this tool.',
912
916
  inputSchema: {
913
917
  type: 'object',
914
918
  properties: withMultiGraphParams({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "MCP server and CLI for Roam Research",
5
5
  "private": false,
6
6
  "repository": {