/** * Notion — Search, fetch, create, and update Notion pages as Markdown * * Tools: * - notion_search — Search pages and databases by title * - notion_fetch — Fetch a page by ID, convert blocks to markdown, save as .md * - notion_create — Create a new page from markdown content * - notion_update — Update an existing page's content with markdown * - notion_databases — List and query Notion databases * * Configuration: * /notion-config — TUI overlay to set API token and options * ~/.pi/agent/notion.json or .pi/notion.json * * Config format: * { * "apiToken": "ntn_xxxxx", * "outputDir": "./notion-docs", * "notionVersion": "2022-06-28" * } * * Or use environment variables: * NOTION_API_TOKEN, NOTION_OUTPUT_DIR * * Usage: pi -e extensions/notion.ts */ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { Type, type Static } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { Text, Key, matchesKey, CURSOR_MARKER, truncateToWidth, visibleWidth, type Focusable, } from "@mariozechner/pi-tui"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; // ═══════════════════════════════════════════════════════════════════ // Configuration // ═══════════════════════════════════════════════════════════════════ interface NotionConfig { apiToken: string; outputDir: string; notionVersion: string; } const DEFAULT_NOTION_VERSION = "2022-06-28"; function getConfigPaths(cwd: string): string[] { return [ join(cwd, ".pi", "notion.json"), join(getAgentDir(), "notion.json"), ]; } function resolveValue(val: string): string { if (val.startsWith("ENV:")) { return process.env[val.slice(4)] ?? ""; } return val; } function loadConfig(cwd: string): NotionConfig | undefined { const paths = getConfigPaths(cwd); for (const p of paths) { if (existsSync(p)) { try { const raw = JSON.parse(readFileSync(p, "utf-8")); const cfg: NotionConfig = { apiToken: resolveValue(raw.apiToken ?? ""), outputDir: raw.outputDir ?? "./notion-docs", notionVersion: raw.notionVersion ?? DEFAULT_NOTION_VERSION, }; if (cfg.apiToken && !cfg.apiToken.includes("YOUR_TOKEN_HERE")) return cfg; } catch { /* ignore */ } } } // Fallback: environment variables const apiToken = process.env.NOTION_API_TOKEN; if (apiToken) { return { apiToken, outputDir: process.env.NOTION_OUTPUT_DIR ?? "./notion-docs", notionVersion: DEFAULT_NOTION_VERSION, }; } return undefined; } function saveConfig(cwd: string, scope: "project" | "global", data: Record): string { const dir = scope === "project" ? join(cwd, ".pi") : getAgentDir(); mkdirSync(dir, { recursive: true }); const filePath = join(dir, "notion.json"); writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); return filePath; } /** Scaffold a dummy settings file so the user knows what to fill in. * Only called when loadConfig() returned undefined (no valid config anywhere). */ function scaffoldConfig(cwd: string): string { // Prefer project-local; fall back to global if .pi/ dir doesn't make sense const projectPath = join(cwd, ".pi", "notion.json"); const globalPath = join(getAgentDir(), "notion.json"); // If project-local already exists (but was invalid), don't overwrite — return it if (existsSync(projectPath)) return projectPath; // Same for global if (existsSync(globalPath)) return globalPath; // Create project-local dummy config const dir = join(cwd, ".pi"); mkdirSync(dir, { recursive: true }); const template = { "$comment": "Fill in your Notion integration token below. Create one at https://www.notion.so/my-integrations — then share pages/databases with the integration in Notion.", apiToken: "ntn_YOUR_TOKEN_HERE", outputDir: "./notion-docs", notionVersion: DEFAULT_NOTION_VERSION, }; writeFileSync(projectPath, JSON.stringify(template, null, 2) + "\n", "utf-8"); return projectPath; } // ═══════════════════════════════════════════════════════════════════ // Notion API Client // ═══════════════════════════════════════════════════════════════════ const NOTION_BASE = "https://api.notion.com/v1"; async function notionRequest( config: NotionConfig, method: string, path: string, body?: any, signal?: AbortSignal, ): Promise { const url = `${NOTION_BASE}${path}`; const res = await fetch(url, { method, headers: { "Authorization": `Bearer ${config.apiToken}`, "Notion-Version": config.notionVersion, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, signal, }); if (!res.ok) { const errBody = await res.text().catch(() => ""); throw new Error(`Notion API ${res.status} ${res.statusText}: ${errBody.slice(0, 500)}`); } return res.json(); } /** Fetch all block children recursively (handles pagination) */ async function fetchAllBlocks( config: NotionConfig, blockId: string, signal?: AbortSignal, depth = 0, ): Promise { if (depth > 5) return []; // Prevent infinite recursion const blocks: any[] = []; let cursor: string | undefined; do { const params = cursor ? `?start_cursor=${cursor}&page_size=100` : "?page_size=100"; const data = await notionRequest(config, "GET", `/blocks/${blockId}/children${params}`, undefined, signal); for (const block of data.results || []) { blocks.push(block); // Recursively fetch children if the block has them if (block.has_children) { block._children = await fetchAllBlocks(config, block.id, signal, depth + 1); } } cursor = data.has_more ? data.next_cursor : undefined; } while (cursor); return blocks; } // ═══════════════════════════════════════════════════════════════════ // Notion Blocks → Markdown Converter // ═══════════════════════════════════════════════════════════════════ /** Convert Notion rich text array to markdown string */ function richTextToMd(richText: any[]): string { if (!richText || !Array.isArray(richText)) return ""; return richText.map((rt) => { let text = rt.plain_text || ""; if (!text) return ""; const ann = rt.annotations || {}; if (ann.code) text = `\`${text}\``; if (ann.bold) text = `**${text}**`; if (ann.italic) text = `*${text}*`; if (ann.strikethrough) text = `~~${text}~~`; if (ann.underline) text = `${text}`; // Links if (rt.href) { text = `[${text}](${rt.href})`; } return text; }).join(""); } /** Convert a single Notion block to markdown */ function blockToMd(block: any, indent = 0): string { const prefix = " ".repeat(indent); const type = block.type; const data = block[type]; if (!data) return ""; const childrenMd = (block._children || []) .map((child: any) => blockToMd(child, indent + 1)) .join("\n"); switch (type) { case "paragraph": return `${prefix}${richTextToMd(data.rich_text)}\n${childrenMd}`; case "heading_1": return `\n${prefix}# ${richTextToMd(data.rich_text)}\n${childrenMd}`; case "heading_2": return `\n${prefix}## ${richTextToMd(data.rich_text)}\n${childrenMd}`; case "heading_3": return `\n${prefix}### ${richTextToMd(data.rich_text)}\n${childrenMd}`; case "bulleted_list_item": return `${prefix}- ${richTextToMd(data.rich_text)}\n${childrenMd}`; case "numbered_list_item": return `${prefix}1. ${richTextToMd(data.rich_text)}\n${childrenMd}`; case "to_do": { const checked = data.checked ? "x" : " "; return `${prefix}- [${checked}] ${richTextToMd(data.rich_text)}\n${childrenMd}`; } case "toggle": return `${prefix}
\n${prefix}${richTextToMd(data.rich_text)}\n\n${childrenMd}\n${prefix}
\n`; case "code": { const lang = data.language || ""; const code = richTextToMd(data.rich_text); const caption = data.caption ? richTextToMd(data.caption) : ""; let md = `${prefix}\`\`\`${lang}\n${code}\n${prefix}\`\`\`\n`; if (caption) md += `${prefix}*${caption}*\n`; return md; } case "quote": return `${prefix}> ${richTextToMd(data.rich_text)}\n${childrenMd}`; case "callout": { const icon = data.icon?.emoji || "💡"; return `${prefix}> ${icon} ${richTextToMd(data.rich_text)}\n${childrenMd}`; } case "divider": return `${prefix}---\n`; case "image": { const url = data.file?.url || data.external?.url || ""; const caption = data.caption ? richTextToMd(data.caption) : ""; return `${prefix}![${caption}](${url})\n`; } case "video": { const url = data.file?.url || data.external?.url || ""; return `${prefix}[Video](${url})\n`; } case "file": { const url = data.file?.url || data.external?.url || ""; const caption = data.caption ? richTextToMd(data.caption) : data.name || "File"; return `${prefix}[${caption}](${url})\n`; } case "bookmark": { const url = data.url || ""; const caption = data.caption ? richTextToMd(data.caption) : url; return `${prefix}[${caption}](${url})\n`; } case "embed": { const url = data.url || ""; return `${prefix}[Embed](${url})\n`; } case "link_preview": { const url = data.url || ""; return `${prefix}[${url}](${url})\n`; } case "table": { if (!block._children || block._children.length === 0) return ""; const rows = block._children.map((row: any) => { const cells = (row.table_row?.cells || []).map((cell: any) => richTextToMd(cell)); return `| ${cells.join(" | ")} |`; }); if (rows.length > 0) { const headerCells = (block._children[0]?.table_row?.cells || []).length; const separator = `| ${Array(headerCells).fill("---").join(" | ")} |`; rows.splice(1, 0, separator); } return `${prefix}${rows.join(`\n${prefix}`)}\n`; } case "table_of_contents": return `${prefix}*[Table of Contents]*\n`; case "equation": return `${prefix}$$${data.expression || ""}$$\n`; case "synced_block": return childrenMd; case "column_list": return childrenMd; case "column": return childrenMd; case "child_page": return `${prefix}📄 [${data.title || "Untitled"}](notion-child-page)\n`; case "child_database": return `${prefix}🗄️ [${data.title || "Untitled Database"}](notion-child-database)\n`; case "breadcrumb": return ""; // UI-only element case "link_to_page": return `${prefix}🔗 [Linked Page](${data.page_id || data.database_id || ""})\n`; default: // Unknown block type — render what we can if (data.rich_text) { return `${prefix}${richTextToMd(data.rich_text)}\n${childrenMd}`; } return `${prefix}\n${childrenMd}`; } } /** Convert full page blocks array to markdown */ function blocksToMarkdown(blocks: any[]): string { const lines = blocks.map((b) => blockToMd(b, 0)); return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } // ═══════════════════════════════════════════════════════════════════ // Markdown → Notion Blocks Converter // ═══════════════════════════════════════════════════════════════════ /** Convert markdown text to Notion rich_text array */ function mdToRichText(text: string): any[] { const segments: any[] = []; // Simple parser: handles **bold**, *italic*, ~~strike~~, `code`, [link](url) const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|`(.+?)`|\[(.+?)\]\((.+?)\)|([^*~`\[]+))/g; let match; while ((match = regex.exec(text)) !== null) { if (match[2]) { // Bold segments.push({ type: "text", text: { content: match[2] }, annotations: { bold: true } }); } else if (match[3]) { // Italic segments.push({ type: "text", text: { content: match[3] }, annotations: { italic: true } }); } else if (match[4]) { // Strikethrough segments.push({ type: "text", text: { content: match[4] }, annotations: { strikethrough: true } }); } else if (match[5]) { // Code segments.push({ type: "text", text: { content: match[5] }, annotations: { code: true } }); } else if (match[6] && match[7]) { // Link segments.push({ type: "text", text: { content: match[6], link: { url: match[7] } } }); } else if (match[8]) { // Plain text segments.push({ type: "text", text: { content: match[8] } }); } } if (segments.length === 0) { segments.push({ type: "text", text: { content: text || "" } }); } return segments; } /** Convert markdown string to Notion block objects */ function markdownToBlocks(markdown: string): any[] { const lines = markdown.split("\n"); const blocks: any[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Code block if (line.startsWith("```")) { const lang = line.slice(3).trim() || "plain text"; const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].startsWith("```")) { codeLines.push(lines[i]); i++; } blocks.push({ object: "block", type: "code", code: { rich_text: [{ type: "text", text: { content: codeLines.join("\n") } }], language: lang, }, }); i++; // skip closing ``` continue; } // Headings if (line.startsWith("### ")) { blocks.push({ object: "block", type: "heading_3", heading_3: { rich_text: mdToRichText(line.slice(4)) }, }); i++; continue; } if (line.startsWith("## ")) { blocks.push({ object: "block", type: "heading_2", heading_2: { rich_text: mdToRichText(line.slice(3)) }, }); i++; continue; } if (line.startsWith("# ")) { blocks.push({ object: "block", type: "heading_1", heading_1: { rich_text: mdToRichText(line.slice(2)) }, }); i++; continue; } // Divider if (/^---+$/.test(line.trim())) { blocks.push({ object: "block", type: "divider", divider: {} }); i++; continue; } // Checkbox const todoMatch = line.match(/^-\s*\[([ xX])\]\s*(.*)$/); if (todoMatch) { blocks.push({ object: "block", type: "to_do", to_do: { rich_text: mdToRichText(todoMatch[2]), checked: todoMatch[1] !== " ", }, }); i++; continue; } // Bulleted list if (line.startsWith("- ") || line.startsWith("* ")) { blocks.push({ object: "block", type: "bulleted_list_item", bulleted_list_item: { rich_text: mdToRichText(line.slice(2)) }, }); i++; continue; } // Numbered list const numMatch = line.match(/^\d+\.\s+(.*)$/); if (numMatch) { blocks.push({ object: "block", type: "numbered_list_item", numbered_list_item: { rich_text: mdToRichText(numMatch[1]) }, }); i++; continue; } // Blockquote if (line.startsWith("> ")) { blocks.push({ object: "block", type: "quote", quote: { rich_text: mdToRichText(line.slice(2)) }, }); i++; continue; } // Image const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); if (imgMatch) { blocks.push({ object: "block", type: "image", image: { type: "external", external: { url: imgMatch[2] }, caption: imgMatch[1] ? mdToRichText(imgMatch[1]) : [] }, }); i++; continue; } // Empty line → skip if (line.trim() === "") { i++; continue; } // Paragraph (default) blocks.push({ object: "block", type: "paragraph", paragraph: { rich_text: mdToRichText(line) }, }); i++; } return blocks; } // ═══════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════ function slugify(title: string): string { return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80); } function ensureDir(dir: string): void { mkdirSync(dir, { recursive: true }); } function getPageTitle(page: any): string { const props = page.properties || {}; // Title is usually in a property named "title" or "Name" for (const key of Object.keys(props)) { const prop = props[key]; if (prop.type === "title" && prop.title?.length) { return prop.title.map((t: any) => t.plain_text || "").join(""); } } return "Untitled"; } function buildFrontmatter(page: any, title: string): string { return [ "---", `title: "${title.replace(/"/g, '\\"')}"`, `notion_id: "${page.id}"`, `url: "${page.url || ""}"`, `created: "${page.created_time || ""}"`, `last_edited: "${page.last_edited_time || ""}"`, `created_by: "${page.created_by?.id || ""}"`, `fetched_at: "${new Date().toISOString()}"`, "---", ].join("\n"); } // ═══════════════════════════════════════════════════════════════════ // TUI: Configuration Overlay // ═══════════════════════════════════════════════════════════════════ interface ConfigField { id: string; label: string; description: string; masked: boolean; value: string; } class NotionConfigEditor implements Focusable { focused = false; private fields: ConfigField[]; private selectedIndex = 0; private editingIndex = -1; private cursorPos = 0; private cachedLines?: string[]; private cachedWidth?: number; private saveScope: "project" | "global" = "global"; constructor( private theme: Theme, private cwd: string, private done: (saved: boolean) => void, ) { const raw = this.loadRawConfig(); this.fields = [ { id: "apiToken", label: "API Token", description: "Internal integration token (ntn_xxx or secret_xxx)", masked: true, value: raw.apiToken ?? "", }, { id: "outputDir", label: "Output Dir", description: "Where to save markdown files (default: ./notion-docs)", masked: false, value: raw.outputDir ?? "./notion-docs", }, { id: "notionVersion", label: "API Version", description: "Notion API version (default: 2022-06-28)", masked: false, value: raw.notionVersion ?? DEFAULT_NOTION_VERSION, }, ]; } private loadRawConfig(): Record { const paths = getConfigPaths(this.cwd); for (const p of paths) { if (existsSync(p)) { try { return JSON.parse(readFileSync(p, "utf-8")); } catch { /* ignore */ } } } return {}; } handleInput(data: string): void { if (matchesKey(data, Key.escape)) { if (this.editingIndex >= 0) { this.editingIndex = -1; } else { this.done(false); } this.invalidate(); return; } // Editing mode if (this.editingIndex >= 0) { const field = this.fields[this.editingIndex]!; if (matchesKey(data, Key.enter)) { this.editingIndex = -1; this.invalidate(); return; } if (matchesKey(data, Key.backspace)) { if (this.cursorPos > 0) { field.value = field.value.slice(0, this.cursorPos - 1) + field.value.slice(this.cursorPos); this.cursorPos--; } } else if (matchesKey(data, Key.delete)) { if (this.cursorPos < field.value.length) { field.value = field.value.slice(0, this.cursorPos) + field.value.slice(this.cursorPos + 1); } } else if (matchesKey(data, Key.left)) { this.cursorPos = Math.max(0, this.cursorPos - 1); } else if (matchesKey(data, Key.right)) { this.cursorPos = Math.min(field.value.length, this.cursorPos + 1); } else if (matchesKey(data, Key.home)) { this.cursorPos = 0; } else if (matchesKey(data, Key.end)) { this.cursorPos = field.value.length; } else if (matchesKey(data, Key.ctrl("u"))) { field.value = ""; this.cursorPos = 0; } else if (data.length === 1 && data.charCodeAt(0) >= 32) { field.value = field.value.slice(0, this.cursorPos) + data + field.value.slice(this.cursorPos); this.cursorPos++; } this.invalidate(); return; } // Navigation mode const totalItems = this.fields.length + 2; // fields + scope toggle + save button if (matchesKey(data, Key.up)) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } else if (matchesKey(data, Key.down)) { this.selectedIndex = Math.min(totalItems - 1, this.selectedIndex + 1); } else if (matchesKey(data, Key.enter)) { if (this.selectedIndex < this.fields.length) { this.editingIndex = this.selectedIndex; this.cursorPos = this.fields[this.selectedIndex]!.value.length; } else if (this.selectedIndex === this.fields.length) { this.saveScope = this.saveScope === "global" ? "project" : "global"; } else { this.save(); this.done(true); return; } } else if (matchesKey(data, Key.ctrl("s"))) { this.save(); this.done(true); return; } else if (matchesKey(data, Key.tab)) { this.selectedIndex = (this.selectedIndex + 1) % totalItems; } this.invalidate(); } private save(): void { const data: Record = {}; for (const f of this.fields) { if (f.value) data[f.id] = f.value; } saveConfig(this.cwd, this.saveScope, data); } render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const th = this.theme; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); add(th.fg("accent", "─".repeat(width))); add(""); add(th.fg("accent", th.bold(" 📝 Notion Configuration"))); add(th.fg("dim", " Connect to your Notion workspace")); add(th.fg("dim", " Create an integration at https://www.notion.so/my-integrations")); add(""); // Fields for (let i = 0; i < this.fields.length; i++) { const field = this.fields[i]!; const isSelected = i === this.selectedIndex; const isEditing = i === this.editingIndex; const prefix = isSelected ? th.fg("accent", " ▸ ") : " "; const label = isSelected ? th.fg("accent", th.bold(field.label)) : th.fg("text", field.label); add(`${prefix}${label} ${th.fg("dim", field.description)}`); if (isEditing) { const raw = field.value; const displayText = field.masked ? "•".repeat(raw.length) : raw; const before = displayText.slice(0, this.cursorPos); const cursorChar = this.cursorPos < displayText.length ? displayText[this.cursorPos]! : " "; const after = displayText.slice(this.cursorPos + 1); const marker = this.focused ? CURSOR_MARKER : ""; const inputLine = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`; add(` ${th.fg("border", "[")} ${inputLine} ${th.fg("border", "]")}`); } else { const display = field.value ? (field.masked ? "•".repeat(Math.min(field.value.length, 32)) : field.value) : th.fg("muted", "(empty)"); const bracket = isSelected ? th.fg("borderAccent", "[") : th.fg("border", "["); const bracketR = isSelected ? th.fg("borderAccent", "]") : th.fg("border", "]"); add(` ${bracket} ${display} ${bracketR}`); } add(""); } // Scope toggle const scopeIdx = this.fields.length; const isScopeSelected = this.selectedIndex === scopeIdx; const scopePrefix = isScopeSelected ? th.fg("accent", " ▸ ") : " "; const scopeLabel = this.saveScope === "global" ? `Global ${th.fg("dim", "(~/.pi/agent/notion.json)")}` : `Project ${th.fg("dim", "(.pi/notion.json)")}`; const scopeText = isScopeSelected ? th.fg("accent", th.bold("Save to: ")) + th.fg("success", scopeLabel) : th.fg("text", "Save to: ") + th.fg("dim", scopeLabel); add(`${scopePrefix}${scopeText}`); add(""); // Save button const isSaveSelected = this.selectedIndex === this.fields.length + 1; if (isSaveSelected) { add(th.fg("accent", th.bold(" ✓ Save Configuration"))); } else { add(th.fg("muted", " ✓ Save Configuration")); } add(""); // Help if (this.editingIndex >= 0) { add(th.fg("dim", " Type to edit • Ctrl+U clear • Enter confirm • Esc stop editing")); } else { add(th.fg("dim", " ↑↓ navigate • Tab next • Enter edit/toggle/save • Ctrl+S quick save • Esc close")); } add(th.fg("accent", "─".repeat(width))); this.cachedLines = lines; this.cachedWidth = width; return lines; } invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; } } // ═══════════════════════════════════════════════════════════════════ // Tool Schemas // ═══════════════════════════════════════════════════════════════════ const SearchParams = Type.Object({ query: Type.String({ description: "Search text to match against page and database titles" }), filter: Type.Optional(StringEnum(["page", "database"] as const)), limit: Type.Optional(Type.Number({ description: "Max results (default: 10, max: 100)" })), }); const FetchParams = Type.Object({ pageId: Type.String({ description: "Notion page ID (UUID format, with or without dashes)" }), filename: Type.Optional(Type.String({ description: "Custom output filename (default: slugified title)" })), includeChildren: Type.Optional(Type.Boolean({ description: "Recursively fetch child page content (default: false)" })), }); const CreateParams = Type.Object({ parentPageId: Type.String({ description: "Parent page ID to create the new page under" }), title: Type.String({ description: "Title for the new page" }), content: Type.String({ description: "Markdown content for the page body" }), icon: Type.Optional(Type.String({ description: "Page icon emoji (e.g. '📄', '🚀')" })), }); const UpdateParams = Type.Object({ pageId: Type.String({ description: "Notion page ID to update" }), content: Type.String({ description: "New markdown content (replaces all existing blocks)" }), title: Type.Optional(Type.String({ description: "New title (leave empty to keep current)" })), }); const DatabaseParams = Type.Object({ action: StringEnum(["list", "query"] as const), databaseId: Type.Optional(Type.String({ description: "Database ID (required for 'query' action)" })), limit: Type.Optional(Type.Number({ description: "Max results (default: 25)" })), }); // ═══════════════════════════════════════════════════════════════════ // Extension Entry Point // ═══════════════════════════════════════════════════════════════════ export default function (pi: ExtensionAPI) { let config: NotionConfig | undefined; // Load config on session start — scaffold a dummy file if nothing exists pi.on("session_start", async (_event, ctx) => { config = loadConfig(ctx.cwd); if (config) { ctx.ui.notify("Notion connected ✓", "info"); ctx.ui.setStatus("notion", "📝 Notion"); } else { const settingsPath = scaffoldConfig(ctx.cwd); ctx.ui.notify( `Notion: settings file created at ${settingsPath} — fill in your API token, then run /notion-config or restart.`, "warning", ); } }); pi.on("session_switch", async (_event, ctx) => { config = loadConfig(ctx.cwd); }); // ── /notion-config command → TUI overlay ── pi.registerCommand("notion-config", { description: "Configure Notion API token and settings", handler: async (_args, ctx) => { const saved = await ctx.ui.custom( (_tui, theme, _kb, done) => new NotionConfigEditor(theme, ctx.cwd, done), { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 55, maxHeight: "80%", }, }, ); if (saved) { config = loadConfig(ctx.cwd); if (config) { ctx.ui.notify("✓ Notion configured successfully", "info"); ctx.ui.setStatus("notion", "📝 Notion"); } else { ctx.ui.notify("⚠ Config saved but API token missing — check values", "warning"); } } }, }); // ── Tool: notion_search ── pi.registerTool({ name: "notion_search", label: "Notion Search", description: "Search Notion pages and databases by title. Returns IDs, titles, URLs, and timestamps. " + "Use notion_fetch to download full page content as markdown.", promptSnippet: "Search Notion workspace pages and databases by title", promptGuidelines: [ "Use notion_search to find pages, then notion_fetch to get full content as markdown.", "Filter by 'page' or 'database' to narrow results.", "Present search results to the user before batch-fetching pages.", ], parameters: SearchParams, async execute(_id, params, signal, onUpdate) { if (!config) throw new Error("Notion not configured. Run /notion-config first."); const limit = Math.min(params.limit ?? 10, 100); onUpdate?.({ content: [{ type: "text", text: `Searching Notion for "${params.query}"...` }], details: { phase: "searching" }, }); const body: any = { query: params.query, page_size: limit, }; if (params.filter) { body.filter = { value: params.filter === "database" ? "data_source" : "page", property: "object", }; } body.sort = { direction: "descending", timestamp: "last_edited_time" }; const data = await notionRequest(config, "POST", "/search", body, signal); const results = (data.results || []).map((item: any) => { const isPage = item.object === "page"; const title = isPage ? getPageTitle(item) : (item.title?.map((t: any) => t.plain_text).join("") || "Untitled Database"); return { id: item.id, type: item.object, title, url: item.url || "", lastEdited: item.last_edited_time || "", createdTime: item.created_time || "", icon: item.icon?.emoji || "", }; }); let text = `Found ${results.length} result(s) for "${params.query}":\n\n`; for (const r of results) { const icon = r.icon ? `${r.icon} ` : ""; text += `- ${icon}**${r.title}** (${r.type}, ID: \`${r.id}\`)\n`; text += ` Last edited: ${r.lastEdited}\n`; text += ` ${r.url}\n\n`; } return { content: [{ type: "text", text }], details: { resultCount: results.length, results }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("notion ")); content += theme.fg("accent", "search "); content += theme.fg("muted", `"${args.query ?? ""}"`); if (args.filter) content += theme.fg("dim", ` [${args.filter}]`); text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "⏳ Searching Notion..."), 0, 0); const details = result.details as any; const count = details?.resultCount ?? 0; const color = count > 0 ? "success" : "warning"; return new Text( theme.fg(color, `${count} result(s)`) + theme.fg("dim", " — use notion_fetch to download"), 0, 0, ); }, }); // ── Tool: notion_fetch ── pi.registerTool({ name: "notion_fetch", label: "Notion Fetch", description: "Fetch a Notion page by ID, convert all blocks to Markdown, and save as a .md file. " + "Handles headings, lists, code blocks, tables, images, callouts, toggles, and more. " + "Returns the markdown content and file path.", promptSnippet: "Fetch a Notion page by ID and save as markdown (.md) file", promptGuidelines: [ "Use page IDs from notion_search results.", "Set includeChildren=true to also fetch linked child pages.", "Files are saved to the configured outputDir (default: ./notion-docs/).", "The saved file includes YAML frontmatter with page metadata.", ], parameters: FetchParams, async execute(_id, params, signal, onUpdate, ctx) { if (!config) throw new Error("Notion not configured. Run /notion-config first."); const outputDir = resolve(ctx.cwd, config.outputDir); ensureDir(outputDir); const fetchPage = async (pageId: string, depth: number): Promise<{ path: string; title: string }[]> => { if (signal?.aborted) throw new Error("Cancelled"); if (depth > 3) return []; // Safety limit onUpdate?.({ content: [{ type: "text", text: `Fetching page ${pageId}${depth > 0 ? ` (depth ${depth})` : ""}...` }], details: { phase: "fetching", pageId, depth }, }); // Get page metadata const page = await notionRequest(config!, "GET", `/pages/${pageId}`, undefined, signal); const title = getPageTitle(page); // Get all blocks recursively const blocks = await fetchAllBlocks(config!, pageId, signal); // Convert to markdown const markdown = blocksToMarkdown(blocks); const frontmatter = buildFrontmatter(page, title); const slug = params.filename && depth === 0 ? params.filename.replace(/\.md$/, "") : slugify(title); const filePath = join(outputDir, `${slug}.md`); const content = `${frontmatter}\n\n# ${title}\n\n${markdown}\n`; writeFileSync(filePath, content, "utf-8"); const results = [{ path: filePath, title }]; // Fetch child pages if requested if (params.includeChildren) { for (const block of blocks) { if (block.type === "child_page" && block.id) { const childResults = await fetchPage(block.id, depth + 1); results.push(...childResults); } } } return results; }; const fetched = await fetchPage(params.pageId, 0); let text = `Fetched ${fetched.length} page(s):\n\n`; for (const f of fetched) { text += `- **${f.title}** → \`${f.path}\`\n`; } return { content: [{ type: "text", text }], details: { pageCount: fetched.length, files: fetched }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("notion ")); content += theme.fg("accent", "fetch "); content += theme.fg("muted", `page:${args.pageId ?? "?"}`); if (args.includeChildren) content += theme.fg("dim", " +children"); text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "⏳ Fetching page..."), 0, 0); const details = result.details as any; const count = details?.pageCount ?? 0; return new Text(theme.fg("success", `✓ ${count} page(s) saved as markdown`), 0, 0); }, }); // ── Tool: notion_create ── pi.registerTool({ name: "notion_create", label: "Notion Create", description: "Create a new Notion page under a parent page. Accepts markdown content which is " + "converted to Notion blocks (headings, lists, code, quotes, images, etc.).", promptSnippet: "Create a new Notion page from markdown content", promptGuidelines: [ "Requires a parentPageId — use notion_search to find the parent first.", "Content is written in standard markdown and auto-converted to Notion blocks.", "Supported: headings, lists, code blocks, quotes, images, dividers, checkboxes.", ], parameters: CreateParams, async execute(_id, params, signal, onUpdate) { if (!config) throw new Error("Notion not configured. Run /notion-config first."); onUpdate?.({ content: [{ type: "text", text: `Creating page "${params.title}"...` }], details: { phase: "creating" }, }); const blocks = markdownToBlocks(params.content); // Notion API limits to 100 blocks per request const firstBatch = blocks.slice(0, 100); const body: any = { parent: { type: "page_id", page_id: params.parentPageId }, properties: { title: { title: [{ type: "text", text: { content: params.title } }], }, }, children: firstBatch, }; if (params.icon) { body.icon = { type: "emoji", emoji: params.icon }; } const page = await notionRequest(config, "POST", "/pages", body, signal); // Append remaining blocks in batches if > 100 if (blocks.length > 100) { for (let i = 100; i < blocks.length; i += 100) { const batch = blocks.slice(i, i + 100); onUpdate?.({ content: [{ type: "text", text: `Appending blocks ${i + 1}-${Math.min(i + 100, blocks.length)}...` }], details: { phase: "appending", progress: i / blocks.length }, }); await notionRequest(config, "PATCH", `/blocks/${page.id}/children`, { children: batch }, signal); } } const text = `Created page: **${params.title}**\n\nID: \`${page.id}\`\nURL: ${page.url}\nBlocks: ${blocks.length}`; return { content: [{ type: "text", text }], details: { pageId: page.id, url: page.url, blockCount: blocks.length }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("notion ")); content += theme.fg("accent", "create "); content += theme.fg("muted", `"${args.title ?? ""}"`); if (args.icon) content += ` ${args.icon}`; text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "⏳ Creating page..."), 0, 0); const details = result.details as any; return new Text( theme.fg("success", "✓ Created") + theme.fg("dim", ` (${details?.blockCount ?? 0} blocks)`), 0, 0, ); }, }); // ── Tool: notion_update ── pi.registerTool({ name: "notion_update", label: "Notion Update", description: "Update an existing Notion page. Replaces all content blocks with new markdown content. " + "Optionally updates the page title.", promptSnippet: "Update a Notion page with new markdown content", promptGuidelines: [ "This REPLACES all existing blocks — not an append.", "Use notion_fetch first to get current content if you need to preserve parts.", "Provide a title to rename the page, or omit to keep the current title.", ], parameters: UpdateParams, async execute(_id, params, signal, onUpdate) { if (!config) throw new Error("Notion not configured. Run /notion-config first."); onUpdate?.({ content: [{ type: "text", text: `Updating page ${params.pageId}...` }], details: { phase: "updating" }, }); // Step 1: Delete existing blocks const existingBlocks = await fetchAllBlocks(config, params.pageId, signal, 0); for (const block of existingBlocks) { if (signal?.aborted) throw new Error("Cancelled"); await notionRequest(config, "DELETE", `/blocks/${block.id}`, undefined, signal); } onUpdate?.({ content: [{ type: "text", text: `Cleared ${existingBlocks.length} old blocks, writing new content...` }], details: { phase: "writing" }, }); // Step 2: Update title if provided if (params.title) { await notionRequest(config, "PATCH", `/pages/${params.pageId}`, { properties: { title: { title: [{ type: "text", text: { content: params.title } }], }, }, }, signal); } // Step 3: Add new blocks const newBlocks = markdownToBlocks(params.content); for (let i = 0; i < newBlocks.length; i += 100) { const batch = newBlocks.slice(i, i + 100); await notionRequest(config, "PATCH", `/blocks/${params.pageId}/children`, { children: batch }, signal); } // Get updated page info const page = await notionRequest(config, "GET", `/pages/${params.pageId}`, undefined, signal); const text = `Updated page: **${getPageTitle(page)}**\n\nID: \`${page.id}\`\nURL: ${page.url}\nOld blocks removed: ${existingBlocks.length}\nNew blocks added: ${newBlocks.length}`; return { content: [{ type: "text", text }], details: { pageId: page.id, url: page.url, oldBlockCount: existingBlocks.length, newBlockCount: newBlocks.length, }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("notion ")); content += theme.fg("accent", "update "); content += theme.fg("muted", `page:${args.pageId ?? "?"}`); if (args.title) content += theme.fg("dim", ` → "${args.title}"`); text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "⏳ Updating page..."), 0, 0); const details = result.details as any; return new Text( theme.fg("success", "✓ Updated") + theme.fg("dim", ` (${details?.oldBlockCount ?? 0} removed, ${details?.newBlockCount ?? 0} added)`), 0, 0, ); }, }); // ── Tool: notion_databases ── pi.registerTool({ name: "notion_databases", label: "Notion Databases", description: "List databases accessible to the integration, or query a specific database for its entries. " + "Use 'list' to discover databases, 'query' with a databaseId to get entries.", promptSnippet: "List or query Notion databases", promptGuidelines: [ "Use action='list' first to discover available databases.", "Then action='query' with a databaseId to get entries.", "Database entries are pages — use notion_fetch to get their full content.", ], parameters: DatabaseParams, async execute(_id, params, signal, onUpdate) { if (!config) throw new Error("Notion not configured. Run /notion-config first."); const limit = Math.min(params.limit ?? 25, 100); if (params.action === "list") { onUpdate?.({ content: [{ type: "text", text: "Listing databases..." }], details: { phase: "listing" }, }); const data = await notionRequest(config, "POST", "/search", { filter: { value: "data_source", property: "object" }, page_size: limit, }, signal); const databases = (data.results || []).map((db: any) => ({ id: db.id, title: db.title?.map((t: any) => t.plain_text).join("") || "Untitled", url: db.url || "", lastEdited: db.last_edited_time || "", icon: db.icon?.emoji || "", propertyNames: Object.keys(db.properties || {}), })); let text = `Found ${databases.length} database(s):\n\n`; for (const db of databases) { const icon = db.icon ? `${db.icon} ` : ""; text += `- ${icon}**${db.title}** (ID: \`${db.id}\`)\n`; text += ` Last edited: ${db.lastEdited}\n`; text += ` Properties: ${db.propertyNames.join(", ")}\n`; text += ` ${db.url}\n\n`; } return { content: [{ type: "text", text }], details: { action: "list", count: databases.length, databases }, }; } // Query a specific database if (!params.databaseId) throw new Error("databaseId is required for 'query' action"); onUpdate?.({ content: [{ type: "text", text: `Querying database ${params.databaseId}...` }], details: { phase: "querying" }, }); const data = await notionRequest(config, "POST", `/databases/${params.databaseId}/query`, { page_size: limit, }, signal); const entries = (data.results || []).map((page: any) => { const title = getPageTitle(page); const props: Record = {}; for (const [key, val] of Object.entries(page.properties || {})) { const v = val as any; switch (v.type) { case "title": props[key] = v.title?.map((t: any) => t.plain_text).join("") || ""; break; case "rich_text": props[key] = v.rich_text?.map((t: any) => t.plain_text).join("") || ""; break; case "number": props[key] = v.number?.toString() ?? ""; break; case "select": props[key] = v.select?.name ?? ""; break; case "multi_select": props[key] = v.multi_select?.map((s: any) => s.name).join(", ") ?? ""; break; case "date": props[key] = v.date?.start ?? ""; break; case "checkbox": props[key] = v.checkbox ? "✓" : "✗"; break; case "url": props[key] = v.url ?? ""; break; case "email": props[key] = v.email ?? ""; break; case "status": props[key] = v.status?.name ?? ""; break; default: props[key] = `[${v.type}]`; } } return { id: page.id, title, url: page.url || "", props }; }); let text = `Database query returned ${entries.length} entries:\n\n`; for (const entry of entries) { text += `### ${entry.title} (\`${entry.id}\`)\n`; for (const [key, val] of Object.entries(entry.props)) { if (val) text += `- **${key}**: ${val}\n`; } text += `- URL: ${entry.url}\n\n`; } return { content: [{ type: "text", text }], details: { action: "query", count: entries.length, databaseId: params.databaseId }, }; }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("notion ")); content += theme.fg("accent", `databases:${args.action ?? "list"}`); if (args.databaseId) content += theme.fg("dim", ` ${args.databaseId.slice(0, 8)}…`); text.setText(content); return text; }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "⏳ Loading..."), 0, 0); const details = result.details as any; const count = details?.count ?? 0; const label = details?.action === "query" ? "entries" : "databases"; return new Text(theme.fg("success", `${count} ${label}`), 0, 0); }, }); }