/** * Magic Docs automatically maintains markdown documentation files marked with special headers. * When a file with "# MAGIC DOC: [title]" is read, it runs periodically in the background * using a forked subagent to update the document with new learnings from the conversation. * * See docs/magic-docs.md for more information. */ import type { Tool, ToolUseContext } from '../../Tool.js' import type { BuiltInAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' import { runAgent } from '../../tools/AgentTool/runAgent.js' import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' import { FileReadTool, type Output as FileReadToolOutput, registerFileReadListener, } from '../../tools/FileReadTool/FileReadTool.js' import { isFsInaccessible } from '../../utils/errors.js' import { cloneFileStateCache } from '../../utils/fileStateCache.js' import { type REPLHookContext, registerPostSamplingHook, } from '../../utils/hooks/postSamplingHooks.js' import { createUserMessage, hasToolCallsInLastAssistantTurn, } from '../../utils/messages.js' import { sequential } from '../../utils/sequential.js' import { buildMagicDocsUpdatePrompt } from './prompts.js' // Magic Doc header pattern: # MAGIC DOC: [title] // Matches at the start of the file (first line) const MAGIC_DOC_HEADER_PATTERN = /^#\s*MAGIC\s+DOC:\s*(.+)$/im // Pattern to match italics on the line immediately after the header const ITALICS_PATTERN = /^[_*](.+?)[_*]\s*$/m // Track magic docs type MagicDocInfo = { path: string } const trackedMagicDocs = new Map() export function clearTrackedMagicDocs(): void { trackedMagicDocs.clear() } /** * Detect if a file content contains a Magic Doc header * Returns an object with title and optional instructions, or null if not a magic doc */ export function detectMagicDocHeader( content: string, ): { title: string; instructions?: string } | null { const match = content.match(MAGIC_DOC_HEADER_PATTERN) if (!match || !match[1]) { return null } const title = match[1].trim() // Look for italics on the next line after the header (allow one optional blank line) const headerEndIndex = match.index! + match[0].length const afterHeader = content.slice(headerEndIndex) // Match: newline, optional blank line, then content line const nextLineMatch = afterHeader.match(/^\s*\n(?:\s*\n)?(.+?)(?:\n|$)/) if (nextLineMatch && nextLineMatch[1]) { const nextLine = nextLineMatch[1] const italicsMatch = nextLine.match(ITALICS_PATTERN) if (italicsMatch && italicsMatch[1]) { const instructions = italicsMatch[1].trim() return { title, instructions, } } } return { title } } /** * Register a file as a Magic Doc when it's read * Only registers once per file path - the hook always reads latest content */ export function registerMagicDoc(filePath: string): void { // Only register if not already tracked if (!trackedMagicDocs.has(filePath)) { trackedMagicDocs.set(filePath, { path: filePath, }) } } /** * Create Magic Docs agent definition */ function getMagicDocsAgent(): BuiltInAgentDefinition { return { agentType: 'magic-docs', whenToUse: 'Update Magic Docs', tools: [FILE_EDIT_TOOL_NAME], // Only allow Edit model: 'sonnet', source: 'built-in', baseDir: 'built-in', getSystemPrompt: () => '', // Will use override systemPrompt } } /** * Update a single Magic Doc */ async function updateMagicDoc( docInfo: MagicDocInfo, context: REPLHookContext, ): Promise { const { messages, systemPrompt, userContext, systemContext, toolUseContext } = context // Clone the FileStateCache to isolate Magic Docs operations. Delete this // doc's entry so FileReadTool's dedup doesn't return a file_unchanged // stub — we need the actual content to re-detect the header. const clonedReadFileState = cloneFileStateCache(toolUseContext.readFileState) clonedReadFileState.delete(docInfo.path) const clonedToolUseContext: ToolUseContext = { ...toolUseContext, readFileState: clonedReadFileState, } // Read the document; if deleted or unreadable, remove from tracking let currentDoc = '' try { const result = await FileReadTool.call( { file_path: docInfo.path }, clonedToolUseContext, ) const output = result.data as FileReadToolOutput if (output.type === 'text') { currentDoc = output.file.content } } catch (e: unknown) { // FileReadTool wraps ENOENT in a plain Error("File does not exist...") with // no .code, so check the message in addition to isFsInaccessible (EACCES/EPERM). if ( isFsInaccessible(e) || (e instanceof Error && e.message.startsWith('File does not exist')) ) { trackedMagicDocs.delete(docInfo.path) return } throw e } // Re-detect title and instructions from latest file content const detected = detectMagicDocHeader(currentDoc) if (!detected) { // File no longer has magic doc header, remove from tracking trackedMagicDocs.delete(docInfo.path) return } // Build update prompt with latest title and instructions const userPrompt = await buildMagicDocsUpdatePrompt( currentDoc, docInfo.path, detected.title, detected.instructions, ) // Create a custom canUseTool that only allows Edit for magic doc files const canUseTool = async (tool: Tool, input: unknown) => { if ( tool.name === FILE_EDIT_TOOL_NAME && typeof input === 'object' && input !== null && 'file_path' in input ) { const filePath = input.file_path if (typeof filePath === 'string' && filePath === docInfo.path) { return { behavior: 'allow' as const, updatedInput: input } } } return { behavior: 'deny' as const, message: `only ${FILE_EDIT_TOOL_NAME} is allowed for ${docInfo.path}`, decisionReason: { type: 'other' as const, reason: `only ${FILE_EDIT_TOOL_NAME} is allowed`, }, } } // Run Magic Docs update using runAgent with forked context for await (const _message of runAgent({ agentDefinition: getMagicDocsAgent(), promptMessages: [createUserMessage({ content: userPrompt })], toolUseContext: clonedToolUseContext, canUseTool, isAsync: true, forkContextMessages: messages, querySource: 'magic_docs', override: { systemPrompt, userContext, systemContext, }, availableTools: clonedToolUseContext.options.tools, })) { // Just consume - let it run to completion } } /** * Magic Docs post-sampling hook that updates all tracked Magic Docs */ const updateMagicDocs = sequential(async function ( context: REPLHookContext, ): Promise { const { messages, querySource } = context if (querySource !== 'repl_main_thread') { return } // Only update when conversation is idle (no tool calls in last turn) const hasToolCalls = hasToolCallsInLastAssistantTurn(messages) if (hasToolCalls) { return } const docCount = trackedMagicDocs.size if (docCount === 0) { return } for (const docInfo of Array.from(trackedMagicDocs.values())) { await updateMagicDoc(docInfo, context) } }) export async function initMagicDocs(): Promise { if (process.env.USER_TYPE === 'ant') { // Register listener to detect magic docs when files are read registerFileReadListener((filePath: string, content: string) => { const result = detectMagicDocHeader(content) if (result) { registerMagicDoc(filePath) } }) registerPostSamplingHook(updateMagicDocs) } }