heartcode 0.1.9 → 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 -34
- package/bin/heartcode.js +56 -79
- package/package.json +1 -1
- package/src/acp.js +7 -1
- package/src/agent.js +20 -54
- package/src/client.js +11 -43
- package/src/config.js +17 -4
- package/src/guided.js +2 -8
- package/src/headless.js +3 -0
- package/src/settings.html +119 -70
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,63 +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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
any error"). A provider that refuses a part
|
|
67
|
-
of the request HeartCode can do without (the model's thinking sent back, keys in the tools'
|
|
68
|
-
schemas, images) gets it again without that part, and that is remembered for the model for a day.
|
|
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.
|
|
69
75
|
Each request is fitted to the model it goes to: one that can't see gets a note in place of each
|
|
70
76
|
image, one that can't call tools gets the tool calls as text.
|
|
71
77
|
|
|
72
78
|
`--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
|
|
73
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
|
+
|
|
74
84
|
`heart` is installed by `npm start` in the HeartCode folder. `heartcode` is the same command.
|
|
75
85
|
Needs Node.js 20 or newer. No providers? See "Setting up a model" below.
|
|
76
86
|
|
|
@@ -134,8 +144,9 @@ server's tools, `mcp` all of them.
|
|
|
134
144
|
## Choose the model
|
|
135
145
|
|
|
136
146
|
```sh
|
|
137
|
-
heart -p -m
|
|
138
|
-
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
|
|
139
150
|
heart -p --provider ollama -m qwen3-coder "…" # the same, written apart
|
|
140
151
|
heart -p --effort high "…" # how hard a thinking model thinks
|
|
141
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/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()
|
package/src/agent.js
CHANGED
|
@@ -104,7 +104,7 @@ export class Session {
|
|
|
104
104
|
const body = native ? chat.messages.slice(head.length) : []
|
|
105
105
|
const conversation = native ? transcript(body) : chat.history.map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n')
|
|
106
106
|
if (!conversation.trim()) return null
|
|
107
|
-
|
|
107
|
+
const room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
|
|
108
108
|
const cut = () => (conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation)
|
|
109
109
|
const before = native ? size(body) : est(conversation)
|
|
110
110
|
ui.spinner(midTask ? 'context almost full: compacting the conversation' : 'compacting the conversation')
|
|
@@ -114,17 +114,7 @@ export class Session {
|
|
|
114
114
|
const request = (extra) => prompt('compact/summarize', { conversation: cut(), focus }) + extra
|
|
115
115
|
const send = (extra) =>
|
|
116
116
|
client.chat({ messages: strat.mergeSystem ? [{ role: 'user', content: `${prompt('compact/summarizer')}\n\n${request(extra)}` }] : [{ role: 'system', content: prompt('compact/summarizer') }, { role: 'user', content: request(extra) }] }, { signal })
|
|
117
|
-
|
|
118
|
-
const ask = async (extra = '') => {
|
|
119
|
-
for (;;) {
|
|
120
|
-
try {
|
|
121
|
-
return await send(extra)
|
|
122
|
-
} catch (e) {
|
|
123
|
-
if (!e.inputLimit || room < 2000) throw e
|
|
124
|
-
room = Math.floor(Math.min(room, (e.inputLimit * 3.5) / (strat.ratio ?? 1)) * 0.7)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
117
|
+
const ask = (extra = '') => send(extra)
|
|
128
118
|
let summary = notSummary((await ask()).content)
|
|
129
119
|
if (!summary) {
|
|
130
120
|
log('compact', 'the reply was not a summary; asking again')
|
|
@@ -603,18 +593,12 @@ async function runNative(s, task, images, signal) {
|
|
|
603
593
|
s.messages.push({ role: 'user', content: s.chat.branchNote }, { role: 'assistant', content: prompt('compact/acknowledged') })
|
|
604
594
|
delete s.chat.branchNote
|
|
605
595
|
}
|
|
606
|
-
let shrink = 1 // drops when the server says a request is too large
|
|
607
596
|
s.compactedAt = -1
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
//
|
|
613
|
-
const windowSize = () => Math.min(strat.context, s.sizeCap ?? Infinity)
|
|
614
|
-
const reply = () => Math.min(strat.reply || 8192, Math.floor(windowSize() * 0.25))
|
|
615
|
-
const budget = () => Math.floor(((windowSize() * 0.9 - reply()) * shrink) / (strat.ratio ?? 1)) - 800 // in estimated tokens
|
|
616
|
-
const nearlyFull = () => s.autoCompact && s.messages.length > 3 && size(s.messages) > budget() * 0.9
|
|
617
|
-
// Already near full from earlier messages: compact first, so the new message stays word for word.
|
|
597
|
+
// Auto-compact: every 100 messages, the conversation is summarized (what fits each model is
|
|
598
|
+
// models.dev's business and the model list's: one that fails, the next takes over)
|
|
599
|
+
const COMPACT_AT = 100
|
|
600
|
+
const nearlyFull = () => s.autoCompact && s.messages.length >= COMPACT_AT + (s.compactFailedAt ?? 0)
|
|
601
|
+
// Already that long from earlier messages: compact first, so the new message stays word for word.
|
|
618
602
|
if (nearlyFull()) {
|
|
619
603
|
await s.compact({ signal }).catch((e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
|
|
620
604
|
}
|
|
@@ -657,17 +641,17 @@ async function runNative(s, task, images, signal) {
|
|
|
657
641
|
ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
|
|
658
642
|
throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
|
|
659
643
|
}
|
|
660
|
-
// after a failed compaction, the next try waits
|
|
661
|
-
if (nearlyFull() &&
|
|
644
|
+
// after a failed compaction, the next try waits for 20 more messages
|
|
645
|
+
if (nearlyFull() && step !== s.compactedAt) {
|
|
662
646
|
s.compactedAt = step
|
|
663
|
-
await s.compact({ signal, midTask: true, task }).
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
647
|
+
await s.compact({ signal, midTask: true, task }).then(
|
|
648
|
+
() => (s.compactFailedAt = 0),
|
|
649
|
+
(e) => {
|
|
650
|
+
if (e.name === 'AbortError') throw e
|
|
651
|
+
s.compactFailedAt = (s.compactFailedAt ?? 0) + 20
|
|
652
|
+
log('compact', `failed (${e.message}); trying again 20 messages later`)
|
|
653
|
+
},
|
|
654
|
+
)
|
|
671
655
|
}
|
|
672
656
|
// what the user typed while it worked goes in before the next step (steering)
|
|
673
657
|
const added = step && !s.isSub ? (ui.takeAhead?.() ?? []) : []
|
|
@@ -679,31 +663,13 @@ async function runNative(s, task, images, signal) {
|
|
|
679
663
|
// the model answering can change mid-message (your model list): the system prompt follows it
|
|
680
664
|
const now = SYSTEM(s)
|
|
681
665
|
if (s.messages[0]?.content !== now && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: now }
|
|
682
|
-
fitMessages(s.messages, budget())
|
|
683
666
|
pairTools(s.messages)
|
|
684
667
|
ui.spinner('thinking')
|
|
685
668
|
const out = ui.response(client.model) // named: helpers think too (theirs are named in helperUI)
|
|
686
669
|
const stream = strat.toolsStream
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
|
|
691
|
-
res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
692
|
-
} catch (e) {
|
|
693
|
-
if (!e.inputLimit || shrink < 0.3) throw e
|
|
694
|
-
// what every request carries (the system prompt and the tools) is over the limit already: no
|
|
695
|
-
// shorter conversation fits (a per-minute cap smaller than that), so not tried again
|
|
696
|
-
const fixed = Math.round((est(JSON.stringify(tools)) + sizeOf(s.messages[0])) * (strat.ratio ?? 1))
|
|
697
|
-
if (fixed >= e.inputLimit) throw Object.assign(e, { message: `${e.message} (the system prompt and tools alone are ~${fixed} tokens: this can't fit)` })
|
|
698
|
-
out.end()
|
|
699
|
-
// e.fitted: the next model's window is smaller (strat already follows it): compacted at once
|
|
700
|
-
if (!e.fitted) {
|
|
701
|
-
s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
|
|
702
|
-
shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
|
|
703
|
-
}
|
|
704
|
-
step--
|
|
705
|
-
continue
|
|
706
|
-
}
|
|
670
|
+
// A long tool call (a whole file) streams in silently: say what's being written.
|
|
671
|
+
const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
|
|
672
|
+
const res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
707
673
|
ui.stopSpin()
|
|
708
674
|
if (!stream) {
|
|
709
675
|
out.think(res.reasoning)
|
package/src/client.js
CHANGED
|
@@ -164,7 +164,8 @@ export class Client {
|
|
|
164
164
|
// 'stay' on the one that worked). onSwitch(target): a different model answered than before.
|
|
165
165
|
setRotation(list, { on = 'failure', retries = 2, next = 'first' } = {}) {
|
|
166
166
|
if (!list || list.length < 2) {
|
|
167
|
-
|
|
167
|
+
const t = list?.[0] ?? this.rotation?.list[0] // one model: that one (a new #1 from the settings page too)
|
|
168
|
+
if (t && (t.model !== this.model || normalizeBaseUrl(t.baseUrl) !== this.baseUrl || t.apiKey !== this.apiKey)) this.#use(t)
|
|
168
169
|
this.rotation = null
|
|
169
170
|
return
|
|
170
171
|
}
|
|
@@ -228,7 +229,8 @@ export class Client {
|
|
|
228
229
|
#rotate(e, tries, pick) {
|
|
229
230
|
const r = this.rotation
|
|
230
231
|
if (!r) return false
|
|
231
|
-
|
|
232
|
+
// at any failure; "after retries": a failure waiting can fix (a rate limit, an outage) is tried again first
|
|
233
|
+
if (r.on === 'retries' && outage(e) && tries < r.retries) return false
|
|
232
234
|
r.at = pick ? pick() : (r.at + 1) % r.list.length
|
|
233
235
|
log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
234
236
|
this.#use(r.list[r.at])
|
|
@@ -400,40 +402,16 @@ export class Client {
|
|
|
400
402
|
// 60s, or the server's Retry-After) until it works or the signal aborts. onRetry(error, ms,
|
|
401
403
|
// attempt) reports each wait; onRetry(null) reports the recovery.
|
|
402
404
|
async chat(body, opts = {}) {
|
|
403
|
-
// "too large": never retried as is; the limit is learned and the caller sends less
|
|
404
|
-
const tooLarge = (e) => {
|
|
405
|
-
const limit = inputLimit(e, Math.round(estMessages(body.messages) * this.tokenRatio), this.knownGood)
|
|
406
|
-
if (!limit) return false
|
|
407
|
-
e.inputLimit = limit // this request is sent smaller
|
|
408
|
-
// remembered only when the server said the number (a guess isn't); a per-minute cap apart
|
|
409
|
-
const said = limitSaid(e)
|
|
410
|
-
log('limit', `request too large; ${said ? `the server says ${said.tokens}${said.rate ? ' per minute' : ''}` : `no limit given, trying ~${limit}`}`)
|
|
411
|
-
if (said) this.onInputLimit?.(said.tokens, { rate: said.rate })
|
|
412
|
-
return true
|
|
413
|
-
}
|
|
414
405
|
if (!this.onRetry) {
|
|
415
|
-
|
|
416
|
-
return await this.#timed(body, opts)
|
|
417
|
-
} catch (e) {
|
|
418
|
-
tooLarge(e)
|
|
419
|
-
throw e
|
|
420
|
-
}
|
|
406
|
+
return await this.#timed(body, opts)
|
|
421
407
|
}
|
|
422
408
|
let tries = 0 // of the model being tried now
|
|
423
409
|
let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
|
|
424
410
|
let hard = true // every failure in this round was one that waiting can't fix (see outage)
|
|
425
|
-
let
|
|
411
|
+
let rounds = 0 // whole rounds of the list that failed
|
|
426
412
|
const tried = new Set() // places in the model list tried for this request
|
|
427
413
|
const down = new Set() // providers that answered it with an error (400 or above): their other models are skipped
|
|
428
|
-
const first = this.target
|
|
429
414
|
for (let attempt = 0; ; attempt++) {
|
|
430
|
-
// moved on to a model with a smaller window than the conversation was fitted to: handed back
|
|
431
|
-
// (as "too large", at that window) so the caller summarizes it first, instead of a refusal
|
|
432
|
-
const sent = this.target !== first && body.messages && this.can?.context && Math.round(estMessages(body.messages) * this.tokenRatio)
|
|
433
|
-
if (sent > this.can.context * 0.9) {
|
|
434
|
-
log('limit', `${this.model}: ~${sent} tokens don't fit its window (${this.can.context}); handed back to be made smaller`)
|
|
435
|
-
throw Object.assign(new Error(`the conversation (~${sent} tokens) is too large for ${this.model} (${this.can.context})`), { inputLimit: this.can.context, fitted: true })
|
|
436
|
-
}
|
|
437
415
|
try {
|
|
438
416
|
const r = await this.#timed(body, { ...opts, once: true })
|
|
439
417
|
if (attempt) this.onRetry(null)
|
|
@@ -442,22 +420,11 @@ export class Client {
|
|
|
442
420
|
return r
|
|
443
421
|
} catch (e) {
|
|
444
422
|
if (e.name === 'AbortError' || opts.signal?.aborted) throw e
|
|
445
|
-
if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
|
|
446
423
|
this.onFail?.(e, { provider: this.provider, model: this.model }) // counted for orchestration
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
if (
|
|
451
|
-
refusedBy++
|
|
452
|
-
this.#next(`refused (${e.status})`, () => this.#pick(e, tried, down))
|
|
453
|
-
this.onRetry(e, 0, attempt + 1, this.target)
|
|
454
|
-
continue
|
|
455
|
-
}
|
|
456
|
-
// A bad request (400…): the same request fails the same way every time, so it isn't sent
|
|
457
|
-
// again. It stops here, unless the model list moves on at any error (the settings page). A
|
|
458
|
-
// model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
|
|
459
|
-
// in the list; a whole round of those stops too, rather than going round.
|
|
460
|
-
if (!outage(e) && !(unusable(e) && this.rotation) && this.rotation?.on !== 'any') throw e
|
|
424
|
+
// With a list of models, any failure makes way for the next one (another provider's first).
|
|
425
|
+
// Without one, only what waiting can fix is tried again.
|
|
426
|
+
if (!(this.rotation?.list.length > 1) && !outage(e)) throw e
|
|
427
|
+
if (this.retryIf && !this.retryIf(e, tries)) throw e // headless: --max-retries for one model
|
|
461
428
|
hard &&= !outage(e)
|
|
462
429
|
tries++
|
|
463
430
|
if (this.#rotate(e, tries, () => this.#pick(e, tried, down))) {
|
|
@@ -466,6 +433,7 @@ export class Client {
|
|
|
466
433
|
// requests a minute); the user chose "round again at once"
|
|
467
434
|
if (++round >= this.rotation.list.length) {
|
|
468
435
|
if (hard) throw e // every model refused it outright: going round again won't change that
|
|
436
|
+
if (this.retryIf && !this.retryIf(e, ++rounds)) throw e // headless: --max-retries whole rounds
|
|
469
437
|
hard = true
|
|
470
438
|
round = 0
|
|
471
439
|
this.onRetry(e, 1000, attempt + 1, this.target)
|
package/src/config.js
CHANGED
|
@@ -72,9 +72,22 @@ function fromFile() {
|
|
|
72
72
|
// cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
|
|
73
73
|
// flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
|
|
74
74
|
// only (cfg.adhoc), unless saved.
|
|
75
|
-
// The model name that means your list: #1, then the
|
|
76
|
-
// Any other model given for a run (-m, HEART_MODEL, a URL of its own)
|
|
77
|
-
|
|
75
|
+
// The model name that means your list: #1, then the next when one fails (also: no model given;
|
|
76
|
+
// "default" is its old name). Any other model given for a run (-m, HEART_MODEL, a URL of its own)
|
|
77
|
+
// is that model alone: fixed (--fallback-model adds others after it).
|
|
78
|
+
export const AUTO_MODEL = 'auto'
|
|
79
|
+
export const isAuto = (m) => m === AUTO_MODEL || m === 'default'
|
|
80
|
+
|
|
81
|
+
// A model as given (-m, --fallback-model, /model): "provider/model" (as opencode writes it; split at
|
|
82
|
+
// the first "/", provider names have none), the older "provider model", or a model of `current`.
|
|
83
|
+
export function parseModel(v, providers, current) {
|
|
84
|
+
v = String(v).trim()
|
|
85
|
+
const spaced = /^(\S+)\s+(.+)$/.exec(v)
|
|
86
|
+
if (spaced && providers[spaced[1]]) return { provider: spaced[1], model: spaced[2] }
|
|
87
|
+
const i = v.indexOf('/')
|
|
88
|
+
if (i > 0 && providers[v.slice(0, i)]) return { provider: v.slice(0, i), model: v.slice(i + 1) }
|
|
89
|
+
return { provider: current, model: v }
|
|
90
|
+
}
|
|
78
91
|
|
|
79
92
|
// providers by name, A to Z: every list of them (settings page, `heart providers`, pickers) follows
|
|
80
93
|
export const byName = (providers) => Object.fromEntries(Object.entries(providers ?? {}).sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' })))
|
|
@@ -98,7 +111,7 @@ export function loadConfig(flags = {}) {
|
|
|
98
111
|
cfg.providers[cfg.provider].apiKey = apiKey
|
|
99
112
|
}
|
|
100
113
|
const model = flags.model || process.env.HEART_MODEL
|
|
101
|
-
if (model && model
|
|
114
|
+
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
|
|
102
115
|
return cfg
|
|
103
116
|
}
|
|
104
117
|
|
package/src/guided.js
CHANGED
|
@@ -225,14 +225,8 @@ class Guided {
|
|
|
225
225
|
|
|
226
226
|
// `answer: true` streams the reply to the user as the answer; otherwise only thinking is shown.
|
|
227
227
|
// room: tokens of context to keep free for the reply (only for fitting the context; never sent).
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return await this.ask1(question, opts, tries)
|
|
231
|
-
} catch (e) {
|
|
232
|
-
// too large for the model: strat.context has dropped to its limit, so the context shrinks
|
|
233
|
-
if (!e.inputLimit || tries >= 3) throw e
|
|
234
|
-
return this.query(question, opts, tries + 1)
|
|
235
|
-
}
|
|
228
|
+
query(question, opts = {}) {
|
|
229
|
+
return this.ask1(question, opts)
|
|
236
230
|
}
|
|
237
231
|
|
|
238
232
|
async ask1(question, { room = 32, schema, oneLine, onToken, answer, skipKey } = {}, tries = 0) {
|
package/src/headless.js
CHANGED
|
@@ -150,6 +150,7 @@ export async function runHeadless(app, opts) {
|
|
|
150
150
|
said = false
|
|
151
151
|
current = { ctl: new AbortController() }
|
|
152
152
|
app.signal = current.ctl.signal
|
|
153
|
+
app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
|
|
153
154
|
let answer = ''
|
|
154
155
|
let error = null
|
|
155
156
|
try {
|
|
@@ -162,6 +163,8 @@ export async function runHeadless(app, opts) {
|
|
|
162
163
|
log('error', error, e.stack)
|
|
163
164
|
} finally {
|
|
164
165
|
app.save()
|
|
166
|
+
app.working = false
|
|
167
|
+
app.afterLine?.()
|
|
165
168
|
}
|
|
166
169
|
const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
|
|
167
170
|
current = null
|
package/src/settings.html
CHANGED
|
@@ -228,11 +228,15 @@
|
|
|
228
228
|
.olist .test button { flex: none; }
|
|
229
229
|
.olist .test.ok { color: var(--ok); }
|
|
230
230
|
.olist .test.warn { color: var(--accent); }
|
|
231
|
-
dialog
|
|
232
|
-
dialog
|
|
233
|
-
dialog
|
|
231
|
+
dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
|
|
232
|
+
dialog::backdrop { background: rgba(0, 0, 0, .55); }
|
|
233
|
+
dialog h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
|
|
234
234
|
dialog#ms .opt:last-of-type { border-bottom: 0; }
|
|
235
|
-
|
|
235
|
+
#pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
|
|
236
|
+
#pr-list li { counter-increment: rank; padding: 9px 12px; margin-bottom: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); cursor: grab; user-select: none; }
|
|
237
|
+
#pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
|
|
238
|
+
#pr-list li.dragging { opacity: .4; }
|
|
239
|
+
dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
236
240
|
.olist .test.failed { color: var(--err); }
|
|
237
241
|
.olist .test.testing, .olist .test.waiting { color: var(--accent); }
|
|
238
242
|
.olist .test button { padding: 0 7px; font-size: 11px; }
|
|
@@ -431,19 +435,6 @@
|
|
|
431
435
|
</div>
|
|
432
436
|
<div class="steps" id="steps"></div>
|
|
433
437
|
<div class="stack">
|
|
434
|
-
<div class="card" id="p-form" hidden>
|
|
435
|
-
<h2 id="p-title">Add a provider</h2>
|
|
436
|
-
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
437
|
-
<div class="presets" id="presets"></div>
|
|
438
|
-
<div class="row" style="margin-top:6px">
|
|
439
|
-
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
440
|
-
<div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
|
|
441
|
-
</div>
|
|
442
|
-
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
443
|
-
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
444
|
-
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
445
|
-
<div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
|
|
446
|
-
</div>
|
|
447
438
|
<div class="work" id="p-work" hidden>
|
|
448
439
|
<div class="card catalog">
|
|
449
440
|
<div class="cat-top">
|
|
@@ -565,20 +556,8 @@
|
|
|
565
556
|
|
|
566
557
|
<!-- MCP servers -->
|
|
567
558
|
<section class="page" id="page-mcp">
|
|
568
|
-
<div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div></div>
|
|
559
|
+
<div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div><button class="primary" id="c-open">+ Add a server</button></div>
|
|
569
560
|
<div class="stack">
|
|
570
|
-
<div class="card">
|
|
571
|
-
<h2>Add a server</h2>
|
|
572
|
-
<p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
|
|
573
|
-
<div class="row">
|
|
574
|
-
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
575
|
-
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
576
|
-
<label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
|
|
577
|
-
</div></div>
|
|
578
|
-
<div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
|
|
579
|
-
<button class="primary" id="c-add">Add server</button>
|
|
580
|
-
</div>
|
|
581
|
-
</div>
|
|
582
561
|
<div class="card" id="c-list"></div>
|
|
583
562
|
<details class="card" id="c-raw"><summary style="cursor:pointer;font-weight:600">Edit mcp.json directly</summary><p class="sub" style="margin-top:10px">For what the form doesn't have: environment variables (<span class="mono">"env"</span>), headers, and so on.</p><div id="c-file"></div></details>
|
|
584
563
|
</div>
|
|
@@ -617,6 +596,40 @@
|
|
|
617
596
|
<div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
|
|
618
597
|
</form>
|
|
619
598
|
</dialog>
|
|
599
|
+
<dialog id="p-form" aria-labelledby="p-title">
|
|
600
|
+
<h2 id="p-title">Add a provider</h2>
|
|
601
|
+
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
602
|
+
<div class="presets" id="presets"></div>
|
|
603
|
+
<div class="row" style="margin-top:6px">
|
|
604
|
+
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
605
|
+
<div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
|
|
606
|
+
</div>
|
|
607
|
+
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
608
|
+
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
609
|
+
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
610
|
+
<div class="actions"><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
|
|
611
|
+
</dialog>
|
|
612
|
+
<dialog id="c-form" aria-labelledby="c-title">
|
|
613
|
+
<h2 id="c-title">Add a server</h2>
|
|
614
|
+
<p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
|
|
615
|
+
<div class="row">
|
|
616
|
+
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
617
|
+
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
618
|
+
<label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
|
|
619
|
+
</div></div>
|
|
620
|
+
<div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
|
|
623
|
+
</dialog>
|
|
624
|
+
<dialog id="k-form" aria-labelledby="k-title"></dialog>
|
|
625
|
+
<dialog id="pr" aria-labelledby="pr-title">
|
|
626
|
+
<form>
|
|
627
|
+
<h2 id="pr-title">Provider ranking</h2>
|
|
628
|
+
<p class="sub">Drag the providers into order, best first. Your models are then sorted by it.</p>
|
|
629
|
+
<ol id="pr-list"></ol>
|
|
630
|
+
<div class="actions"><button id="pr-cancel" type="button">Cancel</button><button class="primary" id="pr-save" type="button">Save</button></div>
|
|
631
|
+
</form>
|
|
632
|
+
</dialog>
|
|
620
633
|
<div id="toast" role="status"></div>
|
|
621
634
|
<div id="tip" role="tooltip"></div>
|
|
622
635
|
<script>
|
|
@@ -766,7 +779,7 @@ const DRAFTS = { prompts: {}, skills: {}, files: {} }
|
|
|
766
779
|
const SEL = { prompts: 'agent/system', skills: null, files: null }
|
|
767
780
|
function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
768
781
|
const all = groups.flatMap((g) => g.items)
|
|
769
|
-
if (!all.some((x) => x.id === SEL[kind])
|
|
782
|
+
if (!all.some((x) => x.id === SEL[kind])) SEL[kind] = all[0]?.id ?? null
|
|
770
783
|
// the list: built when its items change; otherwise each item is updated in place (the chosen
|
|
771
784
|
// one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
|
|
772
785
|
const inside = (x) => [h('div', { className: 't' }, DRAFTS[kind][x.id] !== undefined ? h('span', { className: 'dot', title: 'not saved' }) : null, x.title, x.badge ?? null), x.sub ? h('div', { className: 'd', textContent: x.sub }) : null]
|
|
@@ -787,7 +800,7 @@ function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
|
787
800
|
}
|
|
788
801
|
itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
|
|
789
802
|
itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
|
|
790
|
-
put(detailEl, draw(
|
|
803
|
+
put(detailEl, draw(all.find((x) => x.id === SEL[kind])))
|
|
791
804
|
itemsEl.closest('.md').classList.add('fill')
|
|
792
805
|
fillHeight()
|
|
793
806
|
}
|
|
@@ -879,10 +892,8 @@ const useModel = (provider, model) => act('/api/model', { provider, model }, `He
|
|
|
879
892
|
const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
|
|
880
893
|
|
|
881
894
|
let editing = null
|
|
882
|
-
let formOpen = false
|
|
883
895
|
function openForm(p = null) {
|
|
884
896
|
editing = p
|
|
885
|
-
formOpen = true
|
|
886
897
|
$('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
|
|
887
898
|
$('p-name').value = p?.name ?? ''
|
|
888
899
|
$('p-url').value = p?.baseUrl ?? ''
|
|
@@ -891,15 +902,11 @@ function openForm(p = null) {
|
|
|
891
902
|
$('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
|
|
892
903
|
$('p-save').textContent = p ? 'Save changes' : 'Add provider'
|
|
893
904
|
drawPresets()
|
|
894
|
-
|
|
895
|
-
$('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
905
|
+
$('p-form').showModal()
|
|
896
906
|
;(p ? $('p-url') : $('p-name')).focus()
|
|
897
907
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
formOpen = false
|
|
901
|
-
drawProviders(S)
|
|
902
|
-
}
|
|
908
|
+
const closeForm = () => $('p-form').close()
|
|
909
|
+
$('p-form').onclose = () => (editing = null)
|
|
903
910
|
$('p-open').onclick = () => openForm()
|
|
904
911
|
$('p-cancel').onclick = closeForm
|
|
905
912
|
$('p-save').onclick = async () => {
|
|
@@ -1183,17 +1190,12 @@ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
|
|
|
1183
1190
|
function drawProviders(s) {
|
|
1184
1191
|
const order = s.order
|
|
1185
1192
|
const none = !s.providers.length
|
|
1186
|
-
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1187
|
-
// no provider yet, the form is open (the only way in: no other button)
|
|
1193
|
+
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1188
1194
|
put($('steps'), [
|
|
1189
1195
|
['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
|
|
1190
1196
|
['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
|
|
1191
1197
|
['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
|
|
1192
1198
|
].map(([t, d, cls], i) => h('div', { className: 'step ' + cls }, h('span', { className: 'num', textContent: cls === 'done' ? '✓' : i + 1 }), h('div', {}, h('b', { textContent: t }), d))))
|
|
1193
|
-
const showForm = formOpen || none
|
|
1194
|
-
$('p-form').hidden = !showForm
|
|
1195
|
-
$('p-cancel').hidden = none // nothing to go back to
|
|
1196
|
-
$('p-open').hidden = showForm
|
|
1197
1199
|
$('p-work').hidden = none
|
|
1198
1200
|
fillHeight()
|
|
1199
1201
|
|
|
@@ -1241,9 +1243,9 @@ function drawProviders(s) {
|
|
|
1241
1243
|
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
1242
1244
|
h('div', { className: 'row', style: 'gap:8px' },
|
|
1243
1245
|
h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
|
|
1244
|
-
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model
|
|
1246
|
+
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model (rate limits, outages)']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1245
1247
|
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1246
|
-
h('div', { className: 'hint', textContent: { failure: '
|
|
1248
|
+
h('div', { className: 'hint', textContent: { failure: 'Any error moves on at once, to another provider\'s model first. It stops only when every model fails.', retries: 'A rate limit or outage: the same model is tried again (with a short wait) that many times first. Any other error moves on at once.' }[s.rotation.on] ?? 'Any error moves on at once, to another provider\'s model first.' }),
|
|
1247
1249
|
h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
|
|
1248
1250
|
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
1249
1251
|
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
@@ -1479,7 +1481,7 @@ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.too
|
|
|
1479
1481
|
// how: text (A to Z first), cheap (lowest first), else most first
|
|
1480
1482
|
const COLS = {
|
|
1481
1483
|
model: { label: 'Model', how: 'text', get: (t) => t.model },
|
|
1482
|
-
provider: { label: 'Provider', how: '
|
|
1484
|
+
provider: { label: 'Provider', how: 'rank', get: (t) => RANKED.get(t.provider) }, // by your ranking of the providers
|
|
1483
1485
|
context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
|
|
1484
1486
|
abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
|
|
1485
1487
|
tools: { label: 'Tools', get: (t) => can(t).tools },
|
|
@@ -1496,6 +1498,44 @@ let SORT = (() => {
|
|
|
1496
1498
|
return []
|
|
1497
1499
|
}
|
|
1498
1500
|
})()
|
|
1501
|
+
// your ranking of the providers, best first, kept in this browser; a provider not ranked yet comes after, A to Z
|
|
1502
|
+
let RANK = (() => {
|
|
1503
|
+
try {
|
|
1504
|
+
return JSON.parse(localStorage.getItem('heart.rank') ?? '[]').filter((x) => typeof x === 'string')
|
|
1505
|
+
} catch {
|
|
1506
|
+
return []
|
|
1507
|
+
}
|
|
1508
|
+
})()
|
|
1509
|
+
const ranked = () => {
|
|
1510
|
+
const names = [...new Set(S.order.map((t) => t.provider))]
|
|
1511
|
+
return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
|
|
1512
|
+
}
|
|
1513
|
+
// the ranking dialog: drag a provider to its place, then Save
|
|
1514
|
+
function openRanking() {
|
|
1515
|
+
const list = $('pr-list')
|
|
1516
|
+
let held = null
|
|
1517
|
+
put(list, ranked().map((name) => h('li', { draggable: true, textContent: name })))
|
|
1518
|
+
list.ondragstart = (e) => (held = e.target).classList.add('dragging')
|
|
1519
|
+
list.ondragend = () => held?.classList.remove('dragging')
|
|
1520
|
+
list.ondragover = (e) => {
|
|
1521
|
+
e.preventDefault()
|
|
1522
|
+
const over = e.target.closest('li')
|
|
1523
|
+
if (!held || !over || over === held) return
|
|
1524
|
+
const r = over.getBoundingClientRect()
|
|
1525
|
+
list.insertBefore(held, e.clientY > r.top + r.height / 2 ? over.nextSibling : over)
|
|
1526
|
+
}
|
|
1527
|
+
$('pr').showModal()
|
|
1528
|
+
}
|
|
1529
|
+
$('pr-cancel').onclick = () => $('pr').close()
|
|
1530
|
+
$('pr-save').onclick = () => {
|
|
1531
|
+
const list = [...$('pr-list').children].map((li) => li.textContent)
|
|
1532
|
+
RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
|
|
1533
|
+
try {
|
|
1534
|
+
localStorage.setItem('heart.rank', JSON.stringify(RANK))
|
|
1535
|
+
} catch {}
|
|
1536
|
+
$('pr').close()
|
|
1537
|
+
setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
|
|
1538
|
+
}
|
|
1499
1539
|
const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
|
|
1500
1540
|
function setSort(next) {
|
|
1501
1541
|
SORT = next
|
|
@@ -1511,7 +1551,9 @@ function clickSort(key) {
|
|
|
1511
1551
|
if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
|
|
1512
1552
|
setSort(SORT.filter((x) => x !== at))
|
|
1513
1553
|
}
|
|
1554
|
+
let RANKED = new Map() // provider → its place in the ranking, for one sort
|
|
1514
1555
|
function sorted(order) {
|
|
1556
|
+
RANKED = new Map(ranked().map((n, i) => [n, i]))
|
|
1515
1557
|
const cmp = (a, b, { key, dir }) => {
|
|
1516
1558
|
let x = COLS[key].get(a)
|
|
1517
1559
|
let y = COLS[key].get(b)
|
|
@@ -1523,7 +1565,7 @@ function sorted(order) {
|
|
|
1523
1565
|
}
|
|
1524
1566
|
return order.map((t, i) => ({ t, i })).sort((a, b) => SORT.reduce((r, s) => r || cmp(a.t, b.t, s), 0) || a.i - b.i).map((x) => x.t)
|
|
1525
1567
|
}
|
|
1526
|
-
const dirText = (key, dir) => (key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
|
|
1568
|
+
const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your ranking' : 'your ranking, reversed') : key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
|
|
1527
1569
|
// The models ticked on the Your models page ("provider/model"), to remove several at once; the last
|
|
1528
1570
|
// one ticked, so a Shift+click ticks the ones between
|
|
1529
1571
|
const PICKED = new Set()
|
|
@@ -1594,6 +1636,7 @@ function drawMine(s) {
|
|
|
1594
1636
|
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1595
1637
|
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1596
1638
|
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
1639
|
+
order.some((t) => t.provider !== order[0].provider) ? h('button', { className: 'small', textContent: 'Provider ranking', title: 'drag your providers into order; the Provider column sorts by it', onclick: openRanking }) : null,
|
|
1597
1640
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1598
1641
|
SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
|
|
1599
1642
|
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,
|
|
@@ -1845,8 +1888,10 @@ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange =
|
|
|
1845
1888
|
}
|
|
1846
1889
|
$('c-add').onclick = async () => {
|
|
1847
1890
|
const r = await act('/api/mcp/add', { name: $('c-name').value, [mcpKind()]: $('c-what').value }, `added ${$('c-name').value.trim()}: it starts from the next message`)
|
|
1848
|
-
if (r) $('c-name').value = $('c-what').value = ''
|
|
1891
|
+
if (r) ($('c-name').value = $('c-what').value = ''), $('c-form').close()
|
|
1849
1892
|
}
|
|
1893
|
+
$('c-open').onclick = () => ($('c-form').showModal(), $('c-name').focus())
|
|
1894
|
+
$('c-cancel').onclick = () => $('c-form').close()
|
|
1850
1895
|
function drawMcp(s) {
|
|
1851
1896
|
put($('c-list'), h('h2', { textContent: 'Servers' }), h('p', { className: 'sub', textContent: 'Built in, and yours. Switch one off to keep it without using it.' }),
|
|
1852
1897
|
s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
|
|
@@ -1873,7 +1918,27 @@ function parseSkill(text) {
|
|
|
1873
1918
|
}
|
|
1874
1919
|
const skillText = (k) => `---\n${k.description.trim() ? `description: ${k.description.trim().replace(/\n/g, ' ')}\n` : ''}use: ${k.use}\n${k.extra.map((l) => l + '\n').join('')}---\n\n${k.body.trim()}\n`
|
|
1875
1920
|
const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
|
|
1876
|
-
|
|
1921
|
+
// a new skill: the form in a dialog (what you typed is kept if you close it)
|
|
1922
|
+
function newSkill() {
|
|
1923
|
+
const f = skillForm('+new', NEW, { isNew: true })
|
|
1924
|
+
const first = !S.skills.length
|
|
1925
|
+
put($('k-form'), h('h2', { id: 'k-title', textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1926
|
+
first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
|
|
1927
|
+
f.el,
|
|
1928
|
+
h('div', { className: 'actions' },
|
|
1929
|
+
h('button', { textContent: 'Cancel', onclick: () => $('k-form').close() }),
|
|
1930
|
+
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1931
|
+
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1932
|
+
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1933
|
+
const was = SEL.skills
|
|
1934
|
+
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1935
|
+
if (await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`)) $('k-form').close()
|
|
1936
|
+
else SEL.skills = was
|
|
1937
|
+
} })))
|
|
1938
|
+
$('k-form').showModal()
|
|
1939
|
+
$('k-form').querySelector('input')?.focus()
|
|
1940
|
+
}
|
|
1941
|
+
$('k-new').onclick = newSkill
|
|
1877
1942
|
function skillForm(id, start, { isNew, readOnly }) {
|
|
1878
1943
|
const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
|
|
1879
1944
|
const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
|
|
@@ -1891,27 +1956,11 @@ function skillForm(id, start, { isNew, readOnly }) {
|
|
|
1891
1956
|
] }
|
|
1892
1957
|
}
|
|
1893
1958
|
function drawSkills(s) {
|
|
1894
|
-
if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
|
|
1895
|
-
$('k-new').hidden = SEL.skills === '+new'
|
|
1896
1959
|
const mine = s.skills.filter((k) => k.mine)
|
|
1897
1960
|
const theirs = s.skills.filter((k) => !k.mine)
|
|
1898
1961
|
const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
|
|
1899
1962
|
masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
|
|
1900
|
-
if (x
|
|
1901
|
-
const f = skillForm('+new', NEW, { isNew: true })
|
|
1902
|
-
const first = !s.skills.length
|
|
1903
|
-
return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1904
|
-
first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
|
|
1905
|
-
f.el,
|
|
1906
|
-
h('div', { className: 'actions' },
|
|
1907
|
-
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1908
|
-
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1909
|
-
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1910
|
-
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1911
|
-
if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
|
|
1912
|
-
} }),
|
|
1913
|
-
first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
|
|
1914
|
-
}
|
|
1963
|
+
if (!x) return h('div', { className: 'card' }, h('h2', { textContent: 'No skills yet' }), h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Make one with + New skill, or bring them from Claude Code and others with /import in HeartCode.' }))
|
|
1915
1964
|
const k = x.k
|
|
1916
1965
|
const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
|
|
1917
1966
|
return h('div', { className: 'card' },
|