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 +10 -0
- package/build/Roam_Markdown_Cheatsheet.md +60 -1
- package/build/diff/index.js +1 -1
- package/build/diff/parser.js +51 -0
- package/build/markdown-utils.js +35 -4
- package/build/tools/operations/guidelines.js +17 -3
- package/build/tools/operations/pages.js +58 -6
- package/build/tools/roam-syntax.js +65 -0
- package/build/tools/schemas.js +8 -4
- package/package.json +1 -1
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.
|
|
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
|
|
package/build/diff/index.js
CHANGED
|
@@ -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
|
package/build/diff/parser.js
CHANGED
|
@@ -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.
|
package/build/markdown-utils.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
`
|
|
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
|
-
|
|
531
|
-
const preview =
|
|
532
|
-
? block.string.substring(0,
|
|
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
|
|
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
|
-
|
|
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');
|
package/build/tools/schemas.js
CHANGED
|
@@ -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
|
|
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
|
|
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({
|