heartcode 0.1.1 → 0.1.5

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 CHANGED
@@ -132,6 +132,11 @@ nothing is sent to the model to find out. A model models.dev doesn't know works
132
132
  assumptions, and the user can set it up on the settings page. Models that can't call tools still
133
133
  work, in a slower guided mode.
134
134
 
135
+ The model in use knows the user's other models and what each can do, and hands a job it can't do
136
+ (see an image, call tools…) to one that can: an attached image goes to a model that sees images, an
137
+ image's path to one through its `agent` tool, and a model without tool calls can hand the task to one
138
+ with them. Their tokens are in `usage`, and each model's in `modelUsage`.
139
+
135
140
  ## Setting up a model
136
141
 
137
142
  Ask the user which provider and model to use, and for the key. Never invent one. Then:
package/bin/heartcode.js CHANGED
@@ -452,6 +452,13 @@ class App {
452
452
  return [...COMMANDS, ...mine]
453
453
  }
454
454
 
455
+ // A command: "/name …" with the name of a built-in command or a skill you can type. Anything else
456
+ // starting with "/" (a path like /Users/me/shot.png) is a message for the model.
457
+ isCommand(line) {
458
+ const name = /^\/([\w-]+)(?=\s|$)/.exec(line)?.[1]
459
+ return Boolean(name) && (name === 'quit' || this.commands().some(([n]) => n === `/${name}`))
460
+ }
461
+
455
462
  help() {
456
463
  const mine = this.commands().slice(COMMANDS.length)
457
464
  return helpText(mine.length ? [...SECTIONS, ['your skills', mine]] : SECTIONS)
@@ -502,7 +509,7 @@ class App {
502
509
  modelChoices(typed, start, end) {
503
510
  const mine = this.myModels()
504
511
  const twice = (m) => mine.filter((t) => t.model === m).length > 1
505
- const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''}`, start, end }))
512
+ const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''} · ${this.canText(t)}`, start, end }))
506
513
  return rank(items, typed).slice(0, 200)
507
514
  }
508
515
 
@@ -585,6 +592,8 @@ class App {
585
592
  this.useRotation()
586
593
  Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
587
594
  Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
595
+ this.s.team = () => this.team() // your other models: a job this one can't do goes to one of them
596
+ this.s.engineFor = (t) => this.engine(t.provider, t.model, ui)
588
597
  if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
589
598
  if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
590
599
  if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
@@ -611,6 +620,29 @@ class App {
611
620
  await this.session()
612
621
  }
613
622
 
623
+ // Your other models (not the one in use) and what each can do, from its saved profile or
624
+ // models.dev: nothing is sent to them to find out.
625
+ team() {
626
+ const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
627
+ return this.myModels()
628
+ .filter((t) => !(t.provider === now?.provider && t.model === now?.model))
629
+ .map((t) => ({ ...t, ...this.abilities(t) }))
630
+ }
631
+
632
+ // What a model can do, from its saved profile or models.dev (guessed: neither knows it).
633
+ abilities(t) {
634
+ const cl = this.client(t.provider, t.model)
635
+ const p = loadProfile(cl) || profileFrom(cl, limitsOf(t.provider, cl.baseUrl, t.model))
636
+ const st = strategy(p)
637
+ return { tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context, guessed: p.source === 'assumed' && !p.set }
638
+ }
639
+
640
+ // "tools · images · thinking · 1M", for the model lists.
641
+ canText(t) {
642
+ const a = this.abilities(t)
643
+ return [a.tools ? 'tools' : 'no tools', a.images && 'images', a.thinking && 'thinking', kTokens(a.context) + (a.guessed ? '?' : '')].filter(Boolean).join(' · ')
644
+ }
645
+
614
646
  // Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
615
647
  // provider's whole list is).
616
648
  myModels() {
@@ -636,7 +668,7 @@ class App {
636
668
  if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
637
669
  return ui.print(NO_MODELS)
638
670
  }
639
- const items = mine.map((t, i) => ({ label: t.model, group: t.provider, t, current: i === 0, desc: [i === 0 ? '#1 · in use' : `#${i + 1} · fallback`, loadProfile(this.client(t.provider, t.model)) && '✓ tested'].filter(Boolean).join(' · ') }))
671
+ const items = mine.map((t, i) => ({ label: t.model, group: t.provider, t, current: i === 0, desc: [i === 0 ? '#1 · in use' : `#${i + 1} · fallback`, this.canText(t)].join(' · ') }))
640
672
  const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
641
673
  if (it && !it.current) await this.useMine(it.t)
642
674
  }
@@ -1235,6 +1267,8 @@ async function repl(app, f = {}) {
1235
1267
  let ctl = null
1236
1268
  ui.onEscape = () => ctl?.abort()
1237
1269
  ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
1270
+ ui.isCommand = (l) => app.isCommand(l)
1271
+ loadPrices().then(() => ui.render?.()) // models.dev: what each model can do, for the model lists
1238
1272
  ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
1239
1273
  // Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
1240
1274
  // (a second Ctrl+C within 2s quits, also when something is stuck).
@@ -1303,7 +1337,7 @@ async function repl(app, f = {}) {
1303
1337
  ctl = new AbortController()
1304
1338
  app.signal = ctl.signal // Esc stops whatever this line started (a task, a probe, …)
1305
1339
  ui.busy() // the spinner stays until this line's work is done
1306
- if (t.startsWith('/')) {
1340
+ if (app.isCommand(t)) {
1307
1341
  try {
1308
1342
  if ((await slash(app, t)) === 'exit') {
1309
1343
  if (app.tabs) app.tabs.quitting = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
5
  "type": "module",
6
6
  "bin": {
package/prompts/agent.md CHANGED
@@ -170,6 +170,30 @@ The user didn't answer. Decide yourself, carry on, and say in your reply what yo
170
170
  <!-- Starts a helper agent (only in the main conversation, not in helpers). -->
171
171
  Hand a side job to a helper agent with its own fresh context, so the details stay out of this conversation: searching or reading a lot of code to answer a question, researching on the web, reviewing changes. It has the same tools (no helpers of its own) and sees only your prompt, so say exactly what to do and what to report. Its final reply comes back as the result. description: 3-5 words for the user.
172
172
 
173
+ Several agent calls in one reply run at the same time. Use that to split independent work (one helper per part), or to ask several helpers the same thing (different models, or the same one more than once) and combine their answers: compare them, keep what they agree on, check where they differ.
174
+
175
+ ## tool-agent-model
176
+
177
+ <!-- The agent tool's "model", there when the user has other models. -->
178
+ Another of your models to do the job (from "Your other models" in the system prompt, as "provider model"), when it needs what you can't do or you want its view too. Leave out for a helper on your own model.
179
+
180
+ ## team
181
+
182
+ <!-- In the system prompt when the user set up other models. {{models}}: one per line with what each can do. {{you}}: what this model can do. -->
183
+ # Your other models
184
+
185
+ The user set up these models besides you (you: tool calls, {{you}}):
186
+ {{models}}
187
+
188
+ You can hand a job to one of them with the agent tool and its "model", and to several at once (several agent calls in one reply), for a second opinion or to compare answers. Never decline a part of the task, or say you can't do it, because it needs something you can't do (see an image, call tools, think longer, read more than fits your context): give that part to a model here that can, with everything it needs in the prompt (file paths, what to look for, what to report), then go on with its answer. Answering yourself is better when you can.
189
+
190
+ ## images-described
191
+
192
+ <!-- Added to the user's message when this model can't see images and another of theirs described them. {{model}}: that model. {{descriptions}}: what it saw. -->
193
+ (You can't see images, so {{model}} looked at the attached image(s) for you:
194
+
195
+ {{descriptions}})
196
+
173
197
  ## subagent
174
198
 
175
199
  <!-- Added to a helper agent's system prompt. -->
package/prompts/guided.md CHANGED
@@ -36,6 +36,7 @@ edit: change an existing file
36
36
  create: make a new file
37
37
  run: run a shell command (tests, build, scripts)
38
38
  ask: ask the user a question
39
+ delegate: hand the task to another of your models, one that can use tools itself
39
40
  finish: stop and give your final answer to the user
40
41
 
41
42
  ## next-action
package/src/agent.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs'
2
+ import os from 'node:os'
2
3
  import { log } from './log.js'
3
4
  import { prompt, userInstructions } from './prompts.js'
4
5
  import { newChat } from './sessions.js'
@@ -12,13 +13,16 @@ import { c } from './ui.js'
12
13
 
13
14
  const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
14
15
 
15
- // "@shot.png" in a task attaches that image.
16
+ // An image the message names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
17
+ // ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
18
+ const IMAGE_PATH = /(^|\s)@?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\2|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
16
19
  export function extractImages(task, root) {
17
20
  const images = []
18
- const text = task.replace(/(^|\s)@(\S+\.(png|jpe?g|gif|webp))\b/gi, (m, pre, file, ext) => {
21
+ const text = task.replace(IMAGE_PATH, (m, pre, q, quoted, bare) => {
22
+ const file = (quoted ?? bare.replace(/\\ /g, ' ')).replace(/^~(?=\/)/, os.homedir())
19
23
  const abs = path.resolve(root, file)
20
- if (!fs.existsSync(abs)) return m
21
- images.push(`data:${MIME[ext.toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
24
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return m
25
+ images.push(`data:${MIME[path.extname(abs).slice(1).toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
22
26
  return `${pre}${file}`
23
27
  })
24
28
  return { text, images }
@@ -137,7 +141,9 @@ export class Session {
137
141
  let { text, images } = extractImages(task, this.root)
138
142
  images = [...images, ...extra]
139
143
  if (images.length && !this.strat.vision) {
140
- this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
144
+ const seer = this.team?.().find((t) => t.images) // another of your models that can: it describes them
145
+ if (seer) text += await this.describeImages(images, seer, text, signal)
146
+ else this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
141
147
  images = []
142
148
  }
143
149
  log('task', `${text} (${this.strat.actions} mode, ${images.length} image(s))`)
@@ -152,19 +158,42 @@ export class Session {
152
158
  // own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
153
159
  // under the main conversation's, its file changes count for /rewind like the main agent's.
154
160
  // ponytail: helpers run one after another; run several at once if waiting on them gets long.
161
+ // model: another of your models (team) does it, for what this one can't do (see images, call tools…).
155
162
  async subagent(args, signal) {
156
163
  const task = String(args.prompt ?? args.task ?? '').trim()
157
164
  if (!task) return 'error: give the helper its job in "prompt"'
158
- const label = clip(String(args.description ?? '').trim() || task.split('\n')[0], 40)
159
- const helper = new Session({ client: this.client, profile: this.profile, strat: this.strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
165
+ let { client, profile, strat } = this
166
+ const want = String(args.model ?? '').trim()
167
+ if (want) {
168
+ const team = this.team?.() ?? []
169
+ const t = team.find((x) => `${x.provider} ${x.model}` === want) ?? team.find((x) => x.model === want || want.endsWith(` ${x.model}`))
170
+ if (t) ({ client, profile, strat } = await this.engineFor(t))
171
+ else if (want !== client.model && !want.endsWith(` ${client.model}`)) return `error: no model "${want}". Yours: ${[client.model, ...team.map((x) => `${x.provider} ${x.model}`)].join(', ')}`
172
+ }
173
+ const label = clip(`${client.model}: ${String(args.description ?? '').trim() || task.split('\n')[0]}`, 60)
174
+ const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
160
175
  Object.assign(helper, { isSub: true, mcp: this.mcp, skills: this.skills, hooks: this.hooks, allowTools: this.allowTools, denyTools: this.denyTools, maxTurns: this.maxTurns, autoCompact: this.autoCompact, appendSystem: this.appendSystem, fileCache: this.fileCache, noFormat: this.noFormat, noLsp: this.noLsp })
161
176
  helper.track = (rel, before) => this.track(rel, before)
162
177
  log('agent', `helper "${label}": ${task}`)
163
- const answer = await runNative(helper, task, [], signal)
178
+ const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, [], signal)
164
179
  log('agent', `helper "${label}" done`, answer)
165
180
  return answer?.trim() || '(the helper finished without a reply)'
166
181
  }
167
182
 
183
+ // Images for a model that can't see them: another of yours (seer) describes each, for the task.
184
+ async describeImages(images, seer, task, signal) {
185
+ const { client } = await this.engineFor(seer)
186
+ const out = []
187
+ for (const [i, url] of images.entries()) {
188
+ this.ui.spinner(`${seer.model} is looking at image ${i + 1}`)
189
+ const res = await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('guided/describe-image', { task }) }, { type: 'image_url', image_url: { url } }] }] }, { signal })
190
+ out.push(`Image ${i + 1}:\n${res.content.trim()}`)
191
+ this.ui.step('image', `${seer.model} described image ${i + 1}`)
192
+ }
193
+ log('images', `described by ${seer.provider} ${seer.model}`, out)
194
+ return `\n\n${prompt('agent/images-described', { model: seer.model, descriptions: out.join('\n\n') })}`
195
+ }
196
+
168
197
  // /btw: a side question, answered from the conversation so far. Nothing goes into the
169
198
  // conversation and no tools are offered, so it can run while a task works: on its own client,
170
199
  // so the task's request timer and retries are left alone (its cost still counts).
@@ -282,6 +311,7 @@ const SYSTEM = (s) => {
282
311
  s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
283
312
  s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
284
313
  s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
314
+ !s.isSub && teamPrompt(s),
285
315
  s.isSub && prompt('agent/subagent'),
286
316
  s.appendSystem && prompt('agent/caller'),
287
317
  s.appendSystem,
@@ -290,6 +320,15 @@ const SYSTEM = (s) => {
290
320
  .join('\n\n')
291
321
  }
292
322
 
323
+ // Your other models and what each can do, for the agent tool's model (only when there are any).
324
+ const teamPrompt = (s) => {
325
+ const team = s.team?.() ?? []
326
+ if (!team.length) return ''
327
+ const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`].filter(Boolean).join(', ')
328
+ const you = [s.strat.vision ? 'images' : 'no images', s.strat.thinking && 'thinking'].filter(Boolean).join(', ')
329
+ return prompt('agent/team', { models: team.map((t) => `- ${t.provider} ${t.model}: ${can(t)}`).join('\n'), you })
330
+ }
331
+
293
332
  const sizeOf = (m) =>
294
333
  (Array.isArray(m.content) ? m.content.reduce((n, p) => n + (p.type === 'text' ? est(p.text) : 800), 0) : est(m.content)) +
295
334
  (m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
@@ -506,7 +545,7 @@ async function runNative(s, task, images, signal) {
506
545
  fitMessages(s.messages, budget())
507
546
  pairTools(s.messages)
508
547
  ui.spinner('thinking')
509
- const out = ui.response()
548
+ const out = ui.response(client.model) // named: helpers think too (theirs are named in helperUI)
510
549
  const stream = strat.toolsStream
511
550
  let res
512
551
  try {
@@ -565,12 +604,25 @@ async function runNative(s, task, images, signal) {
565
604
  continue
566
605
  }
567
606
 
607
+ // Helpers (agent) asked for in one reply work at the same time: the same model or several.
608
+ // ponytail: no cap on how many at once; add one if a provider's rate limit makes them crawl.
609
+ const argsOf = (call) => {
610
+ try {
611
+ return JSON.parse(call.function.arguments || '{}')
612
+ } catch {
613
+ return null
614
+ }
615
+ }
616
+ const together = new Map()
617
+ for (const call of res.toolCalls) {
618
+ const args = argsOf(call)
619
+ if (call.function.name !== 'agent' || !args || !allowed(s, 'agent') || res.toolCalls.filter((x) => x.function.name === 'agent').length < 2) continue
620
+ ui.step('agent', clip([args.model && `${args.model}:`, args.description].filter(Boolean).join(' '), 80))
621
+ together.set(call.id, runTool('agent', args, s, signal))
622
+ }
568
623
  for (const call of res.toolCalls) {
569
624
  const name = call.function.name
570
- let args = null
571
- try {
572
- args = JSON.parse(call.function.arguments || '{}')
573
- } catch {}
625
+ const args = argsOf(call)
574
626
  const detail = !args
575
627
  ? ''
576
628
  : args.server
@@ -580,12 +632,12 @@ async function runNative(s, task, images, signal) {
580
632
  : name === 'manage'
581
633
  ? [args.action, args.kind, args.name].filter(Boolean).join(' ')
582
634
  : name === 'agent'
583
- ? (args.description ?? '')
635
+ ? [args.model && `${args.model}:`, args.description].filter(Boolean).join(' ')
584
636
  : name === 'todo' || name === 'ask_user'
585
637
  ? ''
586
638
  : (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
587
- if (name !== 'run_command') ui.step(name, clip(detail, 80))
588
- const out = !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal) : 'error: the tool arguments were not valid JSON'
639
+ if (name !== 'run_command' && !together.has(call.id)) ui.step(name, clip(detail, 80))
640
+ const out = together.has(call.id) ? await together.get(call.id) : !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal) : 'error: the tool arguments were not valid JSON'
589
641
  if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
590
642
  else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
591
643
  else if (out.startsWith('error')) ui.result(clip(out, 120))
@@ -618,13 +670,36 @@ async function notDone(s, signal, again) {
618
670
 
619
671
  // A helper agent's view of the screen: its steps are labelled, and its replies (which go to the
620
672
  // main agent) aren't shown as answers. Everything else is the main UI's.
673
+ // Its thinking is shown whole when its reply is in (helpers working at once don't mix), named.
674
+ // Questions to the user (confirm, pick, ask) wait their turn: helpers working at once ask one by one.
675
+ const asking = new WeakMap() // ui → the question being asked
621
676
  function helperUI(ui, label) {
622
- const quiet = { think() {}, text() {}, end() {} }
677
+ const response = () => {
678
+ const t0 = Date.now()
679
+ let thought = ''
680
+ return {
681
+ think: (t) => (thought += t ?? ''),
682
+ text() {},
683
+ end() {
684
+ if (!thought.trim()) return
685
+ const secs = ((Date.now() - t0) / 1000).toFixed(1)
686
+ ui.print(c.faint(c.italic(` ✻ ${label} thought for ${secs}s`)) + (ui.showThinking ? `\n${c.think(c.italic(thought.trim().replace(/^/gm, ' ')))}` : ''))
687
+ },
688
+ }
689
+ }
690
+ const inTurn = (k) => (...a) => {
691
+ const next = (asking.get(ui) ?? Promise.resolve()).then(() => ui[k](...a))
692
+ asking.set(ui, next.catch(() => {}))
693
+ return next
694
+ }
623
695
  const own = {
624
696
  step: (name, detail) => ui.step(` ${label} › ${name}`, detail),
625
697
  result: (text) => ui.result(` ${text}`),
626
- response: () => quiet,
698
+ response,
627
699
  spinner: (text) => ui.spinner(`${label} · ${text}`),
700
+ confirm: inTurn('confirm'),
701
+ pick: inTurn('pick'),
702
+ ask: inTurn('ask'),
628
703
  }
629
704
  return new Proxy(ui, { get: (t, k) => (k in own ? own[k] : typeof t[k] === 'function' ? t[k].bind(t) : t[k]), set: (t, k, v) => ((t[k] = v), true) })
630
705
  }
@@ -684,7 +759,7 @@ function attachNamed(s, task) {
684
759
  } catch {
685
760
  continue
686
761
  }
687
- if (body.startsWith('error')) continue
762
+ if (body.startsWith('error') || body.startsWith(`${f} is a binary file`)) continue // an image: read_file shows it, or says who can see it
688
763
  const id = `named_${Date.now().toString(36)}_${calls.length}`
689
764
  calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
690
765
  const msg = { role: 'tool', tool_call_id: id, content: body }
package/src/guided.js CHANGED
@@ -44,6 +44,7 @@ export function newFileIn(task, files) {
44
44
  // Obvious next steps are taken without asking: a weak model's effort goes into
45
45
  // writing code, not into navigating. Returns an action, or null to ask.
46
46
  export function autoAction(st) {
47
+ if (st.delegated) return 'finish' // another model did the task: say what it did
47
48
  if (st.newFile && st.wantsChange) return 'create'
48
49
  if (st.failing && st.canRun && !st.runs && !st.edits) return 'run' // see the failure first
49
50
  if (st.lastRun !== null && st.lastRun !== 0 && !st.editsSinceRun && st.wantsChange && st.fixes < 3) return 'edit'
@@ -61,7 +62,7 @@ export function nextActions(st) {
61
62
  if (!st.reads && st.hasFiles) first = ['read', 'search']
62
63
  else if (st.wantsChange && !st.edits) first = st.hasFiles ? ['edit', 'create', 'read'] : ['create']
63
64
  else first = ['finish', 'edit', 'run']
64
- return [...new Set([...first, ...ACTIONS.map(([n]) => n)])]
65
+ return [...new Set([...first, ...ACTIONS.map(([n]) => n)])].filter((n) => n !== 'delegate' || st.canDelegate)
65
66
  }
66
67
 
67
68
  // A reply may be plain text or (when enforced) JSON like {"path": "a.js"}.
@@ -193,7 +194,7 @@ class Guided {
193
194
  this.notes = []
194
195
  this.changed = new Set()
195
196
  this.files = listFiles(s.root)
196
- this.st = { reads: 0, edits: 0, editsSinceRun: 0, runs: 0, fixes: 0, lastRun: null, hasFiles: this.files.length > 0, canRun: suggestCommands(s.root).length > 0 }
197
+ this.st = { reads: 0, edits: 0, editsSinceRun: 0, runs: 0, fixes: 0, lastRun: null, hasFiles: this.files.length > 0, canRun: suggestCommands(s.root).length > 0, canDelegate: Boolean(s.team?.().some((t) => t.tools)) }
197
198
  }
198
199
 
199
200
  async yesNo(question, fallback) {
@@ -585,6 +586,16 @@ class Guided {
585
586
  return `ask:${q}`
586
587
  }
587
588
 
589
+ // Another of your models, one with tool calls, does the whole task; what it did goes in the notes.
590
+ async delegate() {
591
+ const team = this.s.team().filter((t) => t.tools)
592
+ const t = (/\.(png|jpe?g|gif|webp)\b/i.test(this.task) && team.find((x) => x.images)) || team[0]
593
+ const done = await this.s.subagent({ description: 'the task', prompt: this.task, model: `${t.provider} ${t.model}` }, this.signal)
594
+ this.note('delegate', `what ${t.model} did for the task`, done)
595
+ this.st.delegated = true
596
+ return 'delegate'
597
+ }
598
+
588
599
  async finish() {
589
600
  this.ui.spinner('writing answer')
590
601
  let res
package/src/headless.js CHANGED
@@ -93,15 +93,29 @@ export async function runHeadless(app, opts) {
93
93
  const model = `${app.cfg.provider}/${s.client.model}`
94
94
 
95
95
  // usage and request count, over everything this message sends (tool loop, compaction, memory)
96
+ // (also your other models', when this one hands them a job: modelUsage has each)
96
97
  let usage = { input_tokens: 0, output_tokens: 0 }
98
+ let byModel = {}
97
99
  let requests = 0
98
- const chat = s.client.chat.bind(s.client)
99
- s.client.chat = async (...a) => {
100
- const r = await chat(...a)
101
- requests++
102
- usage.input_tokens += r.usage?.prompt_tokens ?? 0
103
- usage.output_tokens += r.usage?.completion_tokens ?? 0
104
- return r
100
+ const count = (client, key) => {
101
+ const chat = client.chat.bind(client)
102
+ client.chat = async (...a) => {
103
+ const r = await chat(...a)
104
+ const m = (byModel[key] ??= { inputTokens: 0, outputTokens: 0 })
105
+ if (client === s.client) requests++
106
+ usage.input_tokens += r.usage?.prompt_tokens ?? 0
107
+ usage.output_tokens += r.usage?.completion_tokens ?? 0
108
+ m.inputTokens += r.usage?.prompt_tokens ?? 0
109
+ m.outputTokens += r.usage?.completion_tokens ?? 0
110
+ return r
111
+ }
112
+ }
113
+ count(s.client, model)
114
+ const engineFor = s.engineFor
115
+ if (engineFor) s.engineFor = async (t) => {
116
+ const e = await engineFor(t)
117
+ count(e.client, `${t.provider}/${t.model}`)
118
+ return e
105
119
  }
106
120
 
107
121
  let said = false // the model's text was already sent as an assistant message
@@ -129,6 +143,7 @@ export async function runHeadless(app, opts) {
129
143
  const t0 = Date.now()
130
144
  const spent = app.chat.usage?.cost ?? 0
131
145
  usage = { input_tokens: 0, output_tokens: 0 }
146
+ byModel = {}
132
147
  requests = 0
133
148
  said = false
134
149
  current = { ctl: new AbortController() }
@@ -148,7 +163,7 @@ export async function runHeadless(app, opts) {
148
163
  }
149
164
  const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
150
165
  current = null
151
- const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens } }, session_id }
166
+ const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: 0, outputTokens: 0 }, ...byModel }, session_id }
152
167
  if (out === 'stream-json') {
153
168
  if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, session_id }) // guided mode
154
169
  emit(result)
package/src/log.js CHANGED
@@ -28,14 +28,23 @@ export function stamp(d = new Date()) {
28
28
  }
29
29
  // Logs older than `days` go: run folders, request folders, and the older one-file-per-run logs.
30
30
  // Returns how many went.
31
- export function pruneLogs(days = DAYS) {
32
- const before = Date.now() - days * 86_400_000
31
+ // When a log was last written to: a folder's newest file (writing into a file leaves its folder's
32
+ // time as it was).
33
+ function lastWritten(at) {
34
+ const s = fs.statSync(at)
35
+ if (!s.isDirectory()) return s.mtimeMs
36
+ let t = s.mtimeMs
37
+ for (const f of fs.readdirSync(at)) t = Math.max(t, fs.statSync(path.join(at, f), { throwIfNoEntry: false })?.mtimeMs ?? 0)
38
+ return t
39
+ }
40
+
41
+ export function pruneLogs(days = DAYS, before = Date.now() - days * 86_400_000) {
33
42
  let gone = 0
34
43
  for (const [dir, isLog] of [[path.join(LOGS, 'runs'), () => true], [REQUESTS, () => true], [LOGS, (f) => f.endsWith('.log')]]) {
35
44
  try {
36
45
  for (const f of fs.readdirSync(dir)) {
37
46
  const at = path.join(dir, f)
38
- if (!isLog(f) || at === LOG_DIR || at === requestLog.dir || fs.statSync(at).mtimeMs >= before) continue
47
+ if (!isLog(f) || at === LOG_DIR || at === requestLog.dir || lastWritten(at) >= before) continue
39
48
  fs.rmSync(at, { recursive: true, force: true })
40
49
  gone++
41
50
  }
@@ -43,6 +52,31 @@ export function pruneLogs(days = DAYS) {
43
52
  }
44
53
  return gone
45
54
  }
55
+ // The settings page's Delete all: every log but the ones being written now (this run's, and any
56
+ // touched in the last minute: another HeartCode's). Returns how many files and folders went.
57
+ export const clearLogs = () => pruneLogs(0, Date.now() - 60_000)
58
+
59
+ // How much room the logs take on disk, in bytes: { runs, requests, total } (runs: the run logs,
60
+ // and older single log files).
61
+ export function logSize() {
62
+ const sum = (dir) => {
63
+ let n = 0
64
+ try {
65
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
66
+ const at = path.join(dir, e.name)
67
+ n += e.isDirectory() ? sum(at) : (fs.statSync(at, { throwIfNoEntry: false })?.size ?? 0)
68
+ }
69
+ } catch {}
70
+ return n
71
+ }
72
+ let loose = 0
73
+ try {
74
+ for (const f of fs.readdirSync(LOGS)) if (f.endsWith('.log')) loose += fs.statSync(path.join(LOGS, f), { throwIfNoEntry: false })?.size ?? 0
75
+ } catch {}
76
+ const runs = sum(path.join(LOGS, 'runs')) + loose
77
+ const requests = sum(REQUESTS)
78
+ return { runs, requests, total: runs + requests }
79
+ }
46
80
  export const logDays = (cfg) => (Number.isInteger(cfg.logDays) && cfg.logDays > 0 ? cfg.logDays : DAYS)
47
81
 
48
82
  // Which tab this is running for (tabs.js runs each tab's work in it): its log file, its requests.
package/src/settings.html CHANGED
@@ -42,6 +42,9 @@
42
42
  .page { display: none; }
43
43
  .page.on { display: block; }
44
44
  .head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
45
+ .disk { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
46
+ .disk b { font-size: 22px; }
47
+ .disk .k { color: var(--muted); font-size: 12.5px; }
45
48
  h1 { font-size: 24px; margin: 0 0 4px; }
46
49
  .lead { color: var(--muted); margin: 0; max-width: 760px; }
47
50
  h2 { font-size: 15px; margin: 0 0 4px; }
@@ -412,7 +415,7 @@
412
415
 
413
416
  <!-- Logging -->
414
417
  <section class="page" id="page-logging">
415
- <div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div></div>
418
+ <div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div><div class="disk" id="l-disk"></div></div>
416
419
  <div class="card">
417
420
  <div class="log-opts">
418
421
  <div>
@@ -606,6 +609,7 @@ function showPage() {
606
609
  for (const p of PAGES) $('page-' + p).classList.toggle('on', p === page())
607
610
  for (const a of document.querySelectorAll('nav a')) a.classList.toggle('on', a.dataset.page === page())
608
611
  if (S) drawUsing(S)
612
+ if (page() === 'logging') drawDisk()
609
613
  scrollTo(0, 0)
610
614
  fillHeight()
611
615
  document.querySelector('nav a.on')?.scrollIntoView({ inline: 'center', block: 'nearest' }) // on a phone the pages scroll sideways
@@ -805,7 +809,7 @@ function testLine(t) {
805
809
  const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
806
810
  const [cls, text, action] =
807
811
  !x ? ['none', 'looking it up in models.dev…', null]
808
- : x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Retry all
812
+ : x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Refresh from models.dev
809
813
  : ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
810
814
  const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
811
815
  return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
@@ -876,7 +880,7 @@ async function tryAll() {
876
880
  toast(`${good} of ${list.length} work${good === list.length ? '' : `; ${list.length - good} don't (Works? says why, on hover)`}`, good < list.length)
877
881
  }
878
882
 
879
- // models.dev looked up again: one model (its Retry), or all of yours (Retry all)
883
+ // models.dev looked up again: one model (its Retry), or all of yours (Refresh from models.dev)
880
884
  const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
881
885
  async function lookUp(t) {
882
886
  toast(t ? `looking up ${t.model} in models.dev…` : 'looking up your models in models.dev…')
@@ -993,7 +997,7 @@ function drawProviders(s) {
993
997
  h('div', { className: 'mine-title' },
994
998
  h('h2', { textContent: 'Your models' }),
995
999
  h('button', { className: 'small', textContent: 'Table', style: order.length ? '' : 'visibility:hidden', title: 'all of them in a table, to compare and sort', onclick: () => (location.hash = 'models') }),
996
- h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
1000
+ h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
997
1001
  h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
998
1002
  h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
999
1003
  order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
@@ -1274,7 +1278,7 @@ function drawMine(s) {
1274
1278
  : order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
1275
1279
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1276
1280
  SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
1277
- order.length ? h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', onclick: () => lookUp() }) : null,
1281
+ order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
1278
1282
  order.length ? h('button', { className: 'small', textContent: 'Test all', title: 'send each a short message, several at once (2 per provider at most), to see which really answer', onclick: () => tryAll() }) : null),
1279
1283
  order.length
1280
1284
  ? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
@@ -1350,7 +1354,36 @@ $('v-save').onclick = () => act('/api/voice', { text: $('v-text').value }, 'voic
1350
1354
  const logSwitch = (s, k, title, about) => h('label', { className: 'opt' },
1351
1355
  h('div', { className: 't' }, h('b', { textContent: title }), h('span', { textContent: about })),
1352
1356
  sw({ checked: s.settings[k], 'aria-label': title, onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${title}: ${e.target.checked ? 'on' : 'off'}`) }))
1357
+ // How much room the logs take: asked again when the page shows, when it's redrawn, and (at most every
1358
+ // few seconds) while requests are being logged.
1359
+ const bytes = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e8 ? 0 : 1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} kB` : `${n} bytes`)
1360
+ let diskAt = 0
1361
+ let diskLater = null
1362
+ async function drawDisk() {
1363
+ if (Date.now() - diskAt < 3000) return void (diskLater ??= setTimeout(() => ((diskLater = null), drawDisk()), 3000))
1364
+ diskAt = Date.now()
1365
+ try {
1366
+ showDisk(await api('/api/log-size'))
1367
+ } catch {}
1368
+ }
1369
+ function showDisk(d) {
1370
+ put($('l-disk'),
1371
+ h('div', { className: 'row', style: 'gap:10px;align-items:center' },
1372
+ h('span', {}, h('span', { className: 'k', textContent: 'Logs on disk ' }), h('b', { textContent: bytes(d.total) })),
1373
+ d.total ? h('button', { className: 'small danger', textContent: 'Delete all', title: 'delete every log (the ones being written right now stay)', onclick: clearAll }) : null),
1374
+ h('div', { className: 'k', textContent: `requests ${bytes(d.requests)} · runs ${bytes(d.runs)}` }))
1375
+ }
1376
+ async function clearAll() {
1377
+ if (!confirm('Delete all logs, of every run and every request? This can\'t be undone. (The ones being written right now stay.)')) return
1378
+ const r = await act('/api/clear-logs', {})
1379
+ if (!r) return
1380
+ showDisk(r.size)
1381
+ diskAt = Date.now()
1382
+ api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => {})
1383
+ toast(`deleted: logs on disk now ${bytes(r.size.total)}`)
1384
+ }
1353
1385
  function drawLogging(s) {
1386
+ drawDisk()
1354
1387
  put($('l-run'), logSwitch(s, 'log', 'Keep a log of every run', `From now on: a folder per run in ${s.logs.runs}, a file per tab (tab-1.log, tab-2.log…).`))
1355
1388
  put($('l-req'), logSwitch(s, 'logRequests', 'Log every request and reply', `Right away: a file per request in ${s.logs.requests}, a folder per run.`))
1356
1389
  $('l-detail').value = s.settings.logDetail
@@ -1388,11 +1421,11 @@ const whyOf = (r) => {
1388
1421
  return r.error
1389
1422
  }
1390
1423
  const resultTag = (r) => h('span', { className: `tag ${r.result === 'ok' ? 'ok' : r.result === 'failed' ? 'err' : ''}`, textContent: r.result === 'ok' ? 'ok' : r.result === 'failed' ? `failed${r.status ? ` ${r.status}` : ''}` : 'stopped' })
1391
- const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : r.of > 1 ? h('span', { className: 'tag', textContent: `#1 of ${r.of}` }) : null)
1424
+ const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : null) // #1: nothing to say
1392
1425
  function reqRow(r) {
1393
1426
  const key = `${r.run}/${r.file}`
1394
1427
  return h('button', { className: `rq ${r.result}${reqOpen === key ? ' on' : ''}`, 'data-key': key, onclick: () => openReq(r) },
1395
- h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model }), placeTag(r)),
1428
+ h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model })), // where it is in your list: in its details
1396
1429
  h('div', { className: 'l2' },
1397
1430
  h('span', { textContent: when(r.at) }), h('span', { textContent: r.provider ?? '' }), h('span', { textContent: secs(r.ms) }),
1398
1431
  r.usage ? h('span', { textContent: tokensOf(r.usage) }) : null,
@@ -1597,6 +1630,7 @@ if (token) {
1597
1630
  // a request logged: at the top of the list, nothing else redrawn
1598
1631
  events.addEventListener('request', (e) => {
1599
1632
  const r = JSON.parse(e.data)
1633
+ if (page() === 'logging') drawDisk() // the logs grew
1600
1634
  if (!REQS) return
1601
1635
  REQS.unshift(r)
1602
1636
  if (REQS.length === 1 || !reqShown(r)) return drawReqs()
package/src/settings.js CHANGED
@@ -17,7 +17,7 @@ import { loadProfile, strategy, profilePath, profileFrom } from './probe.js'
17
17
  import { loadPrices, priceOf, limitsOf } from './usage.js'
18
18
  import { THEMES, PERMISSIONS } from './ui.js'
19
19
  import { loadSkills, frontMatter } from './skills.js'
20
- import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays } from './log.js'
20
+ import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays, logSize, clearLogs } from './log.js'
21
21
 
22
22
  const PAGE = new URL('./settings.html', import.meta.url)
23
23
  const EFFORTS = ['default', 'low', 'medium', 'high']
@@ -189,6 +189,8 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
189
189
 
190
190
  const routes = {
191
191
  'GET /api/state': () => state(),
192
+ 'GET /api/log-size': () => logSize(),
193
+ 'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
192
194
  'POST /api/provider': ({ name, baseUrl, apiKey, keepKey, was }) => {
193
195
  name = safeName(name)
194
196
  if (!name) throw new Error('a name is needed')
package/src/tools.js CHANGED
@@ -115,6 +115,22 @@ export function readFileTool(root, p, start, end, maxLines = 400) {
115
115
  return numberLines(lines.slice(s - 1, e), s) + more
116
116
  }
117
117
 
118
+ // An image file: shown to a model that can see images (after the tool results, as MCP screenshots
119
+ // are); for one that can't, where to send it.
120
+ const IMAGE = /\.(png|jpe?g|gif|webp)$/i
121
+ function readImage(s, p) {
122
+ const abs = resolveIn(s.root, p)
123
+ const rel = path.relative(s.root, abs) || p
124
+ if (!fs.existsSync(abs)) return readFileTool(s.root, p)
125
+ 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')}`)
128
+ return `${rel}: the image is attached in the next message`
129
+ }
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.` : ''}`
132
+ }
133
+
118
134
  export function searchFiles(root, query, sub = '', limit = 50) {
119
135
  const ci = query === query.toLowerCase()
120
136
  let re
@@ -468,7 +484,11 @@ export function extraTools(s) {
468
484
  }
469
485
  // the patch format OpenAI's models are trained on (they also write it as a shell command, below)
470
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']) })
471
- if (!s.isSub && s.subagent) out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str }, ['description', 'prompt']) })
487
+ if (!s.isSub && s.subagent) {
488
+ const team = s.team?.() ?? []
489
+ 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']) })
491
+ }
472
492
  return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
473
493
  }
474
494
 
@@ -811,7 +831,7 @@ async function tool(name, args, s, signal) {
811
831
  }
812
832
  switch (name) {
813
833
  case 'read_file':
814
- return readFileTool(root, args.path, args.start_line, args.end_line)
834
+ return IMAGE.test(String(args.path ?? '')) ? readImage(s, args.path) : readFileTool(root, args.path, args.start_line, args.end_line)
815
835
  case 'list_files': {
816
836
  const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
817
837
  return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
package/src/ui.js CHANGED
@@ -376,6 +376,7 @@ export class UI {
376
376
  this.out = output
377
377
  this.queue = []
378
378
  this.ahead = [] // messages typed (and sent with Enter) while HeartCode was working
379
+ this.isCommand = (l) => l.startsWith('/') // the app's: only real commands (a path like /Users/… is a message)
379
380
  this.keys = { ...DEFAULT_KEYS, ...loadKeys() }
380
381
  this.waiters = []
381
382
  this.closed = false
@@ -398,7 +399,8 @@ export class UI {
398
399
  }
399
400
 
400
401
  // One model reply: thinking (dim, its own block) kept apart from the answer.
401
- response() {
402
+ // who: the model thinking, named over its thinking (helpers say their own: helperUI in agent.js)
403
+ response(who) {
402
404
  const t0 = Date.now()
403
405
  const secs = () => ((Date.now() - t0) / 1000).toFixed(1)
404
406
  const write = (x) => this.write(x)
@@ -406,7 +408,7 @@ export class UI {
406
408
  let text = null
407
409
  let thought = false
408
410
  const summary = () => {
409
- if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ thought for ${secs()}s`)))
411
+ if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ ${who ? `${who} ` : ''}thought for ${secs()}s`)))
410
412
  }
411
413
  return {
412
414
  think: (t) => {
@@ -415,7 +417,7 @@ export class UI {
415
417
  if (!this.showThinking) return this.spinner(`thinking · ${secs()}s`)
416
418
  if (!think) {
417
419
  this.gap()
418
- this.print(c.faint(c.italic('✻ Thinking…')))
420
+ this.print(c.faint(c.italic(`✻ ${who ? `${who} is thinking` : 'Thinking'}…`)))
419
421
  think = new Block(write, { style: (x) => c.think(c.italic(x)) })
420
422
  }
421
423
  think.push(t)
@@ -704,7 +706,13 @@ export class UI {
704
706
  m.off = rl.line
705
707
  return this.refreshMenu()
706
708
  }
707
- if (m.on && key.name === 'tab') return it && this.#accept(it)
709
+ // Tab only completes the text: the menu closes (no list of a command's choices pops up); Enter runs it
710
+ if (m.on && key.name === 'tab') {
711
+ if (!it) return
712
+ this.#accept(it)
713
+ m.off = rl.line
714
+ return this.refreshMenu()
715
+ }
708
716
  if (it && key.name === 'return' && rl.line.slice(it.start, it.end) !== it.value) {
709
717
  this.#accept(it)
710
718
  if (it.insertOnly) return
@@ -736,7 +744,7 @@ export class UI {
736
744
  if (/^\/btw\b/.test(line) && this.onBtw) this.onBtw(line.replace(/^\/btw\b/, '').trim()) // answered now, beside the task
737
745
  else if (line) {
738
746
  this.ahead.push(line)
739
- this.print(`${c.faint(' ↳')} ${c.muted(line.split('\n')[0])} ${c.faint(/^\//.test(line) ? '(runs when this is done)' : '(goes to the model at its next step, or runs next)')}`)
747
+ this.print(`${c.faint(' ↳')} ${c.muted(line.split('\n')[0])} ${c.faint(this.isCommand(line) ? '(runs when this is done)' : '(goes to the model at its next step, or runs next)')}`)
740
748
  }
741
749
  return this.render()
742
750
  }
@@ -841,8 +849,8 @@ export class UI {
841
849
 
842
850
  // Messages typed while working that can go to the running task now (not commands); taken.
843
851
  takeAhead() {
844
- const out = this.ahead.filter((l) => !l.startsWith('/'))
845
- this.ahead = this.ahead.filter((l) => l.startsWith('/'))
852
+ const out = this.ahead.filter((l) => !this.isCommand(l))
853
+ this.ahead = this.ahead.filter((l) => this.isCommand(l))
846
854
  return out
847
855
  }
848
856