/ services / MagicDocs / magicDocs.ts
magicDocs.ts
  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  
  9  import type { Tool, ToolUseContext } from '../../Tool.js'
 10  import type { BuiltInAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
 11  import { runAgent } from '../../tools/AgentTool/runAgent.js'
 12  import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'
 13  import {
 14    FileReadTool,
 15    type Output as FileReadToolOutput,
 16    registerFileReadListener,
 17  } from '../../tools/FileReadTool/FileReadTool.js'
 18  import { isFsInaccessible } from '../../utils/errors.js'
 19  import { cloneFileStateCache } from '../../utils/fileStateCache.js'
 20  import {
 21    type REPLHookContext,
 22    registerPostSamplingHook,
 23  } from '../../utils/hooks/postSamplingHooks.js'
 24  import {
 25    createUserMessage,
 26    hasToolCallsInLastAssistantTurn,
 27  } from '../../utils/messages.js'
 28  import { sequential } from '../../utils/sequential.js'
 29  import { buildMagicDocsUpdatePrompt } from './prompts.js'
 30  
 31  // Magic Doc header pattern: # MAGIC DOC: [title]
 32  // Matches at the start of the file (first line)
 33  const MAGIC_DOC_HEADER_PATTERN = /^#\s*MAGIC\s+DOC:\s*(.+)$/im
 34  // Pattern to match italics on the line immediately after the header
 35  const ITALICS_PATTERN = /^[_*](.+?)[_*]\s*$/m
 36  
 37  // Track magic docs
 38  type MagicDocInfo = {
 39    path: string
 40  }
 41  
 42  const trackedMagicDocs = new Map<string, MagicDocInfo>()
 43  
 44  export 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   */
 52  export 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   */
 87  export 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   */
 99  function 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   */
114  async 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   */
217  const 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  
242  export 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  }