roam-research-mcp 0.20.0 → 0.22.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 +3 -2
- package/build/search/text-search.js +3 -4
- package/build/server/roam-server.js +12 -10
- package/build/tools/helpers/refs.js +47 -0
- package/build/tools/helpers/text.js +6 -0
- package/build/tools/operations/blocks.js +269 -0
- package/build/tools/operations/memory.js +102 -0
- package/build/tools/operations/outline.js +347 -0
- package/build/tools/operations/pages.js +213 -0
- package/build/tools/operations/search/handlers.js +56 -0
- package/build/tools/operations/search/index.js +95 -0
- package/build/tools/operations/search/types.js +1 -0
- package/build/tools/operations/search.js +285 -0
- package/build/tools/operations/todos.js +82 -0
- package/build/tools/schemas.js +14 -23
- package/build/tools/tool-handlers.js +57 -1196
- package/build/tools/types/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,13 +44,14 @@ The server provides fourteen powerful tools for interacting with Roam Research:
|
|
|
44
44
|
12. `roam_search_by_date`: Search for blocks and pages based on creation or modification dates
|
|
45
45
|
13. `roam_search_for_tag`: Search for blocks containing specific tags with optional filtering by nearby tags
|
|
46
46
|
14. `roam_remember`: Store and categorize memories or information with automatic tagging
|
|
47
|
+
15. `roam_recall`: Recall memories of blocks marked with tag MEMORIES_TAG (see below) or blocks on page title of the same name.
|
|
47
48
|
|
|
48
49
|
## Setup
|
|
49
50
|
|
|
50
|
-
1. Create a Roam Research API token:
|
|
51
|
+
1. Create a [Roam Research API token](https://x.com/RoamResearch/status/1789358175474327881):
|
|
51
52
|
|
|
52
53
|
- Go to your graph settings
|
|
53
|
-
- Navigate to the "API tokens" section
|
|
54
|
+
- Navigate to the "API tokens" section (Settings > "Graph" tab > "API Tokens" section and click on the "+ New API Token" button)
|
|
54
55
|
- Create a new token
|
|
55
56
|
|
|
56
57
|
2. Configure the environment variables:
|
|
@@ -8,7 +8,7 @@ export class TextSearchHandler extends BaseSearchHandler {
|
|
|
8
8
|
this.params = params;
|
|
9
9
|
}
|
|
10
10
|
async execute() {
|
|
11
|
-
const { text, page_title_uid
|
|
11
|
+
const { text, page_title_uid } = this.params;
|
|
12
12
|
// Get target page UID if provided for scoped search
|
|
13
13
|
let targetPageUid;
|
|
14
14
|
if (page_title_uid) {
|
|
@@ -19,14 +19,13 @@ export class TextSearchHandler extends BaseSearchHandler {
|
|
|
19
19
|
:in $ ?search-text
|
|
20
20
|
:where
|
|
21
21
|
[?b :block/string ?block-str]
|
|
22
|
-
[(clojure.string/includes?
|
|
23
|
-
${case_sensitive ? '?search-text' : '(clojure.string/lower-case ?search-text)'})]
|
|
22
|
+
[(clojure.string/includes? ?block-str ?search-text)]
|
|
24
23
|
[?b :block/uid ?block-uid]
|
|
25
24
|
[?b :block/page ?p]
|
|
26
25
|
[?p :node/title ?page-title]]`;
|
|
27
26
|
const queryParams = [text];
|
|
28
27
|
const results = await q(this.graph, queryStr, queryParams);
|
|
29
|
-
const searchDescription = `containing "${text}"
|
|
28
|
+
const searchDescription = `containing "${text}"`;
|
|
30
29
|
return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
|
|
31
30
|
}
|
|
32
31
|
}
|
|
@@ -5,7 +5,6 @@ import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
|
5
5
|
import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
|
|
6
6
|
import { toolSchemas } from '../tools/schemas.js';
|
|
7
7
|
import { ToolHandlers } from '../tools/tool-handlers.js';
|
|
8
|
-
import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../search/index.js';
|
|
9
8
|
export class RoamServer {
|
|
10
9
|
server;
|
|
11
10
|
toolHandlers;
|
|
@@ -23,6 +22,7 @@ export class RoamServer {
|
|
|
23
22
|
capabilities: {
|
|
24
23
|
tools: {
|
|
25
24
|
roam_remember: {},
|
|
25
|
+
roam_recall: {},
|
|
26
26
|
roam_add_todo: {},
|
|
27
27
|
roam_fetch_page_by_title: {},
|
|
28
28
|
roam_create_page: {},
|
|
@@ -108,9 +108,8 @@ export class RoamServer {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
case 'roam_search_for_tag': {
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const result = await handler.execute();
|
|
111
|
+
const { primary_tag, page_title_uid, near_tag } = request.params.arguments;
|
|
112
|
+
const result = await this.toolHandlers.searchForTag(primary_tag, page_title_uid, near_tag);
|
|
114
113
|
return {
|
|
115
114
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
116
115
|
};
|
|
@@ -124,16 +123,14 @@ export class RoamServer {
|
|
|
124
123
|
}
|
|
125
124
|
case 'roam_search_block_refs': {
|
|
126
125
|
const params = request.params.arguments;
|
|
127
|
-
const
|
|
128
|
-
const result = await handler.execute();
|
|
126
|
+
const result = await this.toolHandlers.searchBlockRefs(params);
|
|
129
127
|
return {
|
|
130
128
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
131
129
|
};
|
|
132
130
|
}
|
|
133
131
|
case 'roam_search_hierarchy': {
|
|
134
132
|
const params = request.params.arguments;
|
|
135
|
-
const
|
|
136
|
-
const result = await handler.execute();
|
|
133
|
+
const result = await this.toolHandlers.searchHierarchy(params);
|
|
137
134
|
return {
|
|
138
135
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
139
136
|
};
|
|
@@ -146,8 +143,7 @@ export class RoamServer {
|
|
|
146
143
|
}
|
|
147
144
|
case 'roam_search_by_text': {
|
|
148
145
|
const params = request.params.arguments;
|
|
149
|
-
const
|
|
150
|
-
const result = await handler.execute();
|
|
146
|
+
const result = await this.toolHandlers.searchByText(params);
|
|
151
147
|
return {
|
|
152
148
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
153
149
|
};
|
|
@@ -175,6 +171,12 @@ export class RoamServer {
|
|
|
175
171
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
176
172
|
};
|
|
177
173
|
}
|
|
174
|
+
case 'roam_recall': {
|
|
175
|
+
const result = await this.toolHandlers.recall();
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
178
180
|
case 'roam_update_blocks': {
|
|
179
181
|
const { updates } = request.params.arguments;
|
|
180
182
|
const result = await this.toolHandlers.updateBlocks(updates);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Collects all referenced block UIDs from text
|
|
4
|
+
*/
|
|
5
|
+
export const collectRefs = (text, depth = 0, refs = new Set()) => {
|
|
6
|
+
if (depth >= 4)
|
|
7
|
+
return refs; // Max recursion depth
|
|
8
|
+
const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
|
|
9
|
+
let match;
|
|
10
|
+
while ((match = refRegex.exec(text)) !== null) {
|
|
11
|
+
const [_, uid] = match;
|
|
12
|
+
refs.add(uid);
|
|
13
|
+
}
|
|
14
|
+
return refs;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Resolves block references in text by replacing them with their content
|
|
18
|
+
*/
|
|
19
|
+
export const resolveRefs = async (graph, text, depth = 0) => {
|
|
20
|
+
if (depth >= 4)
|
|
21
|
+
return text; // Max recursion depth
|
|
22
|
+
const refs = collectRefs(text, depth);
|
|
23
|
+
if (refs.size === 0)
|
|
24
|
+
return text;
|
|
25
|
+
// Get referenced block contents
|
|
26
|
+
const refQuery = `[:find ?uid ?string
|
|
27
|
+
:in $ [?uid ...]
|
|
28
|
+
:where [?b :block/uid ?uid]
|
|
29
|
+
[?b :block/string ?string]]`;
|
|
30
|
+
const refResults = await q(graph, refQuery, [Array.from(refs)]);
|
|
31
|
+
// Create lookup map of uid -> string
|
|
32
|
+
const refMap = new Map();
|
|
33
|
+
refResults.forEach(([uid, string]) => {
|
|
34
|
+
refMap.set(uid, string);
|
|
35
|
+
});
|
|
36
|
+
// Replace references with their content
|
|
37
|
+
let resolvedText = text;
|
|
38
|
+
for (const uid of refs) {
|
|
39
|
+
const refContent = refMap.get(uid);
|
|
40
|
+
if (refContent) {
|
|
41
|
+
// Recursively resolve nested references
|
|
42
|
+
const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
|
|
43
|
+
resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resolvedText;
|
|
47
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { q, createBlock as createRoamBlock, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
|
+
import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
5
|
+
export class BlockOperations {
|
|
6
|
+
graph;
|
|
7
|
+
constructor(graph) {
|
|
8
|
+
this.graph = graph;
|
|
9
|
+
}
|
|
10
|
+
async createBlock(content, page_uid, title) {
|
|
11
|
+
// If page_uid provided, use it directly
|
|
12
|
+
let targetPageUid = page_uid;
|
|
13
|
+
// If no page_uid but title provided, search for page by title
|
|
14
|
+
if (!targetPageUid && title) {
|
|
15
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
16
|
+
const findResults = await q(this.graph, findQuery, [title]);
|
|
17
|
+
if (findResults && findResults.length > 0) {
|
|
18
|
+
targetPageUid = findResults[0][0];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
// Create page with provided title if it doesn't exist
|
|
22
|
+
try {
|
|
23
|
+
await createPage(this.graph, {
|
|
24
|
+
action: 'create-page',
|
|
25
|
+
page: { title }
|
|
26
|
+
});
|
|
27
|
+
// Get the new page's UID
|
|
28
|
+
const results = await q(this.graph, findQuery, [title]);
|
|
29
|
+
if (!results || results.length === 0) {
|
|
30
|
+
throw new Error('Could not find created page');
|
|
31
|
+
}
|
|
32
|
+
targetPageUid = results[0][0];
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// If neither page_uid nor title provided, use today's date page
|
|
40
|
+
if (!targetPageUid) {
|
|
41
|
+
const today = new Date();
|
|
42
|
+
const dateStr = formatRoamDate(today);
|
|
43
|
+
// Try to find today's page
|
|
44
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
45
|
+
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
46
|
+
if (findResults && findResults.length > 0) {
|
|
47
|
+
targetPageUid = findResults[0][0];
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Create today's page if it doesn't exist
|
|
51
|
+
try {
|
|
52
|
+
await createPage(this.graph, {
|
|
53
|
+
action: 'create-page',
|
|
54
|
+
page: { title: dateStr }
|
|
55
|
+
});
|
|
56
|
+
// Get the new page's UID
|
|
57
|
+
const results = await q(this.graph, findQuery, [dateStr]);
|
|
58
|
+
if (!results || results.length === 0) {
|
|
59
|
+
throw new Error('Could not find created today\'s page');
|
|
60
|
+
}
|
|
61
|
+
targetPageUid = results[0][0];
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
// If the content has multiple lines or is a table, use nested import
|
|
70
|
+
if (content.includes('\n')) {
|
|
71
|
+
// Parse and import the nested content
|
|
72
|
+
const convertedContent = convertToRoamMarkdown(content);
|
|
73
|
+
const nodes = parseMarkdown(convertedContent);
|
|
74
|
+
const actions = convertToRoamActions(nodes, targetPageUid, 'last');
|
|
75
|
+
// Execute batch actions to create the nested structure
|
|
76
|
+
const result = await batchActions(this.graph, {
|
|
77
|
+
action: 'batch-actions',
|
|
78
|
+
actions
|
|
79
|
+
});
|
|
80
|
+
if (!result) {
|
|
81
|
+
throw new Error('Failed to create nested blocks');
|
|
82
|
+
}
|
|
83
|
+
const blockUid = result.created_uids?.[0];
|
|
84
|
+
return {
|
|
85
|
+
success: true,
|
|
86
|
+
block_uid: blockUid,
|
|
87
|
+
parent_uid: targetPageUid
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// For non-table content, create a simple block
|
|
92
|
+
await createRoamBlock(this.graph, {
|
|
93
|
+
action: 'create-block',
|
|
94
|
+
location: {
|
|
95
|
+
"parent-uid": targetPageUid,
|
|
96
|
+
"order": "last"
|
|
97
|
+
},
|
|
98
|
+
block: { string: content }
|
|
99
|
+
});
|
|
100
|
+
// Get the block's UID
|
|
101
|
+
const findBlockQuery = `[:find ?uid
|
|
102
|
+
:in $ ?parent ?string
|
|
103
|
+
:where [?b :block/uid ?uid]
|
|
104
|
+
[?b :block/string ?string]
|
|
105
|
+
[?b :block/parents ?p]
|
|
106
|
+
[?p :block/uid ?parent]]`;
|
|
107
|
+
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
|
|
108
|
+
if (!blockResults || blockResults.length === 0) {
|
|
109
|
+
throw new Error('Could not find created block');
|
|
110
|
+
}
|
|
111
|
+
const blockUid = blockResults[0][0];
|
|
112
|
+
return {
|
|
113
|
+
success: true,
|
|
114
|
+
block_uid: blockUid,
|
|
115
|
+
parent_uid: targetPageUid
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create block: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async updateBlock(block_uid, content, transform) {
|
|
124
|
+
if (!block_uid) {
|
|
125
|
+
throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
|
|
126
|
+
}
|
|
127
|
+
// Get current block content
|
|
128
|
+
const blockQuery = `[:find ?string .
|
|
129
|
+
:where [?b :block/uid "${block_uid}"]
|
|
130
|
+
[?b :block/string ?string]]`;
|
|
131
|
+
const result = await q(this.graph, blockQuery, []);
|
|
132
|
+
if (result === null || result === undefined) {
|
|
133
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
134
|
+
}
|
|
135
|
+
const currentContent = String(result);
|
|
136
|
+
if (currentContent === null || currentContent === undefined) {
|
|
137
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
138
|
+
}
|
|
139
|
+
// Determine new content
|
|
140
|
+
let newContent;
|
|
141
|
+
if (content) {
|
|
142
|
+
newContent = content;
|
|
143
|
+
}
|
|
144
|
+
else if (transform) {
|
|
145
|
+
newContent = transform(currentContent);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await updateRoamBlock(this.graph, {
|
|
152
|
+
action: 'update-block',
|
|
153
|
+
block: {
|
|
154
|
+
uid: block_uid,
|
|
155
|
+
string: newContent
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
success: true,
|
|
160
|
+
content: newContent
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async updateBlocks(updates) {
|
|
168
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
169
|
+
throw new McpError(ErrorCode.InvalidRequest, 'updates must be a non-empty array');
|
|
170
|
+
}
|
|
171
|
+
// Validate each update has required fields
|
|
172
|
+
updates.forEach((update, index) => {
|
|
173
|
+
if (!update.block_uid) {
|
|
174
|
+
throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
|
|
175
|
+
}
|
|
176
|
+
if (!update.content && !update.transform) {
|
|
177
|
+
throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// Get current content for all blocks
|
|
181
|
+
const blockUids = updates.map(u => u.block_uid);
|
|
182
|
+
const blockQuery = `[:find ?uid ?string
|
|
183
|
+
:in $ [?uid ...]
|
|
184
|
+
:where [?b :block/uid ?uid]
|
|
185
|
+
[?b :block/string ?string]]`;
|
|
186
|
+
const blockResults = await q(this.graph, blockQuery, [blockUids]);
|
|
187
|
+
// Create map of uid -> current content
|
|
188
|
+
const contentMap = new Map();
|
|
189
|
+
blockResults.forEach(([uid, string]) => {
|
|
190
|
+
contentMap.set(uid, string);
|
|
191
|
+
});
|
|
192
|
+
// Prepare batch actions
|
|
193
|
+
const actions = [];
|
|
194
|
+
const results = [];
|
|
195
|
+
for (const update of updates) {
|
|
196
|
+
try {
|
|
197
|
+
const currentContent = contentMap.get(update.block_uid);
|
|
198
|
+
if (!currentContent) {
|
|
199
|
+
results.push({
|
|
200
|
+
block_uid: update.block_uid,
|
|
201
|
+
content: '',
|
|
202
|
+
success: false,
|
|
203
|
+
error: `Block with UID "${update.block_uid}" not found`
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Determine new content
|
|
208
|
+
let newContent;
|
|
209
|
+
if (update.content) {
|
|
210
|
+
newContent = update.content;
|
|
211
|
+
}
|
|
212
|
+
else if (update.transform) {
|
|
213
|
+
const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
|
|
214
|
+
newContent = currentContent.replace(regex, update.transform.replace);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// This shouldn't happen due to earlier validation
|
|
218
|
+
throw new Error('Invalid update configuration');
|
|
219
|
+
}
|
|
220
|
+
// Add to batch actions
|
|
221
|
+
actions.push({
|
|
222
|
+
action: 'update-block',
|
|
223
|
+
block: {
|
|
224
|
+
uid: update.block_uid,
|
|
225
|
+
string: newContent
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
results.push({
|
|
229
|
+
block_uid: update.block_uid,
|
|
230
|
+
content: newContent,
|
|
231
|
+
success: true
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
results.push({
|
|
236
|
+
block_uid: update.block_uid,
|
|
237
|
+
content: contentMap.get(update.block_uid) || '',
|
|
238
|
+
success: false,
|
|
239
|
+
error: error.message
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Execute batch update if we have any valid actions
|
|
244
|
+
if (actions.length > 0) {
|
|
245
|
+
try {
|
|
246
|
+
const batchResult = await batchActions(this.graph, {
|
|
247
|
+
action: 'batch-actions',
|
|
248
|
+
actions
|
|
249
|
+
});
|
|
250
|
+
if (!batchResult) {
|
|
251
|
+
throw new Error('Batch update failed');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
// Mark all previously successful results as failed
|
|
256
|
+
results.forEach(result => {
|
|
257
|
+
if (result.success) {
|
|
258
|
+
result.success = false;
|
|
259
|
+
result.error = `Batch update failed: ${error.message}`;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
success: results.every(r => r.success),
|
|
266
|
+
results
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { q, createBlock, createPage } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
|
+
import { resolveRefs } from '../helpers/refs.js';
|
|
5
|
+
import { SearchOperations } from './search/index.js';
|
|
6
|
+
export class MemoryOperations {
|
|
7
|
+
graph;
|
|
8
|
+
searchOps;
|
|
9
|
+
constructor(graph) {
|
|
10
|
+
this.graph = graph;
|
|
11
|
+
this.searchOps = new SearchOperations(graph);
|
|
12
|
+
}
|
|
13
|
+
async remember(memory, categories) {
|
|
14
|
+
// Get today's date
|
|
15
|
+
const today = new Date();
|
|
16
|
+
const dateStr = formatRoamDate(today);
|
|
17
|
+
// Try to find today's page
|
|
18
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
19
|
+
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
20
|
+
let pageUid;
|
|
21
|
+
if (findResults && findResults.length > 0) {
|
|
22
|
+
pageUid = findResults[0][0];
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Create today's page if it doesn't exist
|
|
26
|
+
try {
|
|
27
|
+
await createPage(this.graph, {
|
|
28
|
+
action: 'create-page',
|
|
29
|
+
page: { title: dateStr }
|
|
30
|
+
});
|
|
31
|
+
// Get the new page's UID
|
|
32
|
+
const results = await q(this.graph, findQuery, [dateStr]);
|
|
33
|
+
if (!results || results.length === 0) {
|
|
34
|
+
throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
|
|
35
|
+
}
|
|
36
|
+
pageUid = results[0][0];
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Get memories tag from environment
|
|
43
|
+
const memoriesTag = process.env.MEMORIES_TAG;
|
|
44
|
+
if (!memoriesTag) {
|
|
45
|
+
throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
|
|
46
|
+
}
|
|
47
|
+
// Format categories as Roam tags if provided
|
|
48
|
+
const categoryTags = categories?.map(cat => {
|
|
49
|
+
// Handle multi-word categories
|
|
50
|
+
return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
|
|
51
|
+
}).join(' ') || '';
|
|
52
|
+
// Create block with memory, memories tag, and optional categories
|
|
53
|
+
const blockContent = `${memoriesTag} ${memory} ${categoryTags}`.trim();
|
|
54
|
+
try {
|
|
55
|
+
await createBlock(this.graph, {
|
|
56
|
+
action: 'create-block',
|
|
57
|
+
location: {
|
|
58
|
+
"parent-uid": pageUid,
|
|
59
|
+
"order": "last"
|
|
60
|
+
},
|
|
61
|
+
block: { string: blockContent }
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new McpError(ErrorCode.InternalError, 'Failed to create memory block');
|
|
66
|
+
}
|
|
67
|
+
return { success: true };
|
|
68
|
+
}
|
|
69
|
+
async recall() {
|
|
70
|
+
// Get memories tag from environment
|
|
71
|
+
const memoriesTag = process.env.MEMORIES_TAG;
|
|
72
|
+
if (!memoriesTag) {
|
|
73
|
+
throw new McpError(ErrorCode.InternalError, 'MEMORIES_TAG environment variable not set');
|
|
74
|
+
}
|
|
75
|
+
// Extract the tag text, removing any formatting
|
|
76
|
+
const tagText = memoriesTag
|
|
77
|
+
.replace(/^#/, '') // Remove leading #
|
|
78
|
+
.replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]]
|
|
79
|
+
// Get results from tag search
|
|
80
|
+
const tagResults = await this.searchOps.searchForTag(tagText);
|
|
81
|
+
// Get blocks from the memories page
|
|
82
|
+
const pageQuery = `[:find ?string
|
|
83
|
+
:in $ ?title
|
|
84
|
+
:where [?p :node/title ?title]
|
|
85
|
+
[?b :block/page ?p]
|
|
86
|
+
[?b :block/string ?string]]`;
|
|
87
|
+
const pageResults = await q(this.graph, pageQuery, [tagText]);
|
|
88
|
+
// Combine both sets of results and remove the memories tag
|
|
89
|
+
const allMemories = [
|
|
90
|
+
...tagResults.matches.map((match) => match.content),
|
|
91
|
+
...pageResults.map(([content]) => content)
|
|
92
|
+
].map(content => content.replace(`${memoriesTag} `, ''));
|
|
93
|
+
// Resolve any block references in the combined memories
|
|
94
|
+
const resolvedMemories = await Promise.all(allMemories.map(async (content) => resolveRefs(this.graph, content)));
|
|
95
|
+
// Remove duplicates
|
|
96
|
+
const uniqueMemories = [...new Set(resolvedMemories)];
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
memories: uniqueMemories
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|