File size: 7,683 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | /**
* 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<string, MagicDocInfo>()
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<void> {
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<void> {
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<void> {
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)
}
}
|