heartcode 0.1.5 → 0.1.7
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/SKILL.md +33 -11
- package/bin/heartcode.js +83 -30
- package/package.json +27 -1
- package/prompts/agent.md +42 -32
- package/prompts/guided.md +6 -6
- package/src/acp.js +4 -4
- package/src/agent.js +232 -66
- package/src/client.js +165 -81
- package/src/context.js +2 -2
- package/src/director.js +8 -5
- package/src/edit.js +5 -6
- package/src/format.js +18 -9
- package/src/guided.js +27 -55
- package/src/headless.js +20 -12
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +8 -4
- package/src/settings.js +19 -2
- package/src/tools.js +149 -38
- package/src/usage.js +9 -4
package/src/tools.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
4
|
import { log } from './log.js'
|
|
3
|
-
import { slug } from './memory.js'
|
|
5
|
+
import { slug, fullSlug } from './memory.js'
|
|
4
6
|
import { fillArgs } from './skills.js'
|
|
5
7
|
import { prompt } from './prompts.js'
|
|
6
8
|
import path from 'node:path'
|
|
@@ -23,12 +25,13 @@ export const tail = (s, n) => (s.length > n ? '…' + s.slice(-n) : s)
|
|
|
23
25
|
// Tool output too long for the conversation is saved here (one folder per chat), where read_file
|
|
24
26
|
// and search can read it although it's outside the project.
|
|
25
27
|
export const OUTPUTS = path.join(os.tmpdir(), 'heartcode-outputs')
|
|
26
|
-
|
|
28
|
+
// data: text, or an image's bytes (ext: png…)
|
|
29
|
+
export function saveOutput(chatId, name, data, ext = 'txt') {
|
|
27
30
|
try {
|
|
28
31
|
const dir = path.join(OUTPUTS, String(chatId ?? 'chat'))
|
|
29
32
|
fs.mkdirSync(dir, { recursive: true })
|
|
30
|
-
const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}
|
|
31
|
-
fs.writeFileSync(file,
|
|
33
|
+
const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}.${ext}`)
|
|
34
|
+
fs.writeFileSync(file, data)
|
|
32
35
|
return file
|
|
33
36
|
} catch (e) {
|
|
34
37
|
log('tool', `could not save the output: ${e.message}`)
|
|
@@ -48,12 +51,16 @@ export function resolveIn(root, p) {
|
|
|
48
51
|
return abs
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
// Project files a message names
|
|
54
|
+
// Project files a message names: by its path, or by a file name only one file in the project has
|
|
55
|
+
// ("num.js"; in a project with three index.js, "index.js" names none of them).
|
|
52
56
|
export function namedFiles(text, files) {
|
|
53
57
|
const t = text.toLowerCase()
|
|
58
|
+
const count = new Map()
|
|
59
|
+
for (const f of files) count.set(path.basename(f).toLowerCase(), (count.get(path.basename(f).toLowerCase()) ?? 0) + 1)
|
|
60
|
+
const word = (w) => new RegExp(`(^|[^\\w./-])${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`).test(t)
|
|
54
61
|
return files.filter((f) => {
|
|
55
62
|
const base = path.basename(f).toLowerCase()
|
|
56
|
-
return t.includes(f.toLowerCase()) || (base.includes('.') &&
|
|
63
|
+
return t.includes(f.toLowerCase()) || (base.includes('.') && count.get(base) === 1 && word(base))
|
|
57
64
|
})
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -115,6 +122,21 @@ export function readFileTool(root, p, start, end, maxLines = 400) {
|
|
|
115
122
|
return numberLines(lines.slice(s - 1, e), s) + more
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
// Images: the formats providers take, and the size sent at most (bigger ones, a full-page
|
|
126
|
+
// screenshot of a long page, are refused by some providers; HeartCode has no image library to
|
|
127
|
+
// shrink them).
|
|
128
|
+
export const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
129
|
+
export const MAX_IMAGE = 5_000_000
|
|
130
|
+
// An image file as a data: URL; an error that says why not (shown: the name it was given as).
|
|
131
|
+
export function imageUrl(abs, shown = abs) {
|
|
132
|
+
const mime = IMAGE_MIME[path.extname(abs).slice(1).toLowerCase()]
|
|
133
|
+
if (!mime) throw new Error(`${shown} is not an image file (png, jpg, gif, webp)`)
|
|
134
|
+
if (!fs.existsSync(abs)) throw new Error(`${shown} does not exist`)
|
|
135
|
+
const size = fs.statSync(abs).size
|
|
136
|
+
if (size > MAX_IMAGE) throw new Error(`${shown} is too big to send (${Math.round(size / 1e6)} MB; at most ${MAX_IMAGE / 1e6} MB)`)
|
|
137
|
+
return `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
// An image file: shown to a model that can see images (after the tool results, as MCP screenshots
|
|
119
141
|
// are); for one that can't, where to send it.
|
|
120
142
|
const IMAGE = /\.(png|jpe?g|gif|webp)$/i
|
|
@@ -123,12 +145,14 @@ function readImage(s, p) {
|
|
|
123
145
|
const rel = path.relative(s.root, abs) || p
|
|
124
146
|
if (!fs.existsSync(abs)) return readFileTool(s.root, p)
|
|
125
147
|
if (s.strat?.vision) {
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
try {
|
|
149
|
+
;(s.pendingImages ??= []).push({ url: imageUrl(abs, rel), file: rel })
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return `error: ${e.message}`
|
|
152
|
+
}
|
|
128
153
|
return `${rel}: the image is attached in the next message`
|
|
129
154
|
}
|
|
130
|
-
|
|
131
|
-
return `${rel} is an image, and you can't see images.${seer ? ` Ask a model that can: the agent tool with model "${seer.provider} ${seer.model}", and the image's path in the prompt.` : ''}`
|
|
155
|
+
return s.team?.().some((t) => t.images) ? prompt('agent/images-unseen', { files: rel }) : `${rel} is an image, and you can't see images.`
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
export function searchFiles(root, query, sub = '', limit = 50) {
|
|
@@ -274,7 +298,13 @@ export async function proposeWrite(s, rel, old, next) {
|
|
|
274
298
|
async function afterWrite(s, rel, old, res) {
|
|
275
299
|
const notes = []
|
|
276
300
|
if (!s.noFormat) {
|
|
277
|
-
|
|
301
|
+
// a formatter the project gave (a cloned repo's own commands): only once the user says yes
|
|
302
|
+
const allow = async (cmd) => {
|
|
303
|
+
s.formatters ??= new Map()
|
|
304
|
+
if (!s.formatters.has(cmd)) s.formatters.set(cmd, (await s.ui.confirm(`Run this project's formatter after each write? ${cmd}`, 'command')) === true)
|
|
305
|
+
return s.formatters.get(cmd)
|
|
306
|
+
}
|
|
307
|
+
const f = await formatFile(s.root, rel, allow).catch((e) => (log('format', `${rel}: ${e.message}`), null))
|
|
278
308
|
if (f) {
|
|
279
309
|
res.text = f.text
|
|
280
310
|
res.ops = diffLines(old, f.text)
|
|
@@ -346,10 +376,15 @@ export async function runConfirmed(s, cmd, signal) {
|
|
|
346
376
|
// /rewind, and told to the model. Text files up to 256 KB, read only when they changed since the
|
|
347
377
|
// last look (the contents are cached per session).
|
|
348
378
|
const SNAP_MAX = 256_000
|
|
379
|
+
// complete: every file of the project is in it (a list cut at its limit isn't, and a file missing
|
|
380
|
+
// from it may well have been there: never taken for a new one, which /rewind would delete).
|
|
381
|
+
const SNAP_FILES = 20_000
|
|
349
382
|
function snapshot(s) {
|
|
350
383
|
const cache = (s.fileCache ??= new Map())
|
|
351
384
|
const now = new Map()
|
|
352
|
-
|
|
385
|
+
const files = listFiles(s.root, SNAP_FILES + 1)
|
|
386
|
+
now.complete = files.length <= SNAP_FILES
|
|
387
|
+
for (const rel of files.slice(0, SNAP_FILES)) {
|
|
353
388
|
let st
|
|
354
389
|
try {
|
|
355
390
|
st = fs.statSync(path.join(s.root, rel))
|
|
@@ -373,11 +408,14 @@ function changesSince(s, before) {
|
|
|
373
408
|
const notes = []
|
|
374
409
|
for (const rel of new Set([...before.keys(), ...after.keys()])) {
|
|
375
410
|
if (before.get(rel) === after.get(rel)) continue
|
|
411
|
+
if (!(before.complete && after.complete) && (!before.has(rel) || !after.has(rel))) continue // a list cut at its limit: in one, not the other, says nothing
|
|
376
412
|
const old = was.get(rel)
|
|
377
413
|
const next = after.has(rel) ? s.fileCache.get(rel)?.text : undefined
|
|
378
414
|
let note = !before.has(rel) ? `${rel} (new)` : !after.has(rel) ? `${rel} (deleted)` : rel
|
|
379
|
-
if (!before.has(rel))
|
|
380
|
-
|
|
415
|
+
if (!before.has(rel)) {
|
|
416
|
+
if (before.complete) s.track?.(rel, null) // /rewind removes it
|
|
417
|
+
} else if (typeof old === 'string') s.track?.(rel, old) // /rewind puts this version back
|
|
418
|
+
else if (old === undefined) note += ` (over ${SNAP_MAX / 1000} KB: /rewind can't put it back)`
|
|
381
419
|
if (typeof old === 'string' && typeof next === 'string') {
|
|
382
420
|
const ops = diffLines(old, next)
|
|
383
421
|
s.ui.print(renderDiff(ops, rel, undefined, 2, 40))
|
|
@@ -439,7 +477,11 @@ export async function loadMcpTools(s, signal) {
|
|
|
439
477
|
const notes = []
|
|
440
478
|
for (const [server, { tools, instructions }] of known) {
|
|
441
479
|
if (direct.size + tools.length > MAX_MCP_TOOLS) continue
|
|
442
|
-
for (const t of tools)
|
|
480
|
+
for (const t of tools) {
|
|
481
|
+
let n = mcpName(server, t.name)
|
|
482
|
+
if (direct.has(n)) n = `${n.slice(0, 57)}_${createHash('sha256').update(`${server}/${t.name}`).digest('hex').slice(0, 6)}` // cut to the same 64 characters as another
|
|
483
|
+
direct.set(n, { server, tool: t.name, def: t })
|
|
484
|
+
}
|
|
443
485
|
if (instructions) notes.push(`### ${server}\n${instructions}`)
|
|
444
486
|
}
|
|
445
487
|
const shown = new Set([...direct.values()].map((d) => d.server))
|
|
@@ -454,9 +496,9 @@ function cleanSchema(x) {
|
|
|
454
496
|
return Object.fromEntries(Object.entries(x).filter(([k]) => k !== '$schema').map(([k, v]) => [k, cleanSchema(v)]))
|
|
455
497
|
}
|
|
456
498
|
function mcpDefs(s) {
|
|
457
|
-
return [...(s.mcpDirect?.
|
|
499
|
+
return [...(s.mcpDirect?.entries() ?? [])].map(([name, { server, tool, def }]) => {
|
|
458
500
|
const schema = cleanSchema(def.inputSchema ?? {})
|
|
459
|
-
return { name
|
|
501
|
+
return { name, description: clip(String(def.description ?? `${tool} (${server})`).trim(), 2000), parameters: { ...schema, type: 'object', properties: schema.properties ?? {} } }
|
|
460
502
|
})
|
|
461
503
|
}
|
|
462
504
|
|
|
@@ -482,12 +524,11 @@ export function extraTools(s) {
|
|
|
482
524
|
const skills = forModel.map((k) => `${k.name}: ${k.description}`).join('\n')
|
|
483
525
|
out.push({ name: 'skill', description: prompt('agent/tool-skill', { skills }), parameters: obj({ name: str }, ['name']) })
|
|
484
526
|
}
|
|
485
|
-
// the patch format OpenAI's models are trained on (they also write it as a shell command, below)
|
|
486
|
-
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']) })
|
|
487
527
|
if (!s.isSub && s.subagent) {
|
|
488
528
|
const team = s.team?.() ?? []
|
|
489
529
|
const model = { type: 'string', description: prompt('agent/tool-agent-model') }
|
|
490
|
-
|
|
530
|
+
const images = { type: 'array', items: str, description: prompt('agent/tool-agent-images') }
|
|
531
|
+
out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model, images }) }, ['description', 'prompt']) })
|
|
491
532
|
}
|
|
492
533
|
return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
|
|
493
534
|
}
|
|
@@ -559,11 +600,13 @@ function memoryTool(s, args) {
|
|
|
559
600
|
switch (args.action) {
|
|
560
601
|
case 'write': {
|
|
561
602
|
if (!args.content && !args.description) return 'error: write needs at least content or a description'
|
|
603
|
+
if (!String(args.name ?? '').trim() && !String(args.description ?? '').trim()) return 'error: write needs a "name" (or a description to name it by). Nothing was saved.'
|
|
604
|
+
if (String(args.name ?? '').trim() && fullSlug(args.name).length > 60) return 'error: the name is too long (60 characters at most, as a file name). Nothing was saved.'
|
|
562
605
|
const n = m.write(args)
|
|
563
|
-
s.memoryWrites = (s.memoryWrites ?? 0) + 1
|
|
564
606
|
return `Saved [[${n}]]${m.read(n)?.scope === 'global' ? ' (global)' : ''}.`
|
|
565
607
|
}
|
|
566
608
|
case 'read': {
|
|
609
|
+
if (!name.trim()) return 'error: read needs the note\'s "name" (search finds notes by words).'
|
|
567
610
|
const n = m.read(name)
|
|
568
611
|
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.'}`
|
|
569
612
|
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(', ')}` : ''}`
|
|
@@ -573,6 +616,7 @@ function memoryTool(s, args) {
|
|
|
573
616
|
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'
|
|
574
617
|
}
|
|
575
618
|
case 'delete':
|
|
619
|
+
if (!name.trim()) return 'error: delete needs the note\'s "name". Nothing was deleted.'
|
|
576
620
|
return m.delete(name, args.scope) ? `Deleted [[${slug(name)}]].` : `error: no note [[${slug(name)}]]`
|
|
577
621
|
default:
|
|
578
622
|
return 'error: action must be write, read, search or delete'
|
|
@@ -619,20 +663,52 @@ async function mcpTool(s, args, signal) {
|
|
|
619
663
|
async function callMcp(s, server, tool, a, signal) {
|
|
620
664
|
if (!allowed(s, mcpName(server.name, tool))) return `error: the tool ${mcpName(server.name, tool)} is not allowed here`
|
|
621
665
|
const def = (server.tools ?? s.mcpCatalog?.get(server.name)?.tools)?.find((t) => t.name === tool)
|
|
666
|
+
// a tool its server marks read-only isn't asked about (reading isn't, in any mode; headless
|
|
667
|
+
// declines what it would ask, so a browser's snapshot must not be); any other like a command
|
|
622
668
|
if (!def?.annotations?.readOnlyHint) {
|
|
623
669
|
const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
|
|
624
670
|
if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
|
|
625
671
|
}
|
|
672
|
+
const done = await s.mcpGate?.() // a helper may be using the MCP servers (a browser): its turn first
|
|
626
673
|
s.ui.spinner(`${server.name} · ${tool}`)
|
|
627
|
-
const
|
|
674
|
+
const started = Date.now()
|
|
675
|
+
let r
|
|
676
|
+
try {
|
|
677
|
+
r = await server.call(tool, a, signal)
|
|
678
|
+
} finally {
|
|
679
|
+
done?.()
|
|
680
|
+
}
|
|
628
681
|
const parts = r.content ?? []
|
|
629
682
|
const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
|
|
630
|
-
|
|
683
|
+
// Its images: the ones it returned, and image files it made while it ran (a path in its text to a
|
|
684
|
+
// file written during the call: "Saved screenshot to /…/shot.webp"; an old one it only mentions
|
|
685
|
+
// isn't one). Each is a file too, so a model that can't see them can have one that can look.
|
|
686
|
+
const made = [...new Set([...text.matchAll(/(?:^|[\s"'`(])((?:\/|~\/)[^\s"'`)]+\.(?:png|jpe?g|gif|webp))\b/gi)].map((m) => m[1].replace(/^~(?=\/)/, os.homedir())))].filter((f) => {
|
|
687
|
+
try {
|
|
688
|
+
return fs.statSync(f).mtimeMs >= started - 1000
|
|
689
|
+
} catch {
|
|
690
|
+
return false
|
|
691
|
+
}
|
|
692
|
+
})
|
|
693
|
+
const returned = parts.filter((p) => p.type === 'image' && p.data).map((p, i) => ({ p, file: saveOutput(s.chat?.id, `${tool}-${i + 1}`, Buffer.from(p.data, 'base64'), (p.mimeType ?? 'image/png').split('/')[1].replace('jpeg', 'jpg')) }))
|
|
694
|
+
const files = [...returned.map((x) => x.file).filter(Boolean), ...made]
|
|
695
|
+
s.toolImages ??= new Set()
|
|
696
|
+
for (const f of files) s.toolImages.add(f) // may be sent to a helper (agent images)
|
|
631
697
|
let note = ''
|
|
632
|
-
if (
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
698
|
+
if ((files.length || returned.length) && s.strat?.vision) {
|
|
699
|
+
const left = []
|
|
700
|
+
const shown = (s.pendingImages ??= []).length
|
|
701
|
+
for (const { p, file } of returned) if (!file) s.pendingImages.push({ url: `data:${p.mimeType ?? 'image/png'};base64,${p.data}`, file: '(not saved)' }) // couldn't be saved: shown as it came
|
|
702
|
+
for (const f of files) {
|
|
703
|
+
try {
|
|
704
|
+
s.pendingImages.push({ url: imageUrl(f), file: f })
|
|
705
|
+
} catch (e) {
|
|
706
|
+
left.push(e.message)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
note = `\n(${s.pendingImages.length - shown} image(s): shown to you in the next message${left.length ? `; not shown: ${left.join('; ')}` : ''})`
|
|
710
|
+
} else if (files.length && s.team?.().some((t) => t.images)) note = `\n${prompt('agent/images-unseen', { files: files.join('\n') })}`
|
|
711
|
+
else if (files.length) note = `\n(${files.length} image(s) not shown: this model can't see images)`
|
|
636
712
|
return `${r.isError ? 'error: ' : ''}${text || '(no text)'}${note}`
|
|
637
713
|
}
|
|
638
714
|
|
|
@@ -710,7 +786,9 @@ async function editFiles(s, args) {
|
|
|
710
786
|
const hits = listFiles(root).filter((f) => {
|
|
711
787
|
try {
|
|
712
788
|
const abs = resolveIn(root, f)
|
|
713
|
-
|
|
789
|
+
if (fs.statSync(abs).size >= 1_000_000) return false
|
|
790
|
+
const buf = fs.readFileSync(abs)
|
|
791
|
+
return !isBinary(buf) && buf.toString('utf8').includes(search) // a binary file is never edited as text
|
|
714
792
|
} catch {
|
|
715
793
|
return false
|
|
716
794
|
}
|
|
@@ -720,14 +798,20 @@ async function editFiles(s, args) {
|
|
|
720
798
|
for (const [i, e] of edits.entries()) {
|
|
721
799
|
const { search: s0, replace: r0, path: p0 } = normalize(e ?? {})
|
|
722
800
|
const search = String(s0 ?? '')
|
|
723
|
-
const replace = String(r0 ?? '')
|
|
724
801
|
const given = p0 ?? args.path
|
|
725
802
|
const label = edits.length > 1 ? `edit ${i + 1} of ${edits.length}: ` : ''
|
|
803
|
+
// no "replace" at all isn't "replace with nothing": a model that left it out would delete code
|
|
804
|
+
// (one did). Deleting is an explicit "replace": "".
|
|
805
|
+
if (r0 === undefined || r0 === null) return `error: ${label}"replace" is missing: give the new text (or "replace": "" to delete the search text). Nothing was changed.`
|
|
806
|
+
if (typeof r0 !== 'string' && !(Array.isArray(r0) && r0.every((l) => typeof l === 'string'))) return `error: ${label}"replace" must be text, a string. Nothing was changed.`
|
|
807
|
+
const replace = Array.isArray(r0) ? r0.join('\n') : r0
|
|
726
808
|
let rel = given && fs.existsSync(resolveIn(root, given)) ? path.relative(root, resolveIn(root, given)) : null
|
|
727
809
|
let r = rel ? applyEdit(textOf(rel), search, replace) : null
|
|
728
810
|
if (!r || r.error) {
|
|
729
811
|
const other = search.trim() && holder(search)
|
|
730
|
-
|
|
812
|
+
// no path given: the one file that has the text; a path given: that file only (the other is named)
|
|
813
|
+
if (other && other !== rel && !given) [rel, r] = [other, applyEdit(textOf(other), search, replace)]
|
|
814
|
+
else if (other && other !== rel) return `error: ${label}${rel ? `the search text isn't in ${given}` : `${given} does not exist`}, but the text is in ${other}: give that path if that's the file to change. Nothing was changed.`
|
|
731
815
|
}
|
|
732
816
|
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.`
|
|
733
817
|
if (r.error) return `error: ${label}${r.error}. Nothing was changed.`
|
|
@@ -754,12 +838,17 @@ async function applyPatch(s, text) {
|
|
|
754
838
|
}
|
|
755
839
|
if (!ops.length) return 'error: the patch changes no files. Nothing was changed.'
|
|
756
840
|
const plan = [] // [rel, old, next (null: delete)]
|
|
841
|
+
const seen = new Set()
|
|
757
842
|
for (const op of ops) {
|
|
758
843
|
const abs = resolveIn(s.root, op.path)
|
|
759
844
|
const rel = path.relative(s.root, abs)
|
|
845
|
+
// each file once: a second section for it would be worked out from the original and undo the first
|
|
846
|
+
if (seen.has(rel)) return `error: the patch changes ${op.path} twice; put all its changes in one section. Nothing was changed.`
|
|
847
|
+
seen.add(rel)
|
|
760
848
|
const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null
|
|
761
849
|
if (op.type === 'add') {
|
|
762
|
-
|
|
850
|
+
if (old !== null) return `error: ${op.path} already exists (*** Add File: is for new files; use *** Update File: to change it). Nothing was changed.`
|
|
851
|
+
plan.push([rel, '', op.content.join('\n') + '\n'])
|
|
763
852
|
continue
|
|
764
853
|
}
|
|
765
854
|
if (old === null) return `error: ${op.path} does not exist${op.type === 'update' ? ' (use *** Add File: to create it)' : ''}. Nothing was changed.`
|
|
@@ -769,7 +858,12 @@ async function applyPatch(s, text) {
|
|
|
769
858
|
}
|
|
770
859
|
const r = applyChunks(old, op.chunks)
|
|
771
860
|
if (r.error) return `error: ${op.path}: ${r.error}. Nothing was changed.`
|
|
772
|
-
if (op.moveTo)
|
|
861
|
+
if (op.moveTo) {
|
|
862
|
+
const dest = path.relative(s.root, resolveIn(s.root, op.moveTo))
|
|
863
|
+
if (dest === rel) plan.push([rel, old, r.text]) // moved onto itself: just the change
|
|
864
|
+
else if (fs.existsSync(resolveIn(s.root, op.moveTo)) || seen.has(dest)) return `error: ${op.moveTo} already exists (or the patch writes it twice); a move doesn't overwrite a file. Nothing was changed.`
|
|
865
|
+
else (seen.add(dest), plan.push([dest, '', r.text], [rel, old, null]))
|
|
866
|
+
}
|
|
773
867
|
else plan.push([rel, old, r.text])
|
|
774
868
|
}
|
|
775
869
|
const out = []
|
|
@@ -782,12 +876,25 @@ async function applyPatch(s, text) {
|
|
|
782
876
|
}
|
|
783
877
|
|
|
784
878
|
// Every tool call goes through the user's PreToolUse and PostToolUse hooks (hooks.js).
|
|
785
|
-
|
|
879
|
+
// The tool call running now (its id), through everything it does: an editor's permission prompt
|
|
880
|
+
// names that call, also with several at once (helpers).
|
|
881
|
+
export const runningCall = new AsyncLocalStorage()
|
|
882
|
+
// A tool's result that is HeartCode's error, not the tool's output (command output starts with
|
|
883
|
+
// "exit code", a file's with line numbers).
|
|
884
|
+
export const failed = (out) => /^error: /.test(String(out))
|
|
885
|
+
|
|
886
|
+
// id: the tool call's (a helper's steps are sent as its, for other apps to show under it)
|
|
887
|
+
export async function runTool(name, args, s, signal, id) {
|
|
888
|
+
return id ? runningCall.run(id, () => toolRun(name, args, s, signal, id)) : toolRun(name, args, s, signal, id)
|
|
889
|
+
}
|
|
890
|
+
async function toolRun(name, args, s, signal, id) {
|
|
786
891
|
log('tool', name, args)
|
|
787
|
-
|
|
892
|
+
// hooks see an MCP tool by its own name (mcp__server__tool), also when called through "mcp"
|
|
893
|
+
const hookName = name === 'mcp' && args?.server && args?.tool ? mcpName(String(args.server), String(args.tool)) : name
|
|
894
|
+
const pre = await runHooks(s, 'PreToolUse', { tool_name: hookName, tool_input: args }, signal)
|
|
788
895
|
if (pre.block) return prompt('agent/hook-blocked-tool', { reason: pre.block })
|
|
789
|
-
let out = await tool(name, args, s, signal)
|
|
790
|
-
const post = await runHooks(s, 'PostToolUse', { tool_name:
|
|
896
|
+
let out = await tool(name, args, s, signal, id)
|
|
897
|
+
const post = await runHooks(s, 'PostToolUse', { tool_name: hookName, tool_input: args, tool_response: out }, signal)
|
|
791
898
|
if (post.block) out += `\n\n${prompt('agent/hook-after-tool', { reason: post.block })}`
|
|
792
899
|
log('tool', `${name} result:`, clip(out, 4000))
|
|
793
900
|
return out
|
|
@@ -822,7 +929,7 @@ export function allowed(s, name) {
|
|
|
822
929
|
return names.some((n) => s.allowTools.has(n)) || (name === 'mcp' && [...s.allowTools].some((n) => n.startsWith('mcp__')))
|
|
823
930
|
}
|
|
824
931
|
|
|
825
|
-
async function tool(name, args, s, signal) {
|
|
932
|
+
async function tool(name, args, s, signal, id) {
|
|
826
933
|
if (!name.startsWith('mcp__') && !mcpTarget(s, name)) args = normalize(args) // an MCP tool's arguments are its own
|
|
827
934
|
try {
|
|
828
935
|
const { root } = s
|
|
@@ -843,6 +950,10 @@ async function tool(name, args, s, signal) {
|
|
|
843
950
|
case 'write_file':
|
|
844
951
|
case 'append_file': {
|
|
845
952
|
if (!args.path) return 'error: write_file needs "path" (the file to write) and "content". Nothing was written.'
|
|
953
|
+
// no "content" at all isn't an empty file (a model that left it out would empty one)
|
|
954
|
+
if (args.content === undefined || args.content === null) return 'error: write_file needs "content" (the whole file; "" for an empty one). Nothing was written.'
|
|
955
|
+
if (Array.isArray(args.content) && args.content.every((l) => typeof l === 'string')) args.content = args.content.join('\n') // its lines
|
|
956
|
+
if (typeof args.content !== 'string') return 'error: write_file\'s "content" must be the file\'s text, a string (JSON too: as text). Nothing was written.'
|
|
846
957
|
const abs = resolveIn(root, args.path)
|
|
847
958
|
const rel = path.relative(root, abs)
|
|
848
959
|
const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''
|
|
@@ -865,7 +976,7 @@ async function tool(name, args, s, signal) {
|
|
|
865
976
|
case 'web_fetch':
|
|
866
977
|
return await webTool(s, name, args, signal)
|
|
867
978
|
case 'agent':
|
|
868
|
-
return await s.subagent(args, signal)
|
|
979
|
+
return await s.subagent(args, signal, id)
|
|
869
980
|
case 'ask_user':
|
|
870
981
|
return await askUser(s, args)
|
|
871
982
|
case 'skill': {
|
package/src/usage.js
CHANGED
|
@@ -67,7 +67,7 @@ export function priceOf(provider, baseUrl, model) {
|
|
|
67
67
|
// models.dev's providers that are this one: by its name in HeartCode, or its URL.
|
|
68
68
|
const ownBy = (provider, baseUrl) => {
|
|
69
69
|
const host = hostOf(baseUrl)
|
|
70
|
-
return ([id, p]) => norm(id) === norm(provider) || (p.api && hostOf(p.api) === host)
|
|
70
|
+
return ([id, p]) => norm(id) === norm(provider) || (p.api && hostOf(p.api) === host)
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// models.dev's models with a size, by their names made plain (norm), the whole name and its last
|
|
@@ -109,10 +109,13 @@ function newestOf(name) {
|
|
|
109
109
|
export function limitsOf(provider, baseUrl, model) {
|
|
110
110
|
if (!table || !model) return null
|
|
111
111
|
const last = (m) => m.split('/').pop()
|
|
112
|
-
const want = new Set([model, model.replace(/^models\//, ''), last(model), last(model).replace(/:(free|beta|extended|nitro|floor|online|cloud)$/, '').replace(/-cloud$/, '')].map(norm))
|
|
113
112
|
const own = ownBy(provider, baseUrl)
|
|
114
113
|
const found = { own: [], all: [] }
|
|
115
|
-
|
|
114
|
+
const lookup = (names) => [...new Set(names.map(norm).flatMap((w) => indexOf(table).get(w) ?? []))].sort((a, b) => a.n - b.n)
|
|
115
|
+
let hits = lookup([model, model.replace(/^models\//, ''), last(model)])
|
|
116
|
+
// not by its whole name: without what a provider adds at the end (":free", "-cloud", "-instruct-turbo"), one part
|
|
117
|
+
// at a time, the most specific first
|
|
118
|
+
for (let name = last(model); !hits.length && /[:-][^:-]+$/.test(name); ) hits = lookup([(name = name.replace(/[:-][^:-]+$/, ''))])
|
|
116
119
|
if (!hits.length) hits = newestOf(last(model)) // "magistral-small-latest" by its dated names only
|
|
117
120
|
for (const { entry, info } of hits) {
|
|
118
121
|
const it = { ...info.limit, ...(info.can ?? { tools: true, thinking: false, images: false }) }
|
|
@@ -123,7 +126,9 @@ export function limitsOf(provider, baseUrl, model) {
|
|
|
123
126
|
if (!list.length) return null
|
|
124
127
|
const count = (x) => list.filter((y) => y.context === x.context).length
|
|
125
128
|
const best = list.reduce((a, b) => (count(b) > count(a) || (count(b) === count(a) && b.context > a.context) ? b : a))
|
|
126
|
-
|
|
129
|
+
// not this provider's own entry (a proxy, a provider models.dev doesn't know): images only when
|
|
130
|
+
// every provider that lists it takes them (one that doesn't would refuse the request)
|
|
131
|
+
return { context: best.context, output: best.output, tools: best.tools, thinking: best.thinking, images: found.own.length ? best.images : list.every((x) => x.images) }
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
// Adds one request's usage (OpenAI's names) to the total; returns what it cost (0 if unknown).
|