source dump of claude code
at main 254 lines 7.7 kB view raw
1/** 2 * Magic Docs automatically maintains markdown documentation files marked with special headers. 3 * When a file with "# MAGIC DOC: [title]" is read, it runs periodically in the background 4 * using a forked subagent to update the document with new learnings from the conversation. 5 * 6 * See docs/magic-docs.md for more information. 7 */ 8 9import type { Tool, ToolUseContext } from '../../Tool.js' 10import type { BuiltInAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 11import { runAgent } from '../../tools/AgentTool/runAgent.js' 12import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 13import { 14 FileReadTool, 15 type Output as FileReadToolOutput, 16 registerFileReadListener, 17} from '../../tools/FileReadTool/FileReadTool.js' 18import { isFsInaccessible } from '../../utils/errors.js' 19import { cloneFileStateCache } from '../../utils/fileStateCache.js' 20import { 21 type REPLHookContext, 22 registerPostSamplingHook, 23} from '../../utils/hooks/postSamplingHooks.js' 24import { 25 createUserMessage, 26 hasToolCallsInLastAssistantTurn, 27} from '../../utils/messages.js' 28import { sequential } from '../../utils/sequential.js' 29import { buildMagicDocsUpdatePrompt } from './prompts.js' 30 31// Magic Doc header pattern: # MAGIC DOC: [title] 32// Matches at the start of the file (first line) 33const MAGIC_DOC_HEADER_PATTERN = /^#\s*MAGIC\s+DOC:\s*(.+)$/im 34// Pattern to match italics on the line immediately after the header 35const ITALICS_PATTERN = /^[_*](.+?)[_*]\s*$/m 36 37// Track magic docs 38type MagicDocInfo = { 39 path: string 40} 41 42const trackedMagicDocs = new Map<string, MagicDocInfo>() 43 44export function clearTrackedMagicDocs(): void { 45 trackedMagicDocs.clear() 46} 47 48/** 49 * Detect if a file content contains a Magic Doc header 50 * Returns an object with title and optional instructions, or null if not a magic doc 51 */ 52export function detectMagicDocHeader( 53 content: string, 54): { title: string; instructions?: string } | null { 55 const match = content.match(MAGIC_DOC_HEADER_PATTERN) 56 if (!match || !match[1]) { 57 return null 58 } 59 60 const title = match[1].trim() 61 62 // Look for italics on the next line after the header (allow one optional blank line) 63 const headerEndIndex = match.index! + match[0].length 64 const afterHeader = content.slice(headerEndIndex) 65 // Match: newline, optional blank line, then content line 66 const nextLineMatch = afterHeader.match(/^\s*\n(?:\s*\n)?(.+?)(?:\n|$)/) 67 68 if (nextLineMatch && nextLineMatch[1]) { 69 const nextLine = nextLineMatch[1] 70 const italicsMatch = nextLine.match(ITALICS_PATTERN) 71 if (italicsMatch && italicsMatch[1]) { 72 const instructions = italicsMatch[1].trim() 73 return { 74 title, 75 instructions, 76 } 77 } 78 } 79 80 return { title } 81} 82 83/** 84 * Register a file as a Magic Doc when it's read 85 * Only registers once per file path - the hook always reads latest content 86 */ 87export function registerMagicDoc(filePath: string): void { 88 // Only register if not already tracked 89 if (!trackedMagicDocs.has(filePath)) { 90 trackedMagicDocs.set(filePath, { 91 path: filePath, 92 }) 93 } 94} 95 96/** 97 * Create Magic Docs agent definition 98 */ 99function getMagicDocsAgent(): BuiltInAgentDefinition { 100 return { 101 agentType: 'magic-docs', 102 whenToUse: 'Update Magic Docs', 103 tools: [FILE_EDIT_TOOL_NAME], // Only allow Edit 104 model: 'sonnet', 105 source: 'built-in', 106 baseDir: 'built-in', 107 getSystemPrompt: () => '', // Will use override systemPrompt 108 } 109} 110 111/** 112 * Update a single Magic Doc 113 */ 114async function updateMagicDoc( 115 docInfo: MagicDocInfo, 116 context: REPLHookContext, 117): Promise<void> { 118 const { messages, systemPrompt, userContext, systemContext, toolUseContext } = 119 context 120 121 // Clone the FileStateCache to isolate Magic Docs operations. Delete this 122 // doc's entry so FileReadTool's dedup doesn't return a file_unchanged 123 // stub — we need the actual content to re-detect the header. 124 const clonedReadFileState = cloneFileStateCache(toolUseContext.readFileState) 125 clonedReadFileState.delete(docInfo.path) 126 const clonedToolUseContext: ToolUseContext = { 127 ...toolUseContext, 128 readFileState: clonedReadFileState, 129 } 130 131 // Read the document; if deleted or unreadable, remove from tracking 132 let currentDoc = '' 133 try { 134 const result = await FileReadTool.call( 135 { file_path: docInfo.path }, 136 clonedToolUseContext, 137 ) 138 const output = result.data as FileReadToolOutput 139 if (output.type === 'text') { 140 currentDoc = output.file.content 141 } 142 } catch (e: unknown) { 143 // FileReadTool wraps ENOENT in a plain Error("File does not exist...") with 144 // no .code, so check the message in addition to isFsInaccessible (EACCES/EPERM). 145 if ( 146 isFsInaccessible(e) || 147 (e instanceof Error && e.message.startsWith('File does not exist')) 148 ) { 149 trackedMagicDocs.delete(docInfo.path) 150 return 151 } 152 throw e 153 } 154 155 // Re-detect title and instructions from latest file content 156 const detected = detectMagicDocHeader(currentDoc) 157 if (!detected) { 158 // File no longer has magic doc header, remove from tracking 159 trackedMagicDocs.delete(docInfo.path) 160 return 161 } 162 163 // Build update prompt with latest title and instructions 164 const userPrompt = await buildMagicDocsUpdatePrompt( 165 currentDoc, 166 docInfo.path, 167 detected.title, 168 detected.instructions, 169 ) 170 171 // Create a custom canUseTool that only allows Edit for magic doc files 172 const canUseTool = async (tool: Tool, input: unknown) => { 173 if ( 174 tool.name === FILE_EDIT_TOOL_NAME && 175 typeof input === 'object' && 176 input !== null && 177 'file_path' in input 178 ) { 179 const filePath = input.file_path 180 if (typeof filePath === 'string' && filePath === docInfo.path) { 181 return { behavior: 'allow' as const, updatedInput: input } 182 } 183 } 184 return { 185 behavior: 'deny' as const, 186 message: `only ${FILE_EDIT_TOOL_NAME} is allowed for ${docInfo.path}`, 187 decisionReason: { 188 type: 'other' as const, 189 reason: `only ${FILE_EDIT_TOOL_NAME} is allowed`, 190 }, 191 } 192 } 193 194 // Run Magic Docs update using runAgent with forked context 195 for await (const _message of runAgent({ 196 agentDefinition: getMagicDocsAgent(), 197 promptMessages: [createUserMessage({ content: userPrompt })], 198 toolUseContext: clonedToolUseContext, 199 canUseTool, 200 isAsync: true, 201 forkContextMessages: messages, 202 querySource: 'magic_docs', 203 override: { 204 systemPrompt, 205 userContext, 206 systemContext, 207 }, 208 availableTools: clonedToolUseContext.options.tools, 209 })) { 210 // Just consume - let it run to completion 211 } 212} 213 214/** 215 * Magic Docs post-sampling hook that updates all tracked Magic Docs 216 */ 217const updateMagicDocs = sequential(async function ( 218 context: REPLHookContext, 219): Promise<void> { 220 const { messages, querySource } = context 221 222 if (querySource !== 'repl_main_thread') { 223 return 224 } 225 226 // Only update when conversation is idle (no tool calls in last turn) 227 const hasToolCalls = hasToolCallsInLastAssistantTurn(messages) 228 if (hasToolCalls) { 229 return 230 } 231 232 const docCount = trackedMagicDocs.size 233 if (docCount === 0) { 234 return 235 } 236 237 for (const docInfo of Array.from(trackedMagicDocs.values())) { 238 await updateMagicDoc(docInfo, context) 239 } 240}) 241 242export async function initMagicDocs(): Promise<void> { 243 if (process.env.USER_TYPE === 'ant') { 244 // Register listener to detect magic docs when files are read 245 registerFileReadListener((filePath: string, content: string) => { 246 const result = detectMagicDocHeader(content) 247 if (result) { 248 registerMagicDoc(filePath) 249 } 250 }) 251 252 registerPostSamplingHook(updateMagicDocs) 253 } 254}