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 +18 -7
- package/package.json +1 -1
- package/prompts/agent.md +4 -2
- package/src/agent.js +49 -13
- package/src/ui.js +11 -4
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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`,
|
|
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
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
|
|
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 (
|
|
171
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|