UNPKG

roam-research-mcp

Version:
165 lines (164 loc) • 8.46 kB
import { q } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { generateBlockUid } from '../../markdown-utils.js'; import { collectHiddenUids, isHiddenBlockString } from '../helpers/hidden.js'; import { ANCESTOR_RULE } from '../../search/ancestor-rule.js'; import { sanitizeTagName } from '../../utils/helpers.js'; import { resolveRefs } from '../helpers/refs.js'; import { getOrCreateTodayPage } from '../helpers/page-resolution.js'; import { executeBatch } from '../helpers/batch-utils.js'; import { SearchOperations } from './search/index.js'; export class MemoryOperations { constructor(graph, memoriesTag = 'Memories') { this.graph = graph; this.searchOps = new SearchOperations(graph); this.memoriesTag = memoriesTag; } async remember(memory, categories, heading, parent_uid, include_memories_tag = true) { // Get or create today's daily page const pageUid = await getOrCreateTodayPage(this.graph); // Determine parent block for the memory let targetParentUid; if (parent_uid) { // Use provided parent_uid directly targetParentUid = parent_uid; } else if (heading) { // Search for heading block on today's page, create if not found const headingQuery = `[:find ?uid :in $ ?page-uid ?text :where [?page :block/uid ?page-uid] [?page :block/children ?block] [?block :block/string ?text] [?block :block/uid ?uid]]`; const headingResults = await q(this.graph, headingQuery, [pageUid, heading]); if (headingResults && headingResults.length > 0) { targetParentUid = headingResults[0][0]; } else { // Create the heading block const headingBlockUid = generateBlockUid(); await executeBatch(this.graph, [{ action: 'create-block', location: { 'parent-uid': pageUid, order: 'last' }, block: { uid: headingBlockUid, string: heading } }], 'create heading block'); targetParentUid = headingBlockUid; } } else { // Default: use daily page root targetParentUid = pageUid; } // Get memories tag (use instance property) and format as Roam tag // If memoriesTag is null (disabled for this graph), treat as if include_memories_tag is false const memoriesTagFormatted = (include_memories_tag && this.memoriesTag) ? (this.memoriesTag.includes(' ') || this.memoriesTag.includes('/') ? `#[[${this.memoriesTag}]]` : `#${this.memoriesTag}`) : undefined; // Format categories as Roam tags if provided (strip any pre-existing # or [[ ]] wrappers) const categoryTags = categories?.map(raw => { const cat = sanitizeTagName(raw); return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`; }) ?? []; // Create block with memory, then all tags together at the end const tags = memoriesTagFormatted ? [...categoryTags, memoriesTagFormatted] : categoryTags; const blockContent = [memory, ...tags].join(' ').trim(); // Pre-generate UID so we can return it const blockUid = generateBlockUid(); const actions = [{ action: 'create-block', location: { 'parent-uid': targetParentUid, order: 'last' }, block: { uid: blockUid, string: blockContent } }]; await executeBatch(this.graph, actions, 'create memory block'); return { success: true, block_uid: blockUid, parent_uid: targetParentUid }; } async recall(sort_by = 'newest', filter_tag) { // If memories tag is disabled for this graph, return empty if (!this.memoriesTag) { return { success: true, memories: [] }; } // Extract the tag text, removing any formatting const tagText = this.memoriesTag .replace(/^#/, '') // Remove leading # .replace(/^\[\[/, '').replace(/\]\]$/, ''); // Remove [[ and ]] try { // Query to find all blocks on the page // ?uid is selected so hidden blocks can be filtered by the UID closure // below — a text check alone would miss blocks nested under a tagged // parent, which carry no tag of their own. const pageQuery = `[:find ?string ?time ?uid :in $ % ?title :where [?page :node/title ?title] [?block :block/string ?string] [?block :block/uid ?uid] [?block :create/time ?time] (ancestor ?block ?page)]`; // Execute query const pageResults = await q(this.graph, pageQuery, [ANCESTOR_RULE, tagText]); // Withhold anything the user tagged #.rm-hide / #.rm-private. The tagged // half of recall (searchForTag below) is already filtered by // SearchOperations; this is the page half. const hiddenUids = await collectHiddenUids(this.graph); // Process page blocks with sorting let pageMemories = pageResults .filter(([content, , uid]) => !hiddenUids.has(uid) && !isHiddenBlockString(content)) .sort(([_, aTime], [__, bTime]) => sort_by === 'newest' ? bTime - aTime : aTime - bTime) .map(([content]) => content); // Get tagged blocks from across the graph const tagResults = await this.searchOps.searchForTag(tagText); // Process tagged blocks with sorting let taggedMemories = tagResults.matches .sort((a, b) => { const aTime = a.block_uid ? parseInt(a.block_uid.split('-')[0], 16) : 0; const bTime = b.block_uid ? parseInt(b.block_uid.split('-')[0], 16) : 0; return sort_by === 'newest' ? bTime - aTime : aTime - bTime; }) .map(match => match.content); // Resolve any block references in both sets const resolvedPageMemories = await Promise.all(pageMemories.map(async (content) => resolveRefs(this.graph, content))); const resolvedTaggedMemories = await Promise.all(taggedMemories.map(async (content) => resolveRefs(this.graph, content))); // Combine both sets and remove duplicates while preserving order let uniqueMemories = [ ...resolvedPageMemories, ...resolvedTaggedMemories ].filter((memory, index, self) => self.indexOf(memory) === index); // Format filter tag with exact Roam tag syntax const filterTagFormatted = filter_tag ? (filter_tag.includes(' ') ? `#[[${filter_tag}]]` : `#${filter_tag}`) : null; // Filter by exact tag match if provided if (filterTagFormatted) { uniqueMemories = uniqueMemories.filter(memory => memory.includes(filterTagFormatted)); } // Format memories tag for removal and clean up memories tag const memoriesTagFormatted = tagText.includes(' ') || tagText.includes('/') ? `#[[${tagText}]]` : `#${tagText}`; uniqueMemories = uniqueMemories.map(memory => memory.replace(memoriesTagFormatted, '').trim()); // return { // success: true, // memories: [ // `memoriesTag = ${memoriesTag}`, // `filter_tag = ${filter_tag}`, // `filterTagFormatted = ${filterTagFormatted}`, // `memoriesTagFormatted = ${memoriesTagFormatted}`, // ] // } return { success: true, memories: uniqueMemories }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to recall memories: ${error.message}`); } } }