heartcode 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js ADDED
@@ -0,0 +1,774 @@
1
+ import fs from 'node:fs'
2
+ import { log } from './log.js'
3
+ import { slug } from './memory.js'
4
+ import { fillArgs } from './skills.js'
5
+ import { prompt } from './prompts.js'
6
+ import path from 'node:path'
7
+ import { spawn, execFileSync } from 'node:child_process'
8
+ import { applyEdit } from './edit.js'
9
+ import { readJson } from './config.js'
10
+ import { c, diffLines, renderDiff } from './ui.js'
11
+ import { webFetch, webSearch } from './web.js'
12
+ import { runHooks } from './hooks.js'
13
+ import { parsePatch, applyChunks } from './patch.js'
14
+ import { formatFile } from './format.js'
15
+ import { diagnose } from './lsp.js'
16
+
17
+ const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.venv', 'venv', '__pycache__', 'target', '.cache', '.DS_Store'])
18
+
19
+ export const clip = (s, n) => (String(s).length > n ? String(s).slice(0, n) + '…' : String(s))
20
+ export const tail = (s, n) => (s.length > n ? '…' + s.slice(-n) : s)
21
+
22
+ export function resolveIn(root, p) {
23
+ const clean = String(p ?? '').trim().replace(/^["'`]+|["'`]+$/g, '')
24
+ if (!clean) throw new Error('no path given')
25
+ let abs = path.resolve(root, clean)
26
+ // "num.js:" or "num.js," from a sloppy model: the file without the stray mark, if that one exists
27
+ const trimmed = path.resolve(root, clean.replace(/[:;,.]+$/, ''))
28
+ if (trimmed !== abs && !fs.existsSync(abs) && fs.existsSync(trimmed)) abs = trimmed
29
+ if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error(`${clean} is outside the project; use a path relative to the project folder`)
30
+ return abs
31
+ }
32
+
33
+ // Project files a message names (by path, or by file name like "num.js"), most specific first.
34
+ export function namedFiles(text, files) {
35
+ const t = text.toLowerCase()
36
+ return files.filter((f) => {
37
+ const base = path.basename(f).toLowerCase()
38
+ return t.includes(f.toLowerCase()) || (base.includes('.') && new RegExp(`(^|[^\\w./-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`).test(t))
39
+ })
40
+ }
41
+
42
+ // git-aware file list (respects .gitignore), falling back to a plain walk.
43
+ export function listFiles(root, limit = 2000) {
44
+ try {
45
+ const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
46
+ cwd: root,
47
+ encoding: 'utf8',
48
+ stdio: ['ignore', 'pipe', 'ignore'],
49
+ maxBuffer: 64 << 20,
50
+ })
51
+ return out
52
+ .split('\0')
53
+ .filter((f) => f && fs.existsSync(path.join(root, f)))
54
+ .sort()
55
+ .slice(0, limit)
56
+ } catch {}
57
+ const files = []
58
+ const walk = (dir) => {
59
+ let entries
60
+ try {
61
+ entries = fs.readdirSync(dir, { withFileTypes: true })
62
+ } catch {
63
+ return
64
+ }
65
+ for (const e of entries) {
66
+ if (files.length >= limit) return
67
+ if (IGNORE.has(e.name)) continue
68
+ const p = path.join(dir, e.name)
69
+ if (e.isDirectory()) walk(p)
70
+ else if (e.isFile()) files.push(path.relative(root, p))
71
+ }
72
+ }
73
+ walk(root)
74
+ return files.sort()
75
+ }
76
+
77
+ const isBinary = (buf) => buf.subarray(0, 8000).includes(0)
78
+ export const numberLines = (lines, from = 1) => lines.map((l, i) => `${from + i}| ${l}`).join('\n')
79
+
80
+ export function readFileTool(root, p, start, end, maxLines = 400) {
81
+ const abs = resolveIn(root, p)
82
+ const rel = path.relative(root, abs)
83
+ if (!fs.existsSync(abs)) {
84
+ const base = path.basename(abs)
85
+ const near = listFiles(root).filter((f) => path.basename(f) === base)
86
+ return `error: ${rel} does not exist${near.length ? `. Did you mean: ${near.slice(0, 5).join(', ')}` : ''}`
87
+ }
88
+ if (fs.statSync(abs).isDirectory()) return fs.readdirSync(abs).join('\n') || '(empty directory)'
89
+ const buf = fs.readFileSync(abs)
90
+ if (isBinary(buf)) return `${rel} is a binary file (${buf.length} bytes)`
91
+ const lines = buf.toString('utf8').split('\n')
92
+ const s = Math.max(1, Number(start) || 1)
93
+ let e = Math.min(lines.length, Number(end) || lines.length, s + maxLines - 1)
94
+ // long lines: stop at a line before the result would be cut (12k characters), and say where
95
+ for (let i = s - 1, n = 0; i < e; i++) if ((n += lines[i].length + 8) > 11_000) e = Math.max(s, i)
96
+ const more = e < lines.length ? `\n… (${lines.length} lines total; read with start_line=${e + 1} for more)` : ''
97
+ return numberLines(lines.slice(s - 1, e), s) + more
98
+ }
99
+
100
+ export function searchFiles(root, query, sub = '', limit = 50) {
101
+ const ci = query === query.toLowerCase()
102
+ let re
103
+ try {
104
+ re = new RegExp(query, ci ? 'i' : '')
105
+ } catch {
106
+ re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'i' : '')
107
+ }
108
+ const prefix = sub ? path.relative(root, resolveIn(root, sub)) : ''
109
+ const hits = []
110
+ for (const f of listFiles(root)) {
111
+ if (prefix && f !== prefix && !f.startsWith(prefix + '/')) continue
112
+ let buf
113
+ try {
114
+ buf = fs.readFileSync(path.join(root, f))
115
+ } catch {
116
+ continue
117
+ }
118
+ if (buf.length > 1 << 20 || isBinary(buf)) continue
119
+ const lines = buf.toString('utf8').split('\n')
120
+ for (let i = 0; i < lines.length; i++) {
121
+ if (!re.test(lines[i])) continue
122
+ hits.push(`${f}:${i + 1}: ${clip(lines[i].trim(), 200)}`)
123
+ if (hits.length >= limit) return hits.join('\n') + `\n… (stopped at ${limit} matches)`
124
+ }
125
+ }
126
+ return hits.join('\n') || `no matches for ${query}`
127
+ }
128
+
129
+ // Runs a shell command in its own process group. The command is over when its shell exits, even
130
+ // if something it started in the background (`server &`) still holds the output open; whatever is
131
+ // left in the group is then stopped, so nothing hangs or outlives HeartCode. Esc (signal) and the
132
+ // timeout stop the whole group too.
133
+ // stdin: text for its input; env: added variables; split: stderr returned apart (hooks).
134
+ export function runCommand(root, cmd, { signal, timeout = 120_000, stdin, env, split = false } = {}) {
135
+ return new Promise((resolve) => {
136
+ let out = ''
137
+ let err = ''
138
+ let done = false
139
+ const group = process.platform !== 'win32'
140
+ const child = spawn(cmd, {
141
+ cwd: root,
142
+ shell: true,
143
+ detached: group,
144
+ stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
145
+ env: { ...process.env, CI: '1', FORCE_COLOR: '0', NO_COLOR: '1', ...env },
146
+ })
147
+ if (stdin !== undefined) {
148
+ child.stdin.on('error', () => {}) // a command that doesn't read its input
149
+ child.stdin.end(stdin)
150
+ }
151
+ const kill = (sig) => {
152
+ try {
153
+ if (group) process.kill(-child.pid, sig)
154
+ else child.kill(sig)
155
+ return true
156
+ } catch {
157
+ return false // nothing left
158
+ }
159
+ }
160
+ const finish = (code, note = '') => {
161
+ if (done) return
162
+ done = true
163
+ clearTimeout(timer)
164
+ signal?.removeEventListener('abort', stop)
165
+ if (kill(0) && code !== 'interrupted' && code !== 'timeout') {
166
+ note += '\n(Processes this command left running in the background were stopped: run_command cannot keep a server running.)'
167
+ }
168
+ kill('SIGTERM')
169
+ setTimeout(() => kill('SIGKILL'), 2000).unref()
170
+ child.stdout.destroy()
171
+ child.stderr.destroy()
172
+ const plain = (t) => t.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
173
+ resolve({ code, output: plain(out) + note, ...(split && { stderr: plain(err) }) })
174
+ }
175
+ const add = (d) => {
176
+ out += d
177
+ if (out.length > 200_000) out = out.slice(-100_000)
178
+ }
179
+ const addErr = (d) => {
180
+ err += d
181
+ if (err.length > 200_000) err = err.slice(-100_000)
182
+ }
183
+ const stop = () => finish('interrupted', '\n(stopped by the user)')
184
+ const timer = setTimeout(() => finish('timeout', `\n(stopped after ${timeout / 1000}s)`), timeout)
185
+ signal?.addEventListener('abort', stop, { once: true })
186
+ if (signal?.aborted) stop()
187
+ child.stdout.on('data', add)
188
+ child.stderr.on('data', split ? addErr : add)
189
+ child.on('error', (e) => {
190
+ add(`\n${e.message}`)
191
+ finish('error')
192
+ })
193
+ child.on('exit', (code, sig) => setTimeout(() => finish(code ?? sig), 150)) // last output, then done
194
+ child.on('close', (code, sig) => finish(code ?? sig))
195
+ })
196
+ }
197
+
198
+ export function suggestCommands(root) {
199
+ const out = []
200
+ const has = (f) => fs.existsSync(path.join(root, f))
201
+ const pkg = readJson(path.join(root, 'package.json'), null)
202
+ for (const k of Object.keys(pkg?.scripts ?? {})) out.push(k === 'test' || k === 'start' ? `npm ${k}` : `npm run ${k}`)
203
+ if (has('pyproject.toml') || has('pytest.ini') || has('setup.py')) out.push('python -m pytest')
204
+ if (has('go.mod')) out.push('go test ./...', 'go build ./...')
205
+ if (has('Cargo.toml')) out.push('cargo test', 'cargo build')
206
+ if (has('Makefile')) out.push('make')
207
+ return out.slice(0, 9)
208
+ }
209
+
210
+ const PLACEHOLDER = /(\.\.\.|…)\s*(rest|existing|remaining|unchanged|other)|rest of (the )?(code|file)|existing code (here|unchanged)/i
211
+
212
+ // Shows the diff, asks the user, writes (next = null: deletes the file). Shared by both agent modes.
213
+ export async function proposeWrite(s, rel, old, next) {
214
+ if (old === next) return { status: 'unchanged' }
215
+ if (s.ui.permission === 'plan') return { status: 'rejected', feedback: prompt('agent/plan-refused') } // both modes write through here
216
+ const ops = diffLines(old, next ?? '')
217
+ s.ui.print(renderDiff(ops, rel))
218
+ if (next !== null && PLACEHOLDER.test(next) && !PLACEHOLDER.test(old)) s.ui.warn('the new content seems to contain a placeholder like "rest of code unchanged"')
219
+ const ok = await s.ui.confirm(next === null ? `Delete ${rel}?` : old ? `Apply this change to ${rel}?` : `Create ${rel}?`)
220
+ log('write', rel, ok === true ? 'approved' : `rejected ${typeof ok === 'string' ? ok : ''}`)
221
+ if (ok !== true) return { status: 'rejected', feedback: typeof ok === 'string' ? ok : '' }
222
+ const abs = resolveIn(s.root, rel)
223
+ s.track?.(rel, fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null) // for /rewind
224
+ if (next === null) fs.rmSync(abs, { force: true })
225
+ else {
226
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
227
+ fs.writeFileSync(abs, next)
228
+ }
229
+ const add = ops.filter(([t]) => t === '+').length
230
+ const del = ops.filter(([t]) => t === '-').length
231
+ const res = { status: 'applied', summary: `+${add} -${del}`, diff: renderDiff(ops, rel, false, 2, 60), ops, text: next }
232
+ if (next !== null) await afterWrite(s, rel, old, res)
233
+ return res
234
+ }
235
+
236
+ // After a write: the project's formatter (format.js), then a language server's errors (lsp.js).
237
+ // The model is told both: its next edit must match the formatted text, and errors are its to fix.
238
+ async function afterWrite(s, rel, old, res) {
239
+ const notes = []
240
+ if (!s.noFormat) {
241
+ const f = await formatFile(s.root, rel).catch((e) => (log('format', `${rel}: ${e.message}`), null))
242
+ if (f) {
243
+ res.text = f.text
244
+ res.ops = diffLines(old, f.text)
245
+ res.diff = renderDiff(res.ops, rel, false, 2, 60) // guided mode shows the model this
246
+ res.formatted = true
247
+ s.ui.result(`formatted with ${f.by}`)
248
+ notes.push(`${f.by} formatted ${rel} after the change (the excerpt below is the formatted text).`)
249
+ }
250
+ }
251
+ if (!s.noLsp) {
252
+ s.ui.spinner(`checking ${rel}`)
253
+ const problems = await diagnose(s, rel, res.text)
254
+ s.ui.stopSpin()
255
+ if (problems) {
256
+ const n = /reports (\d+) error/.exec(problems)?.[1]
257
+ s.ui.result(n ? `${n} error${n === '1' ? '' : 's'} from the language server` : 'no errors now')
258
+ notes.push(problems)
259
+ }
260
+ }
261
+ if (notes.length) res.note = notes.join('\n')
262
+ }
263
+
264
+ // A search over the whole disk or the home folder ("grep -r x /", "find ~ -name …"): minutes of
265
+ // waiting for nothing, which weak models do when they've lost track. Refused at once instead.
266
+ const SEARCHES = /^(grep|egrep|rgrep|rg|ag|ack|find|fd|locate|mdfind|du|tree)$/
267
+ export function searchesOutside(cmd, root) {
268
+ const home = process.env.HOME ?? ''
269
+ const wide = (t) => {
270
+ const p = t.replace(/^["']|["']$/g, '').replace(/\/+$/, '') || '/'
271
+ if (['/', '~', '$HOME', '${HOME}'].includes(p) || p === home) return true
272
+ return p.startsWith('/') && root.startsWith(p + '/') // a folder the project is inside
273
+ }
274
+ return String(cmd)
275
+ .split(/[;&|\n]+/)
276
+ .some((part) => {
277
+ const words = part.trim().split(/\s+/).filter((w) => !/^(sudo|nice|timeout|gtimeout|\d+[smh]?)$/.test(w))
278
+ if (!SEARCHES.test(words[0] ?? '') && !(words[0] === 'ls' && words.some((w) => /^-\w*R/.test(w)))) return false
279
+ if (words[0].includes('grep') && !words.some((w) => /^-\w*[rR]|^--(recursive|dereference-recursive)$/.test(w))) return false
280
+ return words.slice(1).some((w) => !w.startsWith('-') && wide(w))
281
+ })
282
+ }
283
+
284
+ export async function runConfirmed(s, cmd, signal) {
285
+ s.ui.step('run', cmd)
286
+ if (s.ui.permission === 'plan' && !readOnlyCommand(cmd)) {
287
+ s.ui.result('not run: plan mode')
288
+ return prompt('agent/plan-refused-command')
289
+ }
290
+ if (searchesOutside(cmd, s.root)) {
291
+ log('command', `refused (searches outside the project): ${cmd}`)
292
+ s.ui.result('not run: searches outside the project')
293
+ return prompt('agent/command-too-wide', { root: s.root })
294
+ }
295
+ const ok = s.ui.permission === 'plan' ? true : await s.ui.confirm(`Run ${c.bold(cmd)}?`, 'command') // plan mode: only read-only ones get here
296
+ if (ok !== true) return `The user declined to run this command.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
297
+ const before = snapshot(s)
298
+ s.ui.spinner(`running ${cmd}`)
299
+ const t0 = Date.now()
300
+ const { code, output } = await runCommand(s.root, cmd, { signal })
301
+ log('command', `${cmd} → exit ${code} in ${Date.now() - t0}ms\n${tail(output, 8000)}`)
302
+ s.ui.stopSpin()
303
+ s.ui.result(`exit ${code}`)
304
+ const changed = changesSince(s, before)
305
+ const why = code === 'timeout' ? `\n${prompt('agent/command-timeout', { root: s.root })}` : ''
306
+ return `exit code ${code}\n${tail(output, 4000) || '(no output)'}${changed ? `\nFiles this command changed: ${changed}` : ''}${why}`
307
+ }
308
+
309
+ // Files a shell command changes are tracked like HeartCode's own edits: shown as diffs, kept for
310
+ // /rewind, and told to the model. Text files up to 256 KB, read only when they changed since the
311
+ // last look (the contents are cached per session).
312
+ const SNAP_MAX = 256_000
313
+ function snapshot(s) {
314
+ const cache = (s.fileCache ??= new Map())
315
+ const now = new Map()
316
+ for (const rel of listFiles(s.root)) {
317
+ let st
318
+ try {
319
+ st = fs.statSync(path.join(s.root, rel))
320
+ } catch {
321
+ continue
322
+ }
323
+ const sig = `${st.mtimeMs}:${st.size}`
324
+ now.set(rel, sig)
325
+ if (st.size > SNAP_MAX || cache.get(rel)?.sig === sig) continue
326
+ try {
327
+ const text = fs.readFileSync(path.join(s.root, rel), 'utf8')
328
+ cache.set(rel, { sig, text: text.includes('\0') ? null : text }) // null: binary
329
+ } catch {}
330
+ }
331
+ return now
332
+ }
333
+
334
+ function changesSince(s, before) {
335
+ const was = new Map([...before.keys()].map((rel) => [rel, s.fileCache.get(rel)?.text])) // before the command: text, null (binary), undefined (too big)
336
+ const after = snapshot(s) // refreshes the cache with the new contents
337
+ const notes = []
338
+ for (const rel of new Set([...before.keys(), ...after.keys()])) {
339
+ if (before.get(rel) === after.get(rel)) continue
340
+ const old = was.get(rel)
341
+ const next = after.has(rel) ? s.fileCache.get(rel)?.text : undefined
342
+ let note = !before.has(rel) ? `${rel} (new)` : !after.has(rel) ? `${rel} (deleted)` : rel
343
+ if (!before.has(rel)) s.track?.(rel, null) // /rewind removes it
344
+ else if (typeof old === 'string') s.track?.(rel, old) // /rewind puts this version back
345
+ if (typeof old === 'string' && typeof next === 'string') {
346
+ const ops = diffLines(old, next)
347
+ s.ui.print(renderDiff(ops, rel, undefined, 2, 40))
348
+ note += ` (+${ops.filter(([t]) => t === '+').length} -${ops.filter(([t]) => t === '-').length})`
349
+ } else s.ui.result(note)
350
+ notes.push(note)
351
+ }
352
+ return notes.slice(0, 30).join(', ') + (notes.length > 30 ? `, … ${notes.length - 30} more` : '')
353
+ }
354
+
355
+ export function writeResult(rel, r) {
356
+ if (r.status === 'applied') return `${r.deleted ? 'Deleted' : 'Changed'} ${rel} (${r.summary}).${r.note ? `\n${r.note}` : ''}`
357
+ if (r.status === 'unchanged') return `No change: the new content of ${rel} is identical to the old content.`
358
+ return `The user rejected the change to ${rel}.${r.feedback ? ' User feedback: ' + r.feedback : ''}`
359
+ }
360
+
361
+ // --- native tool calling ---
362
+
363
+ // Kept short: these definitions go with every request.
364
+ const str = { type: 'string' }
365
+ const int = { type: 'integer' }
366
+ const obj = (properties, required = []) => ({ type: 'object', properties, required })
367
+ export const TOOL_DEFS = [
368
+ { name: 'read_file', description: prompt('agent/tool-read_file'), parameters: obj({ path: str, start_line: int, end_line: int }, ['path']) },
369
+ { name: 'search', description: prompt('agent/tool-search'), parameters: obj({ query: str, path: str }, ['query']) },
370
+ { name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ filter: str }) },
371
+ // One edit per call: a list of edits per call measured 2–6 malformed calls per benchmark run vs 0.
372
+ { name: 'edit_file', description: prompt('agent/tool-edit_file'), parameters: obj({ path: str, search: str, replace: str }, ['path', 'search', 'replace']) },
373
+ { name: 'write_file', description: prompt('agent/tool-write_file'), parameters: obj({ path: str, content: str, append: { type: 'boolean' } }, ['path', 'content']) },
374
+ { name: 'run_command', description: prompt('agent/tool-run_command'), parameters: obj({ command: str }, ['command']) },
375
+ { name: 'todo', description: prompt('agent/tool-todo'), parameters: obj({ todos: { type: 'array', items: obj({ content: str, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } }, ['content', 'status']) } }, ['todos']) },
376
+ { name: 'web_search', description: prompt('agent/tool-web_search'), parameters: obj({ query: str }, ['query']) },
377
+ { name: 'web_fetch', description: prompt('agent/tool-web_fetch'), parameters: obj({ url: str, start: int }, ['url']) },
378
+ { name: 'ask_user', description: prompt('agent/tool-ask_user'), parameters: obj({ question: str, options: { type: 'array', items: str }, multiple: { type: 'boolean' } }, ['question']) },
379
+ ]
380
+
381
+ // Plan mode (permission "plan"): looking around is fine, changing things isn't. Edits are refused,
382
+ // and a command runs only when it only reads (else it's refused, not asked about).
383
+ export const PLAN_HIDDEN = new Set(['edit_file', 'write_file', 'apply_patch'])
384
+ const READ_ONLY = /^(ls|cat|head|tail|wc|grep|egrep|rg|find|fd|tree|pwd|file|stat|du|df|which|type|echo|date|printenv|diff|cmp|sort|uniq|cut|jq|nl|basename|dirname|realpath|readlink|uname|whoami|(node|npm|python3?|go|cargo) --version|npm (ls|list|view|outdated)|git (status|log|diff|show|ls-files|blame|grep|describe|shortlog|rev-parse|config --get)|git (branch|tag|remote)( -[avlr]+| --list| -v)*$)\b/
385
+ export function readOnlyCommand(cmd) {
386
+ const c = String(cmd).trim()
387
+ if (/[>`]|\$\(|\b(sudo|rm|mv|cp|tee|chmod|chown|dd|truncate|mkdir|touch|ln)\b|-delete\b|-exec|-ok\b|sed\s+-i|\bsort\b.*\s-o/.test(c)) return false // writes, or runs something else
388
+ return c.split(/\s*(?:&&|\|\||;|\|)\s*/).every((part) => READ_ONLY.test(part))
389
+ }
390
+
391
+ // The one tool for all MCP servers, and the skill loader; each only when there's something to offer.
392
+ export function extraTools(s) {
393
+ const out = []
394
+ if (s.mcp?.names.length) {
395
+ const servers = s.mcp.names.map((n) => `${n}: ${s.mcp.get(n).cfg.description || 'no description'}`).join('\n')
396
+ out.push({ name: 'mcp', description: prompt('agent/tool-mcp', { servers }), parameters: obj({ server: str, tool: str, arguments: { type: 'object' } }, ['server', 'tool']) })
397
+ }
398
+ if (s.memory) {
399
+ out.push({
400
+ name: 'memory',
401
+ description: prompt('agent/tool-memory'),
402
+ parameters: obj({ action: { type: 'string', enum: ['write', 'read', 'search', 'delete'] }, name: str, description: str, type: str, tags: str, scope: { type: 'string', enum: ['project', 'global'] }, content: str, query: str }, ['action']),
403
+ })
404
+ }
405
+ const forModel = [...(s.skills?.values() ?? [])].filter((k) => k.use !== 'me') // "use: me" skills cost nothing here
406
+ if (forModel.length) {
407
+ const skills = forModel.map((k) => `${k.name}: ${k.description}`).join('\n')
408
+ out.push({ name: 'skill', description: prompt('agent/tool-skill', { skills }), parameters: obj({ name: str }, ['name']) })
409
+ }
410
+ // the patch format OpenAI's models are trained on (they also write it as a shell command, below)
411
+ if (/gpt|codex|\bo\d/i.test(s.client?.model ?? '')) out.push({ name: 'apply_patch', description: prompt('agent/tool-apply_patch'), parameters: obj({ input: str }, ['input']) })
412
+ if (!s.isSub && s.subagent) out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str }, ['description', 'prompt']) })
413
+ return [...out, ...(s.director?.toolDefs(s) ?? [])] // manage (director.js)
414
+ }
415
+
416
+ // todo: the model's checklist for this message, shown to the user each time it changes.
417
+ // Lenient: the list as JSON text, plain strings for items, "done" for completed.
418
+ const MARK = { pending: '☐', in_progress: '◐', completed: '☑' }
419
+ function todoTool(s, args) {
420
+ let list = args.todos ?? args.items ?? args
421
+ if (typeof list === 'string') {
422
+ try {
423
+ list = JSON.parse(list)
424
+ } catch {
425
+ list = list.split('\n').filter((l) => l.trim())
426
+ }
427
+ }
428
+ if (!Array.isArray(list)) return 'error: todos must be a list of {content, status}'
429
+ const todos = list
430
+ .map((t) => (typeof t === 'string' ? { content: t, status: 'pending' } : { content: String(t?.content ?? t?.task ?? t?.title ?? ''), status: String(t?.status ?? 'pending') }))
431
+ .filter((t) => t.content.trim())
432
+ .map((t) => ({ ...t, status: /done|complete/i.test(t.status) ? 'completed' : /progress|doing|active/i.test(t.status) ? 'in_progress' : 'pending' }))
433
+ s.chat.todos = todos
434
+ s.todoTouched = true
435
+ const paint = { pending: c.muted, in_progress: (x) => c.info(c.bold(x)), completed: (x) => c.faint(x) }
436
+ s.ui.print(todos.map((t) => ` ${paint[t.status](`${MARK[t.status]} ${t.content}`)}`).join('\n') || c.faint(' (todo list cleared)'))
437
+ const done = todos.filter((t) => t.status === 'completed').length
438
+ const next = todos.find((t) => t.status !== 'completed')
439
+ return `Todo list saved: ${done} of ${todos.length} done.${next ? ` Next: ${next.content}` : ''}`
440
+ }
441
+
442
+ // ask_user: a question for the user mid-task, with choices (and their own answer) or free text.
443
+ async function askUser(s, args) {
444
+ const question = String(args.question ?? '').trim()
445
+ if (!question) return 'error: ask_user needs "question"'
446
+ if (s.ui.noUser) return prompt('agent/ask-nobody')
447
+ let options = args.options ?? []
448
+ if (typeof options === 'string') {
449
+ try {
450
+ options = JSON.parse(options)
451
+ } catch {
452
+ options = options.split('\n')
453
+ }
454
+ }
455
+ const items = [options].flat().map((o) => (typeof o === 'string' ? { label: o.trim() } : { label: String(o?.label ?? o?.value ?? '').trim(), desc: o?.description })).filter((o) => o.label)
456
+ s.ui.stopSpin()
457
+ let answer
458
+ if (items.length) {
459
+ const got = [await s.ui.pick(question, [...items, { label: 'Something else', desc: 'type your own answer', own: true }], { multi: Boolean(args.multiple) })].flat().filter(Boolean)
460
+ const own = got.some((x) => x.own) ? await s.ui.ask(`${c.bold('Your answer')} ${c.dim('›')} `) : ''
461
+ answer = [...got.filter((x) => !x.own).map((x) => x.label), own?.trim()].filter(Boolean).join(', ')
462
+ } else answer = (await s.ui.ask(`${c.yellow('?')} ${c.bold(question)} `))?.trim()
463
+ log('ask', question, answer)
464
+ return answer ? `The user answered: ${answer}` : prompt('agent/ask-no-answer')
465
+ }
466
+
467
+ async function webTool(s, name, args, signal) {
468
+ const what = name === 'web_search' ? String(args.query ?? '') : String(args.url ?? '')
469
+ if (!what.trim()) return `error: ${name} needs ${name === 'web_search' ? '"query"' : '"url"'}`
470
+ const ok = await s.ui.confirm(name === 'web_search' ? `Search the web for ${c.bold(what)}?` : `Fetch ${c.bold(what)}?`, 'read')
471
+ if (ok !== true) return `The user declined this.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
472
+ s.ui.spinner(name === 'web_search' ? `searching: ${clip(what, 60)}` : `fetching ${clip(what, 60)}`)
473
+ return name === 'web_search' ? await webSearch(what, { signal }) : await webFetch(what, { start: args.start, signal })
474
+ }
475
+
476
+ function memoryTool(s, args) {
477
+ const m = s.memory
478
+ if (!m) return 'error: there is no memory here'
479
+ const name = String(args.name ?? '')
480
+ switch (args.action) {
481
+ case 'write': {
482
+ if (!args.content && !args.description) return 'error: write needs at least content or a description'
483
+ const n = m.write(args)
484
+ s.memoryWrites = (s.memoryWrites ?? 0) + 1
485
+ return `Saved [[${n}]]${m.read(n)?.scope === 'global' ? ' (global)' : ''}.`
486
+ }
487
+ case 'read': {
488
+ const n = m.read(name)
489
+ if (!n) return `error: no note [[${slug(name)}]]. ${m.search(name).slice(0, 5).map((x) => `[[${x.name}]]`).join(', ') || 'Search the memory to find notes.'}`
490
+ return `[[${n.name}]] (${n.scope === 'global' ? 'global' : 'this project'}, ${n.type}${n.tags.length ? `; ${n.tags.join(', ')}` : ''}): ${n.description}\n\n${n.body}${n.backlinks.length ? `\n\nLinked from: ${n.backlinks.join(', ')}` : ''}`
491
+ }
492
+ case 'search': {
493
+ const hits = m.search(args.query ?? name)
494
+ return hits.length ? hits.slice(0, 20).map((n) => `[[${n.name}]] — ${n.description} (${[n.scope === 'global' && 'global', ...n.tags].filter(Boolean).join(', ')})`).join('\n') : 'no notes match'
495
+ }
496
+ case 'delete':
497
+ return m.delete(name, args.scope) ? `Deleted [[${slug(name)}]].` : `error: no note [[${slug(name)}]]`
498
+ default:
499
+ return 'error: action must be write, read, search or delete'
500
+ }
501
+ }
502
+
503
+ // Lenient about slips in names: "chrome-devtools:", "evaluate_script'", no server when there's one.
504
+ const bare = (v) => String(v ?? '').trim().replace(/^["'`]+|["'`:;,.]+$/g, '')
505
+
506
+ async function mcpTool(s, args, signal) {
507
+ const names = s.mcp?.names ?? []
508
+ // everything written into "server": 'chrome-devtools" tool="navigate_page" arguments={...}'
509
+ const crammed = /^\s*"?([\w.-]+)"?\s+tool\s*[=:]\s*"?([\w.-]+)"?(?:\s+arguments\s*[=:]\s*(\{[\s\S]*\}))?/.exec(String(args.server ?? ''))
510
+ if (crammed && names.includes(crammed[1])) {
511
+ let a = args.arguments
512
+ try {
513
+ a = crammed[3] ? JSON.parse(crammed[3]) : a
514
+ } catch {}
515
+ args = { server: crammed[1], tool: args.tool ?? crammed[2], arguments: a }
516
+ }
517
+ let a = args.arguments ?? {}
518
+ if (typeof a === 'string') {
519
+ try {
520
+ a = JSON.parse(a)
521
+ } catch {
522
+ a = {}
523
+ }
524
+ }
525
+ if (bare(args.tool) === 'search') return await searchMcp(s, String(a.query ?? args.query ?? a.name ?? ''), bare(args.server), signal)
526
+ const server = s.mcp?.get(bare(args.server) || (names.length === 1 ? names[0] : ''))
527
+ if (!server) return `error: no MCP server "${args.server}". Servers: ${names.join(', ') || 'none'}`
528
+ const tool = bare(args.tool) || 'list'
529
+ if (tool === 'list' || tool === 'describe') {
530
+ s.ui.spinner(`starting ${server.name}`)
531
+ const tools = await server.listTools(signal)
532
+ if (tool === 'list') return tools.map((t) => `${t.name}: ${String(t.description ?? '').split(/(?<=\.)\s/)[0].slice(0, 160)}`).join('\n')
533
+ const t = tools.find((x) => x.name === a.name)
534
+ return t ? `${t.name}: ${t.description ?? ''}\nArguments (JSON schema): ${JSON.stringify(t.inputSchema ?? {})}` : `error: ${server.name} has no tool "${a.name}"; tool "list" shows its tools`
535
+ }
536
+ const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
537
+ if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
538
+ s.ui.spinner(`${server.name} · ${tool}`)
539
+ const r = await server.call(tool, a, signal)
540
+ const parts = r.content ?? []
541
+ const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
542
+ const images = parts.filter((p) => p.type === 'image' && p.data)
543
+ let note = ''
544
+ if (images.length && s.strat?.vision) {
545
+ ;(s.pendingImages ??= []).push(...images.map((p) => `data:${p.mimeType ?? 'image/png'};base64,${p.data}`))
546
+ note = `\n(${images.length} image(s) attached in the next message)`
547
+ } else if (images.length) note = `\n(${images.length} image(s) not shown: this model can't see images)`
548
+ return `${r.isError ? 'error: ' : ''}${text || '(no text)'}${note}`
549
+ }
550
+
551
+ // tool "search": the tools of every server (or one) whose name or description has the query's
552
+ // words, best first. Servers start for it, so the model needn't list them one by one.
553
+ async function searchMcp(s, query, only, signal) {
554
+ const words = query.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1)
555
+ if (!words.length) return 'error: search needs arguments {"query": "what the tool should do"}'
556
+ const all = s.mcp?.names ?? []
557
+ const names = all.includes(only) ? [only] : all // a server name that isn't one ("mcp", models do): every server
558
+ s.ui.spinner(`searching the tools of ${names.length} MCP server${names.length === 1 ? '' : 's'}`)
559
+ const lists = await Promise.allSettled(names.map((n) => s.mcp.get(n).listTools(signal)))
560
+ const hits = []
561
+ const failed = []
562
+ lists.forEach((r, i) => {
563
+ if (r.status === 'rejected') return failed.push(`${names[i]} (${clip(r.reason?.message ?? r.reason, 80)})`)
564
+ for (const t of r.value) {
565
+ const name = t.name.toLowerCase()
566
+ const text = `${name} ${String(t.description ?? '').toLowerCase()}`
567
+ const score = words.reduce((n, w) => n + (name.includes(w) ? 3 : text.includes(w) ? 1 : 0), 0)
568
+ if (score) hits.push({ score, line: `${names[i]} · ${t.name}: ${String(t.description ?? '').split(/(?<=\.)\s/)[0].slice(0, 160)}` })
569
+ }
570
+ })
571
+ hits.sort((x, y) => y.score - x.score)
572
+ const out = hits.filter((h) => h.score * 2 >= hits[0].score).slice(0, 15).map((h) => h.line).join('\n') || `no tool matches "${query}"` // weak matches (only "the", "page") left out
573
+ return `${out}${failed.length ? `\n(not searched, failed to start: ${failed.join(', ')})` : ''}\nCall one with server and tool; tool "describe" with {"name": …} shows its arguments.`
574
+ }
575
+
576
+ // After an edit: the changed lines with 2 lines around them, numbered, so the model can check its
577
+ // work without reading the whole file again.
578
+ export function excerpt(next, ops, around = 2, max = 40) {
579
+ const lines = next.split('\n')
580
+ const changed = []
581
+ let n = 0
582
+ for (const [t] of ops) {
583
+ if (t === '+') changed.push(n)
584
+ if (t !== '-') n++
585
+ else if (!changed.includes(n)) changed.push(n) // a deletion: show where it was
586
+ }
587
+ const show = new Set()
588
+ for (const i of changed) for (let k = Math.max(0, i - around); k <= Math.min(lines.length - 1, i + around); k++) show.add(k)
589
+ const rows = [...show].sort((a, b) => a - b)
590
+ const out = []
591
+ rows.slice(0, max).forEach((i, j) => {
592
+ if (j && i !== rows[j - 1] + 1) out.push('…')
593
+ out.push(`${i + 1}| ${lines[i]}`)
594
+ })
595
+ if (rows.length > max) out.push(`… (${rows.length - max} more changed lines)`)
596
+ return out.join('\n')
597
+ }
598
+
599
+ // edit_file: all edits apply together or not at all. Lenient about the shape models send: `edits`
600
+ // as a list or as JSON text, the single search/replace form, a `path` per edit. An edit without a
601
+ // path, or whose text isn't in the given file, goes to the one project file that contains its text.
602
+ async function editFiles(s, args) {
603
+ const { root } = s
604
+ let edits = args.edits
605
+ if (typeof edits === 'string') {
606
+ for (const text of [edits, `[${edits}]`]) { // a list as text, or objects without the [ ]
607
+ try {
608
+ edits = JSON.parse(text)
609
+ break
610
+ } catch {}
611
+ }
612
+ }
613
+ if (edits && typeof edits === 'object' && !Array.isArray(edits)) edits = [edits] // one edit, not in a list
614
+ if (!Array.isArray(edits)) edits = [{ search: args.search, replace: args.replace }]
615
+ const files = new Map() // path → { old, text }
616
+ const textOf = (rel) => {
617
+ if (!files.has(rel)) files.set(rel, { old: fs.readFileSync(resolveIn(root, rel), 'utf8') })
618
+ const f = files.get(rel)
619
+ return f.text ?? f.old
620
+ }
621
+ const holder = (search) => {
622
+ const hits = listFiles(root).filter((f) => {
623
+ try {
624
+ const abs = resolveIn(root, f)
625
+ return fs.statSync(abs).size < 1_000_000 && fs.readFileSync(abs, 'utf8').includes(search)
626
+ } catch {
627
+ return false
628
+ }
629
+ })
630
+ return hits.length === 1 ? hits[0] : null
631
+ }
632
+ for (const [i, e] of edits.entries()) {
633
+ const { search: s0, replace: r0, path: p0 } = normalize(e ?? {})
634
+ const search = String(s0 ?? '')
635
+ const replace = String(r0 ?? '')
636
+ const given = p0 ?? args.path
637
+ const label = edits.length > 1 ? `edit ${i + 1} of ${edits.length}: ` : ''
638
+ let rel = given && fs.existsSync(resolveIn(root, given)) ? path.relative(root, resolveIn(root, given)) : null
639
+ let r = rel ? applyEdit(textOf(rel), search, replace) : null
640
+ if (!r || r.error) {
641
+ const other = search.trim() && holder(search)
642
+ if (other && other !== rel) [rel, r] = [other, applyEdit(textOf(other), search, replace)]
643
+ }
644
+ if (!rel) return `error: ${label}${given ? `${given} does not exist; use write_file to create it` : 'no "path" given, and its search text is not in exactly one file'}. Nothing was changed.`
645
+ if (r.error) return `error: ${label}${r.error}. Nothing was changed.`
646
+ files.get(rel).text = r.text
647
+ }
648
+ const out = []
649
+ for (const [rel, f] of files) {
650
+ if (f.text === undefined) continue
651
+ const res = await proposeWrite(s, rel, f.old, f.text)
652
+ out.push(writeResult(rel, res) + (res.status === 'applied' ? `\nNow:\n${excerpt(res.text, res.ops)}` : ''))
653
+ }
654
+ return out.join('\n\n')
655
+ }
656
+
657
+ // apply_patch (patch.js): every file's change is worked out first (nothing is written if one
658
+ // fails), then each is shown, asked about and written like an edit_file change.
659
+ async function applyPatch(s, text) {
660
+ const body = /\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch/.exec(String(text))?.[0] ?? String(text)
661
+ let ops
662
+ try {
663
+ ops = parsePatch(body)
664
+ } catch (e) {
665
+ return `error: ${e.message}. Nothing was changed.`
666
+ }
667
+ if (!ops.length) return 'error: the patch changes no files. Nothing was changed.'
668
+ const plan = [] // [rel, old, next (null: delete)]
669
+ for (const op of ops) {
670
+ const abs = resolveIn(s.root, op.path)
671
+ const rel = path.relative(s.root, abs)
672
+ const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null
673
+ if (op.type === 'add') {
674
+ plan.push([rel, old ?? '', op.content.join('\n') + '\n'])
675
+ continue
676
+ }
677
+ if (old === null) return `error: ${op.path} does not exist${op.type === 'update' ? ' (use *** Add File: to create it)' : ''}. Nothing was changed.`
678
+ if (op.type === 'delete') {
679
+ plan.push([rel, old, null])
680
+ continue
681
+ }
682
+ const r = applyChunks(old, op.chunks)
683
+ if (r.error) return `error: ${op.path}: ${r.error}. Nothing was changed.`
684
+ if (op.moveTo) plan.push([path.relative(s.root, resolveIn(s.root, op.moveTo)), '', r.text], [rel, old, null])
685
+ else plan.push([rel, old, r.text])
686
+ }
687
+ const out = []
688
+ for (const [rel, old, next] of plan) {
689
+ const res = await proposeWrite(s, rel, old, next)
690
+ if (next === null && res.status === 'applied') res.deleted = true
691
+ out.push(writeResult(rel, res) + (res.status === 'applied' && next !== null ? `\nNow:\n${excerpt(res.text, res.ops)}` : ''))
692
+ }
693
+ return out.join('\n\n')
694
+ }
695
+
696
+ // Every tool call goes through the user's PreToolUse and PostToolUse hooks (hooks.js).
697
+ export async function runTool(name, args, s, signal) {
698
+ log('tool', name, args)
699
+ const pre = await runHooks(s, 'PreToolUse', { tool_name: name, tool_input: args }, signal)
700
+ if (pre.block) return prompt('agent/hook-blocked-tool', { reason: pre.block })
701
+ let out = await tool(name, args, s, signal)
702
+ const post = await runHooks(s, 'PostToolUse', { tool_name: name, tool_input: args, tool_response: out }, signal)
703
+ if (post.block) out += `\n\n${prompt('agent/hook-after-tool', { reason: post.block })}`
704
+ log('tool', `${name} result:`, clip(out, 4000))
705
+ return out
706
+ }
707
+
708
+ // Argument names other tools use (Claude Code's file_path, old_string, new_string, …): models
709
+ // trained on them send them here too.
710
+ const ALIASES = { file_path: 'path', filePath: 'path', file: 'path', old_string: 'search', old_str: 'search', oldText: 'search', new_string: 'replace', new_str: 'replace', newText: 'replace', cmd: 'command' }
711
+ function normalize(args) {
712
+ const out = { ...args }
713
+ for (const [from, to] of Object.entries(ALIASES)) if (out[to] === undefined && out[from] !== undefined) out[to] = out[from]
714
+ return out
715
+ }
716
+
717
+ async function tool(name, args, s, signal) {
718
+ args = normalize(args)
719
+ try {
720
+ const { root } = s
721
+ if (s.ui.permission === 'plan') {
722
+ if (PLAN_HIDDEN.has(name) || name === 'append_file' || name === 'manage' && args.action !== 'list' && args.action !== 'show' && args.action !== 'help') return prompt('agent/plan-refused')
723
+ }
724
+ switch (name) {
725
+ case 'read_file':
726
+ return readFileTool(root, args.path, args.start_line, args.end_line)
727
+ case 'list_files': {
728
+ const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
729
+ return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
730
+ }
731
+ case 'search':
732
+ return searchFiles(root, String(args.query ?? ''), args.path)
733
+ case 'edit_file':
734
+ return await editFiles(s, args)
735
+ case 'write_file':
736
+ case 'append_file': {
737
+ if (!args.path) return 'error: write_file needs "path" (the file to write) and "content". Nothing was written.'
738
+ const abs = resolveIn(root, args.path)
739
+ const rel = path.relative(root, abs)
740
+ const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''
741
+ const append = name === 'append_file' || args.append === true || args.append === 'true'
742
+ const res = await proposeWrite(s, rel, old, (append ? old : '') + String(args.content ?? ''))
743
+ return writeResult(rel, res) + (res.formatted ? `\nNow:\n${excerpt(res.text, res.ops)}` : '') // what it wrote isn't what's there now
744
+ }
745
+ case 'run_command':
746
+ if (/^\s*apply_patch\b[\s\S]*\*\*\* Begin Patch/.test(String(args.command ?? ''))) return await applyPatch(s, args.command) // apply_patch <<'EOF' … as Codex runs it
747
+ return await runConfirmed(s, String(args.command ?? ''), signal)
748
+ case 'apply_patch':
749
+ return await applyPatch(s, args.input ?? args.patch ?? args.command ?? '')
750
+ case 'mcp':
751
+ return await mcpTool(s, args, signal)
752
+ case 'memory':
753
+ return memoryTool(s, args)
754
+ case 'todo':
755
+ return todoTool(s, args)
756
+ case 'web_search':
757
+ case 'web_fetch':
758
+ return await webTool(s, name, args, signal)
759
+ case 'agent':
760
+ return await s.subagent(args, signal)
761
+ case 'ask_user':
762
+ return await askUser(s, args)
763
+ case 'skill': {
764
+ const k = s.skills?.get(String(args.name ?? ''))
765
+ return k && k.use !== 'me' ? fillArgs(k.body, '') : `error: no skill "${args.name}". Skills: ${[...(s.skills?.values() ?? [])].filter((x) => x.use !== 'me').map((x) => x.name).join(', ') || 'none'}`
766
+ }
767
+ default:
768
+ return (await s.director?.tool(name, args, s, signal)) ?? `error: there is no tool named ${name}`
769
+ }
770
+ } catch (e) {
771
+ if (e.name === 'AbortError') throw e
772
+ return `error: ${e.message}`
773
+ }
774
+ }