UNPKG

roam-research-mcp

Version:
871 lines (870 loc) • 44.6 kB
import { q, createPage as createRoamPage, updatePage } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { ANCESTOR_RULE } from '../../search/ancestor-rule.js'; import { getPageUid as getPageUidHelper } from '../helpers/page-resolution.js'; import { resolveRefs, resolveBlockRefs } from '../helpers/refs.js'; import { executeBatch, executeBatchSafe } from '../helpers/batch-utils.js'; import { pruneHiddenBlocks } from '../helpers/hidden.js'; import { convertToRoamMarkdown, generateBlockUid } from '../../markdown-utils.js'; import { executeStagedBatch } from '../../shared/staged-batch.js'; import { escapeBlockString, unescapeBlockString, needsNewlineEscaping, ESCAPED_NEWLINES_MARKER, SOFT_BREAK_SENTINEL } from '../../shared/block-escaping.js'; import { pageUidCache } from '../../cache/page-uid-cache.js'; import { buildTableActions } from './table.js'; import { BatchOperations } from './batch.js'; import { parseExistingBlocks, pruneHiddenExistingBlocks, countHiddenExistingBlocks, flattenExistingBlocks, markdownToBlocks, diffBlockTrees, generateBatchActions, getDiffStats, isDiffEmpty, summarizeActions, } from '../../diff/index.js'; import { ednString } from '../../shared/datalog.js'; // Helper to get ordinal suffix for dates function getOrdinalSuffix(day) { if (day > 3 && day < 21) return 'th'; // Handles 11th, 12th, 13th switch (day % 10) { case 1: return 'st'; case 2: return 'nd'; case 3: return 'rd'; default: return 'th'; } } export class PageOperations { constructor(graph) { this.graph = graph; this.batchOps = new BatchOperations(graph); } async findPagesModifiedToday(limit = 50, offset = 0, sort_order = 'desc') { // Get start of today const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); try { // Query for pages modified today, including modification time for sorting let query = `[:find ?title ?time :in $ ?start_of_day % :where [?page :node/title ?title] (ancestor ?block ?page) [?block :edit/time ?time] [(> ?time ?start_of_day)]]`; if (limit !== -1) { query += ` :limit ${limit}`; } if (offset > 0) { query += ` :offset ${offset}`; } const results = await q(this.graph, query, [startOfDay.getTime(), ANCESTOR_RULE]); if (!results || results.length === 0) { return { success: true, pages: [], message: 'No pages have been modified today' }; } // Sort results by modification time results.sort((a, b) => { if (sort_order === 'desc') { return b[1] - a[1]; // Newest first } else { return a[1] - b[1]; // Oldest first } }); // Extract unique page titles from sorted results const uniquePages = Array.from(new Set(results.map(([title]) => title))); return { success: true, pages: uniquePages, message: `Found ${uniquePages.length} page(s) modified today` }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to find modified pages: ${error.message}`); } } async createPage(title, content) { // Ensure title is properly formatted const pageTitle = String(title).trim(); let pageUid; // Check cache first to avoid unnecessary query const cachedUid = pageUidCache.get(pageTitle); if (cachedUid) { pageUid = cachedUid; } else { // First try to find if the page exists const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const findResults = await q(this.graph, findQuery, [pageTitle]); if (findResults && findResults.length > 0) { // Page exists, use its UID and cache it pageUid = findResults[0][0]; pageUidCache.set(pageTitle, pageUid); } else { // Create new page by adding a page reference to today's daily page // This leverages Roam's native behavior: [[Page Title]] creates the page instantly try { // Get today's daily page title const today = new Date(); const day = today.getDate(); const month = today.toLocaleString('en-US', { month: 'long' }); const year = today.getFullYear(); const dailyPageTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; // Get or create daily page UID const dailyPageQuery = `[:find ?uid . :where [?e :node/title ${ednString(dailyPageTitle)}] [?e :block/uid ?uid]]`; let dailyPageUid = await q(this.graph, dailyPageQuery, []); if (!dailyPageUid) { // Create daily page first await createRoamPage(this.graph, { action: 'create-page', page: { title: dailyPageTitle } }); // Small delay for daily page creation to be available as parent await new Promise(resolve => setTimeout(resolve, 400)); dailyPageUid = await q(this.graph, dailyPageQuery, []); } if (!dailyPageUid) { throw new Error(`Could not resolve daily page "${dailyPageTitle}"`); } // Create block with page reference - this instantly creates the target page await executeBatch(this.graph, [{ action: 'create-block', location: { 'parent-uid': dailyPageUid, order: 'last' }, block: { string: `Created page: [[${pageTitle}]]` } }], 'create page reference block'); // Now query for the page UID - should exist immediately const results = await q(this.graph, findQuery, [pageTitle]); if (!results || results.length === 0) { throw new Error(`Could not find created page "${pageTitle}"`); } pageUid = results[0][0]; // Cache the newly created page pageUidCache.onPageCreated(pageTitle, pageUid); // Small delay for new page to be fully available as parent in Roam // (fixes "Parent entity doesn't exist" error when adding content immediately) await new Promise(resolve => setTimeout(resolve, 400)); } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`); } } } // If content is provided, create blocks using batch operations if (content && content.length > 0) { try { // Idempotency check: If page already has content, skip adding more // This prevents duplicate content when the tool is called twice const existingBlocksQuery = `[:find (count ?b) . :where [?p :block/uid ${ednString(pageUid)}] [?p :block/children ?b]]`; const existingBlockCountResult = await q(this.graph, existingBlocksQuery, []); const existingBlockCount = typeof existingBlockCountResult === 'number' ? existingBlockCountResult : 0; if (existingBlockCount && existingBlockCount > 0) { // Page already has content - this might be a duplicate call // Return success without adding duplicate content return { success: true, page_uid: pageUid }; } // Process content items in order, tracking position for correct placement // Tables and text blocks are interleaved at their original positions // Tables can be nested under text blocks based on their level let currentOrder = 0; let pendingTextItems = []; // Track last block UID at each level for nesting tables const levelToLastUid = {}; // Helper to assign UIDs to nodes and track level mapping const assignUidsToNodes = (nodes) => { return nodes.map(node => { const uid = generateBlockUid(); levelToLastUid[node.level] = uid; return { ...node, uid, children: assignUidsToNodes(node.children) }; }); }; // Helper to build batch actions from nodes with pre-assigned UIDs const buildActionsFromNodes = (nodes, parentUid, startOrder) => { const actions = []; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; actions.push({ action: 'create-block', location: { 'parent-uid': parentUid, order: startOrder + i }, block: { uid: node.uid, string: node.content, ...(node.heading_level && { heading: node.heading_level }), ...(node.children_view_type && { 'children-view-type': node.children_view_type }) } }); if (node.children.length > 0) { actions.push(...buildActionsFromNodes(node.children, node.uid, 0)); } } return actions; }; // Helper to flush pending text items as a batch const flushTextItems = async (startOrder) => { if (pendingTextItems.length === 0) return startOrder; // Filter out empty blocks const nonEmptyContent = pendingTextItems.filter(block => block.text && block.text.trim().length > 0); if (nonEmptyContent.length === 0) { pendingTextItems = []; return startOrder; } // Normalize levels to prevent gaps after filtering const normalizedContent = []; for (let i = 0; i < nonEmptyContent.length; i++) { const block = nonEmptyContent[i]; if (i === 0) { normalizedContent.push({ ...block, level: 1 }); } else { const prevLevel = normalizedContent[i - 1].level; const maxAllowedLevel = prevLevel + 1; normalizedContent.push({ ...block, level: Math.min(block.level, maxAllowedLevel) }); } } // Convert to node format with level info const nodes = normalizedContent.map(block => ({ content: convertToRoamMarkdown(block.text.replace(/^#+\s+/, '')), level: block.level, ...(block.heading && { heading_level: block.heading }), ...(block.numbered_children && { children_view_type: 'numbered' }), children: [] })); // Create hierarchical structure based on levels const rootNodes = []; const levelMap = {}; for (const node of nodes) { if (node.level === 1) { rootNodes.push(node); levelMap[1] = node; } else { const parentLevel = node.level - 1; const parent = levelMap[parentLevel]; if (!parent) { throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`); } parent.children.push(node); levelMap[node.level] = node; } } // Assign UIDs to all nodes and track level->UID mapping const nodesWithUids = assignUidsToNodes(rootNodes); // Build batch actions from nodes with UIDs const textActions = buildActionsFromNodes(nodesWithUids, pageUid, startOrder); if (textActions.length > 0) { // Use staged batch to ensure parent blocks exist before children await executeStagedBatch(this.graph, textActions, { context: 'page content creation', delayBetweenLevels: 100 }); } // Return the next order position (number of root-level blocks added) const nextOrder = startOrder + rootNodes.length; pendingTextItems = []; return nextOrder; }; // Process content items in order for (let i = 0; i < content.length; i++) { const item = content[i]; if (item.type === 'table') { // Flush any pending text items first currentOrder = await flushTextItems(currentOrder); // Process table - determine parent based on level const tableItem = item; const tableLevel = tableItem.level || 1; let tableParentUid = pageUid; let tableOrder = currentOrder; if (tableLevel > 1) { // Nested table - find parent block at level-1 const parentLevel = tableLevel - 1; if (levelToLastUid[parentLevel]) { tableParentUid = levelToLastUid[parentLevel]; tableOrder = 'last'; // Append to parent's children } // If no parent found, fall back to page level } const tableActions = buildTableActions({ parent_uid: tableParentUid, headers: tableItem.headers, rows: tableItem.rows, order: tableOrder }); const tableResult = await this.batchOps.processBatch(tableActions); if (!tableResult.success) { throw new Error(`Failed to create table: ${typeof tableResult.error === 'string' ? tableResult.error : tableResult.error?.message}`); } // Only increment top-level order for level 1 tables if (tableLevel === 1) { currentOrder++; } } else { // Accumulate text items pendingTextItems.push(item); } } // Flush any remaining text items await flushTextItems(currentOrder); } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`); } } // Add a "Processed: [[date]]" block as the last block of the newly created page const today = new Date(); const day = today.getDate(); const month = today.toLocaleString('en-US', { month: 'long' }); const year = today.getFullYear(); const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; await executeBatchSafe(this.graph, [{ action: 'create-block', location: { 'parent-uid': pageUid, order: 'last' }, block: { string: `Processed: [[${formattedTodayTitle}]]` } }], 'add Processed block'); return { success: true, page_uid: pageUid }; } /** * Get the UID for a page by its title. * Tries different case variations (original, capitalized, lowercase). * Returns null if not found. */ async getPageUid(title) { return getPageUidHelper(this.graph, title); } /** * Fetch a page by its UID. * Returns the page title and blocks, or null if not found. */ async fetchPageByUid(uid) { if (!uid) { return null; } // First get the page title const titleQuery = `[:find ?title . :where [?e :block/uid ${ednString(uid)}] [?e :node/title ?title]]`; const title = await q(this.graph, titleQuery, []); if (!title) { return null; } // Get all blocks under this page using ancestor rule const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid :in $ % ?page-uid :where [?page :block/uid ?page-uid] [?block :block/string ?block-str] [?block :block/uid ?block-uid] [?block :block/order ?order] (ancestor ?block ?page) [?parent :block/children ?block] [?parent :block/uid ?parent-uid]]`; const blocks = await q(this.graph, blocksQuery, [ANCESTOR_RULE, uid]); if (!blocks || blocks.length === 0) { return { title, blocks: [] }; } // Get heading information for blocks that have it const headingsQuery = `[:find ?block-uid ?heading :in $ % ?page-uid :where [?page :block/uid ?page-uid] [?block :block/uid ?block-uid] [?block :block/heading ?heading] (ancestor ?block ?page)]`; const headings = await q(this.graph, headingsQuery, [ANCESTOR_RULE, uid]); // Create a map of block UIDs to heading levels const headingMap = new Map(); if (headings) { for (const [blockUid, heading] of headings) { headingMap.set(blockUid, heading); } } // Create a map of all blocks const blockMap = new Map(); const rootBlocks = []; // First pass: Create all block objects for (const [blockUid, blockStr, order, parentUid] of blocks) { const block = { uid: blockUid, string: blockStr, order: order, heading: headingMap.get(blockUid) || null, children: [] }; blockMap.set(blockUid, block); // If no parent or parent is the page itself, it's a root block if (!parentUid || parentUid === uid) { rootBlocks.push(block); } } // Second pass: Build parent-child relationships for (const [blockUid, _, __, parentUid] of blocks) { if (parentUid && parentUid !== uid) { const child = blockMap.get(blockUid); const parent = blockMap.get(parentUid); if (child && parent && !parent.children.includes(child)) { parent.children.push(child); } } } // Sort blocks recursively const sortBlocks = (blocks) => { blocks.sort((a, b) => a.order - b.order); blocks.forEach(block => { if (block.children.length > 0) { sortBlocks(block.children); } }); }; sortBlocks(rootBlocks); // Withhold subtrees the user tagged #.rm-hide / #.rm-private. Done here, at // the single point where a page's tree is assembled, so every caller that // renders a page (markdown, raw, structure) inherits it. return { title, blocks: pruneHiddenBlocks(rootBlocks) }; } async fetchPageByTitle(title, format = 'raw', /** * Encode newlines so each block is one line (`shared/block-escaping.ts`). * Required by anything whose output may be fed back to * `roam_update_page_markdown`; wrong for anything shown as prose. * * Defaults to OFF so a caller that has not considered this renders today's * output rather than silently acquiring doubled backslashes. `guidelines.ts` * relies on that default. */ options = {}) { if (!title) { throw new McpError(ErrorCode.InvalidRequest, 'title is required'); } // Use getPageUid which handles caching and case variations const uid = await this.getPageUid(title); if (!uid) { throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`); } // Get all blocks under this page using ancestor rule // Use UID to avoid case-sensitivity issues (getPageUid handles case variations) const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid :in $ % ?page-uid :where [?page :block/uid ?page-uid] [?block :block/string ?block-str] [?block :block/uid ?block-uid] [?block :block/order ?order] (ancestor ?block ?page) [?parent :block/children ?block] [?parent :block/uid ?parent-uid]]`; const blocks = await q(this.graph, blocksQuery, [ANCESTOR_RULE, uid]); if (!blocks || blocks.length === 0) { if (format === 'raw') { return '[]'; // Return JSON string, not array (MCP text field requires string) } return `${title} (no content found)`; } // Get heading information for blocks that have it const headingsQuery = `[:find ?block-uid ?heading :in $ % ?page-uid :where [?page :block/uid ?page-uid] [?block :block/uid ?block-uid] [?block :block/heading ?heading] (ancestor ?block ?page)]`; const headings = await q(this.graph, headingsQuery, [ANCESTOR_RULE, uid]); // Create a map of block UIDs to heading levels const headingMap = new Map(); if (headings) { for (const [blockUid, heading] of headings) { headingMap.set(blockUid, heading); } } // Create a map of all blocks const blockMap = new Map(); const rootBlocks = []; const allBlocks = []; // First pass: Create all block objects for (const [blockUid, blockStr, order, parentUid] of blocks) { const block = { uid: blockUid, string: blockStr, order: order, heading: headingMap.get(blockUid) || null, children: [] }; blockMap.set(blockUid, block); allBlocks.push(block); // If no parent or parent is the page itself, it's a root block if (!parentUid || parentUid === uid) { rootBlocks.push(block); } } // Second pass: Build parent-child relationships for (const [blockUid, _, __, parentUid] of blocks) { if (parentUid && parentUid !== uid) { const child = blockMap.get(blockUid); const parent = blockMap.get(parentUid); if (child && parent && !parent.children.includes(child)) { parent.children.push(child); } } } // Sort blocks recursively const sortBlocks = (blocks) => { blocks.sort((a, b) => a.order - b.order); blocks.forEach(block => { if (block.children.length > 0) { sortBlocks(block.children); } }); }; sortBlocks(rootBlocks); // Withhold #.rm-hide / #.rm-private subtrees before ANY format renders // them. This function builds its own tree rather than reusing // fetchPageByUid, so it needs its own prune — that duplication is exactly // how this path shipped unfiltered the first time. const visibleRoots = pruneHiddenBlocks(rootBlocks); // Collect from the PRUNED tree, not by filtering allBlocks: pruning copies // any node that has children, so the originals are no longer the objects // rendered below, and the markdown branch mutates these in place. const visibleBlocks = []; const collectVisible = (bs) => { for (const b of bs) { visibleBlocks.push(b); if (b.children.length > 0) collectVisible(b.children); } }; collectVisible(visibleRoots); if (format === 'raw') { // Resolve structured references for raw JSON output await resolveBlockRefs(this.graph, visibleBlocks, 2); return JSON.stringify(visibleRoots); } if (format === 'structure') { // Flatten the tree into a list optimized for surgical updates: this // format exists to hand an agent the UIDs and shape of a page cheaply, so // `text` is a PREVIEW, cut at PREVIEW_CHARS. // // That cut is the format's one sharp edge. The tool description sells the // output as "optimized for surgical updates", and the obvious next move — // feed these entries to roam_process_batch_actions as update-block // strings — silently replaces every long block with its own first 80 // characters. The `...` suffix was the only signal, and an agent // reassembling content does not reliably read punctuation as a warning. // // So a cut entry now says so in a field: `truncated: true`, plus // `full_length` so the agent can see how much is missing. The payload // also carries a one-line instruction, but ONLY when something was // actually cut — a warning present on every response is a warning that // gets skimmed. Widening the cut, or removing it, would change what an // unchanged call returns; marking it does not. const PREVIEW_CHARS = 80; const flattenBlocks = (blocks, depth, parentUid) => { const result = []; for (const block of blocks) { const isTruncated = block.string.length > PREVIEW_CHARS; const preview = isTruncated ? block.string.substring(0, PREVIEW_CHARS) + '...' : block.string; const entry = { uid: block.uid, order: block.order, text: preview, depth, parent_uid: parentUid }; if (isTruncated) { entry.truncated = true; entry.full_length = block.string.length; } if (block.heading) { entry.heading = block.heading; } result.push(entry); // Recurse into children if (block.children.length > 0) { result.push(...flattenBlocks(block.children, depth + 1, block.uid)); } } return result; }; const structureBlocks = flattenBlocks(visibleRoots, 0, uid); const truncatedCount = structureBlocks.filter((b) => b.truncated).length; return JSON.stringify({ page_uid: uid, title: title, block_count: structureBlocks.length, ...(truncatedCount > 0 && { truncated_count: truncatedCount, warning: `${truncatedCount} block${truncatedCount === 1 ? '' : 's'} shown here ` + `${truncatedCount === 1 ? 'is' : 'are'} cut off at ${PREVIEW_CHARS} characters ` + `(marked \`truncated: true\`). Their \`text\` is a preview for orientation, not content. ` + `Writing it back would replace the block with its opening fragment — ` + `fetch the block with roam_fetch_block to get its full string before editing it.` }), blocks: structureBlocks }); } // For markdown, resolve references inline await Promise.all(visibleBlocks.map(async (b) => { b.string = await resolveRefs(this.graph, b.string); })); // Collect every visible block string to decide whether this page needs the // encoding at all. A page with no soft line break renders exactly as it // did before this feature existed. const allStrings = []; const collectStrings = (blocks) => { for (const b of blocks) { allStrings.push(b.string); collectStrings(b.children); } }; collectStrings(visibleRoots); const escaping = options.escapeNewlines === true && needsNewlineEscaping(allStrings); // Convert to markdown with proper nesting const toMarkdown = (blocks, level = 0) => { return blocks .map(block => { const indent = ' '.repeat(level); let md; const text = escaping ? escapeBlockString(block.string) : block.string; // Check block heading level and format accordingly if (block.heading && block.heading > 0) { // Format as heading with appropriate number of hashtags const hashtags = '#'.repeat(block.heading); md = `${indent}${hashtags} ${text}`; } else { // No heading, use bullet point (current behavior) md = `${indent}- ${text}`; } if (block.children.length > 0) { md += '\n' + toMarkdown(block.children, level + 1); } return md; }) .join('\n'); }; const body = toMarkdown(visibleRoots); return escaping ? `# ${title}\n${ESCAPED_NEWLINES_MARKER}\n\n${body}` : `# ${title}\n\n${body}`; } /** * Update an existing page with new markdown content using smart diff. * Preserves block UIDs where possible and generates minimal changes. * * @param title - Title of the page to update * @param markdown - New GFM markdown content * @param dryRun - If true, returns actions without executing them * @returns Result with actions, stats, and preserved UIDs */ async updatePageMarkdown(title, markdown, dryRun = false) { if (!title) { throw new McpError(ErrorCode.InvalidRequest, 'title is required'); } if (!markdown) { throw new McpError(ErrorCode.InvalidRequest, 'markdown is required'); } // 1. Fetch existing page with raw block data const pageUid = await getPageUidHelper(this.graph, String(title).trim()); if (!pageUid) { throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found`); } // 2. Fetch existing blocks with full hierarchy const blocksQuery = `[:find (pull ?page [ :block/uid :block/string :block/order :block/heading {:block/children ...} ]) . :where [?page :block/uid ${ednString(pageUid)}]]`; const pageData = await q(this.graph, blocksQuery, []); if (!pageData) { throw new McpError(ErrorCode.InternalError, `Failed to fetch page data for "${title}"`); } // 3. Parse existing blocks into our format const allExistingBlocks = parseExistingBlocks(pageData); // 3a. Withhold #.rm-hide / #.rm-private subtrees from the BASELINE, not // just from reads. The query above deliberately pulls the whole page — // block UIDs and ordering have to be complete for the diff to preserve // references — but diffing against blocks the caller was never shown is // how the hide filter became a deletion mechanism: unseen content cannot // appear in replacement markdown, and this diff deletes whatever the // markdown does not account for. The baseline must match what could be // read. See `pruneHiddenExistingBlocks`. const hiddenCount = countHiddenExistingBlocks(allExistingBlocks); const existingBlocks = pruneHiddenExistingBlocks(allExistingBlocks); // 4. Convert new markdown to block structure // // Decode ONLY when our own renderer said it encoded. The marker may be // the first non-empty line (header stripped by the caller) or the first // non-empty line after a single leading `#` header (payload submitted // verbatim — the path `roam save --update` takes). Revision 2 checked // only the first line; a verbatim submit therefore never decoded, wrote // the marker as a block, and deleted the blocks it rewrote. The tests // that should have caught it stripped the header themselves. const lines = markdown.split('\n'); let markerAt = -1; let nonEmptySeen = 0; for (let i = 0; i < lines.length; i++) { const t = lines[i].trim(); if (t.length === 0) continue; nonEmptySeen++; if (t === ESCAPED_NEWLINES_MARKER) { markerAt = i; break; } // A single leading header line may precede the marker; anything else // (or a second line that is not the marker) means an unmarked payload. if (nonEmptySeen === 1 && t.startsWith('#')) continue; break; } const isEscaped = markerAt !== -1; // `fetchPageByTitle`'s markdown branch always prepends `# ${title}\n` -- // it is page-level metadata describing what page this is, never content. // Tolerating the marker after that header (above) is not enough on its // own: a verbatim round trip still hands that literal `# Title` line to // the parser, which turns it into a real heading block with no match in // the existing tree, so the diff creates it and reparents every sibling // after it -- exactly the "no-op round trip" this marker exists to // guarantee. Strip it whenever the first non-empty line matches this // page's own title exactly -- but ONLY when the payload demonstrably came // from our own renderer (`isEscaped`, or the body still carries the // `⏎` sentinel -- the signature of a marker-dropped degradation, since // that scenario is "an agent rebuilt our output and lost the marker" and // the sentinel is what that rebuilding could not have removed). A FRESH, // hand-authored page can legitimately open with an H1 that echoes its own // title (imported docs; an author who titles their own first line) -- // essentially never with `⏎` in it. The two wrong calls are not // symmetric: preserving the header when it should have been stripped // creates a harmless, visible stray block; stripping it when it should // not have been touched deletes -- or reparents into corruption -- real, // unrecoverable content, with no undo. The gate always takes the harmless // direction whenever the payload carries no provenance signal. const trimmedTitle = String(title).trim(); const firstNonEmptyAt = lines.findIndex((l) => l.trim().length > 0); const hasRendererProvenance = isEscaped || markdown.includes(SOFT_BREAK_SENTINEL); const titleHeaderAt = hasRendererProvenance && firstNonEmptyAt !== -1 && lines[firstNonEmptyAt].trim() === `# ${trimmedTitle}` ? firstNonEmptyAt : -1; let effectiveMarkdown = markdown; const linesToStrip = [isEscaped ? markerAt : -1, titleHeaderAt] .filter((i) => i !== -1) .sort((a, b) => b - a); if (linesToStrip.length > 0) { // Strip the marker line itself, or it becomes a stray block on the // page. Descending order so removing one index never shifts the other. for (const idx of linesToStrip) lines.splice(idx, 1); effectiveMarkdown = lines.join('\n'); } // Decode AFTER parsing, per block -- never on the whole document before // parsing. Decoding the document first would turn every `\n` into a real // newline and then split on newlines, which is exactly the flattening // bug this whole effort exists to fix. const newBlocks = markdownToBlocks(effectiveMarkdown, pageUid); // Submitting empty/whitespace markdown is the documented way to clear a // page, and stays untouched below. This guards the DIFFERENT case: markdown // that is NOT empty but still parsed to zero blocks. That only happens when // the parser swallowed the payload -- the known cause is a first block // whose string is a bare fence opener (e.g. a line reading "- ```js" with // nothing to close it), which is exactly the shape our own renderer emits // for a Roam block whose string starts with a fence, and the shape a // pasted code snippet produces. `markdownToBlocks` then returns an empty // array, and diffing 0 new blocks against N existing ones deletes all N -- // silently, against an API with no undo. The asymmetry is deliberate: an // empty parse of EMPTY input is the documented clear-the-page instruction; // an empty parse of NON-EMPTY input is the parser losing the payload, never // an instruction to delete anything. if (effectiveMarkdown.trim().length > 0 && newBlocks.length === 0) { const existingCount = flattenExistingBlocks(existingBlocks).length; if (existingCount > 0) { throw new McpError(ErrorCode.InvalidParams, `markdown parsed to zero blocks even though it is not empty. This usually ` + `means an unterminated \`\`\` fence swallowed the whole payload (a first ` + `block that opens a code fence and never closes it consumes every line ` + `after it). Refusing to write: this would have deleted all ${existingCount} ` + `existing block${existingCount === 1 ? '' : 's'} on "${title}". Check the ` + `markdown for an unbalanced fence, or call again with dry_run: true to ` + `inspect the planned actions before writing.`); } } if (isEscaped) { for (const block of newBlocks) { block.text = unescapeBlockString(block.text); } } // 5. Compute diff const diff = diffBlockTrees(existingBlocks, newBlocks, pageUid); // 6. Generate ordered batch actions const actions = generateBatchActions(diff); const stats = getDiffStats(diff); const summary = isDiffEmpty(diff) ? 'No changes needed' : summarizeActions(actions); // 7. Execute if not dry run and there are actions if (!dryRun && actions.length > 0) { try { // Use staged batch to ensure parent blocks exist before children await executeStagedBatch(this.graph, actions, { context: 'page update', delayBetweenLevels: 100 }); } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to apply changes: ${error instanceof Error ? error.message : String(error)}`); } } // Report the protection rather than applying it silently. Without this, a // caller told "3 blocks deleted, 5 created" has no way to explain the // blocks still on the page afterwards, and a user debugging that has // nothing to go on. It is a count, never content — and these tags are // documented as "keep it out of the AI's way", not a secrecy boundary // (`tools/helpers/hidden.ts`), so a count is well within their contract. const preservationNote = hiddenCount > 0 ? ` ${hiddenCount} hidden block${hiddenCount === 1 ? '' : 's'} ` + `(#.rm-hide / #.rm-private) ${hiddenCount === 1 ? 'was' : 'were'} excluded from the diff and left untouched.` : ''; return { success: true, actions, stats, preserved_uids: [...diff.preservedUids], ...(hiddenCount > 0 && { preserved_hidden: hiddenCount }), summary: (dryRun ? `[DRY RUN] ${summary}` : summary) + preservationNote }; } /** * Rename a page by updating its title */ async renamePage(params) { const { old_title, uid, new_title } = params; if (!old_title && !uid) { throw new McpError(ErrorCode.InvalidParams, 'Either old_title or uid must be provided to identify the page'); } // Build the page identifier const pageIdentifier = uid ? { uid } : { title: old_title }; try { const success = await updatePage(this.graph, { page: pageIdentifier, title: new_title }); if (success) { const identifier = uid ? `((${uid}))` : `"${old_title}"`; return { success: true, message: `Renamed ${identifier} → "${new_title}"` }; } else { return { success: false, message: 'Failed to rename page (API returned false)' }; } } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to rename page: ${error instanceof Error ? error.message : String(error)}`); } } }