roam-research-mcp 2.23.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 +40 -4
- package/build/config/graph-registry.js +9 -2
- package/build/config/graph-registry.test.js +2 -2
- 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 +9 -4
- package/build/tools/operations/guidelines.test.js +9 -9
- package/build/tools/operations/memory.js +11 -1
- package/build/tools/operations/pages.js +22 -5
- package/build/tools/tool-handlers.js +2 -2
- package/package.json +1 -1
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.
|
|
@@ -133,14 +161,22 @@ This is distinct from `CUSTOM_INSTRUCTIONS_PATH`, and the two compose:
|
|
|
133
161
|
| To change it | edit the file, restart the server | edit the page |
|
|
134
162
|
| Answers | how to write Roam markdown | how *this user* wants *this graph* handled |
|
|
135
163
|
|
|
136
|
-
|
|
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:
|
|
137
167
|
|
|
138
168
|
```bash
|
|
139
|
-
ROAM_GRAPHS='{
|
|
140
|
-
|
|
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
|
|
141
175
|
```
|
|
142
176
|
|
|
143
|
-
|
|
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).
|
|
144
180
|
|
|
145
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.
|
|
146
182
|
|
|
@@ -71,7 +71,14 @@ export class GraphRegistry {
|
|
|
71
71
|
}
|
|
72
72
|
/**
|
|
73
73
|
* Page holding a graph's agent conventions, or null when disabled.
|
|
74
|
-
*
|
|
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.
|
|
75
82
|
*/
|
|
76
83
|
getGuidelinesPage(key) {
|
|
77
84
|
const resolvedKey = key ?? this.defaultKey;
|
|
@@ -79,7 +86,7 @@ export class GraphRegistry {
|
|
|
79
86
|
if (config?.guidelinesPage === false) {
|
|
80
87
|
return null;
|
|
81
88
|
}
|
|
82
|
-
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ??
|
|
89
|
+
return config?.guidelinesPage ?? process.env.ROAM_GUIDELINES_PAGE ?? null;
|
|
83
90
|
}
|
|
84
91
|
/**
|
|
85
92
|
* Get an initialized Graph instance, creating it lazily if needed
|
|
@@ -67,10 +67,10 @@ describe('GraphRegistry', () => {
|
|
|
67
67
|
});
|
|
68
68
|
describe('getGuidelinesPage', () => {
|
|
69
69
|
const make = (configs, def = 'personal') => new GraphRegistry(configs, def);
|
|
70
|
-
it('
|
|
70
|
+
it('is disabled when a graph configures nothing — opt-in, not opt-out', () => {
|
|
71
71
|
delete process.env.ROAM_GUIDELINES_PAGE;
|
|
72
72
|
const r = make({ personal: { token: 't', graph: 'g' } });
|
|
73
|
-
expect(r.getGuidelinesPage('personal')).
|
|
73
|
+
expect(r.getGuidelinesPage('personal')).toBeNull();
|
|
74
74
|
});
|
|
75
75
|
it('prefers per-graph config over the env var', () => {
|
|
76
76
|
process.env.ROAM_GUIDELINES_PAGE = 'env/page';
|
|
@@ -2,6 +2,7 @@ import { q } from '@roam-research/roam-api-sdk';
|
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { resolveBlockRefs } from '../helpers/refs.js';
|
|
4
4
|
import { fetchChildrenByDepth } from '../helpers/fetch-children.js';
|
|
5
|
+
import { pruneHiddenBlocks, isHiddenBlockString } from '../helpers/hidden.js';
|
|
5
6
|
export class BlockRetrievalOperations {
|
|
6
7
|
constructor(graph) {
|
|
7
8
|
this.graph = graph;
|
|
@@ -24,12 +25,17 @@ export class BlockRetrievalOperations {
|
|
|
24
25
|
}
|
|
25
26
|
const [rootString, rootOrder, rootHeading] = rootBlockResults[0];
|
|
26
27
|
const childrenMap = await fetchChildrenByDepth(this.graph, [block_uid], depth);
|
|
28
|
+
// A block the user tagged #.rm-hide / #.rm-private is withheld outright,
|
|
29
|
+
// as is anything nested under it — same rule as the page read paths.
|
|
30
|
+
if (isHiddenBlockString(rootString)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
27
33
|
const rootBlock = {
|
|
28
34
|
uid: block_uid,
|
|
29
35
|
string: rootString,
|
|
30
36
|
order: rootOrder,
|
|
31
37
|
heading: rootHeading || undefined,
|
|
32
|
-
children: childrenMap[block_uid] || [],
|
|
38
|
+
children: pruneHiddenBlocks(childrenMap[block_uid] || []),
|
|
33
39
|
};
|
|
34
40
|
// Fetch ancestors if requested
|
|
35
41
|
if (include_ancestors) {
|
|
@@ -3,6 +3,7 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
|
3
3
|
import { getPageUid as getPageUidHelper } from '../helpers/page-resolution.js';
|
|
4
4
|
import { resolveRefs } from '../helpers/refs.js';
|
|
5
5
|
import { fetchChildrenByDepth } from '../helpers/fetch-children.js';
|
|
6
|
+
import { collectHiddenUids, pruneHiddenBlocks, isHiddenBlockString } from '../helpers/hidden.js';
|
|
6
7
|
export class FullPageViewOperations {
|
|
7
8
|
constructor(graph, pageOps) {
|
|
8
9
|
this.graph = graph;
|
|
@@ -21,12 +22,20 @@ export class FullPageViewOperations {
|
|
|
21
22
|
const refResults = await this.fetchReferringBlocks(title);
|
|
22
23
|
// Deduplicate by block_uid, then cap at max_references
|
|
23
24
|
const seenUids = new Set();
|
|
24
|
-
const
|
|
25
|
+
const dedupedRefs = refResults.filter(r => {
|
|
25
26
|
if (seenUids.has(r.block_uid))
|
|
26
27
|
return false;
|
|
27
28
|
seenUids.add(r.block_uid);
|
|
28
29
|
return true;
|
|
29
30
|
});
|
|
31
|
+
// Backlinks come from other pages, so the page's own prune (pageBlocks
|
|
32
|
+
// above, already filtered by fetchPageByUid) does not cover them. A
|
|
33
|
+
// referring block may itself be tagged, or sit under a tagged parent
|
|
34
|
+
// elsewhere — hence the UID closure rather than a text check alone.
|
|
35
|
+
// Filtered BEFORE the max_references cap so truncation counts what the
|
|
36
|
+
// caller can actually see.
|
|
37
|
+
const hiddenUids = await collectHiddenUids(this.graph);
|
|
38
|
+
const allUniqueRefs = dedupedRefs.filter(r => !hiddenUids.has(r.block_uid) && !isHiddenBlockString(r.block_str));
|
|
30
39
|
const truncated = allUniqueRefs.length > max_references;
|
|
31
40
|
const uniqueRefs = truncated ? allUniqueRefs.slice(0, max_references) : allUniqueRefs;
|
|
32
41
|
const refBlockUids = uniqueRefs.map(r => r.block_uid);
|
|
@@ -89,7 +98,8 @@ export class FullPageViewOperations {
|
|
|
89
98
|
uid: ref.block_uid,
|
|
90
99
|
string: resolvedRefStrings.get(ref.block_uid) || ref.block_str,
|
|
91
100
|
order: 0,
|
|
92
|
-
|
|
101
|
+
// Children of a visible backlink can still contain tagged subtrees.
|
|
102
|
+
children: pruneHiddenBlocks(childrenMap[ref.block_uid] || [])
|
|
93
103
|
};
|
|
94
104
|
groupMap.get(key).references.push({
|
|
95
105
|
breadcrumbs: breadcrumbsMap[ref.block_uid] || [],
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-graph agent guidelines.
|
|
3
3
|
*
|
|
4
|
-
* A page in the graph — `[[roam/agent guidelines]]`
|
|
5
|
-
* user's own conventions: how they tag, how they name pages, what to never do.
|
|
4
|
+
* A page in the graph — conventionally `[[roam/agent guidelines]]` — holding
|
|
5
|
+
* the user's own conventions: how they tag, how they name pages, what to never do.
|
|
6
6
|
* This is the same page Roam's official MCP server reads, so a user writes their
|
|
7
7
|
* conventions once and both servers honour them.
|
|
8
8
|
*
|
|
9
9
|
* Distinct from the markdown cheatsheet, which is Roam *syntax* plus this
|
|
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
|
+
*
|
|
13
|
+
* Opt-in: a graph must name its page via `guidelinesPage` or ROAM_GUIDELINES_PAGE.
|
|
14
|
+
* With neither set the tool reports disabled and never touches the graph.
|
|
12
15
|
*/
|
|
13
16
|
import { PageOperations } from './pages.js';
|
|
14
17
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
15
|
-
/**
|
|
18
|
+
/** The conventional page title, matching Roam's own server. Not applied
|
|
19
|
+
* automatically — a graph must opt in via `guidelinesPage` or
|
|
20
|
+
* ROAM_GUIDELINES_PAGE. */
|
|
16
21
|
export const DEFAULT_GUIDELINES_PAGE = 'roam/agent guidelines';
|
|
17
22
|
/**
|
|
18
23
|
* Short TTL: the point of a page over a config file is that an edit takes
|
|
@@ -25,7 +30,7 @@ export class GuidelinesOperations {
|
|
|
25
30
|
/**
|
|
26
31
|
* @param guidelinesPage Page title to read, or null when disabled for this graph.
|
|
27
32
|
*/
|
|
28
|
-
constructor(graph, guidelinesPage =
|
|
33
|
+
constructor(graph, guidelinesPage = null) {
|
|
29
34
|
this.graph = graph;
|
|
30
35
|
this.guidelinesPage = guidelinesPage;
|
|
31
36
|
this.pageOps = new PageOperations(graph);
|
|
@@ -14,7 +14,7 @@ describe('GuidelinesOperations', () => {
|
|
|
14
14
|
it('returns the page content when the page exists', async () => {
|
|
15
15
|
getPageUid.mockResolvedValue('abc123456');
|
|
16
16
|
fetchPageByTitle.mockResolvedValue('- H2: Tagging Philosophy');
|
|
17
|
-
const res = await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
17
|
+
const res = await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
18
18
|
expect(res.exists).toBe(true);
|
|
19
19
|
expect(res.page).toBe(DEFAULT_GUIDELINES_PAGE);
|
|
20
20
|
expect(res.guidelines).toBe('- H2: Tagging Philosophy');
|
|
@@ -22,7 +22,7 @@ describe('GuidelinesOperations', () => {
|
|
|
22
22
|
});
|
|
23
23
|
it('reports absence rather than failing when no page has been created', async () => {
|
|
24
24
|
getPageUid.mockResolvedValue(null);
|
|
25
|
-
const res = await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
25
|
+
const res = await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
26
26
|
expect(res.exists).toBe(false);
|
|
27
27
|
expect(res.guidelines).toBeNull();
|
|
28
28
|
expect(res.nextSteps).toMatch(/no user conventions/i);
|
|
@@ -44,27 +44,27 @@ describe('GuidelinesOperations', () => {
|
|
|
44
44
|
});
|
|
45
45
|
it('fails open — a lookup error never breaks the tool the agent wanted', async () => {
|
|
46
46
|
getPageUid.mockRejectedValue(new Error('network down'));
|
|
47
|
-
const res = await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
47
|
+
const res = await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
48
48
|
expect(res.exists).toBe(false);
|
|
49
49
|
expect(res.guidelines).toBeNull();
|
|
50
50
|
expect(res.nextSteps).toMatch(/network down/);
|
|
51
51
|
});
|
|
52
52
|
it('always reports today in Roam ordinal date format', async () => {
|
|
53
53
|
getPageUid.mockResolvedValue(null);
|
|
54
|
-
const res = await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
54
|
+
const res = await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
55
55
|
expect(res.todaysDailyNote).toMatch(/^[A-Z][a-z]+ \d{1,2}(st|nd|rd|th), \d{4}$/);
|
|
56
56
|
});
|
|
57
57
|
it('tells the agent not to re-orient, so it does not refetch every call', async () => {
|
|
58
58
|
getPageUid.mockResolvedValue('abc123456');
|
|
59
59
|
fetchPageByTitle.mockResolvedValue('rules');
|
|
60
|
-
const res = await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
60
|
+
const res = await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
61
61
|
expect(res.nextSteps).toMatch(/do not call this tool again/i);
|
|
62
62
|
expect(res.nextSteps).toMatch(/reads as well as writes/i);
|
|
63
63
|
});
|
|
64
64
|
it('caches within the TTL so a burst of calls costs one fetch', async () => {
|
|
65
65
|
getPageUid.mockResolvedValue('abc123456');
|
|
66
66
|
fetchPageByTitle.mockResolvedValue('rules');
|
|
67
|
-
const ops = new GuidelinesOperations(newGraph());
|
|
67
|
+
const ops = new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE);
|
|
68
68
|
await ops.getGuidelines();
|
|
69
69
|
await ops.getGuidelines();
|
|
70
70
|
await ops.getGuidelines();
|
|
@@ -73,7 +73,7 @@ describe('GuidelinesOperations', () => {
|
|
|
73
73
|
it('refetches after the cache is cleared', async () => {
|
|
74
74
|
getPageUid.mockResolvedValue('abc123456');
|
|
75
75
|
fetchPageByTitle.mockResolvedValue('rules');
|
|
76
|
-
const ops = new GuidelinesOperations(newGraph());
|
|
76
|
+
const ops = new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE);
|
|
77
77
|
await ops.getGuidelines();
|
|
78
78
|
ops.clearCache();
|
|
79
79
|
await ops.getGuidelines();
|
|
@@ -82,8 +82,8 @@ describe('GuidelinesOperations', () => {
|
|
|
82
82
|
it('keeps separate graphs separate', async () => {
|
|
83
83
|
getPageUid.mockResolvedValue('abc123456');
|
|
84
84
|
fetchPageByTitle.mockResolvedValue('rules');
|
|
85
|
-
await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
86
|
-
await new GuidelinesOperations(newGraph()).getGuidelines();
|
|
85
|
+
await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
86
|
+
await new GuidelinesOperations(newGraph(), DEFAULT_GUIDELINES_PAGE).getGuidelines();
|
|
87
87
|
expect(getPageUid).toHaveBeenCalledTimes(2);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { q } from '@roam-research/roam-api-sdk';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { generateBlockUid } from '../../markdown-utils.js';
|
|
4
|
+
import { collectHiddenUids, isHiddenBlockString } from '../helpers/hidden.js';
|
|
4
5
|
import { ANCESTOR_RULE } from '../../search/ancestor-rule.js';
|
|
5
6
|
import { sanitizeTagName } from '../../utils/helpers.js';
|
|
6
7
|
import { resolveRefs } from '../helpers/refs.js';
|
|
@@ -92,17 +93,26 @@ export class MemoryOperations {
|
|
|
92
93
|
.replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]]
|
|
93
94
|
try {
|
|
94
95
|
// Query to find all blocks on the page
|
|
95
|
-
|
|
96
|
+
// ?uid is selected so hidden blocks can be filtered by the UID closure
|
|
97
|
+
// below — a text check alone would miss blocks nested under a tagged
|
|
98
|
+
// parent, which carry no tag of their own.
|
|
99
|
+
const pageQuery = `[:find ?string ?time ?uid
|
|
96
100
|
:in $ % ?title
|
|
97
101
|
:where
|
|
98
102
|
[?page :node/title ?title]
|
|
99
103
|
[?block :block/string ?string]
|
|
104
|
+
[?block :block/uid ?uid]
|
|
100
105
|
[?block :create/time ?time]
|
|
101
106
|
(ancestor ?block ?page)]`;
|
|
102
107
|
// Execute query
|
|
103
108
|
const pageResults = await q(this.graph, pageQuery, [ANCESTOR_RULE, tagText]);
|
|
109
|
+
// Withhold anything the user tagged #.rm-hide / #.rm-private. The tagged
|
|
110
|
+
// half of recall (searchForTag below) is already filtered by
|
|
111
|
+
// SearchOperations; this is the page half.
|
|
112
|
+
const hiddenUids = await collectHiddenUids(this.graph);
|
|
104
113
|
// Process page blocks with sorting
|
|
105
114
|
let pageMemories = pageResults
|
|
115
|
+
.filter(([content, , uid]) => !hiddenUids.has(uid) && !isHiddenBlockString(content))
|
|
106
116
|
.sort(([_, aTime], [__, bTime]) => sort_by === 'newest' ? bTime - aTime : aTime - bTime)
|
|
107
117
|
.map(([content]) => content);
|
|
108
118
|
// Get tagged blocks from across the graph
|
|
@@ -501,10 +501,27 @@ export class PageOperations {
|
|
|
501
501
|
});
|
|
502
502
|
};
|
|
503
503
|
sortBlocks(rootBlocks);
|
|
504
|
+
// Withhold #.rm-hide / #.rm-private subtrees before ANY format renders
|
|
505
|
+
// them. This function builds its own tree rather than reusing
|
|
506
|
+
// fetchPageByUid, so it needs its own prune — that duplication is exactly
|
|
507
|
+
// how this path shipped unfiltered the first time.
|
|
508
|
+
const visibleRoots = pruneHiddenBlocks(rootBlocks);
|
|
509
|
+
// Collect from the PRUNED tree, not by filtering allBlocks: pruning copies
|
|
510
|
+
// any node that has children, so the originals are no longer the objects
|
|
511
|
+
// rendered below, and the markdown branch mutates these in place.
|
|
512
|
+
const visibleBlocks = [];
|
|
513
|
+
const collectVisible = (bs) => {
|
|
514
|
+
for (const b of bs) {
|
|
515
|
+
visibleBlocks.push(b);
|
|
516
|
+
if (b.children.length > 0)
|
|
517
|
+
collectVisible(b.children);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
collectVisible(visibleRoots);
|
|
504
521
|
if (format === 'raw') {
|
|
505
522
|
// Resolve structured references for raw JSON output
|
|
506
|
-
await resolveBlockRefs(this.graph,
|
|
507
|
-
return JSON.stringify(
|
|
523
|
+
await resolveBlockRefs(this.graph, visibleBlocks, 2);
|
|
524
|
+
return JSON.stringify(visibleRoots);
|
|
508
525
|
}
|
|
509
526
|
if (format === 'structure') {
|
|
510
527
|
const flattenBlocks = (blocks, depth, parentUid) => {
|
|
@@ -532,7 +549,7 @@ export class PageOperations {
|
|
|
532
549
|
}
|
|
533
550
|
return result;
|
|
534
551
|
};
|
|
535
|
-
const structureBlocks = flattenBlocks(
|
|
552
|
+
const structureBlocks = flattenBlocks(visibleRoots, 0, uid);
|
|
536
553
|
return JSON.stringify({
|
|
537
554
|
page_uid: uid,
|
|
538
555
|
title: title,
|
|
@@ -541,7 +558,7 @@ export class PageOperations {
|
|
|
541
558
|
});
|
|
542
559
|
}
|
|
543
560
|
// For markdown, resolve references inline
|
|
544
|
-
await Promise.all(
|
|
561
|
+
await Promise.all(visibleBlocks.map(async (b) => {
|
|
545
562
|
b.string = await resolveRefs(this.graph, b.string);
|
|
546
563
|
}));
|
|
547
564
|
// Convert to markdown with proper nesting
|
|
@@ -567,7 +584,7 @@ export class PageOperations {
|
|
|
567
584
|
})
|
|
568
585
|
.join('\n');
|
|
569
586
|
};
|
|
570
|
-
return `# ${title}\n\n${toMarkdown(
|
|
587
|
+
return `# ${title}\n\n${toMarkdown(visibleRoots)}`;
|
|
571
588
|
}
|
|
572
589
|
/**
|
|
573
590
|
* Update an existing page with new markdown content using smart diff.
|
|
@@ -9,12 +9,12 @@ import { MemoryOperations } from './operations/memory.js';
|
|
|
9
9
|
import { TodoOperations } from './operations/todos.js';
|
|
10
10
|
import { OutlineOperations } from './operations/outline.js';
|
|
11
11
|
import { BatchOperations } from './operations/batch.js';
|
|
12
|
-
import { GuidelinesOperations
|
|
12
|
+
import { GuidelinesOperations } from './operations/guidelines.js';
|
|
13
13
|
import { TableOperations } from './operations/table.js';
|
|
14
14
|
import { DatomicSearchHandlerImpl } from './operations/search/handlers.js';
|
|
15
15
|
import { FullPageViewOperations } from './operations/full-page-view.js';
|
|
16
16
|
export class ToolHandlers {
|
|
17
|
-
constructor(graph, memoriesTag = 'Memories', guidelinesPage =
|
|
17
|
+
constructor(graph, memoriesTag = 'Memories', guidelinesPage = null) {
|
|
18
18
|
this.graph = graph;
|
|
19
19
|
this.cachedCheatsheet = null;
|
|
20
20
|
this.pageOps = new PageOperations(graph);
|