heartcode 0.1.8 → 0.1.12
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/CHANGELOG.md +78 -0
- package/SKILL.md +45 -30
- package/bin/heartcode.js +56 -79
- package/package.json +1 -1
- package/prompts/agent.md +5 -4
- package/src/acp.js +7 -1
- package/src/agent.js +20 -51
- package/src/client.js +42 -40
- package/src/config.js +17 -4
- package/src/guided.js +2 -8
- package/src/headless.js +3 -0
- package/src/settings.html +120 -71
- package/src/settings.js +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
What changed in each version of HeartCode, newest first.
|
|
4
|
+
|
|
5
|
+
## 0.1.12 (2026-10-30)
|
|
6
|
+
|
|
7
|
+
- `auto` is the name for your model list (#1, then the next when one fails); `default` still works.
|
|
8
|
+
- Models are written `provider/model` (`-m groq/openai/gpt-oss-120b`); the old `"provider model"` still works.
|
|
9
|
+
- `heart models` prints what `-m` takes, one per line (`auto` first); `--json` gives an `id` for each.
|
|
10
|
+
- `--fallback-model <provider/model>`, as in `claude -p`: tried after the `-m` model when it fails.
|
|
11
|
+
- Settings changed while HeartCode runs (also `-p`, stream-json and editors over ACP) are taken in
|
|
12
|
+
without a restart: a new model order from the running task's next request.
|
|
13
|
+
- Settings page: adding a provider, an MCP server or a skill opens a dialog; every dialog is 70% of
|
|
14
|
+
the screen wide. Your models can be sorted by a provider ranking you drag into order.
|
|
15
|
+
|
|
16
|
+
## 0.1.11 (2026-10-30)
|
|
17
|
+
|
|
18
|
+
- A changelog (this file) in place of the old TODO list.
|
|
19
|
+
|
|
20
|
+
## 0.1.10 (2026-10-30)
|
|
21
|
+
|
|
22
|
+
- Any model error (a rate limit, an outage, "request too large", a bad model name, a wrong key…)
|
|
23
|
+
moves on to the next model in your list, another provider's first, without the app using
|
|
24
|
+
HeartCode seeing it. A run stops with an error only when every model has failed.
|
|
25
|
+
- No more token estimates deciding when to shrink a request or switch models. A long
|
|
26
|
+
conversation is summarized every 100 messages instead.
|
|
27
|
+
- Settings: the rotation choice "at any error" is gone; it now works the same as "at its first
|
|
28
|
+
failure".
|
|
29
|
+
|
|
30
|
+
## 0.1.9 (2026-10-29)
|
|
31
|
+
|
|
32
|
+
- After an error, the next model tried is from another provider; the failing provider's other
|
|
33
|
+
models come only after every provider has had a go.
|
|
34
|
+
- Model lists in Cloudflare's format (`{ "result": [...] }`) are read.
|
|
35
|
+
- Settings: model lists no longer stay stuck on "loading…", and an empty saved list is fetched again.
|
|
36
|
+
- A shorter system prompt, with the duplicates taken out.
|
|
37
|
+
|
|
38
|
+
## 0.1.8 (2026-10-29)
|
|
39
|
+
|
|
40
|
+
- Settings page: model tests run on the server, long model lists scroll smoothly, prices are
|
|
41
|
+
shown, and "Test all" shows its progress.
|
|
42
|
+
|
|
43
|
+
## 0.1.7 (2026-10-29)
|
|
44
|
+
|
|
45
|
+
- Each request is fitted to the model it goes to (images, tool calls, thinking).
|
|
46
|
+
- Safer file changes.
|
|
47
|
+
- Better handing of work to the other models.
|
|
48
|
+
|
|
49
|
+
## 0.1.6 (2026-10-29)
|
|
50
|
+
|
|
51
|
+
- Better handing of work to the other models.
|
|
52
|
+
- A helper's steps are sent to apps using HeartCode, like Claude Code's subagents.
|
|
53
|
+
- Fixed switching models.
|
|
54
|
+
- npm page: GitHub links and keywords.
|
|
55
|
+
|
|
56
|
+
## 0.1.5 (2026-10-28)
|
|
57
|
+
|
|
58
|
+
- Several helpers can run at once; thinking is shown with its name; the model lists show what
|
|
59
|
+
each model can do.
|
|
60
|
+
- Tab only completes, it doesn't send.
|
|
61
|
+
|
|
62
|
+
## 0.1.3 (2026-10-28)
|
|
63
|
+
|
|
64
|
+
- Another of your models does what the one in use can't (for example, looking at an image).
|
|
65
|
+
- A path typed at the prompt is no longer taken for a command.
|
|
66
|
+
|
|
67
|
+
## 0.1.2 (2026-10-28)
|
|
68
|
+
|
|
69
|
+
- Logging: the size on disk, and a Delete all button.
|
|
70
|
+
- Refresh model data from models.dev.
|
|
71
|
+
- A Test button on the Your models page, a simpler list, and `-latest` model names matched in models.dev.
|
|
72
|
+
- Screenshots in the README.
|
|
73
|
+
|
|
74
|
+
## 0.1.1 (2026-10-28)
|
|
75
|
+
|
|
76
|
+
- First release: MCP tools as real tools, fallback models that keep going, a model Test button.
|
|
77
|
+
- The npm package holds only what HeartCode needs to run.
|
|
78
|
+
- README, MIT license, and failures described in SKILL.md.
|
package/SKILL.md
CHANGED
|
@@ -14,59 +14,73 @@ answer (or JSON) and exits. Its flags and output match Claude Code's `claude -p`
|
|
|
14
14
|
```sh
|
|
15
15
|
heart --version # installed? (else: node /path/to/heart_cli/bin/heart.js --version)
|
|
16
16
|
heart providers # the providers set up, and their URLs (keys are masked)
|
|
17
|
-
heart models
|
|
18
|
-
heart models --
|
|
17
|
+
heart models # what -m takes, one per line: "auto" first, then the user's models (provider/model), #1 first
|
|
18
|
+
heart models --json # the same, as JSON
|
|
19
|
+
heart models --all # every provider's models too (slower: asks each provider)
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
`heart models` prints one id per line, nothing else (like `opencode models`):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
auto
|
|
26
|
+
groq/openai/gpt-oss-120b
|
|
27
|
+
openrouter/qwen/qwen3-coder
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With `--json`:
|
|
22
31
|
|
|
23
32
|
```json
|
|
24
33
|
{ "models": [
|
|
25
|
-
{ "
|
|
26
|
-
{ "
|
|
27
|
-
{ "
|
|
34
|
+
{ "id": "auto", "fallbacks": true },
|
|
35
|
+
{ "id": "groq/openai/gpt-oss-120b", "provider": "groq", "model": "openai/gpt-oss-120b", "place": 1 },
|
|
36
|
+
{ "id": "openrouter/qwen/qwen3-coder", "provider": "openrouter", "model": "qwen/qwen3-coder", "place": 2 } ],
|
|
28
37
|
"providers": [
|
|
29
|
-
{ "name": "groq", "baseUrl": "https://api.groq.com/openai/v1", "list": true, "models": ["
|
|
38
|
+
{ "name": "groq", "baseUrl": "https://api.groq.com/openai/v1", "list": true, "models": ["groq/…"] },
|
|
30
39
|
{ "name": "local", "baseUrl": "http://127.0.0.1:9/v1", "list": true, "models": [], "error": "cannot reach …" } ] }
|
|
31
40
|
```
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
`id` is exactly what to pass to `-m`:
|
|
34
43
|
|
|
35
|
-
- **`
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
- **`auto`** (always first): the user's list. `fallbacks: true` when it has more than one model:
|
|
45
|
+
#1 is used, and the next one in order when it fails. No `-m` means `auto` too. (`default` is
|
|
46
|
+
its old name and still works.)
|
|
47
|
+
- **the user's models** (`place` 1, 2, …), as `provider/model`: #1 is the one `auto` starts with.
|
|
39
48
|
|
|
40
49
|
No model set up yet: `models` is empty (and `heart -p` fails with "no model set up").
|
|
41
|
-
`heart models [--all] <filter>` keeps only the
|
|
50
|
+
`heart models [--all] <filter>` keeps only the ids that contain `<filter>`.
|
|
42
51
|
|
|
43
52
|
`providers` (only with `--all`): every provider's models. `list: false`: the user switched that
|
|
44
53
|
provider's list off, and `models` are the ones they added by name. A provider that can't be
|
|
45
|
-
reached has an `error` and is skipped; the command still exits `0`.
|
|
54
|
+
reached has an `error` (in text: a line on stderr) and is skipped; the command still exits `0`.
|
|
46
55
|
|
|
47
56
|
**Which model to pass:**
|
|
48
57
|
|
|
49
58
|
| `-m` | What runs |
|
|
50
59
|
|---|---|
|
|
51
|
-
| `
|
|
52
|
-
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
any error
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
| `auto`, or no `-m` | the user's list: #1, and the next one in order when it fails. Use this unless you need one particular model. |
|
|
61
|
+
| `provider/model` (`-m groq/openai/gpt-oss-120b`), or a model of #1's provider alone | that model alone: never another one, also when it fails (a rate limit or an outage is tried again, up to `--max-retries`) |
|
|
62
|
+
| that, plus `--fallback-model provider/model` (repeatable) | that model, then the fallbacks in order when it fails (as `claude -p --fallback-model`) |
|
|
63
|
+
|
|
64
|
+
**Failures:** with `auto` (the list), any failure (an error status of any kind, a timeout, a reply
|
|
65
|
+
that stops coming, a lost connection, an error page from a proxy, a model that keeps replying with
|
|
66
|
+
nothing) makes way for the next model, from another provider first; that provider's other models
|
|
67
|
+
are tried only after every provider has had a go. None of it shows: the run goes on with the model
|
|
68
|
+
that answers. It stops with exit code `1` only when every model in the list fails: at once if they
|
|
69
|
+
all refused the request outright, else after `--max-retries` (10) whole rounds of them. A single
|
|
70
|
+
model (`-m <model>`) waits out rate limits and outages, up to `--max-retries` tries, and stops at
|
|
71
|
+
any other error. A provider that refuses a part of the request HeartCode can do without (the
|
|
72
|
+
model's thinking sent back, keys in the tools' schemas, images) gets it again without that part,
|
|
73
|
+
and that is remembered for the model for a day. A long conversation is summarized every 100
|
|
74
|
+
messages.
|
|
65
75
|
Each request is fitted to the model it goes to: one that can't see gets a note in place of each
|
|
66
76
|
image, one that can't call tools gets the tool calls as text.
|
|
67
77
|
|
|
68
78
|
`--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
|
|
69
79
|
|
|
80
|
+
A run picks up settings the user changes meanwhile (`heart settings`, or `~/.heart` by hand)
|
|
81
|
+
without being restarted: a new model order from its next request, MCP servers and skills from its
|
|
82
|
+
next message.
|
|
83
|
+
|
|
70
84
|
`heart` is installed by `npm start` in the HeartCode folder. `heartcode` is the same command.
|
|
71
85
|
Needs Node.js 20 or newer. No providers? See "Setting up a model" below.
|
|
72
86
|
|
|
@@ -130,8 +144,9 @@ server's tools, `mcp` all of them.
|
|
|
130
144
|
## Choose the model
|
|
131
145
|
|
|
132
146
|
```sh
|
|
133
|
-
heart -p -m
|
|
134
|
-
heart -p -m
|
|
147
|
+
heart -p -m auto "…" # the user's list, with its fallbacks (the same as no -m)
|
|
148
|
+
heart -p -m groq/openai/gpt-oss-120b "…" # that provider and model alone, for this run
|
|
149
|
+
heart -p -m groq/openai/gpt-oss-120b --fallback-model openrouter/qwen/qwen3-coder "…" # and that one when it fails
|
|
135
150
|
heart -p --provider ollama -m qwen3-coder "…" # the same, written apart
|
|
136
151
|
heart -p --effort high "…" # how hard a thinking model thinks
|
|
137
152
|
```
|
package/bin/heartcode.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
import { Client } from '../src/client.js'
|
|
5
|
-
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME,
|
|
5
|
+
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, AUTO_MODEL, isAuto, parseModel, VERSION } from '../src/config.js'
|
|
6
6
|
import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
|
|
7
|
-
import { learnFromUsage,
|
|
7
|
+
import { learnFromUsage, LIMIT_MS } from '../src/context.js'
|
|
8
8
|
import { Session, transcript } from '../src/agent.js'
|
|
9
9
|
import { treeItems, codePlan, move, leaving, leftText, leftFiles } from '../src/tree.js'
|
|
10
10
|
import { UI, c, PERMISSIONS, rank, setTheme, themeCodes, renderDiff, diffLines, DEFAULT_KEYS, loadKeys } from '../src/ui.js'
|
|
@@ -37,8 +37,9 @@ ${c.bold('usage')}
|
|
|
37
37
|
heart ~/code/my-app in that folder (a folder, not a file)
|
|
38
38
|
heart "fix the failing test" run one task and exit
|
|
39
39
|
heart providers list added providers
|
|
40
|
-
heart models [filter]
|
|
41
|
-
|
|
40
|
+
heart models [filter] what -m takes, one per line: "auto" (your list), then your models
|
|
41
|
+
as provider/model, #1 first
|
|
42
|
+
heart models --all [filter] every provider's models (provider/model)
|
|
42
43
|
heart models --json as JSON (with --all too), for other programs
|
|
43
44
|
heart probe test the current model for real (otherwise its abilities come from models.dev)
|
|
44
45
|
heart config show the config file
|
|
@@ -46,7 +47,9 @@ ${c.bold('usage')}
|
|
|
46
47
|
|
|
47
48
|
${c.bold('options')}
|
|
48
49
|
-u, --base-url <url> use this server for this run (add --save to keep it)
|
|
49
|
-
-m, --model <name>
|
|
50
|
+
-m, --model <name> this model alone for this run (provider/model, or a model of #1's provider);
|
|
51
|
+
"auto" (the default): your list, #1 then the next when one fails
|
|
52
|
+
--fallback-model <name> after -m's model when it fails (repeatable)
|
|
50
53
|
--api-key <key> API key for --base-url
|
|
51
54
|
-y, --yes do everything without asking (the default)
|
|
52
55
|
--ask ask before every edit and command, this run
|
|
@@ -61,7 +64,7 @@ ${c.bold('options')}
|
|
|
61
64
|
or to continue in it); removed on exit if nothing changed
|
|
62
65
|
-c, --continue continue the last session in this folder
|
|
63
66
|
-r, --resume [id] pick an earlier session to continue (or the one with this id)
|
|
64
|
-
--provider <name> use this provider for this run (or -m
|
|
67
|
+
--provider <name> use this provider for this run (or -m provider/model)
|
|
65
68
|
|
|
66
69
|
${c.bold('headless')} (for other programs; flags and output as in Claude Code's -p)
|
|
67
70
|
-p, --print run without the screen, print the answer, exit
|
|
@@ -229,13 +232,12 @@ class App {
|
|
|
229
232
|
if (name === 'auto_compact' && this.s) this.s.autoCompact = bool
|
|
230
233
|
return save(`${name}: ${bool ? 'on' : 'off'}${name === 'director' ? ' (from the next message)' : ''}`)
|
|
231
234
|
case 'model': {
|
|
232
|
-
|
|
233
|
-
if (!cfg.providers[p]
|
|
234
|
-
if (!cfg.providers[p] || !m.join(' ')) return `error: give "provider model" or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
|
|
235
|
+
const { provider: p, model: m } = parseModel(v, cfg.providers, cfg.provider)
|
|
236
|
+
if (!cfg.providers[p] || !m) return `error: give provider/model or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
|
|
235
237
|
// headless (-p) and editors (ACP): the program that started HeartCode chooses the model
|
|
236
238
|
if (this.ui instanceof HeadlessUI) return 'error: the model can\'t be switched here: the program that started HeartCode chooses it'
|
|
237
|
-
this.later.push(() => this.use(p, m
|
|
238
|
-
return `switching to ${p} · ${m
|
|
239
|
+
this.later.push(() => this.use(p, m).catch((e) => ui.warn(e.message)))
|
|
240
|
+
return `switching to ${p} · ${m} after this reply`
|
|
239
241
|
}
|
|
240
242
|
default:
|
|
241
243
|
return `error: no setting "${name}"; manage list kind setting shows them`
|
|
@@ -313,14 +315,6 @@ class App {
|
|
|
313
315
|
if (change) log('context', `${change}: ${used} tokens counted, ~${estimate} estimated; window now ${strategy(profile, cfg).context}`, { was, largestAccepted: profile.largestAccepted, inputLimit: profile.inputLimit, tokenRatio: profile.tokenRatio })
|
|
314
316
|
if (change) learned()
|
|
315
317
|
}
|
|
316
|
-
// The server said "too large" with its limit: remembered for this model (also next time). A
|
|
317
|
-
// per-minute cap is your account's at this provider: kept apart, for a day.
|
|
318
|
-
client.onInputLimit = (n, { rate = false } = {}) => {
|
|
319
|
-
if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
|
|
320
|
-
else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
|
|
321
|
-
learned()
|
|
322
|
-
ui.warn(rate ? `${client.provider} takes at most ~${n} tokens a minute on your account; shortening the conversation to fit (for today)` : `${client.model} takes at most ~${n} tokens per request here; shortening the conversation to fit`)
|
|
323
|
-
}
|
|
324
318
|
// models.dev's published size for the model (a day old at most): used when the server doesn't say
|
|
325
319
|
loadPrices().then(() => {
|
|
326
320
|
price = priceOf(client.provider, client.baseUrl, client.model)
|
|
@@ -646,11 +640,11 @@ class App {
|
|
|
646
640
|
}
|
|
647
641
|
|
|
648
642
|
// The model list (settings page): this model first, then the others in order; set on the session.
|
|
649
|
-
// A model given for this run (-m, not "
|
|
643
|
+
// A model given for this run (-m, not "auto") is used alone (cfg.fixed), then --fallback-model's.
|
|
650
644
|
useRotation() {
|
|
651
645
|
const { cfg } = this
|
|
652
646
|
const seen = new Set()
|
|
653
|
-
const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? []))]
|
|
647
|
+
const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? [])), ...(cfg.fallbacks ?? [])]
|
|
654
648
|
.filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
655
649
|
.map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
|
|
656
650
|
this.s?.client.setRotation(list, cfg.rotation ?? {})
|
|
@@ -744,19 +738,16 @@ class App {
|
|
|
744
738
|
}
|
|
745
739
|
}
|
|
746
740
|
|
|
747
|
-
// `heart models`:
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
// "providers": [{ "name", "baseUrl", "list": true, "models": ["…
|
|
753
|
-
// "default" is the name for your list: -m default (or no -m) uses #1 and moves on to the next when
|
|
754
|
-
// one fails; -m with one of the models is that model alone.
|
|
741
|
+
// `heart models`: what -m takes, one per line (as \`opencode models\`): "auto" (your list: #1, then
|
|
742
|
+
// the next when one fails; also no -m), then your models as provider/model, #1 first. all: every
|
|
743
|
+
// provider's models (fetched from each; a provider whose list is off: the ones added by name).
|
|
744
|
+
// json: one JSON object on stdout, for other programs:
|
|
745
|
+
// { "models": [{ "id": "auto", "fallbacks": true }, { "id": "groq/openai/gpt-oss-120b", "provider", "model", "place": 1 }, …],
|
|
746
|
+
// "providers": [{ "name", "baseUrl", "list": true, "models": ["provider/model", …], "error"? }] } (--all)
|
|
755
747
|
async listModels(filter = '', { all = false, json = false } = {}) {
|
|
756
748
|
const { ui, cfg } = this
|
|
757
749
|
const match = (id) => id.toLowerCase().includes(filter.toLowerCase())
|
|
758
|
-
const mine = this.myModels().map((t, i) => ({
|
|
759
|
-
const placeOf = (provider, model) => mine.find((t) => t.provider === provider && t.model === model)?.place
|
|
750
|
+
const mine = this.myModels().map((t, i) => ({ id: `${t.provider}/${t.model}`, provider: t.provider, model: t.model, place: i + 1 }))
|
|
760
751
|
const providers = !all ? [] : await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
761
752
|
const out = { name, baseUrl: p.baseUrl, list: p.list !== false, models: [] }
|
|
762
753
|
if (out.list) {
|
|
@@ -767,32 +758,17 @@ class App {
|
|
|
767
758
|
}
|
|
768
759
|
}
|
|
769
760
|
// and the ones added by name, and any of yours it doesn't list (as on the settings page)
|
|
770
|
-
out.models = [...new Set([...out.models, ...(p.models ?? []), ...mine.filter((t) => t.provider === name).map((t) => t.model)])].sort((a, b) => a.localeCompare(b)).filter(match)
|
|
761
|
+
out.models = [...new Set([...out.models, ...(p.models ?? []), ...mine.filter((t) => t.provider === name).map((t) => t.model)])].sort((a, b) => a.localeCompare(b)).map((id) => `${name}/${id}`).filter(match)
|
|
771
762
|
return out
|
|
772
763
|
}))
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const first = mine.length ? [{ place: null, provider: null, model: DEFAULT_MODEL, role: 'default', rotates: mine.length > 1, about }] : []
|
|
776
|
-
const shown = [...first, ...mine].filter((t) => match(t.model))
|
|
777
|
-
if (json) {
|
|
778
|
-
process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
|
|
779
|
-
return
|
|
780
|
-
}
|
|
764
|
+
const shown = [...(mine.length ? [{ id: AUTO_MODEL, fallbacks: mine.length > 1 }] : []), ...mine].filter((t) => match(t.id))
|
|
765
|
+
if (json) return void process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
|
|
781
766
|
if (!all) {
|
|
782
|
-
if (!
|
|
783
|
-
|
|
784
|
-
return
|
|
785
|
-
}
|
|
786
|
-
if (!providers.length) return ui.print(NO_MODELS)
|
|
787
|
-
for (const p of providers) {
|
|
788
|
-
ui.print(`${c.bold(p.name)} ${c.dim(p.baseUrl)}${p.list ? '' : c.dim(' (its list is off: the models added by name)')}`)
|
|
789
|
-
if (p.error) ui.print(` ${c.red("can't list its models:")} ${c.dim(p.error)}`)
|
|
790
|
-
if (!p.models.length && !p.error) ui.print(c.dim(filter ? ` none matches "${filter}"` : ' no models'))
|
|
791
|
-
for (const id of p.models) {
|
|
792
|
-
const at = placeOf(p.name, id)
|
|
793
|
-
ui.print(` ${at === 1 ? c.magenta('●') : ' '} ${id}${at ? ` ${c.bold(`#${at}`)} ${c.dim(at === 1 ? 'in use' : 'fallback')}` : ''}`)
|
|
794
|
-
}
|
|
767
|
+
if (!mine.length) return ui.print(NO_MODELS)
|
|
768
|
+
return void process.stdout.write(shown.map((t) => t.id + '\n').join(''))
|
|
795
769
|
}
|
|
770
|
+
for (const p of providers) if (p.error) process.stderr.write(`heartcode: can't list ${p.name}'s models: ${p.error}\n`)
|
|
771
|
+
process.stdout.write(providers.flatMap((p) => p.models).map((id) => id + '\n').join(''))
|
|
796
772
|
}
|
|
797
773
|
}
|
|
798
774
|
|
|
@@ -876,8 +852,9 @@ function openBrowser(url) {
|
|
|
876
852
|
spawnSync(cmd, args, { stdio: 'ignore' })
|
|
877
853
|
}
|
|
878
854
|
|
|
879
|
-
// A change on the settings page (in cfg, saved), brought into every open tab
|
|
880
|
-
// model
|
|
855
|
+
// A change on the settings page (in cfg, saved), brought into every open tab and run (also -p and
|
|
856
|
+
// ACP): right away, the model list too (the running task's next request goes to the new #1); MCP
|
|
857
|
+
// servers and skills after the reply in progress.
|
|
881
858
|
function applySettings(apps, cfg, what) {
|
|
882
859
|
const before = themeCodes()
|
|
883
860
|
if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
|
|
@@ -887,18 +864,20 @@ function applySettings(apps, cfg, what) {
|
|
|
887
864
|
settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
|
|
888
865
|
for (const app of apps) {
|
|
889
866
|
const { ui } = app
|
|
890
|
-
const busy = Boolean(ui.busySince)
|
|
867
|
+
const busy = Boolean(ui.busySince || app.working)
|
|
891
868
|
const now = (fn) => (busy ? app.later.push(fn) : fn())
|
|
892
869
|
if (app.cfg !== cfg) for (const k of ['providers', 'provider', 'model', 'effort', 'theme', 'context', 'autoCompact', 'director', 'format', 'lsp', 'voice', 'rotation']) app.cfg[k] = cfg[k]
|
|
893
870
|
if (what === 'model') {
|
|
894
871
|
app.lists = {}
|
|
895
872
|
delete app.cfg.fixed // a model chosen on the page: your list again
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
873
|
+
if (app.s && !(cfg.providers[cfg.provider] && cfg.model)) now(() => ((app.s = null), ui.print(c.dim('settings: no model chosen'))))
|
|
874
|
+
}
|
|
875
|
+
// your model list: into the running session now, its next request goes to the new #1
|
|
876
|
+
if ((what === 'model' || what === 'setting:rotation') && app.s && cfg.providers[cfg.provider] && cfg.model) {
|
|
877
|
+
const was = `${app.s.client.provider} · ${app.s.client.model}`
|
|
878
|
+
app.useRotation()
|
|
879
|
+
const is = `${app.s.client.provider} · ${app.s.client.model}`
|
|
880
|
+
if (is !== was) ui.print(c.dim(`settings: ${is} now`))
|
|
902
881
|
}
|
|
903
882
|
if (what === 'provider') app.lists = {} // its list switched on or off, a model added by name
|
|
904
883
|
if (what === 'setting:permissions') ui.setPermission(cfg.permissions)
|
|
@@ -906,21 +885,11 @@ function applySettings(apps, cfg, what) {
|
|
|
906
885
|
if (what === 'setting:autoCompact' && app.s) app.s.autoCompact = cfg.autoCompact !== false
|
|
907
886
|
if (what === 'setting:format' && app.s) app.s.noFormat = cfg.format === false
|
|
908
887
|
if (what === 'setting:lsp' && app.s) app.s.noLsp = cfg.lsp === false
|
|
909
|
-
|
|
910
|
-
|
|
888
|
+
// the window size, or a model set up or measured on the page: the model in use takes it again
|
|
889
|
+
// from its profile now (it saves its profile as it learns, so it mustn't keep the old one)
|
|
890
|
+
if ((what === 'setting:context' || what === 'profile') && app.s) app.s.client.onUse?.({ provider: app.s.client.provider, model: app.s.client.model })
|
|
911
891
|
if (what === 'mcp' || what === 'skill') now(() => app.reload(what))
|
|
912
892
|
if (what === 'keys') ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
|
|
913
|
-
// a model set up or measured on the page: into the session's profile now (it saves its profile
|
|
914
|
-
// as it learns, so it mustn't keep the old one); its mode from the next message
|
|
915
|
-
if (what === 'profile' && app.s?.profile) {
|
|
916
|
-
const disk = loadProfile({ baseUrl: app.s.profile.baseUrl, model: app.s.profile.model })
|
|
917
|
-
if (disk) {
|
|
918
|
-
for (const k of ['source', 'set', 'known', 'measured', 'measuredAt']) disk[k] === undefined ? delete app.s.profile[k] : (app.s.profile[k] = disk[k])
|
|
919
|
-
const st = strategy(app.s.profile, app.cfg)
|
|
920
|
-
Object.assign(app.s.strat, (({ context, contextSure, reply }) => ({ context, contextSure, reply }))(st))
|
|
921
|
-
if (st.actions !== app.s.strat.actions || st.thinking !== app.s.strat.thinking || st.vision !== app.s.strat.vision || st.stream !== app.s.strat.stream) now(() => (app.s = null))
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
893
|
if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
|
|
925
894
|
else ui.render()
|
|
926
895
|
}
|
|
@@ -1436,6 +1405,7 @@ async function main() {
|
|
|
1436
1405
|
options: {
|
|
1437
1406
|
'base-url': { type: 'string', short: 'u' },
|
|
1438
1407
|
model: { type: 'string', short: 'm' },
|
|
1408
|
+
'fallback-model': { type: 'string', multiple: true },
|
|
1439
1409
|
'api-key': { type: 'string' },
|
|
1440
1410
|
yes: { type: 'boolean', short: 'y' },
|
|
1441
1411
|
ask: { type: 'boolean' },
|
|
@@ -1495,8 +1465,12 @@ async function main() {
|
|
|
1495
1465
|
if (!cfg.providers[f.provider]) throw new Error(`no provider "${f.provider}" (heartcode providers lists them)`)
|
|
1496
1466
|
Object.assign(cfg, { provider: f.provider, fixed: true })
|
|
1497
1467
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1468
|
+
// --fallback-model: after the model given (as in Claude Code's -p), when it fails
|
|
1469
|
+
cfg.fallbacks = (f['fallback-model'] ?? []).map((v) => {
|
|
1470
|
+
const t = parseModel(v, cfg.providers, cfg.provider)
|
|
1471
|
+
if (isAuto(v) || !cfg.providers[t.provider]) throw new Error(`--fallback-model ${v}: give provider/model, or a model of ${cfg.provider} (heart models lists yours)`)
|
|
1472
|
+
return t
|
|
1473
|
+
})
|
|
1500
1474
|
if (f.effort) cfg.effort = f.effort === 'default' ? undefined : f.effort
|
|
1501
1475
|
if (f['mcp-config'] || f['strict-mcp-config']) cfg.mcp = { extra: f['mcp-config'] ?? [], strict: Boolean(f['strict-mcp-config']) }
|
|
1502
1476
|
extraSkillDirs.push(...(f['skills-dir'] ?? []).map((d) => path.resolve(d)))
|
|
@@ -1524,7 +1498,9 @@ async function main() {
|
|
|
1524
1498
|
// each editor session gets its own App, for its folder and its MCP servers
|
|
1525
1499
|
cfg.maxRetries ??= 10
|
|
1526
1500
|
// each editor session its own settings (one choosing a model doesn't change the others')
|
|
1527
|
-
const
|
|
1501
|
+
const apps = []
|
|
1502
|
+
const makeApp = (root, ui, mcp) => apps[apps.push(new App({ ...structuredClone(cfg), ...(mcp && { mcp: { extra: [mcp] } }) }, ui, root)) - 1]
|
|
1503
|
+
watchSettings(() => apps, cfg) // the settings page's changes, into every editor session
|
|
1528
1504
|
process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
|
|
1529
1505
|
}
|
|
1530
1506
|
const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
|
|
@@ -1608,6 +1584,7 @@ async function main() {
|
|
|
1608
1584
|
const chat = f['resume-id'] ? loadChat(f['resume-id']) : f.continue && headless ? listChats(app.root)[0] : f['session-id'] ? (loadChat(f['session-id']) ?? { ...newChat(app.root), id: f['session-id'] }) : null
|
|
1609
1585
|
if (f['resume-id'] && !chat) throw new Error(`no session ${f['resume-id']}`)
|
|
1610
1586
|
if (chat && (headless || !f['resume-id'])) app.setChat(chat) // interactive: resumed below, with its transcript
|
|
1587
|
+
if (headless || positionals.length) watchSettings(() => [app], cfg) // an app's run takes the settings page's changes too
|
|
1611
1588
|
if (headless) {
|
|
1612
1589
|
app.allowTools = [...(f.allowedTools ?? []), ...(f['allowed-tools'] ?? [])]
|
|
1613
1590
|
app.denyTools = [...(f.disallowedTools ?? []), ...(f['disallowed-tools'] ?? [])]
|
|
@@ -1621,7 +1598,7 @@ async function main() {
|
|
|
1621
1598
|
await leaveWorktree(app)
|
|
1622
1599
|
process.exit(ok ? 0 : 1) // MCP servers are stopped by the exit hooks
|
|
1623
1600
|
}
|
|
1624
|
-
watchSettings(() => [app], cfg)
|
|
1601
|
+
if (!positionals.length) watchSettings(() => [app], cfg)
|
|
1625
1602
|
await repl(app, f)
|
|
1626
1603
|
await leaveWorktree(app)
|
|
1627
1604
|
process.exit(process.exitCode ?? 0) // /exit or Ctrl+D: quit, even if a timer or an MCP server is still around
|
package/package.json
CHANGED
package/prompts/agent.md
CHANGED
|
@@ -14,6 +14,7 @@ what the task needs. {{files}} and {{more}} are still filled in, empty, for olde
|
|
|
14
14
|
come at the end: then this says less, so it doesn't contradict them. -->
|
|
15
15
|
{{#if caller}}You are running in HeartCode, an agent in a terminal. Your role and your instructions are at the end of this prompt.{{else}}You are HeartCode, a coding assistant working in a terminal on the user's project.{{/if}}
|
|
16
16
|
Use the tools to look at files, change them and run commands. Find what the task needs with list_files and search rather than guessing paths; read a file before editing it, and prefer small edits with edit_file.
|
|
17
|
+
Check your work by what it produced, not by the absence of an error: read back the file you changed, run the test, look at the output. When something is wrong, find out why before trying again; don't repeat the same attempt. A task that repeats over many items: do one first, check it, then the rest. Before you finish, compare the result with what was asked.
|
|
17
18
|
{{#if caller}}Commands run in the folder {{root}}, and relative paths are relative to it.{{else}}Paths are relative to the project folder, and commands already run in it. The project folder is {{root}} (for file:// URLs in the browser); stay inside it: don't search the rest of the disk.
|
|
18
19
|
When you are done, reply with a short summary of what you did (or the answer to the question) and stop calling tools.{{/if}}
|
|
19
20
|
|
|
@@ -170,13 +171,13 @@ Which of your models does the job (from "Your other models" in the system prompt
|
|
|
170
171
|
Besides you (tool calls, {{you}}), the user set up these models:
|
|
171
172
|
{{models}}
|
|
172
173
|
|
|
173
|
-
Work with them through the agent tool: "model" picks one
|
|
174
|
+
Work with them through the agent tool: "model" picks one, "images" gives it images to look at.
|
|
174
175
|
|
|
175
176
|
- Never decline, guess or work around something because you can't do it (see an image, think it through, read more than fits your context): ask a model that can. Choose by what the job needs: the ability first; a faster one for a simple job, the strongest for a hard one; not one that keeps failing.
|
|
176
177
|
- Ask for what you need in order to act, not for a description. Not "describe this screenshot" but "Which of these 9 tiles (numbered 1-9, left to right, top to bottom) show a chair? Reply with the numbers only." or "Where is the Submit button? Reply with its exact text and its position (top/middle/bottom, left/center/right)." Say what to answer and in what form, and nothing more.
|
|
177
178
|
- Many images, or a job in independent parts: one agent call per image or part, all in the same reply (the same model several times, or different ones). Then put the answers together and decide the next step yourself.
|
|
178
179
|
- A loop that needs the ability at every step (look, act, look again, until done) goes whole to a model that has it: the goal, how to tell it's done, what to report. Better than asking about each step.
|
|
179
|
-
-
|
|
180
|
+
- Give a helper all it needs: the goal, what's known (paths, URLs, the page the browser is on, what was tried), what to reply. It has your tools, the same browser and MCP servers too: helpers that need the same MCP server (a browser) take turns (HeartCode makes the next one wait), so jobs that use it don't gain from running together; and never give two helpers the same files at once.
|
|
180
181
|
- A helper that fails or replies with nothing is handed on to your next model that can do the job, on its own. An answer that doesn't settle it (wrong, unsure, part of it): ask again, a sharper question or another capable model, until it's done.
|
|
181
182
|
- Trust a helper's answer to what you asked; question it only when it looks doubtful (it contradicts what you know, or it's vague) or acting on it can't be undone. Don't redo its work yourself.
|
|
182
183
|
|
|
@@ -188,7 +189,7 @@ You can see images: those in the user's messages, those tools return (a browser
|
|
|
188
189
|
## tool-agent-images
|
|
189
190
|
|
|
190
191
|
<!-- The agent tool's "images", there when the user has other models. -->
|
|
191
|
-
Images for the helper to look at: image file paths (in the project, saved screenshots, ones the user gave) or image URLs. The model must see images
|
|
192
|
+
Images for the helper to look at: image file paths (in the project, saved screenshots, ones the user gave) or image URLs. The model must see images.
|
|
192
193
|
|
|
193
194
|
## images-unseen
|
|
194
195
|
|
|
@@ -324,7 +325,7 @@ Update a note rather than adding a duplicate, and link related notes with [[name
|
|
|
324
325
|
## tool-memory
|
|
325
326
|
|
|
326
327
|
<!-- The memory tool's description. -->
|
|
327
|
-
Memory notes. action "write": name, description (one line), type (user, project, feedback or reference), tags (1-3), scope ("project", the default, or "global"
|
|
328
|
+
Memory notes. action "write": name, description (one line), type (user, project, feedback or reference), tags (1-3), scope ("project", the default, or "global"), content (markdown; link notes with [[name]]). "read": name. "search": query (words, or tag:name). "delete": name (and scope if the name is in both).
|
|
328
329
|
|
|
329
330
|
## memory-extract
|
|
330
331
|
|
package/src/acp.js
CHANGED
|
@@ -126,16 +126,22 @@ export async function runAcp({ makeApp, version, permission = 'ask', input = pro
|
|
|
126
126
|
const { text, images } = promptOf(prompt, app.root)
|
|
127
127
|
it.ctl = new AbortController()
|
|
128
128
|
app.signal = it.ctl.signal
|
|
129
|
+
app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
|
|
129
130
|
try {
|
|
131
|
+
const s = app.s ?? (await app.session()) // a model chosen again on the settings page
|
|
132
|
+
if (!s) throw Object.assign(new Error('no model set up: run heart settings to add a provider and choose a model'), { code: -32000 })
|
|
133
|
+
s.onEvent ??= (e) => events(it.ui, s, e)
|
|
130
134
|
const k = /^\/(\S+)\s*([\s\S]*)$/.exec(text)
|
|
131
135
|
const skill = k && app.skills.get(k[1])
|
|
132
|
-
await
|
|
136
|
+
await s.run(skill && skill.use !== 'model' ? await app.expandSkill(skill, k[2]) : text, it.ctl.signal, images)
|
|
133
137
|
return { stopReason: 'end_turn' }
|
|
134
138
|
} catch (e) {
|
|
135
139
|
if (it.ctl.signal.aborted) return { stopReason: 'cancelled' }
|
|
136
140
|
if (e.maxTurns) return { stopReason: 'max_turn_requests' }
|
|
137
141
|
throw e
|
|
138
142
|
} finally {
|
|
143
|
+
app.working = false
|
|
144
|
+
app.afterLine?.()
|
|
139
145
|
it.ctl = null
|
|
140
146
|
it.ui.calls = []
|
|
141
147
|
app.save()
|