roam-research-mcp 2.4.3 → 2.13.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 +175 -669
- package/build/Roam_Markdown_Cheatsheet.md +24 -4
- package/build/cache/page-uid-cache.js +40 -2
- package/build/cli/batch/translator.js +1 -1
- package/build/cli/commands/batch.js +2 -0
- package/build/cli/commands/get.js +401 -14
- package/build/cli/commands/refs.js +2 -0
- package/build/cli/commands/save.js +56 -1
- package/build/cli/commands/search.js +45 -0
- package/build/cli/commands/status.js +3 -4
- package/build/cli/utils/graph.js +6 -2
- package/build/cli/utils/output.js +28 -5
- package/build/cli/utils/sort-group.js +110 -0
- package/build/config/graph-registry.js +31 -13
- package/build/config/graph-registry.test.js +42 -5
- package/build/markdown-utils.js +114 -4
- package/build/markdown-utils.test.js +125 -0
- package/build/query/generator.js +330 -0
- package/build/query/index.js +149 -0
- package/build/query/parser.js +319 -0
- package/build/query/parser.test.js +389 -0
- package/build/query/types.js +4 -0
- package/build/search/ancestor-rule.js +14 -0
- package/build/search/block-ref-search.js +1 -5
- package/build/search/hierarchy-search.js +5 -12
- package/build/search/index.js +1 -0
- package/build/search/status-search.js +10 -9
- package/build/search/tag-search.js +8 -24
- package/build/search/text-search.js +70 -27
- package/build/search/types.js +13 -0
- package/build/search/utils.js +71 -2
- package/build/server/roam-server.js +2 -1
- package/build/shared/index.js +2 -0
- package/build/shared/page-validator.js +233 -0
- package/build/shared/page-validator.test.js +128 -0
- package/build/shared/staged-batch.js +144 -0
- package/build/tools/helpers/batch-utils.js +57 -0
- package/build/tools/helpers/page-resolution.js +136 -0
- package/build/tools/helpers/refs.js +68 -0
- package/build/tools/operations/batch.js +75 -3
- package/build/tools/operations/block-retrieval.js +15 -4
- package/build/tools/operations/block-retrieval.test.js +87 -0
- package/build/tools/operations/blocks.js +1 -288
- package/build/tools/operations/memory.js +29 -91
- package/build/tools/operations/outline.js +38 -156
- package/build/tools/operations/pages.js +169 -122
- package/build/tools/operations/todos.js +5 -37
- package/build/tools/schemas.js +14 -8
- package/build/tools/tool-handlers.js +2 -2
- package/build/utils/helpers.js +27 -0
- package/package.json +1 -1
|
@@ -27,6 +27,55 @@ function flattenNodes(nodes, baseLevel = 1) {
|
|
|
27
27
|
}
|
|
28
28
|
return result;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Infer hierarchy from heading levels when all blocks are at the same level.
|
|
32
|
+
* This handles prose-style markdown where headings (# ## ###) define structure
|
|
33
|
+
* without explicit indentation.
|
|
34
|
+
*
|
|
35
|
+
* Example: "# Title\n## Chapter\nContent" becomes:
|
|
36
|
+
* - Title (level 1, H1)
|
|
37
|
+
* - Chapter (level 2, H2)
|
|
38
|
+
* - Content (level 3)
|
|
39
|
+
*/
|
|
40
|
+
function adjustLevelsForHeadingHierarchy(blocks) {
|
|
41
|
+
if (blocks.length === 0)
|
|
42
|
+
return blocks;
|
|
43
|
+
// Only apply heading-based adjustment when:
|
|
44
|
+
// 1. All blocks are at the same level (no indentation-based hierarchy)
|
|
45
|
+
// 2. There are headings present
|
|
46
|
+
const allSameLevel = blocks.every(b => b.level === blocks[0].level);
|
|
47
|
+
const hasHeadings = blocks.some(b => b.heading);
|
|
48
|
+
if (!allSameLevel || !hasHeadings) {
|
|
49
|
+
// Indentation-based hierarchy exists, preserve it
|
|
50
|
+
return blocks;
|
|
51
|
+
}
|
|
52
|
+
const result = [];
|
|
53
|
+
// Track heading stack: each entry is { headingLevel: 1|2|3, adjustedLevel: number }
|
|
54
|
+
const headingStack = [];
|
|
55
|
+
for (const block of blocks) {
|
|
56
|
+
if (block.heading) {
|
|
57
|
+
// Pop headings of same or lower priority (higher h-number)
|
|
58
|
+
while (headingStack.length > 0 &&
|
|
59
|
+
headingStack[headingStack.length - 1].headingLevel >= block.heading) {
|
|
60
|
+
headingStack.pop();
|
|
61
|
+
}
|
|
62
|
+
// New heading level is one deeper than parent heading (or 1 if no parent)
|
|
63
|
+
const adjustedLevel = headingStack.length > 0
|
|
64
|
+
? headingStack[headingStack.length - 1].adjustedLevel + 1
|
|
65
|
+
: 1;
|
|
66
|
+
headingStack.push({ headingLevel: block.heading, adjustedLevel });
|
|
67
|
+
result.push({ ...block, level: adjustedLevel });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Content: nest under current heading context
|
|
71
|
+
const adjustedLevel = headingStack.length > 0
|
|
72
|
+
? headingStack[headingStack.length - 1].adjustedLevel + 1
|
|
73
|
+
: 1;
|
|
74
|
+
result.push({ ...block, level: adjustedLevel });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
30
79
|
/**
|
|
31
80
|
* Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
|
|
32
81
|
*/
|
|
@@ -56,6 +105,8 @@ async function findOrCreatePage(graph, title) {
|
|
|
56
105
|
action: 'create-page',
|
|
57
106
|
page: { title }
|
|
58
107
|
});
|
|
108
|
+
// Small delay for new page to be fully available as parent in Roam
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
59
110
|
const results = await q(graph, findQuery, [title]);
|
|
60
111
|
if (!results || results.length === 0) {
|
|
61
112
|
throw new Error(`Could not find created page: ${title}`);
|
|
@@ -137,6 +188,7 @@ export function createSaveCommand() {
|
|
|
137
188
|
.option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
|
|
138
189
|
.option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
|
|
139
190
|
.option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
|
|
191
|
+
.option('--flatten', 'Disable heading hierarchy inference (all blocks at root level)')
|
|
140
192
|
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
141
193
|
.option('--write-key <key>', 'Write confirmation key (non-default graphs)')
|
|
142
194
|
.option('--debug', 'Show debug information')
|
|
@@ -258,7 +310,9 @@ JSON format (--json):
|
|
|
258
310
|
else if (isFile || content.includes('\n')) {
|
|
259
311
|
// Multi-line content: parse as markdown
|
|
260
312
|
const nodes = parseMarkdown(content);
|
|
261
|
-
|
|
313
|
+
const flattened = flattenNodes(nodes);
|
|
314
|
+
// Apply heading hierarchy unless --flatten is specified
|
|
315
|
+
contentBlocks = options.flatten ? flattened : adjustLevelsForHeadingHierarchy(flattened);
|
|
262
316
|
}
|
|
263
317
|
else {
|
|
264
318
|
// Single line text: detect heading syntax and strip hashes
|
|
@@ -298,6 +352,7 @@ JSON format (--json):
|
|
|
298
352
|
printDebug('Input', input || 'stdin');
|
|
299
353
|
printDebug('Is file', isFile);
|
|
300
354
|
printDebug('Is JSON', isJson);
|
|
355
|
+
printDebug('Flatten mode', options.flatten || false);
|
|
301
356
|
printDebug('Graph', options.graph || 'default');
|
|
302
357
|
printDebug('Content blocks', contentBlocks.length);
|
|
303
358
|
printDebug('Parent UID', parentUid || 'none');
|
|
@@ -42,12 +42,17 @@ export function createSearchCommand() {
|
|
|
42
42
|
.option('--inputs <json>', 'JSON array of inputs for Datalog query')
|
|
43
43
|
.option('--regex <pattern>', 'Client-side regex filter on Datalog results')
|
|
44
44
|
.option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)')
|
|
45
|
+
.option('--namespace <prefix>', 'Search for pages by namespace prefix (e.g., "Convention" finds "Convention/*")')
|
|
45
46
|
.addHelpText('after', `
|
|
46
47
|
Examples:
|
|
47
48
|
# Text search
|
|
48
49
|
roam search "meeting notes" # Find blocks containing text
|
|
49
50
|
roam search api integration # Multiple terms (AND logic)
|
|
50
51
|
|
|
52
|
+
# Namespace search (find pages by title prefix)
|
|
53
|
+
roam search --namespace Convention # Find all Convention/* pages
|
|
54
|
+
roam search --namespace "Convention/" # Same (trailing slash optional)
|
|
55
|
+
|
|
51
56
|
# Stdin search
|
|
52
57
|
echo "urgent project" | roam search # Pipe terms
|
|
53
58
|
roam get today | roam search TODO # Search within output
|
|
@@ -58,6 +63,15 @@ Examples:
|
|
|
58
63
|
|
|
59
64
|
# Datalog queries (advanced)
|
|
60
65
|
roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting"
|
|
66
|
+
|
|
67
|
+
# Chaining with jq
|
|
68
|
+
roam search TODO --json | jq '.[].block_uid'
|
|
69
|
+
|
|
70
|
+
Output format:
|
|
71
|
+
Markdown: Flat results with UIDs and content (no hierarchy).
|
|
72
|
+
JSON: [{ block_uid, content, page_title }] or [{ page_uid, page_title }] for namespace
|
|
73
|
+
|
|
74
|
+
Note: For hierarchical output with children, use 'roam get --tag/--text' instead.
|
|
61
75
|
`)
|
|
62
76
|
.action(async (terms, options) => {
|
|
63
77
|
try {
|
|
@@ -81,6 +95,37 @@ Examples:
|
|
|
81
95
|
printDebug('Options', options);
|
|
82
96
|
}
|
|
83
97
|
const searchOps = new SearchOperations(graph);
|
|
98
|
+
// Namespace search mode (search page titles by prefix)
|
|
99
|
+
if (options.namespace) {
|
|
100
|
+
const result = await searchOps.searchByText({
|
|
101
|
+
text: options.namespace,
|
|
102
|
+
scope: 'page_titles'
|
|
103
|
+
});
|
|
104
|
+
if (!result.success) {
|
|
105
|
+
exitWithError(result.message || 'Namespace search failed');
|
|
106
|
+
}
|
|
107
|
+
let matches = result.matches.slice(0, limit);
|
|
108
|
+
if (options.json) {
|
|
109
|
+
// For JSON output, return page_uid and page_title
|
|
110
|
+
const jsonMatches = matches.map(m => ({
|
|
111
|
+
page_uid: m.block_uid,
|
|
112
|
+
page_title: m.page_title
|
|
113
|
+
}));
|
|
114
|
+
console.log(JSON.stringify(jsonMatches, null, 2));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (matches.length === 0) {
|
|
118
|
+
console.log('No pages found.');
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.log(`Found ${result.matches.length} page(s)${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`);
|
|
122
|
+
for (const match of matches) {
|
|
123
|
+
console.log(`- ${match.page_title} (${match.block_uid})`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
84
129
|
// Datalog query mode (bypasses other search options)
|
|
85
130
|
if (options.query) {
|
|
86
131
|
// Parse inputs if provided
|
|
@@ -25,6 +25,8 @@ Examples:
|
|
|
25
25
|
|
|
26
26
|
# JSON output for scripting
|
|
27
27
|
roam status --json
|
|
28
|
+
|
|
29
|
+
JSON output fields: { version, graphs: [{ name, default, protected, connected?, error? }] }
|
|
28
30
|
`)
|
|
29
31
|
.action(async (options) => {
|
|
30
32
|
try {
|
|
@@ -34,15 +36,12 @@ Examples:
|
|
|
34
36
|
for (const key of graphKeys) {
|
|
35
37
|
const config = registry.getConfig(key);
|
|
36
38
|
const isDefault = key === registry.defaultKey;
|
|
37
|
-
const isProtected = !!config.
|
|
39
|
+
const isProtected = !!config.protected;
|
|
38
40
|
const status = {
|
|
39
41
|
name: key,
|
|
40
42
|
default: isDefault,
|
|
41
43
|
protected: isProtected,
|
|
42
44
|
};
|
|
43
|
-
if (isProtected) {
|
|
44
|
-
status.writeKey = config.write_key;
|
|
45
|
-
}
|
|
46
45
|
if (options.ping) {
|
|
47
46
|
try {
|
|
48
47
|
const graph = registry.getGraph(key);
|
package/build/cli/utils/graph.js
CHANGED
|
@@ -28,9 +28,13 @@ export function resolveGraph(options, isWriteOp = false) {
|
|
|
28
28
|
const graphKey = options.graph ?? reg.defaultKey;
|
|
29
29
|
if (!reg.isWriteAllowed(graphKey, options.writeKey)) {
|
|
30
30
|
const config = reg.getConfig(graphKey);
|
|
31
|
-
if (config?.
|
|
31
|
+
if (config?.protected) {
|
|
32
|
+
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
33
|
+
if (!systemWriteKey) {
|
|
34
|
+
throw new Error(`Write to protected graph "${graphKey}" failed: ROAM_SYSTEM_WRITE_KEY not configured.`);
|
|
35
|
+
}
|
|
32
36
|
throw new Error(`Write to "${graphKey}" graph requires --write-key confirmation.\n` +
|
|
33
|
-
`Use: --write-key "${
|
|
37
|
+
`Use: --write-key "${systemWriteKey}"`);
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
}
|
|
@@ -66,10 +66,9 @@ export function formatSearchResults(results, options) {
|
|
|
66
66
|
return 'No results found.';
|
|
67
67
|
}
|
|
68
68
|
let output = `Found ${results.length} result(s):\n\n`;
|
|
69
|
-
results.forEach((result
|
|
70
|
-
const pageInfo = result.page_title ? `
|
|
71
|
-
output += `[${
|
|
72
|
-
output += ` ${result.content}\n\n`;
|
|
69
|
+
results.forEach((result) => {
|
|
70
|
+
const pageInfo = result.page_title ? ` — [[${result.page_title}]]` : '';
|
|
71
|
+
output += `[${result.block_uid}] ${result.content}${pageInfo}\n`;
|
|
73
72
|
});
|
|
74
73
|
return output.trim();
|
|
75
74
|
}
|
|
@@ -102,7 +101,31 @@ export function formatTodoOutput(results, status, options) {
|
|
|
102
101
|
.replace(/\{\{TODO\}\}\s*/g, '')
|
|
103
102
|
.replace(/\{\{\[\[DONE\]\]\}\}\s*/g, '')
|
|
104
103
|
.replace(/\{\{DONE\}\}\s*/g, '');
|
|
105
|
-
output +=
|
|
104
|
+
output += `[${item.block_uid}] [${status === 'DONE' ? 'x' : ' '}] ${cleanContent}\n`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return output.trim();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Format grouped search results for output
|
|
111
|
+
*/
|
|
112
|
+
export function formatGroupedOutput(grouped, options) {
|
|
113
|
+
if (options.json) {
|
|
114
|
+
return JSON.stringify(grouped, null, 2);
|
|
115
|
+
}
|
|
116
|
+
const { groups, meta } = grouped;
|
|
117
|
+
const groupKeys = Object.keys(groups);
|
|
118
|
+
if (groupKeys.length === 0) {
|
|
119
|
+
return 'No results found.';
|
|
120
|
+
}
|
|
121
|
+
let output = `Found ${meta.total} item(s) in ${meta.groups_count} group(s):\n`;
|
|
122
|
+
for (const groupName of groupKeys) {
|
|
123
|
+
const items = groups[groupName];
|
|
124
|
+
output += `\n## ${groupName}\n`;
|
|
125
|
+
for (const item of items) {
|
|
126
|
+
// Format: [uid] content with optional page reference
|
|
127
|
+
const pageRef = item.page_title ? ` — [[${item.page_title}]]` : '';
|
|
128
|
+
output += `[${item.block_uid}] ${item.content}${pageRef}\n`;
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
return output.trim();
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort search results by a specified field and direction
|
|
3
|
+
*/
|
|
4
|
+
export function sortResults(matches, options) {
|
|
5
|
+
const { field, direction } = options;
|
|
6
|
+
const multiplier = direction === 'asc' ? 1 : -1;
|
|
7
|
+
return [...matches].sort((a, b) => {
|
|
8
|
+
let comparison = 0;
|
|
9
|
+
switch (field) {
|
|
10
|
+
case 'created':
|
|
11
|
+
comparison = (a.created || 0) - (b.created || 0);
|
|
12
|
+
break;
|
|
13
|
+
case 'modified':
|
|
14
|
+
comparison = (a.modified || 0) - (b.modified || 0);
|
|
15
|
+
break;
|
|
16
|
+
case 'page':
|
|
17
|
+
comparison = (a.page_title || '').localeCompare(b.page_title || '');
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return comparison * multiplier;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Group search results by page title
|
|
25
|
+
*/
|
|
26
|
+
export function groupByPage(matches) {
|
|
27
|
+
const groups = {};
|
|
28
|
+
for (const match of matches) {
|
|
29
|
+
const key = match.page_title || '(No Page)';
|
|
30
|
+
if (!groups[key]) {
|
|
31
|
+
groups[key] = [];
|
|
32
|
+
}
|
|
33
|
+
groups[key].push(match);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
groups,
|
|
37
|
+
meta: {
|
|
38
|
+
total: matches.length,
|
|
39
|
+
groups_count: Object.keys(groups).length
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Group search results by tag
|
|
45
|
+
* Matches are grouped by the most specific matching subtag of the search tag
|
|
46
|
+
* Each match appears only once, under its most specific matching tag
|
|
47
|
+
*/
|
|
48
|
+
export function groupByTag(matches, searchTag) {
|
|
49
|
+
const groups = {};
|
|
50
|
+
const normalizedSearchTag = normalizeTagName(searchTag);
|
|
51
|
+
for (const match of matches) {
|
|
52
|
+
const tags = match.tags || [];
|
|
53
|
+
// Find the most specific matching tag (longest path that starts with searchTag)
|
|
54
|
+
let bestTag = searchTag; // Default to the search tag itself
|
|
55
|
+
let bestLength = 0;
|
|
56
|
+
for (const tag of tags) {
|
|
57
|
+
const normalizedTag = normalizeTagName(tag);
|
|
58
|
+
// Check if this tag matches or is a subtag of the search tag
|
|
59
|
+
if (normalizedTag === normalizedSearchTag ||
|
|
60
|
+
normalizedTag.startsWith(normalizedSearchTag + '/')) {
|
|
61
|
+
if (normalizedTag.length > bestLength) {
|
|
62
|
+
bestTag = tag;
|
|
63
|
+
bestLength = normalizedTag.length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Group under the best matching tag
|
|
68
|
+
if (!groups[bestTag]) {
|
|
69
|
+
groups[bestTag] = [];
|
|
70
|
+
}
|
|
71
|
+
groups[bestTag].push(match);
|
|
72
|
+
}
|
|
73
|
+
// Sort groups by tag name for consistent output
|
|
74
|
+
const sortedGroups = {};
|
|
75
|
+
const sortedKeys = Object.keys(groups).sort();
|
|
76
|
+
for (const key of sortedKeys) {
|
|
77
|
+
sortedGroups[key] = groups[key];
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
groups: sortedGroups,
|
|
81
|
+
meta: {
|
|
82
|
+
total: matches.length,
|
|
83
|
+
groups_count: Object.keys(sortedGroups).length
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Group results by specified field
|
|
89
|
+
*/
|
|
90
|
+
export function groupResults(matches, options) {
|
|
91
|
+
switch (options.by) {
|
|
92
|
+
case 'page':
|
|
93
|
+
return groupByPage(matches);
|
|
94
|
+
case 'tag':
|
|
95
|
+
return groupByTag(matches, options.searchTag || '');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Normalize tag name for comparison (lowercase, trim whitespace)
|
|
100
|
+
*/
|
|
101
|
+
function normalizeTagName(tag) {
|
|
102
|
+
return tag.toLowerCase().trim();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Get default sort direction for a field
|
|
106
|
+
*/
|
|
107
|
+
export function getDefaultDirection(field) {
|
|
108
|
+
// Dates default to descending (newest first), alphabetical to ascending
|
|
109
|
+
return field === 'page' ? 'asc' : 'desc';
|
|
110
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Supports:
|
|
5
5
|
* - Multiple graph configurations via ROAM_GRAPHS env var
|
|
6
6
|
* - Backwards compatibility with single graph via ROAM_API_TOKEN/ROAM_GRAPH_NAME
|
|
7
|
-
* - Write protection
|
|
7
|
+
* - Write protection via protected: true flag + ROAM_SYSTEM_WRITE_KEY env var
|
|
8
8
|
* - Lazy graph initialization (connects only when first accessed)
|
|
9
9
|
*/
|
|
10
10
|
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
@@ -54,6 +54,21 @@ export class GraphRegistry {
|
|
|
54
54
|
getAvailableGraphs() {
|
|
55
55
|
return Array.from(this.configs.keys());
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Get the memories tag for a graph
|
|
59
|
+
* Priority: graph config > ROAM_MEMORIES_TAG env var > "Memories"
|
|
60
|
+
* Returns null if explicitly disabled (memoriesTag: false)
|
|
61
|
+
*/
|
|
62
|
+
getMemoriesTag(key) {
|
|
63
|
+
const resolvedKey = key ?? this.defaultKey;
|
|
64
|
+
const config = this.configs.get(resolvedKey);
|
|
65
|
+
// If explicitly disabled, return null
|
|
66
|
+
if (config?.memoriesTag === false) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Priority: per-graph config > env var > default
|
|
70
|
+
return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
|
|
71
|
+
}
|
|
57
72
|
/**
|
|
58
73
|
* Get an initialized Graph instance, creating it lazily if needed
|
|
59
74
|
* @param key - Graph key from config. Defaults to defaultKey if not specified.
|
|
@@ -84,8 +99,8 @@ export class GraphRegistry {
|
|
|
84
99
|
* Rules:
|
|
85
100
|
* - Writes to default graph are always allowed
|
|
86
101
|
* - Writes to non-default graphs require:
|
|
87
|
-
* - If
|
|
88
|
-
* - If
|
|
102
|
+
* - If protected: true, must provide matching ROAM_SYSTEM_WRITE_KEY
|
|
103
|
+
* - If not protected: writes are allowed
|
|
89
104
|
*/
|
|
90
105
|
isWriteAllowed(graphKey, providedWriteKey) {
|
|
91
106
|
const resolvedKey = graphKey ?? this.defaultKey;
|
|
@@ -97,12 +112,13 @@ export class GraphRegistry {
|
|
|
97
112
|
if (!config) {
|
|
98
113
|
return false; // Unknown graph
|
|
99
114
|
}
|
|
100
|
-
// If
|
|
101
|
-
if (!config.
|
|
115
|
+
// If graph is not protected, allow writes
|
|
116
|
+
if (!config.protected) {
|
|
102
117
|
return true;
|
|
103
118
|
}
|
|
104
|
-
// Check if provided key matches
|
|
105
|
-
|
|
119
|
+
// Check if provided key matches ROAM_SYSTEM_WRITE_KEY
|
|
120
|
+
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
121
|
+
return !!systemWriteKey && providedWriteKey === systemWriteKey;
|
|
106
122
|
}
|
|
107
123
|
/**
|
|
108
124
|
* Validate write access and return an informative error if denied
|
|
@@ -117,9 +133,13 @@ export class GraphRegistry {
|
|
|
117
133
|
if (!config) {
|
|
118
134
|
throw new McpError(ErrorCode.InvalidParams, `Unknown graph: "${resolvedKey}". Available graphs: ${this.getAvailableGraphs().join(', ')}`);
|
|
119
135
|
}
|
|
136
|
+
const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
|
|
137
|
+
if (!systemWriteKey) {
|
|
138
|
+
throw new McpError(ErrorCode.InvalidParams, `Write to protected graph "${resolvedKey}" failed: ROAM_SYSTEM_WRITE_KEY not configured.`);
|
|
139
|
+
}
|
|
120
140
|
// Provide informative error with the required key
|
|
121
141
|
throw new McpError(ErrorCode.InvalidParams, `Write to "${resolvedKey}" graph requires write_key confirmation.\n` +
|
|
122
|
-
`Provide write_key: "${
|
|
142
|
+
`Provide write_key: "${systemWriteKey}" to proceed.`);
|
|
123
143
|
}
|
|
124
144
|
}
|
|
125
145
|
/**
|
|
@@ -151,15 +171,13 @@ export class GraphRegistry {
|
|
|
151
171
|
for (const key of graphKeys) {
|
|
152
172
|
const config = this.configs.get(key);
|
|
153
173
|
const isDefault = key === this.defaultKey;
|
|
154
|
-
const isProtected = !!config.
|
|
174
|
+
const isProtected = !!config.protected;
|
|
155
175
|
const defaultCol = isDefault ? '✓' : '';
|
|
156
|
-
const protectedCol = isProtected
|
|
157
|
-
? `Yes (requires \`write_key: "${config.write_key}"\`)`
|
|
158
|
-
: 'No';
|
|
176
|
+
const protectedCol = isProtected ? 'Yes' : 'No';
|
|
159
177
|
lines.push(`| ${key} | ${defaultCol} | ${protectedCol} |`);
|
|
160
178
|
}
|
|
161
179
|
lines.push('');
|
|
162
|
-
lines.push('> **Note:** Write operations to protected graphs require the `write_key` parameter.');
|
|
180
|
+
lines.push('> **Note:** Write operations to protected graphs require the `write_key` parameter. The key will be shown in the error message if omitted.');
|
|
163
181
|
lines.push('');
|
|
164
182
|
lines.push('---');
|
|
165
183
|
lines.push('');
|
|
@@ -1,6 +1,43 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
2
|
import { GraphRegistry } from './graph-registry.js';
|
|
3
3
|
describe('GraphRegistry', () => {
|
|
4
|
+
describe('getMemoriesTag', () => {
|
|
5
|
+
const originalEnv = process.env.ROAM_MEMORIES_TAG;
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
// Restore original env
|
|
8
|
+
if (originalEnv !== undefined) {
|
|
9
|
+
process.env.ROAM_MEMORIES_TAG = originalEnv;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
delete process.env.ROAM_MEMORIES_TAG;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
it('returns per-graph memoriesTag when configured', () => {
|
|
16
|
+
const registry = new GraphRegistry({
|
|
17
|
+
personal: { token: 't1', graph: 'g1', memoriesTag: '#PersonalMemories' },
|
|
18
|
+
system: { token: 't2', graph: 'g2', memoriesTag: '#[[PAI/Memories]]' },
|
|
19
|
+
}, 'personal');
|
|
20
|
+
expect(registry.getMemoriesTag('personal')).toBe('#PersonalMemories');
|
|
21
|
+
expect(registry.getMemoriesTag('system')).toBe('#[[PAI/Memories]]');
|
|
22
|
+
});
|
|
23
|
+
it('falls back to ROAM_MEMORIES_TAG env var when not configured per-graph', () => {
|
|
24
|
+
process.env.ROAM_MEMORIES_TAG = '#EnvMemories';
|
|
25
|
+
const registry = new GraphRegistry({ default: { token: 't', graph: 'g' } }, 'default');
|
|
26
|
+
expect(registry.getMemoriesTag()).toBe('#EnvMemories');
|
|
27
|
+
});
|
|
28
|
+
it('falls back to "Memories" when neither per-graph nor env configured', () => {
|
|
29
|
+
delete process.env.ROAM_MEMORIES_TAG;
|
|
30
|
+
const registry = new GraphRegistry({ default: { token: 't', graph: 'g' } }, 'default');
|
|
31
|
+
expect(registry.getMemoriesTag()).toBe('Memories');
|
|
32
|
+
});
|
|
33
|
+
it('uses default graph when key not specified', () => {
|
|
34
|
+
const registry = new GraphRegistry({
|
|
35
|
+
personal: { token: 't1', graph: 'g1', memoriesTag: '#Personal' },
|
|
36
|
+
work: { token: 't2', graph: 'g2', memoriesTag: '#Work' },
|
|
37
|
+
}, 'personal');
|
|
38
|
+
expect(registry.getMemoriesTag()).toBe('#Personal');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
4
41
|
describe('getGraphInfoMarkdown', () => {
|
|
5
42
|
it('returns empty string for single-graph mode with default key', () => {
|
|
6
43
|
const registry = new GraphRegistry({ default: { token: 'token', graph: 'graph' } }, 'default');
|
|
@@ -9,21 +46,21 @@ describe('GraphRegistry', () => {
|
|
|
9
46
|
it('returns markdown table for multi-graph mode', () => {
|
|
10
47
|
const registry = new GraphRegistry({
|
|
11
48
|
personal: { token: 'token1', graph: 'personal-graph' },
|
|
12
|
-
work: { token: 'token2', graph: 'work-graph',
|
|
49
|
+
work: { token: 'token2', graph: 'work-graph', protected: true },
|
|
13
50
|
}, 'personal');
|
|
14
51
|
const markdown = registry.getGraphInfoMarkdown();
|
|
15
52
|
expect(markdown).toContain('## Available Graphs');
|
|
16
53
|
expect(markdown).toContain('| personal | ✓ | No |');
|
|
17
|
-
expect(markdown).toContain('| work | | Yes
|
|
54
|
+
expect(markdown).toContain('| work | | Yes |');
|
|
18
55
|
expect(markdown).toContain('> **Note:** Write operations to protected graphs');
|
|
19
56
|
});
|
|
20
57
|
it('shows write protection for default graph if configured', () => {
|
|
21
58
|
const registry = new GraphRegistry({
|
|
22
|
-
main: { token: 'token1', graph: 'main-graph',
|
|
59
|
+
main: { token: 'token1', graph: 'main-graph', protected: true },
|
|
23
60
|
backup: { token: 'token2', graph: 'backup-graph' },
|
|
24
61
|
}, 'main');
|
|
25
62
|
const markdown = registry.getGraphInfoMarkdown();
|
|
26
|
-
expect(markdown).toContain('| main | ✓ | Yes
|
|
63
|
+
expect(markdown).toContain('| main | ✓ | Yes |');
|
|
27
64
|
expect(markdown).toContain('| backup | | No |');
|
|
28
65
|
});
|
|
29
66
|
});
|