roam-research-mcp 3.1.0 → 4.0.1

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
@@ -20,6 +20,24 @@ Whether you want to give Claude superpowers over your knowledge base or just wan
20
20
 
21
21
  ![Before and after: copy-pasting notes into Roam by hand, versus Claude and your terminal reading and writing the graph directly — install with npm i -g roam-research-mcp, then `roam save "idea"` or pipe with `echo "Buy milk" | roam save --todo`](./roam-research-mcp-sketchnote.png)
22
22
 
23
+ ## What's New in v4.0
24
+
25
+ **In one line:** a block containing a soft line break (Shift+Enter) now survives a page rewrite. Read a page, write it back, and nothing moves.
26
+
27
+ Until now, a multi-line block rendered as two physical lines, the second at column 0. That reset the parser's indentation baseline, so every block after it collapsed toward the root and `roam_update_page_markdown` dutifully generated the moves to make your real page match. Reading a page and writing back a revision, the documented purpose of the tool, was enough to trigger it. Callout bodies and fenced code blocks are exactly the blocks that carry soft breaks.
28
+
29
+ - **Soft breaks render as `⏎`.** A page containing one gains a leading `<!-- roam:escaped-newlines -->` marker line; keep it if you write the markdown back. Pages with no multi-line block render byte-identical to 3.x, with no marker and no encoding.
30
+ - **Backslashes are never special.** The earlier design escaped newlines as `\n`, which is also a common prefix in authored text: `\nabla`, `\neq`, `C:\newdir`. The sentinel needs no such rule, so all of those are written exactly as typed, everywhere.
31
+ - **Verbatim round-trips are no-ops.** Renderer output submitted back unchanged, title header included, produces zero actions. The CLI shares the fix: `roam get` piped into `roam save --update` leaves the page as it was.
32
+ - **Two more guards on page rewrites.** Non-empty markdown that parses to zero blocks is now refused rather than deleting every block on the page (genuinely empty markdown still clears a page, as documented). And a hand-authored first block that happens to be an H1 echoing the page title is no longer stripped on an ordinary update.
33
+ - **Linked references are encoded too.** `roam_fetch_page_full_view` escapes soft breaks in referring blocks and breadcrumbs, not just the page's own content.
34
+ - **Browser clients pass CORS preflight.** The HTTP transport now allows `MCP-Protocol-Version` and `Last-Event-ID`, both of which a client must send after initialization. Non-browser clients were never affected.
35
+ - **The MCP SDK is pinned** to the version the test suite runs against, so a fresh install gets the protocol surface that was tested rather than whatever npm serves that day.
36
+
37
+ **Why a major.** Four read surfaces return different bytes for any page containing a multi-line block: `roam_fetch_page_by_title` (`format: "markdown"`), `roam_fetch_page_full_view`, `roam_get_subpages`, and `roam get`. If you use the server through an AI assistant, nothing is required of you. A script that parses markdown output of multi-line pages sees the new encoding. If you pinned `roam-research-mcp@3`, you keep 3.2.0's fixes and its documented multi-line limitation until you re-pin.
38
+
39
+ Full detail, including the corner cases and how each fix was verified against the prior state, is in the [changelog](CHANGELOG.md).
40
+
23
41
  ## How this differs from Roam's official MCP server
24
42
 
25
43
  Roam Research ships its own MCP server and CLI ([`@roam-research/roam-mcp`](https://github.com/Roam-Research/roam-tools)). It is a good tool, and this project is not trying to replace it. **They talk to two different Roam APIs, which is the difference everything else follows from.**
@@ -162,6 +180,8 @@ Three things worth knowing:
162
180
  - **Read tools deliberately have neither.** They already serialise their whole result into the text channel, so a schema would just double the payload.
163
181
  - **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
182
 
183
+ > **Upgrading to 4.0.0:** markdown reads of a page containing a soft line break (Shift+Enter) now render that break as `⏎` and carry a leading `<!-- roam:escaped-newlines -->` marker, so the page survives a write-back intact. Pages without multi-line blocks are byte-identical to 3.x. AI-assistant users need do nothing; scripts parsing markdown output of multi-line pages see the new encoding. See the [changelog](CHANGELOG.md).
184
+
165
185
  > **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
186
 
167
187
  ---
@@ -183,6 +203,14 @@ This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
183
203
 
184
204
  If the page doesn't exist, the tool returns `exists: false` rather than failing, so it is always safe to call.
185
205
 
206
+ ### It also returns the rules that aren't yours to set
207
+
208
+ 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.
209
+
210
+ 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.
211
+
212
+ The full syntax reference — components, queries, embeds, tool selection — stays in `roam_markdown_cheatsheet`. `roamSyntax` is ~800 tokens and deliberately capped.
213
+
186
214
  Each graph can point at a different page, or turn it off:
187
215
 
188
216
  ```bash
@@ -200,6 +228,14 @@ Results are cached for 30 seconds — an edit to the page takes effect without a
200
228
 
201
229
  Note that guidelines are read through the normal page path, so blocks tagged `#.rm-hide` / `#.rm-private` are withheld from them too — see below.
202
230
 
231
+ Reads of a page containing a soft line break render it as `⏎` so each block
232
+ stays on one line — an unescaped newline lands at column 0 and reparents
233
+ everything after it on write-back. Such payloads carry a leading
234
+ `<!-- roam:escaped-newlines -->` marker; keep it if you write the markdown
235
+ back. Markdown you author is never decoded: backslashes are not special, and
236
+ only `⏎` inside a marked payload is interpreted. `roam_get_guidelines` output
237
+ is plain prose — no sentinel, no marker.
238
+
203
239
  ---
204
240
 
205
241
  ## Hiding content from the AI
@@ -210,6 +246,8 @@ This follows the same convention as Roam's official MCP server, so a block tagge
210
246
 
211
247
  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
248
 
249
+ **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).
250
+
213
251
  **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
252
 
215
253
  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,16 +1,22 @@
1
- # Roam Markdown Cheatsheet v2.3.0
1
+ # Roam Markdown Cheatsheet v2.8.0
2
2
 
3
3
  ## Core Syntax
4
4
 
5
5
  ### Formatting
6
6
  `**bold**` · `__italic__` · `^^highlight^^` · `~~strike~~` · `` `code` `` · `$$LaTeX$$`
7
7
 
8
+ ⚠️ `*italic*` and `_italic_` are normalized to `__italic__` by the markdown tools, but `roam_process_batch_actions` writes strings literally and Roam does not render single markers. Write `__italic__` everywhere so reads and writes agree.
9
+
10
+ ### Headings
11
+ `# H1` · `## H2` · `### H3` at the start of a block. Roam has H1–H3 only; `####` and deeper are not headings and stay literal text. A heading is a property of its own block: it does not nest the blocks after it. Indent to nest.
12
+
8
13
  ### Links & References
9
14
  - **Page ref:** `[[Page Name]]` — creates/links to page
10
15
  - **Block ref:** `((block-uid))` — embeds block content inline
11
16
  - **Block embed:** `{{[[embed]]: ((block-uid))}}` — full block with children
12
17
  - **Embed children:** `{{[[embed-children]]: ((block-uid))}}` — children only (not the parent block)
13
18
  - **Embed path:** `{{[[embed-path]]: ((block-uid))}}` — block with its ancestor path
19
+ - **Page embed:** `{{[[embed]]: [[Page Name]]}}` — the whole page inline
14
20
  - **External:** `[text](URL)`
15
21
  - **Aliased page:** `[display text]([[Actual Page]])`
16
22
  - **Aliased block:** `[display text](<((block-uid))>)` — note the angle brackets
@@ -32,6 +38,50 @@ Always ordinal format: `[[January 1st, 2025]]`, `[[December 23rd, 2024]]`
32
38
  - Todo: `{{[[TODO]]}} task`
33
39
  - Done: `{{[[DONE]]}} task`
34
40
 
41
+ ⚠️ `{{[[TODO]]}}` must lead the block. Anywhere else it renders a checkbox that does not toggle to DONE.
42
+
43
+ ### Callouts
44
+ Styled blockquotes with an icon and colour. Two page refs open the block: `[[>]]` marks it a callout, `[[!TYPE]]` picks the style.
45
+
46
+ ```
47
+ [[>]] [[!TIP]] Title text
48
+ Body on the next line
49
+ ```
50
+
51
+ 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.
52
+
53
+ Types: `NOTE` `INFO` `SUMMARY` `TIP` `SUCCESS` `QUESTION` `WARNING` `FAILURE` `DANGER` `BUG` `EXAMPLE` `QUOTE`
54
+
55
+ Append `+` or `-` to make it foldable — `[[!TIP]]+` starts expanded, `[[!TIP]]-` starts collapsed.
56
+
57
+ ⚠️ `[[>]]` 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."
58
+ ⚠️ A plain `> quote` is an ordinary blockquote, not a callout. The two are unrelated.
59
+
60
+ ### Soft line breaks
61
+
62
+ A block can hold more than one line — a **soft line break** (Shift+Enter in the
63
+ UI). It stays *one* block, which is what callout bodies and fenced code blocks
64
+ rely on.
65
+
66
+ **You cannot write one through the markdown tools.** `roam_create_page`,
67
+ `roam_import_markdown`, `roam_create_outline` and `roam_update_page_markdown`
68
+ all treat a line break as a block break. Use `roam_process_batch_actions`, which
69
+ writes block strings literally — put a real newline in the `string`.
70
+
71
+ The one exception: inside a payload carrying the `<!-- roam:escaped-newlines -->`
72
+ marker (below), a `⏎` decodes to a real soft line break on write. So keeping —
73
+ or adding — a `⏎` in a marker-carrying payload handed to
74
+ `roam_update_page_markdown` *is* the one markdown-tools path that writes one.
75
+ Without the marker, `⏎` is just the character; it does not decode.
76
+
77
+ ⚠️ Reads render a soft line break as `⏎` so the block stays on one line, and a
78
+ payload containing any is marked with a leading `<!-- roam:escaped-newlines -->`
79
+ comment. If you edit that markdown and pass it back to
80
+ `roam_update_page_markdown`, **keep the marker line** — it is what tells the
81
+ server `⏎` means a line break there. Content you add yourself is safe either
82
+ way: backslashes are never special, and `$$\nabla f$$` or `C:\newdir` are
83
+ written exactly as typed.
84
+
35
85
  ### Attributes
36
86
  ```
37
87
  Type:: Book
@@ -54,6 +104,9 @@ Rating:: 4/5
54
104
  - Grandchild
55
105
  ```
56
106
 
107
+ ### Numbered & document lists
108
+ A numbered list is a **view type on the parent**, not `1.` markers in the text. Set `children-view-type: "numbered"` (or `"document"` for no bullets) on the parent through `roam_process_batch_actions` (`create-block` / `update-block`) or a `roam_create_page` content item. `1.` markers written into block text stay literal.
109
+
57
110
  ### Code Blocks
58
111
  ````
59
112
  ```javascript
@@ -61,14 +114,40 @@ const x = 1;
61
114
  ```
62
115
  ````
63
116
 
117
+ ⚠️ Through `roam_process_batch_actions` (literal), do not leave a trailing newline before the closing fence. Roam stores it verbatim and renders it wrong. The markdown tools normalize it away.
118
+
64
119
  ### Queries
65
120
  ```
66
121
  {{[[query]]: {and: [[tag1]] [[tag2]]}}}
67
122
  {{[[query]]: {or: [[A]] [[B]]}}}
68
123
  {{[[query]]: {not: [[exclude]]}}}
69
124
  {{[[query]]: {between: [[January 1st, 2025]] [[January 31st, 2025]]}}}
125
+ {{[[query]]: {and: [[Project]] {search: mobile}}}}
126
+ ```
127
+
128
+ Clauses nest: `{and: [[Project]] {not: [[DONE]]}}`.
129
+
130
+ **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.
131
+
132
+ **`{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.
133
+
134
+ **`{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]]`.
135
+
136
+ Also available: `{created-by: [[User]]}`, `{edited-by: [[User]]}`, `{by: [[User]]}`.
137
+
138
+ #### Page-ref inheritance — the non-obvious one
139
+ **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:
140
+
141
+ ```
142
+ - Notes on [[Project Alpha]]
143
+ - {{[[TODO]]}} Ship the thing
70
144
  ```
71
145
 
146
+ Three consequences:
147
+ - **Don't tag every child with the parent's ref** — it is already inherited, and the duplication just clutters the backlinks.
148
+ - **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.
149
+ - **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.
150
+
72
151
  ### Calculator
73
152
  `{{[[calc]]: 2 + 2}}`
74
153
 
@@ -115,13 +194,15 @@ Diagram definition via nested bullets or a code block child:
115
194
  - A[Start] --> B{Decision}
116
195
  - B -->|Yes| C[Action]
117
196
  ```
118
- Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
197
+ Per-diagram theme: first child line `%%{init: {"theme":"forest"}}%%`. Graph-wide via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
119
198
 
120
199
  ### Hiccup
121
200
  `:hiccup [:iframe {:width "600" :height "400" :src "URL"}]`
122
201
 
123
202
  ## Advanced Components
124
203
 
204
+ Write the `{{[[name]]: arg}}` form. The `/name` slash commands are UI-only and do nothing in a written block string.
205
+
125
206
  ### Dropdowns & Tooltips
126
207
  - **Dropdown:** `{{or: option A|option B|option C}}` — select from options, display chosen one
127
208
  - **Tooltip:** `{{=:text|hidden content}}` — click to reveal/hide content
@@ -134,7 +215,28 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
134
215
  - **Datalog block query:** `{{datalog-block-query: [:find ?b :where [?b :block/string "text"]]}}` — renders results like native queries
135
216
  - **Datalog table:** `:q [:find ?title :where [?p :node/title ?title]]` — renders results in sortable table
136
217
  - Supports column transforms, date arithmetic, resizable columns, pagination
137
- - Built-in rules: `(created-by ?user ?block)`, `(edited-by ?user ?block)`, `(by ?user ?block)`, `(refs-page ?title ?b)`, `(block-or-parent-refs-page ?title ?b)`, `(created-between ?t1 ?t2 ?b)`, `(edited-between ?t1 ?t2 ?b)`
218
+ - `:q` extensions, usable **only inside a `:q` block in the graph**: date symbols `ms/today-start`, `ms/this-week-start`, `ms/+1D-start`, `dnp/today`, `dnp/-1D` (a daily-page title), `current/page-title`; inbuilt rules `(created-by ?user ?b)`, `(edited-by ?user ?b)`, `(by ?user ?b)`, `(refs-page ?title ?b)`, `(block-or-parent-refs-page ?title ?b)`, `(created-between ?t1 ?t2 ?b)`, `(edited-between ?t1 ?t2 ?b)`, `(in-dnp ?dnp ?b)`, `(refs-dnp ?dnp ?b)`, `(in-dnp-between ?start ?end ?b)`
219
+
220
+ #### `roam_datomic_query` runs plain DataScript only
221
+ ⚠️ The `:q` extensions above do **not** work through `roam_datomic_query`. A rule such as `(refs-page "X" ?b)` fails with `Missing rules var '%'`. Write the raw clauses instead, `[?p :node/title "X"] [?b :block/refs ?p]`, and use epoch-millisecond literals for time bounds.
222
+
223
+ **Schema** (what `:where` clauses match):
224
+ - Pages: `:node/title`
225
+ - Blocks: `:block/string` (raw stored text), `:block/uid`, `:block/order`, `:block/page`, `:block/children` (immediate), `:block/parents` (all ancestors), `:block/refs` (outgoing), `:block/heading` (1–3), `:children/view-type`
226
+ - Both: `:create/time`, `:edit/time` (epoch ms); `:create/user`, `:edit/user` point at a user entity with `:user/uid` (every user) and `:user/display-page`
227
+
228
+ Three gotchas:
229
+ - **`:user/email` exists only on human users.** API-token and AI writers have none, so a join on `:user/email` silently drops their blocks. Match on `:user/uid`.
230
+ - **Results are unordered.** Sort client-side; `:create/time` descending for newest-first.
231
+ - **Recursive child pulls cap at 1000 per level** unless written `{[:block/children :limit nil] ...}`. `[*]` alone is immediate children only.
232
+
233
+ ```clojure
234
+ ;; blocks referencing a page (the portable form of refs-page)
235
+ [:find ?uid :where [?p :node/title "Project Alpha"] [?b :block/refs ?p] [?b :block/uid ?uid]]
236
+
237
+ ;; a page and its full block tree, uncapped
238
+ [:find (pull ?e [* {[:block/children :limit nil] ...}]) :where [?e :node/title "Page Name"]]
239
+ ```
138
240
 
139
241
  ### Document Mode
140
242
  `:document` — opens inline WYSIWYG text editor in the block
@@ -145,6 +247,15 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
145
247
  - `{{word-count}}` — displays word count for the block
146
248
  - `{{chart: ATTR_PAGE_TO_CHART}}` — chart component
147
249
  - `{{a}}` — anonymous slider (shared graphs)
250
+ - `{{[[video]]: URL}}` — YouTube / Vimeo / Loom
251
+ - `{{[[mentions]]: [[Page]]}}` — inline a page's linked and unlinked references
252
+ - `{{date}}` — date picker that inserts a date-page ref
253
+ - `{{[[slider]]}}` — inline rating control, e.g. `certainty:: {{[[slider]]}}`
254
+ - `{{[[streak]]: [[Goal]]}}` — heatmap of how often a ref appears in daily notes
255
+ - `{{roam/render: ((codeUid))}}` — render a referenced code block as a component
256
+ - `{{[[roam/css]]}}` — a child fenced `css` block applies graph-wide styling
257
+ - `{{diagram: Title}}` — 2D canvas; each node is a real block
258
+ - `{{encrypt}}` — write it bare; Roam converts it once the user supplies content and a password
148
259
 
149
260
  ### CSS Tags
150
261
  - `#.classname` — applies CSS class `.classname` to the block
@@ -153,10 +264,13 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
153
264
  | Tag | Effect |
154
265
  |-----|--------|
155
266
  | `#.rm-E` | Display children horizontally |
156
- | `#.rm-g` | Hide block when children expanded |
157
- | `#.rm-hide` | Hide block when collapsed (clickable bar to reveal) |
267
+ | `#.rm-g` | Promote children up a level; `[[.rm-g]]` keeps the container visible |
268
+ | `#.rm-hide` | Collapse to a clickable bar in the UI; **withheld from every AI read** |
269
+ | `#.rm-private` | Roam's hidden-from-other-users tag; **also withheld from every AI read** |
158
270
  | `#.rm-hide-for-readers` | Hide block for read-only users |
159
271
 
272
+ ⚠️ `#.rm-hide` and `#.rm-private` remove the block **and its whole subtree** from what the read tools return, with no marker in the output. A page can look complete when it is not.
273
+
160
274
  ## Anti-Patterns
161
275
 
162
276
  | ❌ Wrong | ✅ Correct |
@@ -170,6 +284,16 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
170
284
  | `- *bullet` | `- bullet` |
171
285
  | `* bullet` | `- bullet` |
172
286
  | `**Attr**:: val` | `Attr:: val` |
287
+ | `{and: TODO}` | `{and: [[TODO]]}` (queries match refs, not words) |
288
+ | `{and: "project alpha"}` | `{and: [[project alpha]]}` |
289
+ | `{{[[query]]: {search: text}}}` | `{{[[query]]: {and: {search: text}}}}` (never standalone) |
290
+ | `> [[!TIP]] Title` | `[[>]] [[!TIP]] Title` |
291
+ | callout body as a child block | body as `\n` in the same block |
292
+ | `*italic*` in batch actions | `__italic__` |
293
+ | `#### Heading` | `### Heading` (H1–H3 only) |
294
+ | `1. item` as a numbered list | `children-view-type: "numbered"` on the parent |
295
+ | `- [ ] task` | `{{[[TODO]]}} task` |
296
+ | `(refs-page "X" ?b)` in `roam_datomic_query` | `[?p :node/title "X"] [?b :block/refs ?p]` |
173
297
 
174
298
  ## Tool Selection
175
299
 
@@ -229,222 +353,62 @@ Use `{{uid:name}}` for parent refs in batch actions:
229
353
  ```
230
354
  Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
231
355
 
356
+ ⚠️ `delete-block` breaks every `((uid))` that pointed at the block, and Roam has no undo for API writes. Check `roam_search_block_refs` before deleting a block others may reference.
357
+
232
358
  ## Structural Defaults
233
359
 
234
360
  - **Hierarchy:** 2-4 levels preferred, rarely exceed 5
235
361
  - **Blocks:** One idea per block
236
- - **Page refs vs tags:** `[[Page]]` for expandable concepts, `#tag` for filtering
237
362
  - **Embed vs ref:** `((uid))` inline, `{{[[embed]]: ((uid))}}` with children, `{{[[embed-children]]: ((uid))}}` children only, `{{[[embed-path]]: ((uid))}}` with ancestors, `[text](<((uid))>)` link only
238
- - **No empty blocks or `---` dividers** — use hierarchy for visual separation
363
+ - **No empty blocks** — use hierarchy for visual separation
364
+ - **Never invent a `((uid))`:** use only uids a tool actually returned. A fabricated ref is a broken link.
239
365
 
240
- ## Output Conventions
366
+ ## Conventions
241
367
 
242
- **Quote:** `<text> —[[Author]] #quote`
243
- **Definition:** `Term:: definition #definition`
244
- **Open question:** `{{[[TODO]]}} Research: <question> #[[open questions]]`
368
+ This cheatsheet is syntax only. How to tag, when to use a page ref versus a hashtag, and the shape of a quote, definition, TODO, or footnote are conventions, and they belong to the graph's `[[roam/agent guidelines]]` page (returned by `roam_get_guidelines`) and the personalization layer appended below. Where a convention and this sheet appear to disagree, the convention wins on style; this sheet wins on what Roam will actually render.
245
369
 
246
370
  ---
247
- # Roam Preferences — Personalization Layer
248
-
249
- > This section contains YOUR specific conventions, tagging philosophy, and graph-specific rules. Customize to match your workflow.
250
-
251
- ---
252
-
253
- ## Graph-Level Behaviors
254
-
255
- ### On Creating New Pages
256
- <!-- CUSTOMIZE: What should happen when a new page is created? -->
257
- - After creating a new page, add a reference block on today's daily page: `Created page: [[New Page Name]]`
258
- - <!-- Add any naming conventions, required metadata, etc. -->
259
-
260
- ### On Adding Content
261
- <!-- CUSTOMIZE: Any rules about where/how content gets added? -->
262
- - Default location for quick captures: Daily page
263
- - Long-form content: Create dedicated page, link from daily page
264
- - <!-- Your preferences here -->
265
-
266
- ---
267
-
268
- ## Tagging Philosophy
269
-
270
- ### Core Principle
271
- > Tag for **intellectual collision** and **future discovery**, not just categorization. Every tag should maximize potential for unexpected connections.
272
-
273
- ### The Serendipity Test
274
- Before tagging, ask: *"Could this concept surprise me by connecting to something completely unrelated?"*
275
-
276
- ### What To Tag — Decision Framework
277
-
278
- ```
279
- ASK YOURSELF:
280
- ┌─ How will Future Me find this?
281
- │ └─ Tag by retrieval context, not just content
282
- │
283
- ├─ What domain does this belong to?
284
- │ └─ Use broad category tags: #[[knowledge management]], #[[decision-making]]
285
- │
286
- ├─ Is this a proper noun?
287
- │ └─ YES → Wrap name (no titles): [[Werner Erhard]], [[NASA]]
288
- │ └─ For abbreviations: [NASA]([[National Aeronautics and Space Administration (NASA)]])
289
- │
290
- ├─ Could this alias to existing page?
291
- │ └─ YES → [displayed phrase]([[existing page name]])
292
- │ └─ Example: [frameworks for decisions]([[decision-making frameworks]])
293
- │
294
- └─ Parent block with children?
295
- └─ Tag parent when category applies to all children
296
- └─ Tag individual children for specific categorization
297
- ```
298
-
299
- ### Tag Type Selection
300
-
301
- | Use This | When |
302
- |----------|------|
303
- | `[[Page Reference]]` | Concept deserves its own page, will be expanded |
304
- | `#[[hashtag]]` | Categorization, filtering, won't be a standalone page |
305
- | `#single-word` | Simple, unambiguous category |
306
- | Attribute `Type::` | Structured metadata for queries |
307
-
308
- ### WHEN creating Endnotes/Footnotes:
309
- - Find/Create the block with heading "Footnotes::" and nest footnote item below. (Footnotes do not need to be on the same page as the block to which it references. Typically on the same page unless instructed otherwise.)
310
- - If not known, retrieve the block_uid reference for this footnote item.
311
- - In the block referencing the footnote, append the reference with footnote-item-block_id, example: "- <block_text> #ref ((block_uid))"
312
-
313
- ### Structural Tagging (Beyond Content)
314
-
315
- Tag by **patterns and mechanisms**, not just subjects:
316
-
317
- | Structural Tag | Connects |
318
- |----------------|----------|
319
- | `#[[has feedback loops]]` | Systems, habits, markets, conversations |
320
- | `#[[requires calibration]]` | Instruments, relationships, AI prompts |
321
- | `#[[exhibits emergence]]` | Complexity, culture, creativity |
322
- | `#[[perspective switching]]` | Photography, negotiation, analysis |
323
- | `#[[flow dynamics]]` | Fluids, music, conversation, sequences |
324
-
325
- ### Problem-Oriented Tagging
326
-
327
- Tag by problems solved, not methods used:
328
-
329
- - `#[[breaking cognitive constraints]]`
330
- - `#[[expanding solution spaces]]`
331
- - `#[[preventing expert blindness]]`
332
-
333
- ### Temporal & State-Based Tags
334
-
335
- | Tag Type | Examples |
336
- |----------|----------|
337
- | Future relevance | `#[[will be relevant in 5 years]]`, `#[[connects to unborn projects]]` |
338
- | Mental state triggers | `#[[feeling stuck in patterns]]`, `#[[needing fresh perspective]]` |
339
- | Review scheduling | `[[For review]]: [[August 12th, 2026]]` |
340
-
341
- ---
342
-
343
- ## Formatting Conventions
344
-
345
- ### Quotes
346
- ```
347
- <quote text> —[[Author Name]] #quote #[[topic1]] #[[topic2]]
348
- ```
349
- Always include 2-3 relevant hashtags after quotes.
350
-
351
- ### TODOs and Follow-ups
352
- ```
353
- {{[[TODO]]}} <action needed>
354
- {{[[TODO]]}} #researchThis : <topic to investigate>
355
- ```
356
-
357
- ### Scheduled Reviews
358
-
359
- - Any block tagged with a date will show on that respective daily page.
360
-
361
- ```
362
- [[For review]]: [[Date in ordinal format]]
363
- ```
364
- Optional labels: "Deadline", "Approved", "Pending", "Deferred", "Postponed until"
365
-
366
- ### Aliasing for Case Sensitivity
367
- When a tag would awkwardly affect sentence capitalization:
368
- ```
369
- [Cognitive biases]([[cognitive biases]]) affect decision-making...
370
- ```
371
-
372
- ### Definitions (OVERRIDE)
373
- ```
374
- #def [[<term>]] : <definition>
375
- ```
376
-
377
- ---
378
-
379
- ## Constraints & Guardrails
380
-
381
- ### DON'T
382
- - **Overtag** — Quality over quantity; each tag should earn its place
383
- - **Tag obvious/redundant** — If parent block is tagged, children inherit context
384
- - **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
385
- - **Create orphan tags** — Check if existing page/tag serves the purpose
386
- - **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
387
- - **Separators** - `---` Don't use them.
388
-
389
- ### DO
390
- - **Think retrieval-first** — How will you search for this later?
391
- - **Cross-pollinate domains** — Force unlikely intellectual meetings
392
- - **Update aging tags** — As interests evolve, so should tag vocabulary
393
- - **Track surprise discoveries** — When unexpected connections yield insights, engineer more of those patterns
394
-
395
- ---
396
-
397
- ## Custom Rules
398
-
399
- <!--
400
- CUSTOMIZE THIS SECTION with your specific conventions:
401
- - Naming patterns for certain page types
402
- - Required attributes for books/articles/people
403
- - Project-specific tagging schemes
404
- - Integration rules with other tools
405
- - etc.
406
- -->
407
-
408
- ### Example Custom Rules (modify as needed):
409
-
410
- **Books:**
411
- ```
412
- [[Book/<title> | <author>]]
413
- Type:: Book
414
- Author:: [[Author Name]]
415
- Status:: Reading | Completed | Abandoned
416
- Rating:: X/5
417
- ```
418
-
419
- **People:**
420
- ```
421
- [[Person Name]]
422
- Type:: Person
423
- Context:: How I know them
424
- ```
425
- - When linking bibliographic references —>
426
- Example: `McAdams, D.P. (2001) [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100) — foundational paper`
427
- - [McAdams, D.P.]([[Dan McAdams]]) - author's name in the graph
428
- - If source URL, link to source: [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100)
429
- - If notes page exists or will exist in Roam: append ` | [Notes]([[Article/The Psychology of Life Stories]]), if not, just leave it without link.
430
-
431
- **Projects:**
432
- ```
433
- [[Project/<project anme>]]
434
- Status:: Active | Paused | Completed
435
- Start:: [[Date]]
436
- ```
437
- ---
438
-
439
- ## Integration Notes
440
-
441
- <!-- CUSTOMIZE: Any rules about how Roam integrates with your other tools/systems -->
442
-
443
- - Daily pages serve as: <!-- inbox / journal / task list / etc. -->
444
- - Weekly reviews occur on: <!-- day of week -->
445
- - Content flows from: <!-- capture tools, read-later apps, etc. -->
446
- - Content flows to: <!-- publishing, archives, etc. -->
447
-
448
- ---
449
-
450
- *End of Personalization Layer*
371
+ <personalization_layer>
372
+ # Roam: Nova operating notes (server-side supplement)
373
+
374
+ Style conventions (voice, `#Nova` attribution, tagging, namespacing, formatting) live on the `[[roam/agent guidelines]]` page and arrive through `roam_get_guidelines`. This file carries only what that page deliberately leaves out: the mechanics behind the `#@Nova` handshake, so an interactive session produces the same artifacts as the nightly job and neither one answers a block twice.
375
+
376
+ ## The `#@Nova` handshake: mechanics
377
+
378
+ Ownership rules are on the guidelines page (`#@Nova` is Ian's, `#@Nova-DONE` is Nova's, never self-tag). What follows is the output shape the nightly job writes and checks for.
379
+
380
+ ### Reading the request
381
+ - The request is the tagged block's text with `#@Nova` removed, plus every child block that is not a `[NOVA]` reply, read as further instructions.
382
+ - Context is the page title, the ancestor chain, and the sibling blocks under the same parent. Fetch any URL present before answering.
383
+ - Empty tag line and no children means "review and respond to the surrounding context."
384
+
385
+ ### Writing the reply
386
+ - First child of the tagged block: `[NOVA] <one-line summary, at most 150 characters>`.
387
+ - The rest of the reply nests under that `[NOVA]` block as ordinary blocks. Headings and bullets become nesting.
388
+ - Then flip the tag in place: `#@Nova` becomes `#@Nova-DONE`. Leave the rest of the block text untouched. A `{{[[TODO]]}}` on the block stays as-is; that checkbox is Ian's.
389
+ - Then embed on today's daily page under a `## Nova Responses` heading: a block `Nova response on [[<origin page>]]` with the child `{{[[embed]]: ((<uid of the [NOVA] block>))}}`. The guidelines' root-level rule for daily pages applies to memories; Nova responses go under this heading.
390
+
391
+ ### Idempotency
392
+ - A block that already has a `[NOVA]` child is answered. Do not answer it again, even if the tag still reads `#@Nova`. The nightly cleanup pass removes stray duplicates; do not create them.
393
+ - `#@Nova-DONE` is never a work queue. Search `@Nova` for open items and skip anything containing `-DONE`.
394
+
395
+ ### Who runs when (America/Los_Angeles)
396
+ | When | Job | Scope |
397
+ |---|---|---|
398
+ | 03:00 nightly | AI Intel sweep, `Skills/AIIntel/Tools/ProcessTriage.ts` | `#@Nova` and `#Status/*` on the `AI Intel Report` and `PAI-Anthropic Report` pages |
399
+ | 04:30 nightly | `Skills/Productivity/scripts/ProcessNovaTodos.ts`, EventBridge job `nova_todo_processing` | `#@Nova` on every other page |
400
+ | Interactive | This session, when Ian points at a block or asks for a sweep | Same output shape as above |
401
+
402
+ - Both nightly jobs post their own Telegram summary. An interactive answer does not need one.
403
+ - Dry run by hand: `bun $PAI_DIR/Skills/Productivity/scripts/ProcessNovaTodos.ts --dry-run`. Add `--cleanup` to only remove duplicate `[NOVA]` blocks.
404
+ - On AI Intel pages, `#Status/ExecSum`, `#Status/Explore`, `#Status/Backburner`, and `#Status/Reject` run unattended. `#Status/Implement`, `#Status/PRD`, and `#@Nova` wait for an interactive session. Processed items end as `#Status/Processed`.
405
+
406
+ ## Retired conventions
407
+ - `#PAI/do`, `#PAI/done`, `#PAI/hold`, `#PAI/ignore`: replaced by `#@Nova` and `#@Nova-DONE`. Nothing processes them and no block carried them as of September 6th, 2026. Never create them. If one turns up, treat it as `#@Nova`.
408
+ - The `| [[Date]]` suffix once used to surface nested results on the daily page: replaced by the embed above.
409
+
410
+ ## Ian's capture tags (not Nova's queue)
411
+ - `{{[[TODO]]}} #task <action>` on the daily page is Ian's capture inbox. ProcessFlow routes it to OmniFocus weekly. Do not convert `#task` to `#@Nova` unprompted; Ian decides what to delegate.
412
+ - `#researchThis` means look into this later. It is not a task and not a delegation.
413
+ - `#suggested-next-action` is a follow-up Nova proposes under a reply. Ian adds `#@Nova` to delegate it or checks it `{{[[DONE]]}}` to close it himself.
414
+ </personalization_layer>
@@ -1,26 +1,49 @@
1
+ import { escapeBlockString, needsNewlineEscaping, ESCAPED_NEWLINES_MARKER } from '../../shared/block-escaping.js';
1
2
  /**
2
- * Convert RoamBlock hierarchy to markdown with proper indentation
3
+ * Convert RoamBlock hierarchy to markdown with proper indentation.
4
+ *
5
+ * `escape` mirrors `PageOperations.fetchPageByTitle`'s markdown branch: a
6
+ * block may contain a soft line break (Shift+Enter), and this renderer emits
7
+ * one `- ` line per block, so an unescaped newline spills onto a second
8
+ * physical line with no bullet, resetting the parser's indentation baseline
9
+ * and reparenting everything after it. Callers that render a full page
10
+ * compute whether escaping is needed and set this flag; callers that never
11
+ * feed their output back through `updatePageMarkdown` can leave it off.
3
12
  */
4
- export function blocksToMarkdown(blocks, level = 0) {
13
+ export function blocksToMarkdown(blocks, level = 0, escape = false) {
5
14
  return blocks
6
15
  .map(block => {
7
16
  const indent = ' '.repeat(level);
8
17
  let md;
18
+ const text = escape ? escapeBlockString(block.string) : block.string;
9
19
  // Check block heading level and format accordingly
10
20
  if (block.heading && block.heading > 0) {
11
21
  const hashtags = '#'.repeat(block.heading);
12
- md = `${indent}${hashtags} ${block.string}`;
22
+ md = `${indent}${hashtags} ${text}`;
13
23
  }
14
24
  else {
15
- md = `${indent}- ${block.string}`;
25
+ md = `${indent}- ${text}`;
16
26
  }
17
27
  if (block.children && block.children.length > 0) {
18
- md += '\n' + blocksToMarkdown(block.children, level + 1);
28
+ md += '\n' + blocksToMarkdown(block.children, level + 1, escape);
19
29
  }
20
30
  return md;
21
31
  })
22
32
  .join('\n');
23
33
  }
34
+ /**
35
+ * Collect every block string in a tree, for deciding whether a page needs
36
+ * newline escaping at all (see `blocksToMarkdown`).
37
+ */
38
+ function collectBlockStrings(blocks, out = []) {
39
+ for (const block of blocks) {
40
+ out.push(block.string);
41
+ if (block.children && block.children.length > 0) {
42
+ collectBlockStrings(block.children, out);
43
+ }
44
+ }
45
+ return out;
46
+ }
24
47
  /**
25
48
  * Flatten block hierarchy to single-level list
26
49
  */
@@ -42,7 +65,11 @@ export function formatPageOutput(title, blocks, options) {
42
65
  return JSON.stringify({ title, children: data }, null, 2);
43
66
  }
44
67
  const displayBlocks = options.flat ? flattenBlocks(blocks) : blocks;
45
- return `# ${title}\n\n${blocksToMarkdown(displayBlocks)}`;
68
+ const escape = needsNewlineEscaping(collectBlockStrings(displayBlocks));
69
+ const body = blocksToMarkdown(displayBlocks, 0, escape);
70
+ return escape
71
+ ? `# ${title}\n${ESCAPED_NEWLINES_MARKER}\n\n${body}`
72
+ : `# ${title}\n\n${body}`;
46
73
  }
47
74
  /**
48
75
  * Format block content for output
@@ -53,7 +80,9 @@ export function formatBlockOutput(block, options) {
53
80
  return JSON.stringify(data, null, 2);
54
81
  }
55
82
  const displayBlocks = options.flat ? flattenBlocks([block]) : [block];
56
- return blocksToMarkdown(displayBlocks);
83
+ const escape = needsNewlineEscaping(collectBlockStrings(displayBlocks));
84
+ const body = blocksToMarkdown(displayBlocks, 0, escape);
85
+ return escape ? `${ESCAPED_NEWLINES_MARKER}\n\n${body}` : body;
57
86
  }
58
87
  /**
59
88
  * Format search results for output