heartcode 0.1.6 → 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 +24 -10
- package/bin/heartcode.js +70 -30
- package/package.json +1 -1
- package/prompts/agent.md +15 -30
- package/prompts/guided.md +6 -6
- package/src/acp.js +3 -3
- package/src/agent.js +168 -68
- package/src/client.js +162 -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 +18 -11
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +6 -3
- package/src/settings.js +19 -2
- package/src/tools.js +138 -41
- 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'
|
|
@@ -49,12 +51,16 @@ export function resolveIn(root, p) {
|
|
|
49
51
|
return abs
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
// 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).
|
|
53
56
|
export function namedFiles(text, files) {
|
|
54
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)
|
|
55
61
|
return files.filter((f) => {
|
|
56
62
|
const base = path.basename(f).toLowerCase()
|
|
57
|
-
return t.includes(f.toLowerCase()) || (base.includes('.') &&
|
|
63
|
+
return t.includes(f.toLowerCase()) || (base.includes('.') && count.get(base) === 1 && word(base))
|
|
58
64
|
})
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -116,6 +122,21 @@ export function readFileTool(root, p, start, end, maxLines = 400) {
|
|
|
116
122
|
return numberLines(lines.slice(s - 1, e), s) + more
|
|
117
123
|
}
|
|
118
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
|
+
|
|
119
140
|
// An image file: shown to a model that can see images (after the tool results, as MCP screenshots
|
|
120
141
|
// are); for one that can't, where to send it.
|
|
121
142
|
const IMAGE = /\.(png|jpe?g|gif|webp)$/i
|
|
@@ -124,12 +145,14 @@ function readImage(s, p) {
|
|
|
124
145
|
const rel = path.relative(s.root, abs) || p
|
|
125
146
|
if (!fs.existsSync(abs)) return readFileTool(s.root, p)
|
|
126
147
|
if (s.strat?.vision) {
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
try {
|
|
149
|
+
;(s.pendingImages ??= []).push({ url: imageUrl(abs, rel), file: rel })
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return `error: ${e.message}`
|
|
152
|
+
}
|
|
129
153
|
return `${rel}: the image is attached in the next message`
|
|
130
154
|
}
|
|
131
|
-
|
|
132
|
-
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.`
|
|
133
156
|
}
|
|
134
157
|
|
|
135
158
|
export function searchFiles(root, query, sub = '', limit = 50) {
|
|
@@ -275,7 +298,13 @@ export async function proposeWrite(s, rel, old, next) {
|
|
|
275
298
|
async function afterWrite(s, rel, old, res) {
|
|
276
299
|
const notes = []
|
|
277
300
|
if (!s.noFormat) {
|
|
278
|
-
|
|
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))
|
|
279
308
|
if (f) {
|
|
280
309
|
res.text = f.text
|
|
281
310
|
res.ops = diffLines(old, f.text)
|
|
@@ -347,10 +376,15 @@ export async function runConfirmed(s, cmd, signal) {
|
|
|
347
376
|
// /rewind, and told to the model. Text files up to 256 KB, read only when they changed since the
|
|
348
377
|
// last look (the contents are cached per session).
|
|
349
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
|
|
350
382
|
function snapshot(s) {
|
|
351
383
|
const cache = (s.fileCache ??= new Map())
|
|
352
384
|
const now = new Map()
|
|
353
|
-
|
|
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)) {
|
|
354
388
|
let st
|
|
355
389
|
try {
|
|
356
390
|
st = fs.statSync(path.join(s.root, rel))
|
|
@@ -374,11 +408,14 @@ function changesSince(s, before) {
|
|
|
374
408
|
const notes = []
|
|
375
409
|
for (const rel of new Set([...before.keys(), ...after.keys()])) {
|
|
376
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
|
|
377
412
|
const old = was.get(rel)
|
|
378
413
|
const next = after.has(rel) ? s.fileCache.get(rel)?.text : undefined
|
|
379
414
|
let note = !before.has(rel) ? `${rel} (new)` : !after.has(rel) ? `${rel} (deleted)` : rel
|
|
380
|
-
if (!before.has(rel))
|
|
381
|
-
|
|
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)`
|
|
382
419
|
if (typeof old === 'string' && typeof next === 'string') {
|
|
383
420
|
const ops = diffLines(old, next)
|
|
384
421
|
s.ui.print(renderDiff(ops, rel, undefined, 2, 40))
|
|
@@ -440,7 +477,11 @@ export async function loadMcpTools(s, signal) {
|
|
|
440
477
|
const notes = []
|
|
441
478
|
for (const [server, { tools, instructions }] of known) {
|
|
442
479
|
if (direct.size + tools.length > MAX_MCP_TOOLS) continue
|
|
443
|
-
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
|
+
}
|
|
444
485
|
if (instructions) notes.push(`### ${server}\n${instructions}`)
|
|
445
486
|
}
|
|
446
487
|
const shown = new Set([...direct.values()].map((d) => d.server))
|
|
@@ -455,9 +496,9 @@ function cleanSchema(x) {
|
|
|
455
496
|
return Object.fromEntries(Object.entries(x).filter(([k]) => k !== '$schema').map(([k, v]) => [k, cleanSchema(v)]))
|
|
456
497
|
}
|
|
457
498
|
function mcpDefs(s) {
|
|
458
|
-
return [...(s.mcpDirect?.
|
|
499
|
+
return [...(s.mcpDirect?.entries() ?? [])].map(([name, { server, tool, def }]) => {
|
|
459
500
|
const schema = cleanSchema(def.inputSchema ?? {})
|
|
460
|
-
return { name
|
|
501
|
+
return { name, description: clip(String(def.description ?? `${tool} (${server})`).trim(), 2000), parameters: { ...schema, type: 'object', properties: schema.properties ?? {} } }
|
|
461
502
|
})
|
|
462
503
|
}
|
|
463
504
|
|
|
@@ -483,8 +524,6 @@ export function extraTools(s) {
|
|
|
483
524
|
const skills = forModel.map((k) => `${k.name}: ${k.description}`).join('\n')
|
|
484
525
|
out.push({ name: 'skill', description: prompt('agent/tool-skill', { skills }), parameters: obj({ name: str }, ['name']) })
|
|
485
526
|
}
|
|
486
|
-
// the patch format OpenAI's models are trained on (they also write it as a shell command, below)
|
|
487
|
-
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']) })
|
|
488
527
|
if (!s.isSub && s.subagent) {
|
|
489
528
|
const team = s.team?.() ?? []
|
|
490
529
|
const model = { type: 'string', description: prompt('agent/tool-agent-model') }
|
|
@@ -561,11 +600,13 @@ function memoryTool(s, args) {
|
|
|
561
600
|
switch (args.action) {
|
|
562
601
|
case 'write': {
|
|
563
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.'
|
|
564
605
|
const n = m.write(args)
|
|
565
|
-
s.memoryWrites = (s.memoryWrites ?? 0) + 1
|
|
566
606
|
return `Saved [[${n}]]${m.read(n)?.scope === 'global' ? ' (global)' : ''}.`
|
|
567
607
|
}
|
|
568
608
|
case 'read': {
|
|
609
|
+
if (!name.trim()) return 'error: read needs the note\'s "name" (search finds notes by words).'
|
|
569
610
|
const n = m.read(name)
|
|
570
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.'}`
|
|
571
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(', ')}` : ''}`
|
|
@@ -575,6 +616,7 @@ function memoryTool(s, args) {
|
|
|
575
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'
|
|
576
617
|
}
|
|
577
618
|
case 'delete':
|
|
619
|
+
if (!name.trim()) return 'error: delete needs the note\'s "name". Nothing was deleted.'
|
|
578
620
|
return m.delete(name, args.scope) ? `Deleted [[${slug(name)}]].` : `error: no note [[${slug(name)}]]`
|
|
579
621
|
default:
|
|
580
622
|
return 'error: action must be write, read, search or delete'
|
|
@@ -621,31 +663,52 @@ async function mcpTool(s, args, signal) {
|
|
|
621
663
|
async function callMcp(s, server, tool, a, signal) {
|
|
622
664
|
if (!allowed(s, mcpName(server.name, tool))) return `error: the tool ${mcpName(server.name, tool)} is not allowed here`
|
|
623
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
|
|
624
668
|
if (!def?.annotations?.readOnlyHint) {
|
|
625
669
|
const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
|
|
626
670
|
if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
|
|
627
671
|
}
|
|
672
|
+
const done = await s.mcpGate?.() // a helper may be using the MCP servers (a browser): its turn first
|
|
628
673
|
s.ui.spinner(`${server.name} · ${tool}`)
|
|
629
|
-
const
|
|
674
|
+
const started = Date.now()
|
|
675
|
+
let r
|
|
676
|
+
try {
|
|
677
|
+
r = await server.call(tool, a, signal)
|
|
678
|
+
} finally {
|
|
679
|
+
done?.()
|
|
680
|
+
}
|
|
630
681
|
const parts = r.content ?? []
|
|
631
682
|
const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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)
|
|
640
697
|
let note = ''
|
|
641
|
-
if (
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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)`
|
|
649
712
|
return `${r.isError ? 'error: ' : ''}${text || '(no text)'}${note}`
|
|
650
713
|
}
|
|
651
714
|
|
|
@@ -723,7 +786,9 @@ async function editFiles(s, args) {
|
|
|
723
786
|
const hits = listFiles(root).filter((f) => {
|
|
724
787
|
try {
|
|
725
788
|
const abs = resolveIn(root, f)
|
|
726
|
-
|
|
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
|
|
727
792
|
} catch {
|
|
728
793
|
return false
|
|
729
794
|
}
|
|
@@ -733,14 +798,20 @@ async function editFiles(s, args) {
|
|
|
733
798
|
for (const [i, e] of edits.entries()) {
|
|
734
799
|
const { search: s0, replace: r0, path: p0 } = normalize(e ?? {})
|
|
735
800
|
const search = String(s0 ?? '')
|
|
736
|
-
const replace = String(r0 ?? '')
|
|
737
801
|
const given = p0 ?? args.path
|
|
738
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
|
|
739
808
|
let rel = given && fs.existsSync(resolveIn(root, given)) ? path.relative(root, resolveIn(root, given)) : null
|
|
740
809
|
let r = rel ? applyEdit(textOf(rel), search, replace) : null
|
|
741
810
|
if (!r || r.error) {
|
|
742
811
|
const other = search.trim() && holder(search)
|
|
743
|
-
|
|
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.`
|
|
744
815
|
}
|
|
745
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.`
|
|
746
817
|
if (r.error) return `error: ${label}${r.error}. Nothing was changed.`
|
|
@@ -767,12 +838,17 @@ async function applyPatch(s, text) {
|
|
|
767
838
|
}
|
|
768
839
|
if (!ops.length) return 'error: the patch changes no files. Nothing was changed.'
|
|
769
840
|
const plan = [] // [rel, old, next (null: delete)]
|
|
841
|
+
const seen = new Set()
|
|
770
842
|
for (const op of ops) {
|
|
771
843
|
const abs = resolveIn(s.root, op.path)
|
|
772
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)
|
|
773
848
|
const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null
|
|
774
849
|
if (op.type === 'add') {
|
|
775
|
-
|
|
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'])
|
|
776
852
|
continue
|
|
777
853
|
}
|
|
778
854
|
if (old === null) return `error: ${op.path} does not exist${op.type === 'update' ? ' (use *** Add File: to create it)' : ''}. Nothing was changed.`
|
|
@@ -782,7 +858,12 @@ async function applyPatch(s, text) {
|
|
|
782
858
|
}
|
|
783
859
|
const r = applyChunks(old, op.chunks)
|
|
784
860
|
if (r.error) return `error: ${op.path}: ${r.error}. Nothing was changed.`
|
|
785
|
-
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
|
+
}
|
|
786
867
|
else plan.push([rel, old, r.text])
|
|
787
868
|
}
|
|
788
869
|
const out = []
|
|
@@ -795,13 +876,25 @@ async function applyPatch(s, text) {
|
|
|
795
876
|
}
|
|
796
877
|
|
|
797
878
|
// Every tool call goes through the user's PreToolUse and PostToolUse hooks (hooks.js).
|
|
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
|
+
|
|
798
886
|
// id: the tool call's (a helper's steps are sent as its, for other apps to show under it)
|
|
799
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) {
|
|
800
891
|
log('tool', name, args)
|
|
801
|
-
|
|
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)
|
|
802
895
|
if (pre.block) return prompt('agent/hook-blocked-tool', { reason: pre.block })
|
|
803
896
|
let out = await tool(name, args, s, signal, id)
|
|
804
|
-
const post = await runHooks(s, 'PostToolUse', { tool_name:
|
|
897
|
+
const post = await runHooks(s, 'PostToolUse', { tool_name: hookName, tool_input: args, tool_response: out }, signal)
|
|
805
898
|
if (post.block) out += `\n\n${prompt('agent/hook-after-tool', { reason: post.block })}`
|
|
806
899
|
log('tool', `${name} result:`, clip(out, 4000))
|
|
807
900
|
return out
|
|
@@ -857,6 +950,10 @@ async function tool(name, args, s, signal, id) {
|
|
|
857
950
|
case 'write_file':
|
|
858
951
|
case 'append_file': {
|
|
859
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.'
|
|
860
957
|
const abs = resolveIn(root, args.path)
|
|
861
958
|
const rel = path.relative(root, abs)
|
|
862
959
|
const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''
|
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).
|