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/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
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
|
+
}
|