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.
Files changed (51) hide show
  1. package/README.md +175 -669
  2. package/build/Roam_Markdown_Cheatsheet.md +24 -4
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +2 -0
  6. package/build/cli/commands/get.js +401 -14
  7. package/build/cli/commands/refs.js +2 -0
  8. package/build/cli/commands/save.js +56 -1
  9. package/build/cli/commands/search.js +45 -0
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/utils/graph.js +6 -2
  12. package/build/cli/utils/output.js +28 -5
  13. package/build/cli/utils/sort-group.js +110 -0
  14. package/build/config/graph-registry.js +31 -13
  15. package/build/config/graph-registry.test.js +42 -5
  16. package/build/markdown-utils.js +114 -4
  17. package/build/markdown-utils.test.js +125 -0
  18. package/build/query/generator.js +330 -0
  19. package/build/query/index.js +149 -0
  20. package/build/query/parser.js +319 -0
  21. package/build/query/parser.test.js +389 -0
  22. package/build/query/types.js +4 -0
  23. package/build/search/ancestor-rule.js +14 -0
  24. package/build/search/block-ref-search.js +1 -5
  25. package/build/search/hierarchy-search.js +5 -12
  26. package/build/search/index.js +1 -0
  27. package/build/search/status-search.js +10 -9
  28. package/build/search/tag-search.js +8 -24
  29. package/build/search/text-search.js +70 -27
  30. package/build/search/types.js +13 -0
  31. package/build/search/utils.js +71 -2
  32. package/build/server/roam-server.js +2 -1
  33. package/build/shared/index.js +2 -0
  34. package/build/shared/page-validator.js +233 -0
  35. package/build/shared/page-validator.test.js +128 -0
  36. package/build/shared/staged-batch.js +144 -0
  37. package/build/tools/helpers/batch-utils.js +57 -0
  38. package/build/tools/helpers/page-resolution.js +136 -0
  39. package/build/tools/helpers/refs.js +68 -0
  40. package/build/tools/operations/batch.js +75 -3
  41. package/build/tools/operations/block-retrieval.js +15 -4
  42. package/build/tools/operations/block-retrieval.test.js +87 -0
  43. package/build/tools/operations/blocks.js +1 -288
  44. package/build/tools/operations/memory.js +29 -91
  45. package/build/tools/operations/outline.js +38 -156
  46. package/build/tools/operations/pages.js +169 -122
  47. package/build/tools/operations/todos.js +5 -37
  48. package/build/tools/schemas.js +14 -8
  49. package/build/tools/tool-handlers.js +2 -2
  50. package/build/utils/helpers.js +27 -0
  51. package/package.json +1 -1
@@ -91,6 +91,8 @@ Examples:
91
91
 
92
92
  # Block references
93
93
  roam refs "((abc123def))" # Blocks embedding this block
94
+
95
+ JSON output fields: uid, content, page
94
96
  `)
95
97
  .action(async (identifier, options) => {
96
98
  try {
@@ -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
- contentBlocks = flattenNodes(nodes);
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.write_key;
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);
@@ -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?.write_key) {
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 "${config.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, index) => {
70
- const pageInfo = result.page_title ? ` (${result.page_title})` : '';
71
- output += `[${index + 1}] ${result.block_uid}${pageInfo}\n`;
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 += `- [${status === 'DONE' ? 'x' : ' '}] ${cleanContent} (${item.block_uid})\n`;
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 for non-default graphs via write_key confirmation
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 write_key is configured: must provide matching write_key
88
- * - If no write_key configured: writes are allowed
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 no write_key is configured, allow writes
101
- if (!config.write_key) {
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
- return providedWriteKey === config.write_key;
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: "${config.write_key}" to proceed.`);
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.write_key;
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', write_key: 'confirm' },
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 (requires `write_key: "confirm"`) |');
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', write_key: 'secret' },
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 (requires `write_key: "secret"`) |');
63
+ expect(markdown).toContain('| main | ✓ | Yes |');
27
64
  expect(markdown).toContain('| backup | | No |');
28
65
  });
29
66
  });