forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmagicDocs.ts
More file actions
254 lines (228 loc) · 7.5 KB
/
Copy pathmagicDocs.ts
File metadata and controls
254 lines (228 loc) · 7.5 KB
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
/**
* 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)
}
}