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/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 (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).
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('.') && new RegExp(`(^|[^\\w./-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`).test(t))
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
- const ext = path.extname(abs).slice(1).toLowerCase()
128
- ;(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
+ }
129
153
  return `${rel}: the image is attached in the next message`
130
154
  }
131
- const seer = s.team?.().find((t) => t.images)
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
- 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))
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
- 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)) {
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)) s.track?.(rel, null) // /rewind removes it
381
- 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)`
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) 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
+ }
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?.values() ?? [])].map(({ server, tool, def }) => {
499
+ return [...(s.mcpDirect?.entries() ?? [])].map(([name, { server, tool, def }]) => {
459
500
  const schema = cleanSchema(def.inputSchema ?? {})
460
- 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 ?? {} } }
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 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
+ }
630
681
  const parts = r.content ?? []
631
682
  const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
632
- const images = parts.filter((p) => p.type === 'image' && p.data)
633
- // image files the tool says it saved ("Saved screenshot to /…/shot.webp"): as if returned
634
- const saved = [...new Set([...text.matchAll(/(?:^|[\s"'`(])((?:\/|~\/)[^\s"'`)]+\.(?:png|jpe?g|gif|webp))\b/gi)].map((m) => m[1].replace(/^~(?=\/)/, os.homedir())))].filter((f) => fs.existsSync(f))
635
- if (saved.length) {
636
- s.toolImages ??= new Set()
637
- for (const f of saved) s.toolImages.add(f) // may be sent to a helper (agent images)
638
- if (s.strat?.vision) images.push(...saved.map((f) => ({ data: fs.readFileSync(f).toString('base64'), mimeType: `image/${path.extname(f).slice(1).toLowerCase().replace('jpg', 'jpeg')}`, file: f })))
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 (images.length && s.strat?.vision) {
642
- ;(s.pendingImages ??= []).push(...images.map((p) => `data:${p.mimeType ?? 'image/png'};base64,${p.data}`))
643
- note = `\n(${images.length} image(s) attached in the next message)`
644
- } else if ((images.length || saved.length) && s.team?.().some((t) => t.images)) {
645
- // saved, for a model that can see to be asked about (the agent tool's images)
646
- const files = [...images.map((p, i) => saveOutput(s.chat?.id, `${tool}-${i + 1}`, Buffer.from(p.data, 'base64'), (p.mimeType ?? 'image/png').split('/')[1].replace('jpeg', 'jpg'))).filter(Boolean), ...saved]
647
- note = `\n${prompt('agent/images-unseen', { files: files.join('\n') })}`
648
- } 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)`
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
- 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
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
- 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.`
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
- 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'])
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) 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
+ }
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
- 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)
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: name, tool_input: args, tool_response: out }, signal)
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) || (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).