heartcode 0.1.9 → 0.1.13
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 +89 -0
- package/SKILL.md +47 -35
- package/bin/heart.js +30 -8
- package/bin/heartcode.js +75 -103
- package/package.json +1 -1
- package/src/acp.js +7 -1
- package/src/agent.js +23 -54
- package/src/client.js +11 -43
- package/src/config.js +65 -6
- package/src/guided.js +2 -8
- package/src/headless.js +3 -0
- package/src/settings.html +149 -111
- package/src/settings.js +36 -1
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, takeIn, whatOf, 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'
|
|
@@ -26,6 +26,7 @@ import { createWorktree, worktreeState, removeWorktree } from '../src/worktree.j
|
|
|
26
26
|
import { runAcp } from '../src/acp.js'
|
|
27
27
|
import { Tabs } from '../src/tabs.js'
|
|
28
28
|
import { startSettings } from '../src/settings.js'
|
|
29
|
+
import { loadHooks } from '../src/hooks.js'
|
|
29
30
|
import { RESETS, reset, resetPaths, resetCode } from '../src/reset.js'
|
|
30
31
|
|
|
31
32
|
const LOCAL_SERVERS = ['http://localhost:11434/v1', 'http://localhost:1234/v1', 'http://localhost:8080/v1']
|
|
@@ -37,8 +38,9 @@ ${c.bold('usage')}
|
|
|
37
38
|
heart ~/code/my-app in that folder (a folder, not a file)
|
|
38
39
|
heart "fix the failing test" run one task and exit
|
|
39
40
|
heart providers list added providers
|
|
40
|
-
heart models [filter]
|
|
41
|
-
|
|
41
|
+
heart models [filter] what -m takes, one per line: "auto" (your list), then your models
|
|
42
|
+
as provider/model, #1 first
|
|
43
|
+
heart models --all [filter] every provider's models (provider/model)
|
|
42
44
|
heart models --json as JSON (with --all too), for other programs
|
|
43
45
|
heart probe test the current model for real (otherwise its abilities come from models.dev)
|
|
44
46
|
heart config show the config file
|
|
@@ -46,14 +48,17 @@ ${c.bold('usage')}
|
|
|
46
48
|
|
|
47
49
|
${c.bold('options')}
|
|
48
50
|
-u, --base-url <url> use this server for this run (add --save to keep it)
|
|
49
|
-
-m, --model <name>
|
|
51
|
+
-m, --model <name> this model alone for this run (provider/model, or a model of #1's provider);
|
|
52
|
+
"auto" (the default): your list, #1 then the next when one fails
|
|
53
|
+
--fallback-model <name> after -m's model when it fails (repeatable)
|
|
50
54
|
--api-key <key> API key for --base-url
|
|
51
55
|
-y, --yes do everything without asking (the default)
|
|
52
56
|
--ask ask before every edit and command, this run
|
|
53
57
|
--hide-thinking collapse the model's thinking to one line
|
|
54
58
|
-C, --cwd <dir> project folder (default: current folder)
|
|
55
|
-
--temp a throwaway settings folder
|
|
56
|
-
|
|
59
|
+
--temp a throwaway settings folder (your providers, model tests and prices
|
|
60
|
+
copied in), shared by the --temp runs going on at once and deleted
|
|
61
|
+
after the last; ~/.heart isn't touched
|
|
57
62
|
--context <n> context window size in tokens
|
|
58
63
|
--force re-run the probe
|
|
59
64
|
--save keep the given -u/-m/--api-key/... in ${CONFIG_FILE}
|
|
@@ -61,7 +66,7 @@ ${c.bold('options')}
|
|
|
61
66
|
or to continue in it); removed on exit if nothing changed
|
|
62
67
|
-c, --continue continue the last session in this folder
|
|
63
68
|
-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
|
|
69
|
+
--provider <name> use this provider for this run (or -m provider/model)
|
|
65
70
|
|
|
66
71
|
${c.bold('headless')} (for other programs; flags and output as in Claude Code's -p)
|
|
67
72
|
-p, --print run without the screen, print the answer, exit
|
|
@@ -176,12 +181,15 @@ class App {
|
|
|
176
181
|
return mcp
|
|
177
182
|
}
|
|
178
183
|
|
|
179
|
-
// After a skill or MCP server was saved or deleted (
|
|
180
|
-
|
|
184
|
+
// After a skill or MCP server was saved or deleted (the settings page, the director, /import):
|
|
185
|
+
// from the next tool call. The old servers are stopped once the message in progress is done
|
|
186
|
+
// (busy), so a call to one of them finishes.
|
|
187
|
+
reload(kind, busy = false) {
|
|
181
188
|
if (kind === 'skill') this.skills = loadSkills(this.root)
|
|
182
189
|
if (kind === 'mcp') {
|
|
183
|
-
this.mcp
|
|
190
|
+
const old = this.mcp
|
|
184
191
|
this.mcp = this.newMcp()
|
|
192
|
+
busy ? this.later.push(() => old.dispose()) : old.dispose()
|
|
185
193
|
}
|
|
186
194
|
if (this.s) Object.assign(this.s, { skills: this.skills, mcp: this.mcp })
|
|
187
195
|
}
|
|
@@ -229,13 +237,12 @@ class App {
|
|
|
229
237
|
if (name === 'auto_compact' && this.s) this.s.autoCompact = bool
|
|
230
238
|
return save(`${name}: ${bool ? 'on' : 'off'}${name === 'director' ? ' (from the next message)' : ''}`)
|
|
231
239
|
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(', ')}`
|
|
240
|
+
const { provider: p, model: m } = parseModel(v, cfg.providers, cfg.provider)
|
|
241
|
+
if (!cfg.providers[p] || !m) return `error: give provider/model or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
|
|
235
242
|
// headless (-p) and editors (ACP): the program that started HeartCode chooses the model
|
|
236
243
|
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
|
|
244
|
+
this.later.push(() => this.use(p, m).catch((e) => ui.warn(e.message)))
|
|
245
|
+
return `switching to ${p} · ${m} after this reply`
|
|
239
246
|
}
|
|
240
247
|
default:
|
|
241
248
|
return `error: no setting "${name}"; manage list kind setting shows them`
|
|
@@ -313,14 +320,6 @@ class App {
|
|
|
313
320
|
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
321
|
if (change) learned()
|
|
315
322
|
}
|
|
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
323
|
// models.dev's published size for the model (a day old at most): used when the server doesn't say
|
|
325
324
|
loadPrices().then(() => {
|
|
326
325
|
price = priceOf(client.provider, client.baseUrl, client.model)
|
|
@@ -646,11 +645,11 @@ class App {
|
|
|
646
645
|
}
|
|
647
646
|
|
|
648
647
|
// 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 "
|
|
648
|
+
// A model given for this run (-m, not "auto") is used alone (cfg.fixed), then --fallback-model's.
|
|
650
649
|
useRotation() {
|
|
651
650
|
const { cfg } = this
|
|
652
651
|
const seen = new Set()
|
|
653
|
-
const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? []))]
|
|
652
|
+
const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? [])), ...(cfg.fallbacks ?? [])]
|
|
654
653
|
.filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
655
654
|
.map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
|
|
656
655
|
this.s?.client.setRotation(list, cfg.rotation ?? {})
|
|
@@ -744,19 +743,16 @@ class App {
|
|
|
744
743
|
}
|
|
745
744
|
}
|
|
746
745
|
|
|
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.
|
|
746
|
+
// `heart models`: what -m takes, one per line (as \`opencode models\`): "auto" (your list: #1, then
|
|
747
|
+
// the next when one fails; also no -m), then your models as provider/model, #1 first. all: every
|
|
748
|
+
// provider's models (fetched from each; a provider whose list is off: the ones added by name).
|
|
749
|
+
// json: one JSON object on stdout, for other programs:
|
|
750
|
+
// { "models": [{ "id": "auto", "fallbacks": true }, { "id": "groq/openai/gpt-oss-120b", "provider", "model", "place": 1 }, …],
|
|
751
|
+
// "providers": [{ "name", "baseUrl", "list": true, "models": ["provider/model", …], "error"? }] } (--all)
|
|
755
752
|
async listModels(filter = '', { all = false, json = false } = {}) {
|
|
756
753
|
const { ui, cfg } = this
|
|
757
754
|
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
|
|
755
|
+
const mine = this.myModels().map((t, i) => ({ id: `${t.provider}/${t.model}`, provider: t.provider, model: t.model, place: i + 1 }))
|
|
760
756
|
const providers = !all ? [] : await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
761
757
|
const out = { name, baseUrl: p.baseUrl, list: p.list !== false, models: [] }
|
|
762
758
|
if (out.list) {
|
|
@@ -767,32 +763,17 @@ class App {
|
|
|
767
763
|
}
|
|
768
764
|
}
|
|
769
765
|
// 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)
|
|
766
|
+
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
767
|
return out
|
|
772
768
|
}))
|
|
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
|
-
}
|
|
769
|
+
const shown = [...(mine.length ? [{ id: AUTO_MODEL, fallbacks: mine.length > 1 }] : []), ...mine].filter((t) => match(t.id))
|
|
770
|
+
if (json) return void process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
|
|
781
771
|
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
|
-
}
|
|
772
|
+
if (!mine.length) return ui.print(NO_MODELS)
|
|
773
|
+
return void process.stdout.write(shown.map((t) => t.id + '\n').join(''))
|
|
795
774
|
}
|
|
775
|
+
for (const p of providers) if (p.error) process.stderr.write(`heartcode: can't list ${p.name}'s models: ${p.error}\n`)
|
|
776
|
+
process.stdout.write(providers.flatMap((p) => p.models).map((id) => id + '\n').join(''))
|
|
796
777
|
}
|
|
797
778
|
}
|
|
798
779
|
|
|
@@ -876,29 +857,32 @@ function openBrowser(url) {
|
|
|
876
857
|
spawnSync(cmd, args, { stdio: 'ignore' })
|
|
877
858
|
}
|
|
878
859
|
|
|
879
|
-
// A change
|
|
880
|
-
//
|
|
860
|
+
// A change of the settings (the settings page, another HeartCode on the same folder, a hand), brought
|
|
861
|
+
// into every open tab and run (also -p and ACP) right away: the running task's next request has it
|
|
862
|
+
// (the model list's #1, the prompts, hooks, skills, MCP servers…).
|
|
881
863
|
function applySettings(apps, cfg, what) {
|
|
882
864
|
const before = themeCodes()
|
|
883
865
|
if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
|
|
884
|
-
if (what === 'prompts') reloadPrompts()
|
|
866
|
+
if (what === 'prompts') reloadPrompts()
|
|
885
867
|
if (['setting:log', 'setting:logRequests', 'setting:logDetail'].includes(what)) useRequestLog(cfg) // the run's log and the requests', on or off now
|
|
886
868
|
if (what === 'setting:logDays') pruneLogs(logDays(cfg))
|
|
887
869
|
settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
|
|
888
870
|
for (const app of apps) {
|
|
889
871
|
const { ui } = app
|
|
890
|
-
const busy = Boolean(ui.busySince)
|
|
872
|
+
const busy = Boolean(ui.busySince || app.working)
|
|
891
873
|
const now = (fn) => (busy ? app.later.push(fn) : fn())
|
|
892
874
|
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
875
|
if (what === 'model') {
|
|
894
876
|
app.lists = {}
|
|
895
877
|
delete app.cfg.fixed // a model chosen on the page: your list again
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
878
|
+
if (app.s && !(cfg.providers[cfg.provider] && cfg.model)) now(() => ((app.s = null), ui.print(c.dim('settings: no model chosen'))))
|
|
879
|
+
}
|
|
880
|
+
// your model list: into the running session now, its next request goes to the new #1
|
|
881
|
+
if ((what === 'model' || what === 'setting:rotation') && app.s && cfg.providers[cfg.provider] && cfg.model) {
|
|
882
|
+
const was = `${app.s.client.provider} · ${app.s.client.model}`
|
|
883
|
+
app.useRotation()
|
|
884
|
+
const is = `${app.s.client.provider} · ${app.s.client.model}`
|
|
885
|
+
if (is !== was) ui.print(c.dim(`settings: ${is} now`))
|
|
902
886
|
}
|
|
903
887
|
if (what === 'provider') app.lists = {} // its list switched on or off, a model added by name
|
|
904
888
|
if (what === 'setting:permissions') ui.setPermission(cfg.permissions)
|
|
@@ -906,21 +890,13 @@ function applySettings(apps, cfg, what) {
|
|
|
906
890
|
if (what === 'setting:autoCompact' && app.s) app.s.autoCompact = cfg.autoCompact !== false
|
|
907
891
|
if (what === 'setting:format' && app.s) app.s.noFormat = cfg.format === false
|
|
908
892
|
if (what === 'setting:lsp' && app.s) app.s.noLsp = cfg.lsp === false
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
if (what === '
|
|
893
|
+
// the window size, or a model set up or measured on the page: the model in use takes it again
|
|
894
|
+
// from its profile now (it saves its profile as it learns, so it mustn't keep the old one)
|
|
895
|
+
if ((what === 'setting:context' || what === 'profile') && app.s) app.s.client.onUse?.({ provider: app.s.client.provider, model: app.s.client.model })
|
|
896
|
+
if (what === 'mcp' || what === 'skill') app.reload(what, busy)
|
|
897
|
+
if (what === 'prompts' && app.s) app.s.refreshSystem = true // the system prompt, again before the next request
|
|
898
|
+
if (what === 'hooks' && app.s && app.s.hooks !== false) app.s.hooks = loadHooks(app.root)
|
|
912
899
|
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
900
|
if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
|
|
925
901
|
else ui.render()
|
|
926
902
|
}
|
|
@@ -928,10 +904,10 @@ function applySettings(apps, cfg, what) {
|
|
|
928
904
|
}
|
|
929
905
|
|
|
930
906
|
// Changes saved by another HeartCode, or \`heart settings\` in another terminal, while this one runs:
|
|
931
|
-
// taken in as the page's own are (applySettings). config.json
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
const WATCHED = { 'mcp.json': 'mcp', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', skills: 'skill' }
|
|
907
|
+
// taken in as the page's own are (applySettings). config.json: the keys others changed (takeIn; a
|
|
908
|
+
// model given for this run with -m stays unless the list's #1 changes); the other files by their
|
|
909
|
+
// size and time, and only when this run didn't just apply them itself.
|
|
910
|
+
const WATCHED = { 'mcp.json': 'mcp', 'hooks.json': 'hooks', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', 'HEARTCODE.md': 'prompts', skills: 'skill' }
|
|
935
911
|
const seenStamp = {}
|
|
936
912
|
const stampOf = (p) => {
|
|
937
913
|
try {
|
|
@@ -946,21 +922,9 @@ function settingsSeen(what) {
|
|
|
946
922
|
for (const [name, w] of Object.entries(WATCHED)) if (w === what || (what === 'setting:theme' && w === 'theme')) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
947
923
|
}
|
|
948
924
|
function watchSettings(apps, cfg) {
|
|
949
|
-
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
950
|
-
let last = readJson(CONFIG_FILE, {})
|
|
951
925
|
for (const name of Object.keys(WATCHED)) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
952
926
|
const configChanged = () => {
|
|
953
|
-
const
|
|
954
|
-
if (!now) return
|
|
955
|
-
const keys = [...new Set([...Object.keys(now), ...Object.keys(last)])].filter((k) => !same(now[k], last[k]))
|
|
956
|
-
last = now
|
|
957
|
-
const whats = new Set()
|
|
958
|
-
for (const k of keys) {
|
|
959
|
-
const value = k === 'providers' ? { ...now.providers, ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : now[k]
|
|
960
|
-
if (same(value, cfg[k])) continue
|
|
961
|
-
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
|
962
|
-
whats.add(['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
|
|
963
|
-
}
|
|
927
|
+
const whats = new Set(takeIn(cfg).map(whatOf))
|
|
964
928
|
for (const what of whats) applySettings(apps(), cfg, what)
|
|
965
929
|
if (whats.size) log('settings', `taken in from config.json: ${[...whats].join(', ')}`)
|
|
966
930
|
}
|
|
@@ -1436,6 +1400,7 @@ async function main() {
|
|
|
1436
1400
|
options: {
|
|
1437
1401
|
'base-url': { type: 'string', short: 'u' },
|
|
1438
1402
|
model: { type: 'string', short: 'm' },
|
|
1403
|
+
'fallback-model': { type: 'string', multiple: true },
|
|
1439
1404
|
'api-key': { type: 'string' },
|
|
1440
1405
|
yes: { type: 'boolean', short: 'y' },
|
|
1441
1406
|
ask: { type: 'boolean' },
|
|
@@ -1495,8 +1460,12 @@ async function main() {
|
|
|
1495
1460
|
if (!cfg.providers[f.provider]) throw new Error(`no provider "${f.provider}" (heartcode providers lists them)`)
|
|
1496
1461
|
Object.assign(cfg, { provider: f.provider, fixed: true })
|
|
1497
1462
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1463
|
+
// --fallback-model: after the model given (as in Claude Code's -p), when it fails
|
|
1464
|
+
cfg.fallbacks = (f['fallback-model'] ?? []).map((v) => {
|
|
1465
|
+
const t = parseModel(v, cfg.providers, cfg.provider)
|
|
1466
|
+
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)`)
|
|
1467
|
+
return t
|
|
1468
|
+
})
|
|
1500
1469
|
if (f.effort) cfg.effort = f.effort === 'default' ? undefined : f.effort
|
|
1501
1470
|
if (f['mcp-config'] || f['strict-mcp-config']) cfg.mcp = { extra: f['mcp-config'] ?? [], strict: Boolean(f['strict-mcp-config']) }
|
|
1502
1471
|
extraSkillDirs.push(...(f['skills-dir'] ?? []).map((d) => path.resolve(d)))
|
|
@@ -1524,7 +1493,9 @@ async function main() {
|
|
|
1524
1493
|
// each editor session gets its own App, for its folder and its MCP servers
|
|
1525
1494
|
cfg.maxRetries ??= 10
|
|
1526
1495
|
// each editor session its own settings (one choosing a model doesn't change the others')
|
|
1527
|
-
const
|
|
1496
|
+
const apps = []
|
|
1497
|
+
const makeApp = (root, ui, mcp) => apps[apps.push(new App({ ...structuredClone(cfg), ...(mcp && { mcp: { extra: [mcp] } }) }, ui, root)) - 1]
|
|
1498
|
+
watchSettings(() => apps, cfg) // the settings page's changes, into every editor session
|
|
1528
1499
|
process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
|
|
1529
1500
|
}
|
|
1530
1501
|
const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
|
|
@@ -1608,6 +1579,7 @@ async function main() {
|
|
|
1608
1579
|
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
1580
|
if (f['resume-id'] && !chat) throw new Error(`no session ${f['resume-id']}`)
|
|
1610
1581
|
if (chat && (headless || !f['resume-id'])) app.setChat(chat) // interactive: resumed below, with its transcript
|
|
1582
|
+
if (headless || positionals.length) watchSettings(() => [app], cfg) // an app's run takes the settings page's changes too
|
|
1611
1583
|
if (headless) {
|
|
1612
1584
|
app.allowTools = [...(f.allowedTools ?? []), ...(f['allowed-tools'] ?? [])]
|
|
1613
1585
|
app.denyTools = [...(f.disallowedTools ?? []), ...(f['disallowed-tools'] ?? [])]
|
|
@@ -1621,7 +1593,7 @@ async function main() {
|
|
|
1621
1593
|
await leaveWorktree(app)
|
|
1622
1594
|
process.exit(ok ? 0 : 1) // MCP servers are stopped by the exit hooks
|
|
1623
1595
|
}
|
|
1624
|
-
watchSettings(() => [app], cfg)
|
|
1596
|
+
if (!positionals.length) watchSettings(() => [app], cfg)
|
|
1625
1597
|
await repl(app, f)
|
|
1626
1598
|
await leaveWorktree(app)
|
|
1627
1599
|
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,16 @@ 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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
+
// the prompts or HEARTCODE.md changed meanwhile (applySettings): the system prompt again, now
|
|
673
|
+
if (s.refreshSystem && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: SYSTEM(s) }
|
|
674
|
+
s.refreshSystem = false
|
|
675
|
+
const res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
707
676
|
ui.stopSpin()
|
|
708
677
|
if (!stream) {
|
|
709
678
|
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)
|