roam-research-mcp 0.32.4 → 0.36.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 +22 -2
- package/build/Roam_Markdown_Cheatsheet.md +63 -4
- package/build/markdown-utils.js +3 -2
- package/build/search/tag-search.js +72 -12
- package/build/search/text-search.js +51 -12
- package/build/tools/helpers/text.js +59 -0
- package/build/tools/operations/block-retrieval.js +1 -1
- package/build/tools/operations/outline.js +224 -162
- package/build/tools/schemas.js +41 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
121
121
|
10. `roam_search_by_text`: Search for blocks containing specific text.
|
|
122
122
|
11. `roam_search_by_status`: Search for blocks with a specific status (TODO/DONE) across all pages or within a specific page.
|
|
123
123
|
12. `roam_search_by_date`: Search for blocks or pages based on creation or modification dates.
|
|
124
|
-
13. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
|
|
124
|
+
13. `roam_search_for_tag`: Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag.
|
|
125
125
|
14. `roam_remember`: Add a memory or piece of information to remember. (Internally uses `roam_process_batch_actions`.)
|
|
126
126
|
15. `roam_recall`: Retrieve all stored memories.
|
|
127
127
|
16. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools.
|
|
@@ -129,7 +129,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
129
129
|
18. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.)
|
|
130
130
|
|
|
131
131
|
**Deprecated Tools**:
|
|
132
|
-
The following tools have been deprecated as of `
|
|
132
|
+
The following tools have been deprecated as of `v1.36.0` in favor of the more powerful and flexible `roam_process_batch_actions`:
|
|
133
133
|
|
|
134
134
|
- `roam_create_block`: Use `roam_process_batch_actions` with the `create-block` action.
|
|
135
135
|
- `roam_update_block`: Use `roam_process_batch_actions` with the `update-block` action.
|
|
@@ -176,6 +176,20 @@ Please note that while the `roam_process_batch_actions` tool can set block headi
|
|
|
176
176
|
|
|
177
177
|
---
|
|
178
178
|
|
|
179
|
+
## Propose Improvements
|
|
180
|
+
|
|
181
|
+
### Pagination for Search Tools
|
|
182
|
+
|
|
183
|
+
The `roam_search_for_tag` and `roam_search_by_text` tools now support `limit` and `offset` parameters, enabling basic pagination. To achieve full, robust pagination (e.g., retrieving "page 2" of results), the client consuming these tools would need to:
|
|
184
|
+
|
|
185
|
+
1. Make an initial call with `limit` and `offset=0` to get the first set of results and the `total_count`.
|
|
186
|
+
2. Calculate the total number of pages based on `total_count` and the desired `limit`.
|
|
187
|
+
3. Make subsequent calls, incrementing the `offset` by `limit` for each "page" of results.
|
|
188
|
+
|
|
189
|
+
Example: To get the second page of 10 results, the call would be `roam_search_by_text(text: "your query", limit: 10, offset: 10)`.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
179
193
|
## Example Prompts
|
|
180
194
|
|
|
181
195
|
Here are some examples of how to creatively use the Roam tool in an LLM to interact with your Roam graph, particularly leveraging `roam_process_batch_actions` for complex operations.
|
|
@@ -351,3 +365,9 @@ This will:
|
|
|
351
365
|
## License
|
|
352
366
|
|
|
353
367
|
MIT License
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## About the Author
|
|
372
|
+
|
|
373
|
+
This project is maintained by [Ian Shen](https://github.com/2b3pro).
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
- {{[[TODO]]}} todo text
|
|
16
16
|
- {{[[DONE]]}} todo text
|
|
17
17
|
- LaTeX: `$$E=mc^2$$` or `$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$`
|
|
18
|
+
- Bullet points use dashes not asterisks.
|
|
18
19
|
|
|
19
20
|
## Roam-specific Markdown:
|
|
20
21
|
|
|
@@ -112,12 +113,70 @@ The provided markdown structure represents a Roam Research Kanban board. It star
|
|
|
112
113
|
This markdown structure allows embedding custom HTML or other content using Hiccup syntax. The `:hiccup` keyword is followed by a Clojure-like vector defining the HTML elements and their attributes in one block. This provides a powerful way to inject dynamic or custom components into your Roam graph. Example: `:hiccup [:iframe {:width "600" :height "400" :src "https://www.example.com"}]`
|
|
113
114
|
|
|
114
115
|
## Specific notes and preferences concerning my Roam Research graph
|
|
115
|
-
###
|
|
116
|
+
### When creating new pages
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
- After creating a new page, ensure you add a new block on the daily page that references it if one doesn't already exist: `Created page: [[…]]`
|
|
118
119
|
|
|
119
|
-
###
|
|
120
|
+
### What To Tag
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
- Always think in terms of Future Me: How will I find this gem of information in this block in the future? What about this core idea might I be looking for? Aim for high relevance and helpfulness.
|
|
123
|
+
- My notes in Roam are interconnected via wrapped terms/expressions [[…]] and hashtags (#…). The general salience guidelines are as follows:
|
|
124
|
+
|
|
125
|
+
- What greater domain would this core idea be best categorization?
|
|
126
|
+
- If block is parent of children blocks, can a word/phrase in the parent be wrapped in brackets.
|
|
127
|
+
- If not, could a hashtag be appended to the block? (This is like a "please see more"-on-this-subject categorization).
|
|
128
|
+
- Wrap any proper names—no titles—(Dr. [[Werner Erhard]]), organizations, abbreviations e.g. `[NASA]([[National Aeronautics and Space Administration (NASA)]])`
|
|
129
|
+
- Any synonyms or multi-worded phrases of already established categories/pages, e.g. `[frameworks for making quality decisions]([[decision-making frameworks]])`
|
|
130
|
+
|
|
131
|
+
#### Advanced tagging methodology combining [[zettelkasten]] principles with serendipity engineering for maximum intellectual discovery potential. #[[knowledge management]] #[[tagging methodology]]
|
|
132
|
+
|
|
133
|
+
- # Core Philosophy
|
|
134
|
+
- Tag for **intellectual collision** and **future conversation**, not just categorization. Every tag should maximize the potential for unexpected discoveries and cross-domain insights.
|
|
135
|
+
- Traditional academic categorization creates silos; serendipity-engineered tagging creates **conceptual magnets** that attract surprising connections.
|
|
136
|
+
- ## Zettelkasten-Informed Principles
|
|
137
|
+
- **Atomic Thought Units**: Each tagged concept should function as a standalone intellectual object capable of connecting to ANY other domain
|
|
138
|
+
- **Conversation-Driven Linking**: Tag by the intellectual dialogues and debates a concept could inform, not just its subject matter
|
|
139
|
+
- **Temporal Intellectual Journey**: Tag for different phases of understanding - beginner discoveries, expert refinements, teaching moments
|
|
140
|
+
- **Intellectual Genealogy**: Create concept lineages that trace how ideas evolve and connect across domains
|
|
141
|
+
- ## Serendipity Engineering Techniques
|
|
142
|
+
- **Conceptual Collision Matrix**: Force unlikely intellectual meetings by tagging across disparate domains
|
|
143
|
+
- Example: #[[parenting techniques]] + [[cognitive engineering]] = developing children's meta-cognitive awareness
|
|
144
|
+
- Example: #[[cooking methods]] + [[cultural rotation]] = how different cultures balance flavors → balancing competing AI instructions
|
|
145
|
+
- **Anti-Obvious Tagging**: Deliberately tag against natural categorization to maximize surprise
|
|
146
|
+
- Tag by **structural similarities** rather than surface content: #[[has feedback loops]], #[[requires calibration]], #[[exhibits emergence]]
|
|
147
|
+
- Connect concepts through **metaphorical bridges**: meditation techniques + bias detection (both about noticing the unnoticed)
|
|
148
|
+
- **Question Cascade Strategy**: Tag by what questions a concept could answer across unexpected domains
|
|
149
|
+
- Instead of "this is about X," ask "what problems could this solve that I haven't thought of yet?"
|
|
150
|
+
- Examples: #[[how do experts prevent tunnel vision?]], #[[what creates cognitive flexibility?]], #[[how do systems self-correct?]]
|
|
151
|
+
- **Future Collision Potential**: Tag with temporal discovery triggers
|
|
152
|
+
- [[will be relevant in 5 years]], #[[connects to unborn projects]], #[[solves problems I don't know I have yet]]
|
|
153
|
+
- ## Practical Implementation
|
|
154
|
+
- **The Serendipity Test**: Before tagging, ask "Could this concept surprise me by connecting to something completely unrelated?"
|
|
155
|
+
- **Cross-Domain Bridge Tags**: Use structural rather than content-based categorization
|
|
156
|
+
- [[flow dynamics]] - connects fluid mechanics, music, conversation, AI prompt sequences
|
|
157
|
+
- [[calibration processes]] - bridges instrument tuning, relationships, AI parameters, personal habits
|
|
158
|
+
- [[perspective switching]] - connects photography, negotiation, cultural analysis, prompt engineering
|
|
159
|
+
- **Problem-Solution Pairing**: Tag by the problems solved rather than methods used
|
|
160
|
+
- [[breaking cognitive constraints]], #[[expanding solution spaces]], #[[preventing expert blindness]]
|
|
161
|
+
- **Intellectual State Triggers**: Tag for when you'd actually search based on mental/emotional context
|
|
162
|
+
- [[feeling stuck in patterns]], #[[needing fresh perspective]], #[[frustrated with conventional approaches]]
|
|
163
|
+
- ## Tag Maintenance Strategy
|
|
164
|
+
- **Regular Collision Audits**: Periodically review tags to identify missed connection opportunities
|
|
165
|
+
- **Surprise Discovery Log**: Track when unexpected tag connections lead to insights, then engineer more of those patterns
|
|
166
|
+
- **Question Evolution**: Update question-based tags as your intellectual interests and problems evolve
|
|
167
|
+
- **Cross-Reference Integration**: Ensure new tagging approach complements existing page reference `[[…]]` and hashtag `#[[…]]` conventions
|
|
168
|
+
|
|
169
|
+
### How to Tag (nuances)
|
|
170
|
+
|
|
171
|
+
- Consider that when linking a parent block, all children blocks will also accompany future search results. Linking a childblock will only link the child block, not siblings.
|
|
172
|
+
- The convention for tags is lower case unless proper nouns. When tagging might affect the capitalization within a sentence, use aliases. e.g. `[Cognitive biases]([[cognitive biases]]) of the person are listed…"
|
|
173
|
+
- Can longer phrases and expressions be aliased to existing notes? `How one can leverage [[cognitive dissonance]] to [hypnotically influence others using language]([[hypnotic language techniques]])`
|
|
174
|
+
- Reformat quotes in this format structure: `<quote> —[[<Quoted Person>]] #quote <hashtags>` with 2-3 additional relevant hashtag links
|
|
175
|
+
- Anything that needs follow-up, further research, preface with `{{[[TODO]]}}`
|
|
176
|
+
- Scheduled review: Tag future dates in ordinal format so that it can come up for review when relevant, e.g. `[[For review]]: [[August 12th, 2026]]` Optionally, prepend with label ("Deadline", "For review", "Approved", "Pending", "Deferred", "Postponed until"). Place on parent block if relevant (if children blocks are related).
|
|
177
|
+
|
|
178
|
+
### Constraints
|
|
179
|
+
|
|
180
|
+
- Don't overtag.
|
|
122
181
|
|
|
123
182
|
⭐️📋 END (Cheat Sheet LOADED) < < < 📋⭐️
|
package/build/markdown-utils.js
CHANGED
|
@@ -262,13 +262,14 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
262
262
|
const actions = [];
|
|
263
263
|
// Helper function to recursively create actions
|
|
264
264
|
function createBlockActions(blocks, parentUid, order) {
|
|
265
|
-
for (
|
|
265
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
266
|
+
const block = blocks[i];
|
|
266
267
|
// Create the current block
|
|
267
268
|
const action = {
|
|
268
269
|
action: 'create-block',
|
|
269
270
|
location: {
|
|
270
271
|
'parent-uid': parentUid,
|
|
271
|
-
order
|
|
272
|
+
order: typeof order === 'number' ? order + i : i
|
|
272
273
|
},
|
|
273
274
|
block: {
|
|
274
275
|
uid: block.uid,
|
|
@@ -8,42 +8,102 @@ export class TagSearchHandler extends BaseSearchHandler {
|
|
|
8
8
|
this.params = params;
|
|
9
9
|
}
|
|
10
10
|
async execute() {
|
|
11
|
-
const { primary_tag, page_title_uid, near_tag, exclude_tag } = this.params;
|
|
11
|
+
const { primary_tag, page_title_uid, near_tag, exclude_tag, case_sensitive = false, limit = -1, offset = 0 } = this.params;
|
|
12
|
+
let nearTagUid;
|
|
13
|
+
if (near_tag) {
|
|
14
|
+
nearTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, near_tag);
|
|
15
|
+
if (!nearTagUid) {
|
|
16
|
+
return {
|
|
17
|
+
success: false,
|
|
18
|
+
matches: [],
|
|
19
|
+
message: `Near tag "${near_tag}" not found.`,
|
|
20
|
+
total_count: 0
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
let excludeTagUid;
|
|
25
|
+
if (exclude_tag) {
|
|
26
|
+
excludeTagUid = await SearchUtils.findPageByTitleOrUid(this.graph, exclude_tag);
|
|
27
|
+
if (!excludeTagUid) {
|
|
28
|
+
return {
|
|
29
|
+
success: false,
|
|
30
|
+
matches: [],
|
|
31
|
+
message: `Exclude tag "${exclude_tag}" not found.`,
|
|
32
|
+
total_count: 0
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
12
36
|
// Get target page UID if provided for scoped search
|
|
13
37
|
let targetPageUid;
|
|
14
38
|
if (page_title_uid) {
|
|
15
39
|
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
16
40
|
}
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
const searchTags = [];
|
|
42
|
+
if (case_sensitive) {
|
|
43
|
+
searchTags.push(primary_tag);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
searchTags.push(primary_tag);
|
|
47
|
+
searchTags.push(primary_tag.charAt(0).toUpperCase() + primary_tag.slice(1));
|
|
48
|
+
searchTags.push(primary_tag.toUpperCase());
|
|
49
|
+
searchTags.push(primary_tag.toLowerCase());
|
|
50
|
+
}
|
|
51
|
+
const tagWhereClauses = searchTags.map(tag => {
|
|
52
|
+
// Roam tags can be [[tag name]] or #tag-name or #[[tag name]]
|
|
53
|
+
// The :node/title for a tag page is just the tag name without any # or [[ ]]
|
|
54
|
+
return `[?ref-page :node/title "${tag}"]`;
|
|
55
|
+
}).join(' ');
|
|
56
|
+
let inClause = `:in $`;
|
|
57
|
+
let queryLimit = limit === -1 ? '' : `:limit ${limit}`;
|
|
58
|
+
let queryOffset = offset === 0 ? '' : `:offset ${offset}`;
|
|
59
|
+
let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID
|
|
19
60
|
let queryWhereClauses = `
|
|
20
|
-
|
|
21
|
-
[(clojure.string/lower-case ?title-match) ?lower-title]
|
|
22
|
-
[(clojure.string/lower-case ?title) ?search-title]
|
|
23
|
-
[(= ?lower-title ?search-title)]
|
|
61
|
+
(or ${tagWhereClauses})
|
|
24
62
|
[?b :block/refs ?ref-page]
|
|
25
63
|
[?b :block/string ?block-str]
|
|
26
64
|
[?b :block/uid ?block-uid]
|
|
27
65
|
[?b :block/page ?p]
|
|
28
|
-
[?p :node/title ?page-title]
|
|
29
|
-
|
|
66
|
+
[?p :node/title ?page-title]
|
|
67
|
+
[?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting
|
|
68
|
+
if (nearTagUid) {
|
|
69
|
+
queryWhereClauses += `
|
|
70
|
+
[?b :block/refs ?near-tag-page]
|
|
71
|
+
[?near-tag-page :block/uid "${nearTagUid}"]`;
|
|
72
|
+
}
|
|
73
|
+
if (excludeTagUid) {
|
|
74
|
+
queryWhereClauses += `
|
|
75
|
+
(not [?b :block/refs ?exclude-tag-page])
|
|
76
|
+
[?exclude-tag-page :block/uid "${excludeTagUid}"]`;
|
|
77
|
+
}
|
|
30
78
|
if (targetPageUid) {
|
|
31
79
|
inClause += ` ?target-page-uid`;
|
|
32
|
-
queryArgs.push(targetPageUid);
|
|
33
80
|
queryWhereClauses += `
|
|
34
81
|
[?p :block/uid ?target-page-uid]`;
|
|
35
82
|
}
|
|
36
83
|
const queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
37
|
-
${inClause}
|
|
84
|
+
${inClause} ${queryLimit} ${queryOffset} ${queryOrder}
|
|
38
85
|
:where
|
|
39
86
|
${queryWhereClauses}]`;
|
|
87
|
+
const queryArgs = [];
|
|
88
|
+
if (targetPageUid) {
|
|
89
|
+
queryArgs.push(targetPageUid);
|
|
90
|
+
}
|
|
40
91
|
const rawResults = await q(this.graph, queryStr, queryArgs);
|
|
92
|
+
// Query to get total count without limit
|
|
93
|
+
const countQueryStr = `[:find (count ?b)
|
|
94
|
+
${inClause}
|
|
95
|
+
:where
|
|
96
|
+
${queryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query
|
|
97
|
+
const totalCountResults = await q(this.graph, countQueryStr, queryArgs);
|
|
98
|
+
const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
|
|
41
99
|
// Resolve block references in content
|
|
42
100
|
const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
|
|
43
101
|
const resolvedContent = await resolveRefs(this.graph, content);
|
|
44
102
|
return [uid, resolvedContent, pageTitle];
|
|
45
103
|
}));
|
|
46
104
|
const searchDescription = `referencing "${primary_tag}"`;
|
|
47
|
-
|
|
105
|
+
const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
|
|
106
|
+
formattedResults.total_count = totalCount;
|
|
107
|
+
return formattedResults;
|
|
48
108
|
}
|
|
49
109
|
}
|
|
@@ -8,29 +8,68 @@ export class TextSearchHandler extends BaseSearchHandler {
|
|
|
8
8
|
this.params = params;
|
|
9
9
|
}
|
|
10
10
|
async execute() {
|
|
11
|
-
const { text, page_title_uid } = this.params;
|
|
11
|
+
const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0 } = this.params;
|
|
12
12
|
// Get target page UID if provided for scoped search
|
|
13
13
|
let targetPageUid;
|
|
14
14
|
if (page_title_uid) {
|
|
15
15
|
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
const searchTerms = [];
|
|
18
|
+
if (case_sensitive) {
|
|
19
|
+
searchTerms.push(text);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
searchTerms.push(text);
|
|
23
|
+
// Add capitalized version (e.g., "Hypnosis")
|
|
24
|
+
searchTerms.push(text.charAt(0).toUpperCase() + text.slice(1));
|
|
25
|
+
// Add all caps version (e.g., "HYPNOSIS")
|
|
26
|
+
searchTerms.push(text.toUpperCase());
|
|
27
|
+
// Add all lowercase version (e.g., "hypnosis")
|
|
28
|
+
searchTerms.push(text.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
const whereClauses = searchTerms.map(term => `[(clojure.string/includes? ?block-str "${term}")]`).join(' ');
|
|
31
|
+
let queryStr;
|
|
32
|
+
let queryParams = [];
|
|
33
|
+
let queryLimit = limit === -1 ? '' : `:limit ${limit}`;
|
|
34
|
+
let queryOffset = offset === 0 ? '' : `:offset ${offset}`;
|
|
35
|
+
let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID
|
|
36
|
+
let baseQueryWhereClauses = `
|
|
37
|
+
[?b :block/string ?block-str]
|
|
38
|
+
(or ${whereClauses})
|
|
39
|
+
[?b :block/uid ?block-uid]
|
|
40
|
+
[?b :block/page ?p]
|
|
41
|
+
[?p :node/title ?page-title]
|
|
42
|
+
[?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting
|
|
43
|
+
if (targetPageUid) {
|
|
44
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
45
|
+
:in $ ?page-uid ${queryLimit} ${queryOffset} ${queryOrder}
|
|
46
|
+
:where
|
|
47
|
+
${baseQueryWhereClauses}
|
|
48
|
+
[?p :block/uid ?page-uid]]`;
|
|
49
|
+
queryParams = [targetPageUid];
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
53
|
+
:in $ ${queryLimit} ${queryOffset} ${queryOrder}
|
|
54
|
+
:where
|
|
55
|
+
${baseQueryWhereClauses}]`;
|
|
56
|
+
}
|
|
27
57
|
const rawResults = await q(this.graph, queryStr, queryParams);
|
|
58
|
+
// Query to get total count without limit
|
|
59
|
+
const countQueryStr = `[:find (count ?b)
|
|
60
|
+
:in $
|
|
61
|
+
:where
|
|
62
|
+
${baseQueryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query
|
|
63
|
+
const totalCountResults = await q(this.graph, countQueryStr, queryParams);
|
|
64
|
+
const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
|
|
28
65
|
// Resolve block references in content
|
|
29
66
|
const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
|
|
30
67
|
const resolvedContent = await resolveRefs(this.graph, content);
|
|
31
68
|
return [uid, resolvedContent, pageTitle];
|
|
32
69
|
}));
|
|
33
70
|
const searchDescription = `containing "${text}"`;
|
|
34
|
-
|
|
71
|
+
const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
|
|
72
|
+
formattedResults.total_count = totalCount;
|
|
73
|
+
return formattedResults;
|
|
35
74
|
}
|
|
36
75
|
}
|
|
@@ -1,6 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capitalizes each word in a string
|
|
3
|
+
*/
|
|
4
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
1
5
|
/**
|
|
2
6
|
* Capitalizes each word in a string
|
|
3
7
|
*/
|
|
4
8
|
export const capitalizeWords = (str) => {
|
|
5
9
|
return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
6
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Retrieves a block's UID based on its exact text content.
|
|
13
|
+
* This function is intended for internal use by other MCP tools.
|
|
14
|
+
* @param graph The Roam graph instance.
|
|
15
|
+
* @param blockText The exact text content of the block to find.
|
|
16
|
+
* @returns The UID of the block if found, otherwise null.
|
|
17
|
+
*/
|
|
18
|
+
export const getBlockUidByText = async (graph, blockText) => {
|
|
19
|
+
const query = `[:find ?uid .
|
|
20
|
+
:in $ ?blockString
|
|
21
|
+
:where [?b :block/string ?blockString]
|
|
22
|
+
[?b :block/uid ?uid]]`;
|
|
23
|
+
const result = await q(graph, query, [blockText]);
|
|
24
|
+
return result && result.length > 0 ? result[0][0] : null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Retrieves all UIDs nested under a given block_uid or block_text (exact match).
|
|
28
|
+
* This function is intended for internal use by other MCP tools.
|
|
29
|
+
* @param graph The Roam graph instance.
|
|
30
|
+
* @param rootIdentifier The UID or exact text content of the root block.
|
|
31
|
+
* @returns An array of UIDs of all descendant blocks, including the root block's UID.
|
|
32
|
+
*/
|
|
33
|
+
export const getNestedUids = async (graph, rootIdentifier) => {
|
|
34
|
+
let rootUid = rootIdentifier;
|
|
35
|
+
// If the rootIdentifier is not a UID (simple check for 9 alphanumeric characters), try to resolve it as block text
|
|
36
|
+
if (!rootIdentifier.match(/^[a-zA-Z0-9]{9}$/)) {
|
|
37
|
+
rootUid = await getBlockUidByText(graph, rootIdentifier);
|
|
38
|
+
}
|
|
39
|
+
if (!rootUid) {
|
|
40
|
+
return []; // No root block found
|
|
41
|
+
}
|
|
42
|
+
const query = `[:find ?child-uid
|
|
43
|
+
:in $ ?root-uid
|
|
44
|
+
:where
|
|
45
|
+
[?root-block :block/uid ?root-uid]
|
|
46
|
+
[?root-block :block/children ?child-block]
|
|
47
|
+
[?child-block :block/uid ?child-uid]]`;
|
|
48
|
+
const results = await q(graph, query, [rootUid]);
|
|
49
|
+
return results.map(r => r[0]);
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Retrieves all UIDs nested under a given block_text (exact match).
|
|
53
|
+
* This function is intended for internal use by other MCP tools.
|
|
54
|
+
* It strictly requires an exact text match for the root block.
|
|
55
|
+
* @param graph The Roam graph instance.
|
|
56
|
+
* @param blockText The exact text content of the root block.
|
|
57
|
+
* @returns An array of UIDs of all descendant blocks, including the root block's UID.
|
|
58
|
+
*/
|
|
59
|
+
export const getNestedUidsByText = async (graph, blockText) => {
|
|
60
|
+
const rootUid = await getBlockUidByText(graph, blockText);
|
|
61
|
+
if (!rootUid) {
|
|
62
|
+
return []; // No root block found with exact text match
|
|
63
|
+
}
|
|
64
|
+
return getNestedUids(graph, rootUid);
|
|
65
|
+
};
|
|
@@ -16,7 +16,7 @@ export class BlockRetrievalOperations {
|
|
|
16
16
|
const childrenQuery = `[:find ?parentUid ?childUid ?childString ?childOrder ?childHeading
|
|
17
17
|
:in $ [?parentUid ...]
|
|
18
18
|
:where [?parent :block/uid ?parentUid]
|
|
19
|
-
[?
|
|
19
|
+
[?parent :block/children ?child]
|
|
20
20
|
[?child :block/uid ?childUid]
|
|
21
21
|
[?child :block/string ?childString]
|
|
22
22
|
[?child :block/order ?childOrder]
|
|
@@ -6,6 +6,188 @@ import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../.
|
|
|
6
6
|
export class OutlineOperations {
|
|
7
7
|
constructor(graph) {
|
|
8
8
|
this.graph = graph;
|
|
9
|
+
/**
|
|
10
|
+
* Helper function to check if string is a valid Roam UID (9 characters)
|
|
11
|
+
*/
|
|
12
|
+
this.isValidUid = (str) => {
|
|
13
|
+
return typeof str === 'string' && str.length === 9;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Helper function to find block with improved relationship checks
|
|
18
|
+
*/
|
|
19
|
+
async findBlockWithRetry(pageUid, blockString, maxRetries = 5, initialDelay = 1000) {
|
|
20
|
+
// Try multiple query strategies
|
|
21
|
+
const queries = [
|
|
22
|
+
// Strategy 1: Direct page and string match
|
|
23
|
+
`[:find ?b-uid ?order
|
|
24
|
+
:where [?p :block/uid "${pageUid}"]
|
|
25
|
+
[?b :block/page ?p]
|
|
26
|
+
[?b :block/string "${blockString}"]
|
|
27
|
+
[?b :block/order ?order]
|
|
28
|
+
[?b :block/uid ?b-uid]]`,
|
|
29
|
+
// Strategy 2: Parent-child relationship
|
|
30
|
+
`[:find ?b-uid ?order
|
|
31
|
+
:where [?p :block/uid "${pageUid}"]
|
|
32
|
+
[?b :block/parents ?p]
|
|
33
|
+
[?b :block/string "${blockString}"]
|
|
34
|
+
[?b :block/order ?order]
|
|
35
|
+
[?b :block/uid ?b-uid]]`,
|
|
36
|
+
// Strategy 3: Broader page relationship
|
|
37
|
+
`[:find ?b-uid ?order
|
|
38
|
+
:where [?p :block/uid "${pageUid}"]
|
|
39
|
+
[?b :block/page ?page]
|
|
40
|
+
[?p :block/page ?page]
|
|
41
|
+
[?b :block/string "${blockString}"]
|
|
42
|
+
[?b :block/order ?order]
|
|
43
|
+
[?b :block/uid ?b-uid]]`
|
|
44
|
+
];
|
|
45
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
46
|
+
// Try each query strategy
|
|
47
|
+
for (const queryStr of queries) {
|
|
48
|
+
const blockResults = await q(this.graph, queryStr, []);
|
|
49
|
+
if (blockResults && blockResults.length > 0) {
|
|
50
|
+
// Use the most recently created block
|
|
51
|
+
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
|
|
52
|
+
return sorted[0][0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Exponential backoff
|
|
56
|
+
const delay = initialDelay * Math.pow(2, retry);
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
58
|
+
}
|
|
59
|
+
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
|
|
60
|
+
}
|
|
61
|
+
;
|
|
62
|
+
/**
|
|
63
|
+
* Helper function to create and verify block with improved error handling
|
|
64
|
+
*/
|
|
65
|
+
async createAndVerifyBlock(content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) {
|
|
66
|
+
try {
|
|
67
|
+
// Initial delay before any operations
|
|
68
|
+
if (!isRetry) {
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, initialDelay));
|
|
70
|
+
}
|
|
71
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
72
|
+
console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
|
|
73
|
+
// Create block using batchActions
|
|
74
|
+
const batchResult = await batchActions(this.graph, {
|
|
75
|
+
action: 'batch-actions',
|
|
76
|
+
actions: [{
|
|
77
|
+
action: 'create-block',
|
|
78
|
+
location: {
|
|
79
|
+
'parent-uid': parentUid,
|
|
80
|
+
order: 'last'
|
|
81
|
+
},
|
|
82
|
+
block: { string: content }
|
|
83
|
+
}]
|
|
84
|
+
});
|
|
85
|
+
if (!batchResult) {
|
|
86
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
|
|
87
|
+
}
|
|
88
|
+
// Wait with exponential backoff
|
|
89
|
+
const delay = initialDelay * Math.pow(2, retry);
|
|
90
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
91
|
+
try {
|
|
92
|
+
// Try to find the block using our improved findBlockWithRetry
|
|
93
|
+
return await this.findBlockWithRetry(parentUid, content);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
97
|
+
// console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
|
|
98
|
+
if (retry === maxRetries - 1)
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// If this is already a retry, throw the error
|
|
106
|
+
if (isRetry)
|
|
107
|
+
throw error;
|
|
108
|
+
// Otherwise, try one more time with a clean slate
|
|
109
|
+
// console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
|
|
111
|
+
return this.createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
;
|
|
115
|
+
/**
|
|
116
|
+
* Helper function to fetch a block and its children recursively
|
|
117
|
+
*/
|
|
118
|
+
async fetchBlockWithChildren(blockUid, level = 1) {
|
|
119
|
+
const query = `
|
|
120
|
+
[:find ?childUid ?childString ?childOrder
|
|
121
|
+
:in $ ?parentUid
|
|
122
|
+
:where
|
|
123
|
+
[?parentEntity :block/uid ?parentUid]
|
|
124
|
+
[?parentEntity :block/children ?childEntity] ; This ensures direct children
|
|
125
|
+
[?childEntity :block/uid ?childUid]
|
|
126
|
+
[?childEntity :block/string ?childString]
|
|
127
|
+
[?childEntity :block/order ?childOrder]]
|
|
128
|
+
`;
|
|
129
|
+
const blockQuery = `
|
|
130
|
+
[:find ?string
|
|
131
|
+
:in $ ?uid
|
|
132
|
+
:where
|
|
133
|
+
[?e :block/uid ?uid]
|
|
134
|
+
[?e :block/string ?string]]
|
|
135
|
+
`;
|
|
136
|
+
try {
|
|
137
|
+
const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
|
|
138
|
+
if (!blockStringResult || blockStringResult.length === 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const text = blockStringResult[0][0];
|
|
142
|
+
const childrenResults = await q(this.graph, query, [blockUid]);
|
|
143
|
+
const children = [];
|
|
144
|
+
if (childrenResults && childrenResults.length > 0) {
|
|
145
|
+
// Sort children by order
|
|
146
|
+
const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
|
|
147
|
+
for (const childResult of sortedChildren) {
|
|
148
|
+
const childUid = childResult[0];
|
|
149
|
+
const nestedChild = await this.fetchBlockWithChildren(childUid, level + 1);
|
|
150
|
+
if (nestedChild) {
|
|
151
|
+
children.push(nestedChild);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// The order of the root block is not available from this query, so we set it to 0
|
|
156
|
+
return { uid: blockUid, text, level, order: 0, children: children.length > 0 ? children : undefined };
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
;
|
|
163
|
+
/**
|
|
164
|
+
* Recursively fetches a nested structure of blocks under a given root block UID.
|
|
165
|
+
*/
|
|
166
|
+
async fetchNestedStructure(rootUid) {
|
|
167
|
+
const query = `[:find ?child-uid ?child-string ?child-order
|
|
168
|
+
:in $ ?parent-uid
|
|
169
|
+
:where
|
|
170
|
+
[?parent :block/uid ?parent-uid]
|
|
171
|
+
[?parent :block/children ?child]
|
|
172
|
+
[?child :block/uid ?child-uid]
|
|
173
|
+
[?child :block/string ?child-string]
|
|
174
|
+
[?child :block/order ?child-order]]`;
|
|
175
|
+
const directChildrenResult = await q(this.graph, query, [rootUid]);
|
|
176
|
+
if (directChildrenResult.length === 0) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const nestedBlocks = [];
|
|
180
|
+
for (const [childUid, childString, childOrder] of directChildrenResult) {
|
|
181
|
+
const children = await this.fetchNestedStructure(childUid);
|
|
182
|
+
nestedBlocks.push({
|
|
183
|
+
uid: childUid,
|
|
184
|
+
text: childString,
|
|
185
|
+
level: 0, // Level is not easily determined here, so we set it to 0
|
|
186
|
+
children: children,
|
|
187
|
+
order: childOrder
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return nestedBlocks.sort((a, b) => a.order - b.order);
|
|
9
191
|
}
|
|
10
192
|
/**
|
|
11
193
|
* Creates an outline structure on a Roam Research page, optionally under a specific block.
|
|
@@ -86,147 +268,6 @@ export class OutlineOperations {
|
|
|
86
268
|
};
|
|
87
269
|
// Get or create the target page
|
|
88
270
|
const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
|
|
89
|
-
// Helper function to find block with improved relationship checks
|
|
90
|
-
const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000) => {
|
|
91
|
-
// Try multiple query strategies
|
|
92
|
-
const queries = [
|
|
93
|
-
// Strategy 1: Direct page and string match
|
|
94
|
-
`[:find ?b-uid ?order
|
|
95
|
-
:where [?p :block/uid "${pageUid}"]
|
|
96
|
-
[?b :block/page ?p]
|
|
97
|
-
[?b :block/string "${blockString}"]
|
|
98
|
-
[?b :block/order ?order]
|
|
99
|
-
[?b :block/uid ?b-uid]]`,
|
|
100
|
-
// Strategy 2: Parent-child relationship
|
|
101
|
-
`[:find ?b-uid ?order
|
|
102
|
-
:where [?p :block/uid "${pageUid}"]
|
|
103
|
-
[?b :block/parents ?p]
|
|
104
|
-
[?b :block/string "${blockString}"]
|
|
105
|
-
[?b :block/order ?order]
|
|
106
|
-
[?b :block/uid ?b-uid]]`,
|
|
107
|
-
// Strategy 3: Broader page relationship
|
|
108
|
-
`[:find ?b-uid ?order
|
|
109
|
-
:where [?p :block/uid "${pageUid}"]
|
|
110
|
-
[?b :block/page ?page]
|
|
111
|
-
[?p :block/page ?page]
|
|
112
|
-
[?b :block/string "${blockString}"]
|
|
113
|
-
[?b :block/order ?order]
|
|
114
|
-
[?b :block/uid ?b-uid]]`
|
|
115
|
-
];
|
|
116
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
117
|
-
// Try each query strategy
|
|
118
|
-
for (const queryStr of queries) {
|
|
119
|
-
const blockResults = await q(this.graph, queryStr, []);
|
|
120
|
-
if (blockResults && blockResults.length > 0) {
|
|
121
|
-
// Use the most recently created block
|
|
122
|
-
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
|
|
123
|
-
return sorted[0][0];
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Exponential backoff
|
|
127
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
128
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
129
|
-
}
|
|
130
|
-
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
|
|
131
|
-
};
|
|
132
|
-
// Helper function to create and verify block with improved error handling
|
|
133
|
-
const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false) => {
|
|
134
|
-
try {
|
|
135
|
-
// Initial delay before any operations
|
|
136
|
-
if (!isRetry) {
|
|
137
|
-
await new Promise(resolve => setTimeout(resolve, initialDelay));
|
|
138
|
-
}
|
|
139
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
140
|
-
console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
|
|
141
|
-
// Create block using batchActions
|
|
142
|
-
const batchResult = await batchActions(this.graph, {
|
|
143
|
-
action: 'batch-actions',
|
|
144
|
-
actions: [{
|
|
145
|
-
action: 'create-block',
|
|
146
|
-
location: {
|
|
147
|
-
'parent-uid': parentUid,
|
|
148
|
-
order: 'last'
|
|
149
|
-
},
|
|
150
|
-
block: { string: content }
|
|
151
|
-
}]
|
|
152
|
-
});
|
|
153
|
-
if (!batchResult) {
|
|
154
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create block "${content}" via batch action`);
|
|
155
|
-
}
|
|
156
|
-
// Wait with exponential backoff
|
|
157
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
158
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
159
|
-
try {
|
|
160
|
-
// Try to find the block using our improved findBlockWithRetry
|
|
161
|
-
return await findBlockWithRetry(parentUid, content);
|
|
162
|
-
}
|
|
163
|
-
catch (error) {
|
|
164
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
165
|
-
// console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`); // Removed console.log
|
|
166
|
-
if (retry === maxRetries - 1)
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
// If this is already a retry, throw the error
|
|
174
|
-
if (isRetry)
|
|
175
|
-
throw error;
|
|
176
|
-
// Otherwise, try one more time with a clean slate
|
|
177
|
-
// console.log(`Retrying block creation for "${content}" with fresh attempt`); // Removed console.log
|
|
178
|
-
await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
|
|
179
|
-
return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true);
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
// Helper function to check if string is a valid Roam UID (9 characters)
|
|
183
|
-
const isValidUid = (str) => {
|
|
184
|
-
return typeof str === 'string' && str.length === 9;
|
|
185
|
-
};
|
|
186
|
-
// Helper function to fetch a block and its children recursively
|
|
187
|
-
const fetchBlockWithChildren = async (blockUid, level = 1) => {
|
|
188
|
-
const query = `
|
|
189
|
-
[:find ?childUid ?childString ?childOrder
|
|
190
|
-
:in $ ?parentUid
|
|
191
|
-
:where
|
|
192
|
-
[?parentEntity :block/uid ?parentUid]
|
|
193
|
-
[?parentEntity :block/children ?childEntity] ; This ensures direct children
|
|
194
|
-
[?childEntity :block/uid ?childUid]
|
|
195
|
-
[?childEntity :block/string ?childString]
|
|
196
|
-
[?childEntity :block/order ?childOrder]]
|
|
197
|
-
`;
|
|
198
|
-
const blockQuery = `
|
|
199
|
-
[:find ?string
|
|
200
|
-
:in $ ?uid
|
|
201
|
-
:where
|
|
202
|
-
[?e :block/uid ?uid]
|
|
203
|
-
[?e :block/string ?string]]
|
|
204
|
-
`;
|
|
205
|
-
try {
|
|
206
|
-
const blockStringResult = await q(this.graph, blockQuery, [blockUid]);
|
|
207
|
-
if (!blockStringResult || blockStringResult.length === 0) {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
const text = blockStringResult[0][0];
|
|
211
|
-
const childrenResults = await q(this.graph, query, [blockUid]);
|
|
212
|
-
const children = [];
|
|
213
|
-
if (childrenResults && childrenResults.length > 0) {
|
|
214
|
-
// Sort children by order
|
|
215
|
-
const sortedChildren = childrenResults.sort((a, b) => a[2] - b[2]);
|
|
216
|
-
for (const childResult of sortedChildren) {
|
|
217
|
-
const childUid = childResult[0];
|
|
218
|
-
const nestedChild = await fetchBlockWithChildren(childUid, level + 1);
|
|
219
|
-
if (nestedChild) {
|
|
220
|
-
children.push(nestedChild);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return { uid: blockUid, text, level, children: children.length > 0 ? children : undefined };
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
throw new McpError(ErrorCode.InternalError, `Failed to fetch block with children for UID "${blockUid}": ${error.message}`);
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
271
|
// Get or create the parent block
|
|
231
272
|
let targetParentUid;
|
|
232
273
|
if (!block_text_uid) {
|
|
@@ -234,7 +275,7 @@ export class OutlineOperations {
|
|
|
234
275
|
}
|
|
235
276
|
else {
|
|
236
277
|
try {
|
|
237
|
-
if (isValidUid(block_text_uid)) {
|
|
278
|
+
if (this.isValidUid(block_text_uid)) {
|
|
238
279
|
// First try to find block by UID
|
|
239
280
|
const uidQuery = `[:find ?uid
|
|
240
281
|
:where [?e :block/uid "${block_text_uid}"]
|
|
@@ -250,12 +291,12 @@ export class OutlineOperations {
|
|
|
250
291
|
}
|
|
251
292
|
else {
|
|
252
293
|
// Create header block and get its UID if not a valid UID
|
|
253
|
-
targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
|
|
294
|
+
targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid);
|
|
254
295
|
}
|
|
255
296
|
}
|
|
256
297
|
catch (error) {
|
|
257
298
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
258
|
-
throw new McpError(ErrorCode.InternalError, `Failed to ${isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
|
|
299
|
+
throw new McpError(ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}`);
|
|
259
300
|
}
|
|
260
301
|
}
|
|
261
302
|
// Initialize result variable
|
|
@@ -325,9 +366,9 @@ export class OutlineOperations {
|
|
|
325
366
|
for (const item of topLevelOutlineItems) {
|
|
326
367
|
try {
|
|
327
368
|
// Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
|
|
328
|
-
const foundUid = await findBlockWithRetry(targetParentUid, item.text);
|
|
369
|
+
const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
|
|
329
370
|
if (foundUid) {
|
|
330
|
-
const nestedBlock = await fetchBlockWithChildren(foundUid);
|
|
371
|
+
const nestedBlock = await this.fetchBlockWithChildren(foundUid);
|
|
331
372
|
if (nestedBlock) {
|
|
332
373
|
createdBlocks.push(nestedBlock);
|
|
333
374
|
}
|
|
@@ -346,7 +387,7 @@ export class OutlineOperations {
|
|
|
346
387
|
created_uids: createdBlocks
|
|
347
388
|
};
|
|
348
389
|
}
|
|
349
|
-
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = '
|
|
390
|
+
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'last') {
|
|
350
391
|
// First get the page UID
|
|
351
392
|
let targetPageUid = page_uid;
|
|
352
393
|
if (!targetPageUid && page_title) {
|
|
@@ -393,15 +434,20 @@ export class OutlineOperations {
|
|
|
393
434
|
throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
|
|
394
435
|
}
|
|
395
436
|
// Find block by exact string match within the page
|
|
396
|
-
const findBlockQuery = `[:find ?uid
|
|
397
|
-
:
|
|
437
|
+
const findBlockQuery = `[:find ?b-uid
|
|
438
|
+
:in $ ?page-uid ?block-string
|
|
439
|
+
:where [?p :block/uid ?page-uid]
|
|
398
440
|
[?b :block/page ?p]
|
|
399
|
-
[?b :block/string
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
441
|
+
[?b :block/string ?block-string]
|
|
442
|
+
[?b :block/uid ?b-uid]]`;
|
|
443
|
+
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, parent_string]);
|
|
444
|
+
if (blockResults && blockResults.length > 0) {
|
|
445
|
+
targetParentUid = blockResults[0][0];
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
// If parent_string block doesn't exist, create it
|
|
449
|
+
targetParentUid = await this.createAndVerifyBlock(parent_string, targetPageUid);
|
|
403
450
|
}
|
|
404
|
-
targetParentUid = blockResults[0][0];
|
|
405
451
|
}
|
|
406
452
|
// If no parent specified, use page as parent
|
|
407
453
|
if (!targetParentUid) {
|
|
@@ -423,8 +469,8 @@ export class OutlineOperations {
|
|
|
423
469
|
if (!result) {
|
|
424
470
|
throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
|
|
425
471
|
}
|
|
426
|
-
//
|
|
427
|
-
const createdUids =
|
|
472
|
+
// After successful batch action, get all nested UIDs under the parent
|
|
473
|
+
const createdUids = await this.fetchNestedStructure(targetParentUid);
|
|
428
474
|
return {
|
|
429
475
|
success: true,
|
|
430
476
|
page_uid: targetPageUid,
|
|
@@ -438,26 +484,42 @@ export class OutlineOperations {
|
|
|
438
484
|
action: 'create-block',
|
|
439
485
|
location: {
|
|
440
486
|
"parent-uid": targetParentUid,
|
|
441
|
-
order
|
|
487
|
+
"order": order
|
|
442
488
|
},
|
|
443
489
|
block: { string: content }
|
|
444
490
|
}];
|
|
445
491
|
try {
|
|
446
|
-
|
|
492
|
+
await batchActions(this.graph, {
|
|
447
493
|
action: 'batch-actions',
|
|
448
494
|
actions
|
|
449
495
|
});
|
|
450
|
-
if (!result) {
|
|
451
|
-
throw new McpError(ErrorCode.InternalError, 'Failed to create content block via batch action');
|
|
452
|
-
}
|
|
453
496
|
}
|
|
454
497
|
catch (error) {
|
|
455
498
|
throw new McpError(ErrorCode.InternalError, `Failed to create content block: ${error instanceof Error ? error.message : String(error)}`);
|
|
456
499
|
}
|
|
500
|
+
// For single-line content, we still need to fetch the UID and construct a NestedBlock
|
|
501
|
+
const createdUids = [];
|
|
502
|
+
try {
|
|
503
|
+
const foundUid = await this.findBlockWithRetry(targetParentUid, content);
|
|
504
|
+
if (foundUid) {
|
|
505
|
+
createdUids.push({
|
|
506
|
+
uid: foundUid,
|
|
507
|
+
text: content,
|
|
508
|
+
level: 0,
|
|
509
|
+
order: 0,
|
|
510
|
+
children: []
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
// Log warning but don't re-throw, as the block might be created, just not immediately verifiable
|
|
516
|
+
// console.warn(`Could not verify single block creation for "${content}": ${error.message}`);
|
|
517
|
+
}
|
|
457
518
|
return {
|
|
458
519
|
success: true,
|
|
459
520
|
page_uid: targetPageUid,
|
|
460
|
-
parent_uid: targetParentUid
|
|
521
|
+
parent_uid: targetParentUid,
|
|
522
|
+
created_uids: createdUids
|
|
461
523
|
};
|
|
462
524
|
}
|
|
463
525
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -40,7 +40,7 @@ export const toolSchemas = {
|
|
|
40
40
|
},
|
|
41
41
|
roam_create_page: {
|
|
42
42
|
name: 'roam_create_page',
|
|
43
|
-
description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
43
|
+
description: 'Create a new standalone page in Roam with optional content, including structured outlines, using explicit nesting levels and headings (H1-H3). This is the preferred method for creating a new page with an outline in a single step. Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
44
44
|
inputSchema: {
|
|
45
45
|
type: 'object',
|
|
46
46
|
properties: {
|
|
@@ -80,7 +80,7 @@ export const toolSchemas = {
|
|
|
80
80
|
},
|
|
81
81
|
roam_create_outline: {
|
|
82
82
|
name: 'roam_create_outline',
|
|
83
|
-
description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
83
|
+
description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. To create a new page with an outline, use the `roam_create_page` tool instead. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
84
84
|
inputSchema: {
|
|
85
85
|
type: 'object',
|
|
86
86
|
properties: {
|
|
@@ -129,7 +129,7 @@ export const toolSchemas = {
|
|
|
129
129
|
},
|
|
130
130
|
roam_import_markdown: {
|
|
131
131
|
name: 'roam_import_markdown',
|
|
132
|
-
description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
132
|
+
description: 'Import nested markdown content into Roam under a specific block. Can locate the parent block by UID (preferred) or by exact string match within a specific page. If a `parent_string` is provided and the block does not exist, it will be created. Returns a nested structure of the created blocks.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.',
|
|
133
133
|
inputSchema: {
|
|
134
134
|
type: 'object',
|
|
135
135
|
properties: {
|
|
@@ -151,7 +151,7 @@ export const toolSchemas = {
|
|
|
151
151
|
},
|
|
152
152
|
parent_string: {
|
|
153
153
|
type: 'string',
|
|
154
|
-
description: 'Optional: Exact string content of
|
|
154
|
+
description: 'Optional: Exact string content of an existing parent block to add content under (used if parent_uid is not provided; requires page_uid or page_title). If the block does not exist, it will be created.'
|
|
155
155
|
},
|
|
156
156
|
order: {
|
|
157
157
|
type: 'string',
|
|
@@ -165,7 +165,7 @@ export const toolSchemas = {
|
|
|
165
165
|
},
|
|
166
166
|
roam_search_for_tag: {
|
|
167
167
|
name: 'roam_search_for_tag',
|
|
168
|
-
description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby.
|
|
168
|
+
description: 'Search for blocks containing a specific tag and optionally filter by blocks that also contain another tag nearby or exclude blocks with a specific tag. This tool supports pagination via the `limit` and `offset` parameters. Use this tool to search for memories tagged with the MEMORIES_TAG.',
|
|
169
169
|
inputSchema: {
|
|
170
170
|
type: 'object',
|
|
171
171
|
properties: {
|
|
@@ -180,6 +180,21 @@ export const toolSchemas = {
|
|
|
180
180
|
near_tag: {
|
|
181
181
|
type: 'string',
|
|
182
182
|
description: 'Optional: Another tag to filter results by - will only return blocks where both tags appear',
|
|
183
|
+
},
|
|
184
|
+
case_sensitive: {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
description: 'Optional: Whether the search should be case-sensitive. If false, it will search for the provided tag, capitalized versions, and first word capitalized versions.',
|
|
187
|
+
default: false
|
|
188
|
+
},
|
|
189
|
+
limit: {
|
|
190
|
+
type: 'integer',
|
|
191
|
+
description: 'Optional: The maximum number of results to return. Defaults to 1. Use -1 for no limit.',
|
|
192
|
+
default: 1
|
|
193
|
+
},
|
|
194
|
+
offset: {
|
|
195
|
+
type: 'integer',
|
|
196
|
+
description: 'Optional: The number of results to skip before returning matches. Useful for pagination. Defaults to 0.',
|
|
197
|
+
default: 0
|
|
183
198
|
}
|
|
184
199
|
},
|
|
185
200
|
required: ['primary_tag']
|
|
@@ -273,7 +288,7 @@ export const toolSchemas = {
|
|
|
273
288
|
},
|
|
274
289
|
roam_search_by_text: {
|
|
275
290
|
name: 'roam_search_by_text',
|
|
276
|
-
description: 'Search for blocks containing specific text across all pages or within a specific page.',
|
|
291
|
+
description: 'Search for blocks containing specific text across all pages or within a specific page. This tool supports pagination via the `limit` and `offset` parameters.',
|
|
277
292
|
inputSchema: {
|
|
278
293
|
type: 'object',
|
|
279
294
|
properties: {
|
|
@@ -284,6 +299,21 @@ export const toolSchemas = {
|
|
|
284
299
|
page_title_uid: {
|
|
285
300
|
type: 'string',
|
|
286
301
|
description: 'Optional: Title or UID of the page to search in (UID is preferred for accuracy). If not provided, searches across all pages.'
|
|
302
|
+
},
|
|
303
|
+
case_sensitive: {
|
|
304
|
+
type: 'boolean',
|
|
305
|
+
description: 'Optional: Whether the search should be case-sensitive. If false, it will search for the provided text, capitalized versions, and first word capitalized versions.',
|
|
306
|
+
default: false
|
|
307
|
+
},
|
|
308
|
+
limit: {
|
|
309
|
+
type: 'integer',
|
|
310
|
+
description: 'Optional: The maximum number of results to return. Defaults to 1. Use -1 for no limit.',
|
|
311
|
+
default: 1
|
|
312
|
+
},
|
|
313
|
+
offset: {
|
|
314
|
+
type: 'integer',
|
|
315
|
+
description: 'Optional: The number of results to skip before returning matches. Useful for pagination. Defaults to 0.',
|
|
316
|
+
default: 0
|
|
287
317
|
}
|
|
288
318
|
},
|
|
289
319
|
required: ['text']
|
|
@@ -354,7 +384,7 @@ export const toolSchemas = {
|
|
|
354
384
|
},
|
|
355
385
|
roam_recall: {
|
|
356
386
|
name: 'roam_recall',
|
|
357
|
-
description: 'Retrieve all stored memories on page titled MEMORIES_TAG, or tagged block content with the same name. Returns a combined, deduplicated list of memories. Optionally filter blcoks with a
|
|
387
|
+
description: 'Retrieve all stored memories on page titled MEMORIES_TAG, or tagged block content with the same name. Returns a combined, deduplicated list of memories. Optionally filter blcoks with a specific tag and sort by creation date.',
|
|
358
388
|
inputSchema: {
|
|
359
389
|
type: 'object',
|
|
360
390
|
properties: {
|
|
@@ -373,13 +403,13 @@ export const toolSchemas = {
|
|
|
373
403
|
},
|
|
374
404
|
roam_datomic_query: {
|
|
375
405
|
name: 'roam_datomic_query',
|
|
376
|
-
description: 'Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. This provides direct access to Roam\'s query engine. Note: Roam graph is case-sensitive.\nList of some of Roam\'s data model Namespaces and Attributes: ancestor (descendants), attrs (lookup), block (children, heading, open, order, page, parents, props, refs, string, text-align, uid), children (view-type), create (email, time), descendant (ancestors), edit (email, seen-by, time), entity (attrs), log (id), node (title), page (uid, title), refs (text).\nPredicates (clojure.string/includes?, clojure.string/starts-with?, clojure.string/ends-with?, <, >, <=, >=, =, not=, !=).\nAggregates (distinct, count, sum, max, min, avg, limit).\nTips: Use :block/parents for all ancestor levels, :block/children for direct descendants only; combine clojure.string for complex matching, use distinct to deduplicate, leverage Pull patterns for hierarchies, handle case-sensitivity carefully, and chain ancestry rules for multi-level queries.',
|
|
406
|
+
description: 'Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. This provides direct access to Roam\'s query engine. Note: Roam graph is case-sensitive.\n\n__Optimal Use Cases for `roam_datomic_query`:__\n- __Regex Search:__ Use for scenarios requiring regex, as Datalog does not natively support full regular expressions. It can fetch broader results for client-side post-processing.\n- __Highly Complex Boolean Logic:__ Ideal for intricate combinations of "AND", "OR", and "NOT" conditions across multiple terms or attributes.\n- __Arbitrary Sorting Criteria:__ The go-to for highly customized sorting needs beyond default options.\n- __Proximity Search:__ For advanced search capabilities involving proximity, which are difficult to implement efficiently with simpler tools.\n\nList of some of Roam\'s data model Namespaces and Attributes: ancestor (descendants), attrs (lookup), block (children, heading, open, order, page, parents, props, refs, string, text-align, uid), children (view-type), create (email, time), descendant (ancestors), edit (email, seen-by, time), entity (attrs), log (id), node (title), page (uid, title), refs (text).\nPredicates (clojure.string/includes?, clojure.string/starts-with?, clojure.string/ends-with?, <, >, <=, >=, =, not=, !=).\nAggregates (distinct, count, sum, max, min, avg, limit).\nTips: Use :block/parents for all ancestor levels, :block/children for direct descendants only; combine clojure.string for complex matching, use distinct to deduplicate, leverage Pull patterns for hierarchies, handle case-sensitivity carefully, and chain ancestry rules for multi-level queries.',
|
|
377
407
|
inputSchema: {
|
|
378
408
|
type: 'object',
|
|
379
409
|
properties: {
|
|
380
410
|
query: {
|
|
381
411
|
type: 'string',
|
|
382
|
-
description: 'The Datomic query to execute (in Datalog syntax)'
|
|
412
|
+
description: 'The Datomic query to execute (in Datalog syntax). Example: `[:find ?block-string :where [?block :block/string ?block-string] (or [(clojure.string/includes? ?block-string "hypnosis")] [(clojure.string/includes? ?block-string "trance")] [(clojure.string/includes? ?block-string "suggestion")]) :limit 25]`'
|
|
383
413
|
},
|
|
384
414
|
inputs: {
|
|
385
415
|
type: 'array',
|
|
@@ -445,10 +475,7 @@ export const toolSchemas = {
|
|
|
445
475
|
description: 'The UID of the parent block or page.'
|
|
446
476
|
},
|
|
447
477
|
"order": {
|
|
448
|
-
|
|
449
|
-
{ type: 'integer', description: 'Zero-indexed position.' },
|
|
450
|
-
{ type: 'string', enum: ['first', 'last'], description: 'Position keyword.' }
|
|
451
|
-
],
|
|
478
|
+
type: ['integer', 'string'],
|
|
452
479
|
description: 'The position of the block under its parent (e.g., 0, 1, 2) or a keyword ("first", "last").'
|
|
453
480
|
}
|
|
454
481
|
}
|
|
@@ -463,7 +490,7 @@ export const toolSchemas = {
|
|
|
463
490
|
},
|
|
464
491
|
roam_fetch_block_with_children: {
|
|
465
492
|
name: 'roam_fetch_block_with_children',
|
|
466
|
-
description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth.',
|
|
493
|
+
description: 'Fetch a block by its UID along with its hierarchical children down to a specified depth. Returns a nested object structure containing the block\'s UID, text, order, and an array of its children.',
|
|
467
494
|
inputSchema: {
|
|
468
495
|
type: 'object',
|
|
469
496
|
properties: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roam-research-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for Roam Research API integration",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"build"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"build": "tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
|
|
31
|
+
"build": "echo \"Using custom instructions: .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md\" && tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js",
|
|
32
32
|
"clean": "rm -rf build",
|
|
33
33
|
"watch": "tsc --watch",
|
|
34
34
|
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
|