roam-research-mcp 2.22.0 → 2.24.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 +96 -7
- package/build/Roam_Markdown_Cheatsheet.md +5 -180
- package/build/cli/utils/graph.js +4 -2
- package/build/config/graph-registry.js +30 -7
- package/build/config/graph-registry.test.js +92 -1
- package/build/markdown-utils.js +4 -1
- package/build/markdown-utils.test.js +22 -1
- package/build/server/roam-server.js +33 -1
- package/build/server/session-404.test.js +116 -0
- package/build/shared/errors.js +47 -0
- package/build/shared/errors.test.js +61 -0
- package/build/shared/retry.js +55 -0
- package/build/shared/staged-batch.js +5 -2
- package/build/shared/staged-batch.test.js +88 -0
- package/build/tools/helpers/hidden.js +147 -0
- package/build/tools/helpers/hidden.test.js +105 -0
- package/build/tools/operations/block-retrieval.js +7 -1
- package/build/tools/operations/full-page-view.js +12 -2
- package/build/tools/operations/guidelines.js +103 -0
- package/build/tools/operations/guidelines.test.js +89 -0
- package/build/tools/operations/memory.js +11 -1
- package/build/tools/operations/pages.js +27 -6
- package/build/tools/operations/search/index.js +43 -6
- package/build/tools/schemas.js +92 -24
- package/build/tools/schemas.test.js +153 -0
- package/build/tools/tool-handlers.js +7 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -18,6 +18,34 @@ What started as an backend for AI agents evolved into a full-featured **Standalo
|
|
|
18
18
|
|
|
19
19
|
Whether you want to give Claude superpowers over your knowledge base or just want a robust CLI for your own scripts, this project has you covered.
|
|
20
20
|
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## How this differs from Roam's official MCP server
|
|
24
|
+
|
|
25
|
+
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.**
|
|
26
|
+
|
|
27
|
+
| | **This project** | **Official `@roam-research/roam-mcp`** |
|
|
28
|
+
| ------------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
29
|
+
| Talks to | Roam's **backend REST API** (graph token + graph name) | Roam **Desktop's local HTTP API** |
|
|
30
|
+
| Needs Roam running | No — works headless | Yes, the desktop app must be open (it deep-links to launch it) |
|
|
31
|
+
| Where it can run | Anywhere: laptop, server, container, CI | The machine running Roam Desktop |
|
|
32
|
+
| Shared daemon | Yes — `roam server` runs one HTTP daemon for every client | Per-client stdio |
|
|
33
|
+
| Multi-graph | `ROAM_GRAPHS` env var, with `write_key` protection for chosen graphs | `~/.roam-tools.json`, one token per graph |
|
|
34
|
+
| Web-only graphs | Works | Desktop only |
|
|
35
|
+
|
|
36
|
+
**Reach for the official server when** you want Roam's own supported path, or you need things only the running app can do: controlling the Desktop UI (open a page, read the current selection, drive the sidebar), semantic/embeddings search, link suggestions, file upload, comments, or invoking tools that Roam extensions register.
|
|
37
|
+
|
|
38
|
+
**Reach for this one when** Roam isn't running or isn't installed — a server, a container, a cron job, a CI step. Or when you want the extras this project has grown: a full standalone CLI with stdin piping, a shared HTTP daemon with optional bearer auth, smart page diffing that preserves block UIDs (and therefore your block references), batch operations with UID placeholders for building nested structures in one call, and agent memory tools.
|
|
39
|
+
|
|
40
|
+
One deliberate omission: **there is no page-delete tool here.** Roam has no undo that can reverse a bulk API deletion. The official server does offer `delete_page`; this project takes the more conservative line.
|
|
41
|
+
|
|
42
|
+
### They interoperate
|
|
43
|
+
|
|
44
|
+
The two servers share conventions on purpose, so running both costs you nothing:
|
|
45
|
+
|
|
46
|
+
- **`[[roam/agent guidelines]]`** — both read the same page for your conventions. Write them once; both honour them. See [Agent guidelines](#agent-guidelines-per-graph).
|
|
47
|
+
- **`#.rm-hide` / `#.rm-private`** — both withhold tagged blocks from AI-facing content. Tag once, hidden from both. See [Hiding content from the AI](#hiding-content-from-the-ai).
|
|
48
|
+
|
|
21
49
|
## Standalone CLI: `roam`
|
|
22
50
|
|
|
23
51
|
The `roam` CLI lets you interact with your graph directly from the terminal. It supports **standard input (stdin) piping** for all content creation and retrieval commands, making it perfect for automation workflows.
|
|
@@ -116,6 +144,55 @@ The MCP server exposes these tools to AI assistants (like Claude), enabling them
|
|
|
116
144
|
| `roam_remember` / `roam_recall` | specialized tools for AI memory management within Roam. |
|
|
117
145
|
| `roam_datomic_query` | Execute raw Datalog queries for advanced filtering. |
|
|
118
146
|
| `roam_markdown_cheatsheet` | Retrieve the Roam-flavored markdown reference. |
|
|
147
|
+
| `roam_get_guidelines` | Retrieve this graph's user-defined agent conventions. |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Agent guidelines (per-graph)
|
|
152
|
+
|
|
153
|
+
`roam_get_guidelines` reads a page **inside the graph** — `[[roam/agent guidelines]]` by default — holding your own conventions: how you tag, how you namespace pages, what an agent should never do. Roam's official MCP server reads the same page title, so one page serves both.
|
|
154
|
+
|
|
155
|
+
This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
|
|
156
|
+
|
|
157
|
+
| | `CUSTOM_INSTRUCTIONS_PATH` | `[[roam/agent guidelines]]` |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| Lives in | a file on disk | a page in the graph |
|
|
160
|
+
| Scope | server-wide, all graphs | **per-graph** |
|
|
161
|
+
| To change it | edit the file, restart the server | edit the page |
|
|
162
|
+
| Answers | how to write Roam markdown | how *this user* wants *this graph* handled |
|
|
163
|
+
|
|
164
|
+
**Opt-in.** A graph with no `guidelinesPage` and no `ROAM_GUIDELINES_PAGE` fallback has guidelines **disabled** — the tool reports that and never touches the graph. Nothing is read until you name a page, so a graph that happens to contain a similarly-titled page won't start feeding it to agents.
|
|
165
|
+
|
|
166
|
+
Each graph can point at a different page:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
ROAM_GRAPHS='{
|
|
170
|
+
"personal": {"token": "...", "graph": "...", "guidelinesPage": "roam/agent guidelines"},
|
|
171
|
+
"work": {"token": "...", "graph": "...", "guidelinesPage": "work/agent rules"},
|
|
172
|
+
"archive": {"token": "...", "graph": "..."}
|
|
173
|
+
}'
|
|
174
|
+
ROAM_GUIDELINES_PAGE='roam/agent guidelines' # fallback for graphs that name none
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Resolution order is **per-graph `guidelinesPage` → `ROAM_GUIDELINES_PAGE` → disabled**. Setting `guidelinesPage: false` disables it for one graph even when the env fallback is set. In the example above, `archive` uses the fallback; without that env var it would be disabled.
|
|
178
|
+
|
|
179
|
+
If the named page doesn't exist the tool returns `exists: false` rather than failing, so it is safe to call unconditionally. Results are cached for 30 seconds — an edit to the page takes effect without a restart. A starter template lives at [`.roam/agent-guidelines.template.md`](.roam/agent-guidelines.template.md).
|
|
180
|
+
|
|
181
|
+
Note that guidelines are read through the normal page path, so blocks tagged `#.rm-hide` / `#.rm-private` are withheld from them too — see below.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Hiding content from the AI
|
|
186
|
+
|
|
187
|
+
Blocks tagged `#.rm-hide` or `#.rm-private` — and everything nested under them — are omitted from the content these tools return. Both the hashtag (`#.rm-hide`, `#[[.rm-hide]]`) and link (`[[.rm-hide]]`) forms work. `.rm-private` is Roam's existing "hidden from other users" tag; `.rm-hide` hides from the AI specifically.
|
|
188
|
+
|
|
189
|
+
This follows the same convention as Roam's official MCP server, so a block tagged for one is hidden from the other.
|
|
190
|
+
|
|
191
|
+
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`.
|
|
192
|
+
|
|
193
|
+
**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."
|
|
194
|
+
|
|
195
|
+
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.
|
|
119
196
|
|
|
120
197
|
---
|
|
121
198
|
|
|
@@ -155,14 +232,28 @@ ROAM_SYSTEM_WRITE_KEY=your-secret-key
|
|
|
155
232
|
| `protected` | No | If `true`, writes require `ROAM_SYSTEM_WRITE_KEY` confirmation |
|
|
156
233
|
| `memoriesTag` | No | Tag for `roam_remember`/`roam_recall` (overrides global default) |
|
|
157
234
|
|
|
158
|
-
**
|
|
159
|
-
|
|
235
|
+
**Two kinds of access control (and how they differ)**
|
|
236
|
+
|
|
237
|
+
The server has two independent locks. They're easy to mix up because both are "keys" — here's the plain version (both are **optional and off by default**):
|
|
238
|
+
|
|
239
|
+
| | **Bearer token** — `HTTP_AUTH_TOKEN` | **Write key** — `ROAM_SYSTEM_WRITE_KEY` |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| In a phrase | The key to the **front door** | The latch on a **safe inside** |
|
|
242
|
+
| Controls | *Who can reach the server at all* | *Whether a write to a `protected` graph is allowed* |
|
|
243
|
+
| Covers | Everything — reads **and** writes, all graphs | Only **writes**, and only to graphs marked `protected` |
|
|
244
|
+
| Protects reading? | **Yes** | **No** |
|
|
245
|
+
| When you need it | Only if the server is reachable beyond your own machine (e.g. `-H 0.0.0.0`) | Whenever you want a guard against accidental edits to important graphs |
|
|
246
|
+
| How it's sent | HTTP header: `Authorization: Bearer <token>` | A `write_key` argument on write tools / CLI commands |
|
|
247
|
+
|
|
248
|
+
Think of a house: the **bearer token locks the front door** (keeps strangers out entirely), and the **write key locks a safe inside** (even someone already in the house needs it to change what's in the safe). On your own machine bound to `127.0.0.1`, the front door faces a wall — you don't need the bearer token there. The write key is still handy locally as an "are you sure?" guard, because **Roam has no undo**.
|
|
249
|
+
|
|
250
|
+
So: to mark a graph as needing the write key, set `protected: true` on it and configure `ROAM_SYSTEM_WRITE_KEY`; callers then pass a matching `write_key` for any write to that graph.
|
|
160
251
|
|
|
161
252
|
*Optional:*
|
|
162
253
|
- `ROAM_MEMORIES_TAG`: Default tag for `roam_remember`/`roam_recall` (fallback when per-graph `memoriesTag` not set).
|
|
163
254
|
- `HTTP_STREAM_PORT`: Port for the HTTP Stream transport (defaults to 8088).
|
|
164
255
|
- `HTTP_STREAM_HOST`: Host to bind the HTTP transport to in `--server` mode (defaults to `127.0.0.1`, loopback-only). Set to `0.0.0.0` to expose on the LAN.
|
|
165
|
-
- `HTTP_AUTH_TOKEN`: Optional bearer token
|
|
256
|
+
- `HTTP_AUTH_TOKEN`: Optional bearer token that locks the **whole** HTTP endpoint. Unset = open (fine for loopback). When set, every MCP request must send `Authorization: Bearer <token>` (`GET /health` stays open). Use it whenever you bind beyond `127.0.0.1`. Different from `ROAM_SYSTEM_WRITE_KEY` — see [Two kinds of access control](#two-kinds-of-access-control-and-how-they-differ).
|
|
166
257
|
|
|
167
258
|
### Running the Server
|
|
168
259
|
|
|
@@ -233,11 +324,9 @@ Clients then send the token as a header:
|
|
|
233
324
|
}
|
|
234
325
|
```
|
|
235
326
|
|
|
236
|
-
|
|
237
|
-
- **`HTTP_AUTH_TOKEN`** = *authentication* (who may connect). Gates **all** requests — reads and writes, every graph.
|
|
238
|
-
- **`ROAM_SYSTEM_WRITE_KEY`** = *authorization* (what a connected caller may do). Gates only **writes** to `protected` graphs; it does **not** protect reads.
|
|
327
|
+
Keep **both** — they do different jobs (see [Two kinds of access control](#two-kinds-of-access-control-and-how-they-differ) above): the bearer token controls **who can connect**, the write key only guards **writes to protected graphs**.
|
|
239
328
|
|
|
240
|
-
> ⚠️
|
|
329
|
+
> ⚠️ The write key is **not** a substitute for the bearer token. On an exposed server without `HTTP_AUTH_TOKEN`, anyone on the network can still **read every graph** (and write non-protected ones). For anything beyond loopback, set `HTTP_AUTH_TOKEN`.
|
|
241
330
|
|
|
242
331
|
**Keeping it running (macOS LaunchAgent):**
|
|
243
332
|
Create `~/Library/LaunchAgents/com.example.roam-mcp.plist` with `RunAtLoad` + `KeepAlive`, your env vars under `EnvironmentVariables`, and `--server` as the last `ProgramArguments` entry. Keep `StandardOutPath`/`StandardErrorPath` on a **local** path (e.g. `~/Library/Logs/`), then:
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
⚠️ Never concatenate: `#knowledgemanagement` ≠ `#[[knowledge management]]`
|
|
25
25
|
⚠️ `#` always creates tags — write `Step 1` not `#1`
|
|
26
|
+
⚠️ Because `#` creates tags, a bare `#N` silently creates a numbered page. When you must show the literal form, put it in quotes: `"#1"`, `"#2"` — otherwise rephrase (`Step 1`, `No. 1`, `item 1`)
|
|
26
27
|
|
|
27
28
|
### Dates
|
|
28
29
|
Always ordinal format: `[[January 1st, 2025]]`, `[[December 23rd, 2024]]`
|
|
@@ -161,7 +162,7 @@ Theme via CSS: `:root { --mermaidjs-theme: dark; }` (in `roam/css`)
|
|
|
161
162
|
| ❌ Wrong | ✅ Correct |
|
|
162
163
|
|----------|-----------|
|
|
163
164
|
| `#multiplewords` | `#[[multiple words]]` |
|
|
164
|
-
| `#1`, `#2` | `Step 1`, `No. 1` |
|
|
165
|
+
| `#1`, `#2` | `Step 1`, `No. 1`, or quoted: `"#1"` |
|
|
165
166
|
| `[[january 1, 2025]]` | `[[January 1st, 2025]]` |
|
|
166
167
|
| `[text](((uid)))` | `[text](<((uid))>)` |
|
|
167
168
|
| `{{embed: ((uid))}}` | `{{[[embed]]: ((uid))}}` |
|
|
@@ -243,6 +244,8 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
|
|
|
243
244
|
**Open question:** `{{[[TODO]]}} Research: <question> #[[open questions]]`
|
|
244
245
|
|
|
245
246
|
---
|
|
247
|
+
<personalization_layer>
|
|
248
|
+
|
|
246
249
|
# Roam Preferences — Personalization Layer
|
|
247
250
|
|
|
248
251
|
> This section contains YOUR specific conventions, tagging philosophy, and graph-specific rules. Customize to match your workflow.
|
|
@@ -251,199 +254,21 @@ Server returns `{"uid_map": {"parent": "Xk7mN2pQ9"}}`.
|
|
|
251
254
|
|
|
252
255
|
## Graph-Level Behaviors
|
|
253
256
|
|
|
254
|
-
### On Creating New Pages
|
|
255
|
-
<!-- CUSTOMIZE: What should happen when a new page is created? -->
|
|
256
|
-
- After creating a new page, add a reference block on today's daily page: `Created page: [[New Page Name]]`
|
|
257
|
-
- <!-- Add any naming conventions, required metadata, etc. -->
|
|
258
|
-
|
|
259
|
-
### On Adding Content
|
|
260
|
-
<!-- CUSTOMIZE: Any rules about where/how content gets added? -->
|
|
261
|
-
- Default location for quick captures: Daily page
|
|
262
|
-
- Long-form content: Create dedicated page, link from daily page
|
|
263
|
-
- <!-- Your preferences here -->
|
|
264
|
-
|
|
265
|
-
---
|
|
266
257
|
|
|
267
258
|
## Tagging Philosophy
|
|
268
259
|
|
|
269
|
-
### Core Principle
|
|
270
|
-
> Tag for **intellectual collision** and **future discovery**, not just categorization. Every tag should maximize potential for unexpected connections.
|
|
271
|
-
|
|
272
|
-
### The Serendipity Test
|
|
273
|
-
Before tagging, ask: *"Could this concept surprise me by connecting to something completely unrelated?"*
|
|
274
|
-
|
|
275
|
-
### What To Tag — Decision Framework
|
|
276
|
-
|
|
277
|
-
```
|
|
278
|
-
ASK YOURSELF:
|
|
279
|
-
┌─ How will Future Me find this?
|
|
280
|
-
│ └─ Tag by retrieval context, not just content
|
|
281
|
-
│
|
|
282
|
-
├─ What domain does this belong to?
|
|
283
|
-
│ └─ Use broad category tags: #[[knowledge management]], #[[decision-making]]
|
|
284
|
-
│
|
|
285
|
-
├─ Is this a proper noun?
|
|
286
|
-
│ └─ YES → Wrap name (no titles): [[Werner Erhard]], [[NASA]]
|
|
287
|
-
│ └─ For abbreviations: [NASA]([[National Aeronautics and Space Administration (NASA)]])
|
|
288
|
-
│
|
|
289
|
-
├─ Could this alias to existing page?
|
|
290
|
-
│ └─ YES → [displayed phrase]([[existing page name]])
|
|
291
|
-
│ └─ Example: [frameworks for decisions]([[decision-making frameworks]])
|
|
292
|
-
│
|
|
293
|
-
└─ Parent block with children?
|
|
294
|
-
└─ Tag parent when category applies to all children
|
|
295
|
-
└─ Tag individual children for specific categorization
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
### Tag Type Selection
|
|
299
|
-
|
|
300
|
-
| Use This | When |
|
|
301
|
-
|----------|------|
|
|
302
|
-
| `[[Page Reference]]` | Concept deserves its own page, will be expanded |
|
|
303
|
-
| `#[[hashtag]]` | Categorization, filtering, won't be a standalone page |
|
|
304
|
-
| `#single-word` | Simple, unambiguous category |
|
|
305
|
-
| Attribute `Type::` | Structured metadata for queries |
|
|
306
|
-
|
|
307
|
-
### WHEN creating Endnotes/Footnotes:
|
|
308
|
-
- 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.)
|
|
309
|
-
- If not known, retrieve the block_uid reference for this footnote item.
|
|
310
|
-
- In the block referencing the footnote, append the reference with footnote-item-block_id, example: "- <block_text> #ref ((block_uid))"
|
|
311
|
-
|
|
312
|
-
### Structural Tagging (Beyond Content)
|
|
313
|
-
|
|
314
|
-
Tag by **patterns and mechanisms**, not just subjects:
|
|
315
|
-
|
|
316
|
-
| Structural Tag | Connects |
|
|
317
|
-
|----------------|----------|
|
|
318
|
-
| `#[[has feedback loops]]` | Systems, habits, markets, conversations |
|
|
319
|
-
| `#[[requires calibration]]` | Instruments, relationships, AI prompts |
|
|
320
|
-
| `#[[exhibits emergence]]` | Complexity, culture, creativity |
|
|
321
|
-
| `#[[perspective switching]]` | Photography, negotiation, analysis |
|
|
322
|
-
| `#[[flow dynamics]]` | Fluids, music, conversation, sequences |
|
|
323
|
-
|
|
324
|
-
### Problem-Oriented Tagging
|
|
325
|
-
|
|
326
|
-
Tag by problems solved, not methods used:
|
|
327
|
-
|
|
328
|
-
- `#[[breaking cognitive constraints]]`
|
|
329
|
-
- `#[[expanding solution spaces]]`
|
|
330
|
-
- `#[[preventing expert blindness]]`
|
|
331
|
-
|
|
332
|
-
### Temporal & State-Based Tags
|
|
333
|
-
|
|
334
|
-
| Tag Type | Examples |
|
|
335
|
-
|----------|----------|
|
|
336
|
-
| Future relevance | `#[[will be relevant in 5 years]]`, `#[[connects to unborn projects]]` |
|
|
337
|
-
| Mental state triggers | `#[[feeling stuck in patterns]]`, `#[[needing fresh perspective]]` |
|
|
338
|
-
| Review scheduling | `[[For review]]: [[August 12th, 2026]]` |
|
|
339
|
-
|
|
340
|
-
---
|
|
341
260
|
|
|
342
261
|
## Formatting Conventions
|
|
343
262
|
|
|
344
|
-
### Quotes
|
|
345
|
-
```
|
|
346
|
-
<quote text> —[[Author Name]] #quote #[[topic1]] #[[topic2]]
|
|
347
|
-
```
|
|
348
|
-
Always include 2-3 relevant hashtags after quotes.
|
|
349
|
-
|
|
350
|
-
### TODOs and Follow-ups
|
|
351
|
-
```
|
|
352
|
-
{{[[TODO]]}} <action needed>
|
|
353
|
-
{{[[TODO]]}} #researchThis : <topic to investigate>
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
### Scheduled Reviews
|
|
357
|
-
|
|
358
|
-
- Any block tagged with a date will show on that respective daily page.
|
|
359
|
-
|
|
360
|
-
```
|
|
361
|
-
[[For review]]: [[Date in ordinal format]]
|
|
362
|
-
```
|
|
363
|
-
Optional labels: "Deadline", "Approved", "Pending", "Deferred", "Postponed until"
|
|
364
|
-
|
|
365
|
-
### Aliasing for Case Sensitivity
|
|
366
|
-
When a tag would awkwardly affect sentence capitalization:
|
|
367
|
-
```
|
|
368
|
-
[Cognitive biases]([[cognitive biases]]) affect decision-making...
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
### Definitions (OVERRIDE)
|
|
372
|
-
```
|
|
373
|
-
#def [[<term>]] : <definition>
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
---
|
|
377
263
|
|
|
378
264
|
## Constraints & Guardrails
|
|
379
265
|
|
|
380
|
-
### DON'T
|
|
381
|
-
- **Overtag** — Quality over quantity; each tag should earn its place
|
|
382
|
-
- **Tag obvious/redundant** — If parent block is tagged, children inherit context
|
|
383
|
-
- **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
|
|
384
|
-
- **Create orphan tags** — Check if existing page/tag serves the purpose
|
|
385
|
-
- **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
|
|
386
|
-
- **Separators** - `---` Don't use them.
|
|
387
|
-
|
|
388
|
-
### DO
|
|
389
|
-
- **Think retrieval-first** — How will you search for this later?
|
|
390
|
-
- **Cross-pollinate domains** — Force unlikely intellectual meetings
|
|
391
|
-
- **Update aging tags** — As interests evolve, so should tag vocabulary
|
|
392
|
-
- **Track surprise discoveries** — When unexpected connections yield insights, engineer more of those patterns
|
|
393
|
-
|
|
394
|
-
---
|
|
395
266
|
|
|
396
267
|
## Custom Rules
|
|
397
268
|
|
|
398
|
-
<!--
|
|
399
|
-
CUSTOMIZE THIS SECTION with your specific conventions:
|
|
400
|
-
- Naming patterns for certain page types
|
|
401
|
-
- Required attributes for books/articles/people
|
|
402
|
-
- Project-specific tagging schemes
|
|
403
|
-
- Integration rules with other tools
|
|
404
|
-
- etc.
|
|
405
|
-
-->
|
|
406
|
-
|
|
407
|
-
### Example Custom Rules (modify as needed):
|
|
408
|
-
|
|
409
|
-
**Books:**
|
|
410
|
-
```
|
|
411
|
-
[[Book/<title> | <author>]]
|
|
412
|
-
Type:: Book
|
|
413
|
-
Author:: [[Author Name]]
|
|
414
|
-
Status:: Reading | Completed | Abandoned
|
|
415
|
-
Rating:: X/5
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
**People:**
|
|
419
|
-
```
|
|
420
|
-
[[Person Name]]
|
|
421
|
-
Type:: Person
|
|
422
|
-
Context:: How I know them
|
|
423
|
-
```
|
|
424
|
-
- When linking bibliographic references —>
|
|
425
|
-
Example: `McAdams, D.P. (2001) [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100) — foundational paper`
|
|
426
|
-
- [McAdams, D.P.]([[Dan McAdams]]) - author's name in the graph
|
|
427
|
-
- If source URL, link to source: [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100)
|
|
428
|
-
- If notes page exists or will exist in Roam: append ` | [Notes]([[Article/The Psychology of Life Stories]]), if not, just leave it without link.
|
|
429
|
-
|
|
430
|
-
**Projects:**
|
|
431
|
-
```
|
|
432
|
-
[[Project/<project anme>]]
|
|
433
|
-
Status:: Active | Paused | Completed
|
|
434
|
-
Start:: [[Date]]
|
|
435
|
-
```
|
|
436
|
-
---
|
|
437
269
|
|
|
438
270
|
## Integration Notes
|
|
439
271
|
|
|
440
272
|
<!-- CUSTOMIZE: Any rules about how Roam integrates with your other tools/systems -->
|
|
441
273
|
|
|
442
|
-
|
|
443
|
-
- Weekly reviews occur on: <!-- day of week -->
|
|
444
|
-
- Content flows from: <!-- capture tools, read-later apps, etc. -->
|
|
445
|
-
- Content flows to: <!-- publishing, archives, etc. -->
|
|
446
|
-
|
|
447
|
-
---
|
|
448
|
-
|
|
449
|
-
*End of Personalization Layer*
|
|
274
|
+
</personalization_layer>
|
package/build/cli/utils/graph.js
CHANGED
|
@@ -33,8 +33,10 @@ export function resolveGraph(options, isWriteOp = false) {
|
|
|
33
33
|
if (!systemWriteKey) {
|
|
34
34
|
throw new Error(`Write to protected graph "${graphKey}" failed: ROAM_SYSTEM_WRITE_KEY not configured.`);
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// Never echo the key itself — CLI output lands in scrollback, logs and
|
|
37
|
+
// transcripts. Name the variable so the shell can expand it instead.
|
|
38
|
+
throw new Error(`Write to protected graph "${graphKey}" requires --write-key confirmation.\n` +
|
|
39
|
+
`Use: --write-key "$ROAM_SYSTEM_WRITE_KEY"`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Lazy graph initialization (connects only when first accessed)
|
|
9
9
|
*/
|
|
10
10
|
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
11
|
-
import {
|
|
11
|
+
import { RoamError } from '../shared/errors.js';
|
|
12
12
|
/** List of tool names that perform write operations */
|
|
13
13
|
export const WRITE_OPERATIONS = [
|
|
14
14
|
'roam_create_page',
|
|
@@ -69,6 +69,25 @@ export class GraphRegistry {
|
|
|
69
69
|
// Priority: per-graph config > env var > default
|
|
70
70
|
return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Page holding a graph's agent conventions, or null when disabled.
|
|
74
|
+
*
|
|
75
|
+
* Precedence: per-graph config > ROAM_GUIDELINES_PAGE env var > **disabled**.
|
|
76
|
+
*
|
|
77
|
+
* Unlike memoriesTag this is opt-in: a graph with no `guidelinesPage` and no
|
|
78
|
+
* env fallback returns null rather than silently reading a conventional page
|
|
79
|
+
* title. Reading a page nobody asked us to read is a surprise, and a graph
|
|
80
|
+
* that happens to contain a similarly-named page should not start feeding it
|
|
81
|
+
* to agents.
|
|
82
|
+
*/
|
|
83
|
+
getGuidelinesPage(key) {
|
|
84
|
+
const resolvedKey = key ?? this.defaultKey;
|
|
85
|
+
const config = this.configs.get(resolvedKey);
|
|
86
|
+
if (config?.guidelinesPage === false) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? null;
|
|
90
|
+
}
|
|
72
91
|
/**
|
|
73
92
|
* Get an initialized Graph instance, creating it lazily if needed
|
|
74
93
|
* @param key - Graph key from config. Defaults to defaultKey if not specified.
|
|
@@ -83,7 +102,7 @@ export class GraphRegistry {
|
|
|
83
102
|
// Get config
|
|
84
103
|
const config = this.configs.get(resolvedKey);
|
|
85
104
|
if (!config) {
|
|
86
|
-
throw new
|
|
105
|
+
throw new RoamError(`Unknown graph: "${resolvedKey}".`, 'UNKNOWN_GRAPH', { requested_graph: resolvedKey, available_graphs: this.getAvailableGraphs() });
|
|
87
106
|
}
|
|
88
107
|
// Initialize the graph
|
|
89
108
|
const graph = initializeGraph({
|
|
@@ -131,15 +150,19 @@ export class GraphRegistry {
|
|
|
131
150
|
if (!this.isWriteAllowed(resolvedKey, providedWriteKey)) {
|
|
132
151
|
const config = this.configs.get(resolvedKey);
|
|
133
152
|
if (!config) {
|
|
134
|
-
throw new
|
|
153
|
+
throw new RoamError(`Unknown graph: "${resolvedKey}".`, 'UNKNOWN_GRAPH', { requested_graph: resolvedKey, available_graphs: this.getAvailableGraphs() });
|
|
135
154
|
}
|
|
136
155
|
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
137
156
|
if (!systemWriteKey) {
|
|
138
|
-
throw new
|
|
157
|
+
throw new RoamError(`Write to protected graph "${resolvedKey}" failed: ROAM_SYSTEM_WRITE_KEY is not configured on the server.`, 'WRITE_KEY_NOT_CONFIGURED', { graph: resolvedKey });
|
|
139
158
|
}
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
// Say what is required, never what the value is. Echoing the key here
|
|
160
|
+
// hands the caller the means to retry and get through, which makes the
|
|
161
|
+
// whole gate decorative — including for a caller that simply guessed
|
|
162
|
+
// wrong. A legitimate operator can read ROAM_SYSTEM_WRITE_KEY from their
|
|
163
|
+
// own environment; an agent that cannot is exactly who this stops.
|
|
164
|
+
throw new RoamError(`Write to protected graph "${resolvedKey}" requires write_key confirmation. ` +
|
|
165
|
+
`Pass the value of the ROAM_SYSTEM_WRITE_KEY environment variable as the write_key parameter.`, 'WRITE_KEY_REQUIRED', { graph: resolvedKey, required_parameter: 'write_key' });
|
|
143
166
|
}
|
|
144
167
|
}
|
|
145
168
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { GraphRegistry } from './graph-registry.js';
|
|
3
3
|
describe('GraphRegistry', () => {
|
|
4
4
|
describe('getMemoriesTag', () => {
|
|
@@ -65,3 +65,94 @@ describe('GraphRegistry', () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
+
describe('getGuidelinesPage', () => {
|
|
69
|
+
const make = (configs, def = 'personal') => new GraphRegistry(configs, def);
|
|
70
|
+
it('is disabled when a graph configures nothing — opt-in, not opt-out', () => {
|
|
71
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
72
|
+
const r = make({ personal: { token: 't', graph: 'g' } });
|
|
73
|
+
expect(r.getGuidelinesPage('personal')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
it('prefers per-graph config over the env var', () => {
|
|
76
|
+
process.env.ROAM_GUIDELINES_PAGE = 'env/page';
|
|
77
|
+
const r = make({ work: { token: 't', graph: 'g', guidelinesPage: 'work/rules' } }, 'work');
|
|
78
|
+
expect(r.getGuidelinesPage('work')).toBe('work/rules');
|
|
79
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
80
|
+
});
|
|
81
|
+
it('falls back to the env var when a graph sets nothing', () => {
|
|
82
|
+
process.env.ROAM_GUIDELINES_PAGE = 'env/page';
|
|
83
|
+
const r = make({ personal: { token: 't', graph: 'g' } });
|
|
84
|
+
expect(r.getGuidelinesPage('personal')).toBe('env/page');
|
|
85
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
86
|
+
});
|
|
87
|
+
it('returns null when a graph disables guidelines', () => {
|
|
88
|
+
const r = make({ personal: { token: 't', graph: 'g', guidelinesPage: false } });
|
|
89
|
+
expect(r.getGuidelinesPage('personal')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
it('resolves the default graph when no key is given', () => {
|
|
92
|
+
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
93
|
+
const r = make({ personal: { token: 't', graph: 'g', guidelinesPage: 'p/rules' } });
|
|
94
|
+
expect(r.getGuidelinesPage()).toBe('p/rules');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('write-key denial does not disclose the key', () => {
|
|
98
|
+
/**
|
|
99
|
+
* The write key is the whole gate on protected graphs. An error that tells
|
|
100
|
+
* the caller what the key is hands the agent the means to retry and get
|
|
101
|
+
* through — the protection becomes decorative.
|
|
102
|
+
*/
|
|
103
|
+
const SECRET = 'super-secret-write-key';
|
|
104
|
+
const original = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
process.env.ROAM_SYSTEM_WRITE_KEY = SECRET;
|
|
107
|
+
});
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
if (original !== undefined)
|
|
110
|
+
process.env.ROAM_SYSTEM_WRITE_KEY = original;
|
|
111
|
+
else
|
|
112
|
+
delete process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
113
|
+
});
|
|
114
|
+
// 'work' must be NON-default: writes to the default graph bypass protection
|
|
115
|
+
// by design, so a protected default graph never reaches the denial path.
|
|
116
|
+
const protectedRegistry = () => new GraphRegistry({
|
|
117
|
+
personal: { token: 't', graph: 'p' },
|
|
118
|
+
work: { token: 't', graph: 'g', protected: true },
|
|
119
|
+
}, 'personal');
|
|
120
|
+
it('never puts the key in the denial message', () => {
|
|
121
|
+
let message = '';
|
|
122
|
+
try {
|
|
123
|
+
protectedRegistry().validateWriteAccess('roam_create_page', 'work', undefined);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
message = e.message;
|
|
127
|
+
}
|
|
128
|
+
expect(message, 'denial message must not be empty').not.toBe('');
|
|
129
|
+
expect(message).not.toContain(SECRET);
|
|
130
|
+
});
|
|
131
|
+
it('never discloses the key when a wrong one is supplied', () => {
|
|
132
|
+
let message = '';
|
|
133
|
+
try {
|
|
134
|
+
protectedRegistry().validateWriteAccess('roam_create_page', 'work', 'wrong-guess');
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
message = e.message;
|
|
138
|
+
}
|
|
139
|
+
expect(message).not.toContain(SECRET);
|
|
140
|
+
});
|
|
141
|
+
it('still explains what is required, so a legitimate caller can proceed', () => {
|
|
142
|
+
let message = '';
|
|
143
|
+
try {
|
|
144
|
+
protectedRegistry().validateWriteAccess('roam_create_page', 'work', undefined);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
message = e.message;
|
|
148
|
+
}
|
|
149
|
+
expect(message).toMatch(/write_key/);
|
|
150
|
+
expect(message).toMatch(/work/);
|
|
151
|
+
});
|
|
152
|
+
it('lets a correct key through', () => {
|
|
153
|
+
expect(() => protectedRegistry().validateWriteAccess('roam_create_page', 'work', SECRET)).not.toThrow();
|
|
154
|
+
});
|
|
155
|
+
it('does not gate reads on protected graphs', () => {
|
|
156
|
+
expect(() => protectedRegistry().validateWriteAccess('roam_search_by_text', 'work', undefined)).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
package/build/markdown-utils.js
CHANGED
|
@@ -83,7 +83,10 @@ function convertToRoamMarkdown(text) {
|
|
|
83
83
|
text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
|
|
84
84
|
// Handle single asterisks/underscores (italic)
|
|
85
85
|
text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore
|
|
86
|
-
|
|
86
|
+
// Single underscore to italic, but only at word boundaries. Underscores flanked by
|
|
87
|
+
// word chars (\w includes _) are literal per CommonMark, so snake_case filenames and
|
|
88
|
+
// URLs like /wiki/Ning_Li_(physicist) are left intact instead of becoming __Li__.
|
|
89
|
+
text = text.replace(/(?<!\w)_(?!_)(.+?)_(?!\w)/g, '__$1__');
|
|
87
90
|
// Handle highlights
|
|
88
91
|
text = text.replace(/==(.+?)==/g, '^^$1^^');
|
|
89
92
|
// Convert tasks
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseMarkdown, convertToRoamActions, convertToRoamActionsWithBlocks, nestUnderHeadings } from './markdown-utils.js';
|
|
2
|
+
import { parseMarkdown, convertToRoamActions, convertToRoamActionsWithBlocks, nestUnderHeadings, convertToRoamMarkdown } from './markdown-utils.js';
|
|
3
3
|
describe('markdown-utils', () => {
|
|
4
4
|
describe('parseMarkdown - numbered lists', () => {
|
|
5
5
|
it('should detect numbered list items and strip prefixes', () => {
|
|
@@ -153,6 +153,27 @@ And this--too`;
|
|
|
153
153
|
expect(blocks[1].children[0].children).toHaveLength(1); // Beta.1.a
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
|
+
describe('convertToRoamMarkdown - italic vs literal underscores', () => {
|
|
157
|
+
it('still converts genuine word-boundary italics to Roam __italic__', () => {
|
|
158
|
+
expect(convertToRoamMarkdown('this is _italic_ text')).toBe('this is __italic__ text');
|
|
159
|
+
expect(convertToRoamMarkdown('_leading_ and *starred*')).toBe('__leading__ and __starred__');
|
|
160
|
+
});
|
|
161
|
+
it('leaves intra-word (snake_case) underscores literal', () => {
|
|
162
|
+
const line = 'Source transcript on disk :: american_alchemy_dave_rossi.txt';
|
|
163
|
+
expect(convertToRoamMarkdown(line)).toBe(line);
|
|
164
|
+
});
|
|
165
|
+
it('leaves underscores inside URLs literal', () => {
|
|
166
|
+
const line = 'Ning Li: https://en.wikipedia.org/wiki/Ning_Li_(physicist)';
|
|
167
|
+
expect(convertToRoamMarkdown(line)).toBe(line);
|
|
168
|
+
});
|
|
169
|
+
it('does not treat word_bounded_ trailing underscores as emphasis', () => {
|
|
170
|
+
expect(convertToRoamMarkdown('foo_bar_baz')).toBe('foo_bar_baz');
|
|
171
|
+
});
|
|
172
|
+
it('keeps underscores literal when wrapped in inline code (backtick escape hatch)', () => {
|
|
173
|
+
const line = 'literal `_not_italic_` here';
|
|
174
|
+
expect(convertToRoamMarkdown(line)).toBe(line);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
156
177
|
describe('nestUnderHeadings', () => {
|
|
157
178
|
const node = (content, heading_level = 0, children = []) => ({ content, level: 1, heading_level, children });
|
|
158
179
|
it('nests content and deeper headings under their section heading', () => {
|