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/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
- export function saveOutput(chatId, name, text) {
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)}.txt`)
31
- fs.writeFileSync(file, text)
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 (by path, or by file name like "num.js"), most specific first.
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('.') && new RegExp(`(^|[^\\w./-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`).test(t))
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
- const ext = path.extname(abs).slice(1).toLowerCase()
127
- ;(s.pendingImages ??= []).push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${fs.readFileSync(abs).toString('base64')}`)
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
- const seer = s.team?.().find((t) => t.images)
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
- const f = await formatFile(s.root, rel).catch((e) => (log('format', `${rel}: ${e.message}`), null))
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
- for (const rel of listFiles(s.root)) {
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)) s.track?.(rel, null) // /rewind removes it
380
- else if (typeof old === 'string') s.track?.(rel, old) // /rewind puts this version back
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) direct.set(mcpName(server, t.name), { server, tool: t.name, def: t })
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?.values() ?? [])].map(({ server, tool, def }) => {
499
+ return [...(s.mcpDirect?.entries() ?? [])].map(([name, { server, tool, def }]) => {
458
500
  const schema = cleanSchema(def.inputSchema ?? {})
459
- return { name: mcpName(server, tool), description: clip(String(def.description ?? `${tool} (${server})`).trim(), 2000), parameters: { ...schema, type: 'object', properties: schema.properties ?? {} } }
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
- out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model }) }, ['description', 'prompt']) })
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 r = await server.call(tool, a, signal)
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
- const images = parts.filter((p) => p.type === 'image' && p.data)
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 (images.length && s.strat?.vision) {
633
- ;(s.pendingImages ??= []).push(...images.map((p) => `data:${p.mimeType ?? 'image/png'};base64,${p.data}`))
634
- note = `\n(${images.length} image(s) attached in the next message)`
635
- } else if (images.length) note = `\n(${images.length} image(s) not shown: this model can't see images)`
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
- return fs.statSync(abs).size < 1_000_000 && fs.readFileSync(abs, 'utf8').includes(search)
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
- if (other && other !== rel) [rel, r] = [other, applyEdit(textOf(other), search, replace)]
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
- plan.push([rel, old ?? '', op.content.join('\n') + '\n'])
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) plan.push([path.relative(s.root, resolveIn(s.root, op.moveTo)), '', r.text], [rel, old, null])
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
- export async function runTool(name, args, s, signal) {
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
- const pre = await runHooks(s, 'PreToolUse', { tool_name: name, tool_input: args }, signal)
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: name, tool_input: args, tool_response: out }, signal)
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) || (host.includes(id) && id.length > 3)
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
- let hits = [...new Set([...want].flatMap((w) => indexOf(table).get(w) ?? []))].sort((a, b) => a.n - b.n)
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
- return { context: best.context, output: best.output, tools: best.tools, thinking: best.thinking, images: best.images }
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).