heartcode 0.1.3 → 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/bin/heartcode.js CHANGED
@@ -509,7 +509,7 @@ class App {
509
509
  modelChoices(typed, start, end) {
510
510
  const mine = this.myModels()
511
511
  const twice = (m) => mine.filter((t) => t.model === m).length > 1
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' : ''}`, 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 }))
513
513
  return rank(items, typed).slice(0, 200)
514
514
  }
515
515
 
@@ -626,11 +626,21 @@ class App {
626
626
  const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
627
627
  return this.myModels()
628
628
  .filter((t) => !(t.provider === now?.provider && t.model === now?.model))
629
- .map((t) => {
630
- const cl = this.client(t.provider, t.model)
631
- const st = strategy(loadProfile(cl) || profileFrom(cl, limitsOf(t.provider, cl.baseUrl, t.model)))
632
- return { ...t, tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context }
633
- })
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(' · ')
634
644
  }
635
645
 
636
646
  // Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
@@ -658,7 +668,7 @@ class App {
658
668
  if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
659
669
  return ui.print(NO_MODELS)
660
670
  }
661
- 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(' · ') }))
662
672
  const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
663
673
  if (it && !it.current) await this.useMine(it.t)
664
674
  }
@@ -1258,6 +1268,7 @@ async function repl(app, f = {}) {
1258
1268
  ui.onEscape = () => ctl?.abort()
1259
1269
  ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
1260
1270
  ui.isCommand = (l) => app.isCommand(l)
1271
+ loadPrices().then(() => ui.render?.()) // models.dev: what each model can do, for the model lists
1261
1272
  ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
1262
1273
  // Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
1263
1274
  // (a second Ctrl+C within 2s quits, also when something is stuck).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.3",
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,10 +170,12 @@ 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
+
173
175
  ## tool-agent-model
174
176
 
175
177
  <!-- The agent tool's "model", there when the user has other models. -->
176
- 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. Leave out to do it with yourself.
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.
177
179
 
178
180
  ## team
179
181
 
@@ -183,7 +185,7 @@ Another of your models to do the job (from "Your other models" in the system pro
183
185
  The user set up these models besides you (you: tool calls, {{you}}):
184
186
  {{models}}
185
187
 
186
- You can hand a job to one of them with the agent tool and its "model". 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.
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.
187
189
 
188
190
  ## images-described
189
191
 
package/src/agent.js CHANGED
@@ -167,10 +167,10 @@ export class Session {
167
167
  if (want) {
168
168
  const team = this.team?.() ?? []
169
169
  const t = team.find((x) => `${x.provider} ${x.model}` === want) ?? team.find((x) => x.model === want || want.endsWith(` ${x.model}`))
170
- if (!t) return `error: no model "${want}". Yours: ${team.map((x) => `${x.provider} ${x.model}`).join(', ') || 'none besides this one'}`
171
- ;({ client, profile, strat } = await this.engineFor(t))
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
172
  }
173
- const label = clip((want ? `${client.model}: ` : '') + (String(args.description ?? '').trim() || task.split('\n')[0]), 60)
173
+ const label = clip(`${client.model}: ${String(args.description ?? '').trim() || task.split('\n')[0]}`, 60)
174
174
  const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
175
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 })
176
176
  helper.track = (rel, before) => this.track(rel, before)
@@ -545,7 +545,7 @@ async function runNative(s, task, images, signal) {
545
545
  fitMessages(s.messages, budget())
546
546
  pairTools(s.messages)
547
547
  ui.spinner('thinking')
548
- const out = ui.response()
548
+ const out = ui.response(client.model) // named: helpers think too (theirs are named in helperUI)
549
549
  const stream = strat.toolsStream
550
550
  let res
551
551
  try {
@@ -604,12 +604,25 @@ async function runNative(s, task, images, signal) {
604
604
  continue
605
605
  }
606
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
+ }
607
623
  for (const call of res.toolCalls) {
608
624
  const name = call.function.name
609
- let args = null
610
- try {
611
- args = JSON.parse(call.function.arguments || '{}')
612
- } catch {}
625
+ const args = argsOf(call)
613
626
  const detail = !args
614
627
  ? ''
615
628
  : args.server
@@ -619,12 +632,12 @@ async function runNative(s, task, images, signal) {
619
632
  : name === 'manage'
620
633
  ? [args.action, args.kind, args.name].filter(Boolean).join(' ')
621
634
  : name === 'agent'
622
- ? (args.description ?? '')
635
+ ? [args.model && `${args.model}:`, args.description].filter(Boolean).join(' ')
623
636
  : name === 'todo' || name === 'ask_user'
624
637
  ? ''
625
638
  : (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
626
- if (name !== 'run_command') ui.step(name, clip(detail, 80))
627
- 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'
628
641
  if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
629
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)}`)
630
643
  else if (out.startsWith('error')) ui.result(clip(out, 120))
@@ -657,13 +670,36 @@ async function notDone(s, signal, again) {
657
670
 
658
671
  // A helper agent's view of the screen: its steps are labelled, and its replies (which go to the
659
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
660
676
  function helperUI(ui, label) {
661
- 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
+ }
662
695
  const own = {
663
696
  step: (name, detail) => ui.step(` ${label} › ${name}`, detail),
664
697
  result: (text) => ui.result(` ${text}`),
665
- response: () => quiet,
698
+ response,
666
699
  spinner: (text) => ui.spinner(`${label} · ${text}`),
700
+ confirm: inTurn('confirm'),
701
+ pick: inTurn('pick'),
702
+ ask: inTurn('ask'),
667
703
  }
668
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) })
669
705
  }
package/src/ui.js CHANGED
@@ -399,7 +399,8 @@ export class UI {
399
399
  }
400
400
 
401
401
  // One model reply: thinking (dim, its own block) kept apart from the answer.
402
- response() {
402
+ // who: the model thinking, named over its thinking (helpers say their own: helperUI in agent.js)
403
+ response(who) {
403
404
  const t0 = Date.now()
404
405
  const secs = () => ((Date.now() - t0) / 1000).toFixed(1)
405
406
  const write = (x) => this.write(x)
@@ -407,7 +408,7 @@ export class UI {
407
408
  let text = null
408
409
  let thought = false
409
410
  const summary = () => {
410
- 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`)))
411
412
  }
412
413
  return {
413
414
  think: (t) => {
@@ -416,7 +417,7 @@ export class UI {
416
417
  if (!this.showThinking) return this.spinner(`thinking · ${secs()}s`)
417
418
  if (!think) {
418
419
  this.gap()
419
- this.print(c.faint(c.italic('✻ Thinking…')))
420
+ this.print(c.faint(c.italic(`✻ ${who ? `${who} is thinking` : 'Thinking'}…`)))
420
421
  think = new Block(write, { style: (x) => c.think(c.italic(x)) })
421
422
  }
422
423
  think.push(t)
@@ -705,7 +706,13 @@ export class UI {
705
706
  m.off = rl.line
706
707
  return this.refreshMenu()
707
708
  }
708
- 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
+ }
709
716
  if (it && key.name === 'return' && rl.line.slice(it.start, it.end) !== it.value) {
710
717
  this.#accept(it)
711
718
  if (it.insertOnly) return