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/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, DEFAULT_MODEL, VERSION } from '../src/config.js'
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, RATE_CAP_MS, LIMIT_MS } from '../src/context.js'
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] "default" (your list), then your models: #1 (in use), then its fallbacks
41
- heart models --all [filter] every provider's models, yours marked
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> use this model alone for this run (no fallbacks); "default": your list, with its fallbacks
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 for this run (your providers, model tests
56
- and prices copied in; deleted after); ~/.heart isn't touched
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 "provider model")
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 (by the director or /import).
180
- reload(kind) {
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.dispose()
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
- let [p, ...m] = v.split(/\s+/)
233
- if (!cfg.providers[p]) [p, m] = [cfg.provider, [v]]
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.join(' ')).catch((e) => ui.warn(e.message)))
238
- return `switching to ${p} · ${m.join(' ')} after this reply`
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 "default") is used alone: no fallbacks (cfg.fixed).
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`: your models, in order (every provider's whole list is on the settings page).
748
- // all: every provider's models too (fetched from each; a provider whose list is switched off: the
749
- // ones added by name), yours marked. json: one JSON object on stdout, for other programs:
750
- // { "models": [{ "place": null, "provider": null, "model": "default", "role": "default", "rotates": true, "about": "…" },
751
- // { "place": 1, "provider", "model", "role": "in use" | "fallback", "rotates": false }, …],
752
- // "providers": [{ "name", "baseUrl", "list": true, "models": ["…"], "error"? }] } (--all)
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) => ({ place: i + 1, provider: t.provider, model: t.model, role: i === 0 ? 'in use' : 'fallback', rotates: false }))
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
- // "default" first, in the same list: your models, rotating (when there's any)
774
- const about = mine.length > 1 ? `your models in order: #1, then the next when one fails (-m ${DEFAULT_MODEL}, or no -m)` : `your one model, #1 (-m ${DEFAULT_MODEL}, or no -m)`
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 (!shown.length) return ui.print(filter ? `None of your models matches "${filter}".` : NO_MODELS)
783
- for (const t of shown) ui.print(t.place === null ? `${c.magenta('●')} ${c.bold(t.model)} ${c.dim(t.about)}` : ` ${c.bold(`#${t.place}`)} ${t.model} ${c.dim(`${t.provider} · ${t.role}; alone with -m "${t.provider} ${t.model}"`)}`)
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 on the settings page (in cfg, saved), brought into every open tab: right away, or the
880
- // model and MCP servers after the reply in progress.
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() // from each tab's next message
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
- const want = cfg.providers[cfg.provider] && app.client(cfg.provider, cfg.model)
897
- if (app.s && !(want && ['model', 'baseUrl', 'apiKey'].every((k) => want[k] === app.s.client[k])))
898
- now(() => {
899
- app.s = null
900
- ui.print(c.dim(cfg.model ? `settings: ${cfg.provider} · ${cfg.model} from the next message` : 'settings: no model chosen'))
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
- if (what === 'setting:context') now(() => (app.s = null))
910
- if (what === 'model' || what === 'setting:rotation') now(() => app.useRotation())
911
- if (what === 'mcp' || what === 'skill') now(() => app.reload(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 is compared key by key with what it
932
- // was (so a model given for this run with -m stays unless the list's #1 changes); the other files
933
- // by their size and time, and only when this run didn't just apply them itself.
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 now = readJson(CONFIG_FILE, null)
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
- const [p0, ...rest] = String(f.model ?? '').split(/\s+/) // -m "groq openai/gpt-oss-120b"
1499
- if (rest.length && cfg.providers[p0]) Object.assign(cfg, { provider: p0, model: rest.join(' ') })
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 makeApp = (root, ui, mcp) => new App({ ...structuredClone(cfg), ...(mcp && { mcp: { extra: [mcp] } }) }, ui, root)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.9",
3
+ "version": "0.1.13",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
5
  "keywords": [
6
6
  "ai",
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 app.s.run(skill && skill.use !== 'model' ? await app.expandSkill(skill, k[2]) : text, it.ctl.signal, images)
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
- let room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
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
- // too large even for the summary request: send less of it (the newest part) until it fits
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
- // What may be sent: the window less a tenth (estimates are estimates) and less room for the
609
- // reply (the longest the model gives, from models.dev, at most a quarter of the window; 8k when
610
- // unknown) and the tools; trimming keeps to it. Auto-compact starts at 90% of it, so it comes
611
- // before any trimming.
612
- // s.sizeCap: "too large" without the server saying its limit: a guess for this conversation only
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 until the conversation grew by a fifth
661
- if (nearlyFull() && s.messages.length > 4 && step !== s.compactedAt && size(s.messages) > (s.compactFailedAt ?? 0) * 1.2) {
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 }).catch((e) => {
664
- if (e.name === 'AbortError') throw e
665
- // no summary: free the space anyway, by shortening older tool output and dropping the oldest turns
666
- fitMessages(s.messages, Math.floor(budget() * 0.5))
667
- s.compactFailedAt = size(s.messages)
668
- ui.warn(`could not compact (${e.message}); shortened older tool output and messages instead`)
669
- log('compact', `failed (${e.message}); trimmed to ~${s.compactFailedAt} tokens`)
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
- let res
688
- try {
689
- // A long tool call (a whole file) streams in silently: say what's being written.
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
+ // 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
- if (this.rotation) this.#use(this.rotation.list[0])
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
- if (!(r.on === 'any' || (r.on === 'failure' && (outage(e) || unusable(e))) || (r.on === 'retries' && (tries >= r.retries || unusable(e))))) return false
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
- try {
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 refusedBy = 0 // models that refused this request (a bad request), tried in turn
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
- if (this.retryIf && !this.retryIf(e, attempt)) throw e
448
- // A bad request (400…) with a list of models: the next one may take it (it's often what the
449
- // conversation holds from another model). Two more at most, then it stops.
450
- if (!outage(e) && !unusable(e) && this.rotation && this.rotation.on !== 'any' && refusedBy < Math.min(2, this.rotation.list.length - 1)) {
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)