heartcode 0.1.8 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,28 +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
- 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
- s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
699
- out.end()
700
- shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
701
- step--
702
- continue
703
- }
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 })
704
673
  ui.stopSpin()
705
674
  if (!stream) {
706
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
- 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
  }
@@ -184,12 +185,34 @@ export class Client {
184
185
  this.#next(why)
185
186
  return true
186
187
  }
187
- #next(why) {
188
+ #next(why, pick) {
188
189
  const r = this.rotation
189
- r.at = (r.at + 1) % r.list.length
190
+ r.at = pick ? pick() : (r.at + 1) % r.list.length
190
191
  log('rotate', `${why} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
191
192
  this.#use(r.list[r.at])
192
193
  }
194
+ // Which model of the list to go on to after this one failed, for one request (tried: the places
195
+ // tried, down: the providers that answered with an error, 400 or above). A provider that answered
196
+ // with an error isn't asked again through its next model: the next one is from another provider.
197
+ // When every provider has failed, the models not tried yet, in order; when all have been tried,
198
+ // simply the next one (round again).
199
+ #pick(e, tried, down) {
200
+ const r = this.rotation
201
+ tried.add(r.at)
202
+ if (e?.status >= 400) down.add(r.list[r.at].provider)
203
+ const n = r.list.length
204
+ const after = (ok) => {
205
+ for (let k = 1; k <= n; k++) if (ok((r.at + k) % n)) return (r.at + k) % n
206
+ return -1
207
+ }
208
+ let i = after((i) => !tried.has(i) && !down.has(r.list[i].provider))
209
+ if (i < 0) i = after((i) => !tried.has(i))
210
+ if (i < 0) {
211
+ tried.clear() // all tried: round again
212
+ i = (r.at + 1) % n
213
+ }
214
+ return i
215
+ }
193
216
  // A new message: back to #1 (unless it stays on the one that worked)
194
217
  newMessage() {
195
218
  const r = this.rotation
@@ -203,11 +226,12 @@ export class Client {
203
226
  if (changed) this.onUse?.(t)
204
227
  }
205
228
  // After a failure: go on to the next model? (then it's tried at once)
206
- #rotate(e, tries) {
229
+ #rotate(e, tries, pick) {
207
230
  const r = this.rotation
208
231
  if (!r) return false
209
- if (!(r.on === 'any' || (r.on === 'failure' && (outage(e) || unusable(e))) || (r.on === 'retries' && (tries >= r.retries || unusable(e))))) return false
210
- r.at = (r.at + 1) % r.list.length
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
234
+ r.at = pick ? pick() : (r.at + 1) % r.list.length
211
235
  log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
212
236
  this.#use(r.list[r.at])
213
237
  return true
@@ -284,6 +308,8 @@ export class Client {
284
308
  // The server's own entries for its models ({ id, owned_by, context_length, pricing… }, as it sends them).
285
309
  async modelEntries(timeout = 10_000) {
286
310
  const data = await this.#json(this.baseUrl + '/models', undefined, timeout)
311
+ // { result: [...] } (Cloudflare's shape): there the id is an internal one and the name is the model's
312
+ if (Array.isArray(data.result)) return data.result.filter((m) => m?.name).map((m) => ({ ...m, id: m.name }))
287
313
  return (data.data ?? data.models ?? []).filter((m) => m && (m.id ?? m.name))
288
314
  }
289
315
 
@@ -376,29 +402,15 @@ export class Client {
376
402
  // 60s, or the server's Retry-After) until it works or the signal aborts. onRetry(error, ms,
377
403
  // attempt) reports each wait; onRetry(null) reports the recovery.
378
404
  async chat(body, opts = {}) {
379
- // "too large": never retried as is; the limit is learned and the caller sends less
380
- const tooLarge = (e) => {
381
- const limit = inputLimit(e, Math.round(estMessages(body.messages) * this.tokenRatio), this.knownGood)
382
- if (!limit) return false
383
- e.inputLimit = limit // this request is sent smaller
384
- // remembered only when the server said the number (a guess isn't); a per-minute cap apart
385
- const said = limitSaid(e)
386
- log('limit', `request too large; ${said ? `the server says ${said.tokens}${said.rate ? ' per minute' : ''}` : `no limit given, trying ~${limit}`}`)
387
- if (said) this.onInputLimit?.(said.tokens, { rate: said.rate })
388
- return true
389
- }
390
405
  if (!this.onRetry) {
391
- try {
392
- return await this.#timed(body, opts)
393
- } catch (e) {
394
- tooLarge(e)
395
- throw e
396
- }
406
+ return await this.#timed(body, opts)
397
407
  }
398
408
  let tries = 0 // of the model being tried now
399
409
  let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
400
410
  let hard = true // every failure in this round was one that waiting can't fix (see outage)
401
- 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
412
+ const tried = new Set() // places in the model list tried for this request
413
+ const down = new Set() // providers that answered it with an error (400 or above): their other models are skipped
402
414
  for (let attempt = 0; ; attempt++) {
403
415
  try {
404
416
  const r = await this.#timed(body, { ...opts, once: true })
@@ -408,30 +420,20 @@ export class Client {
408
420
  return r
409
421
  } catch (e) {
410
422
  if (e.name === 'AbortError' || opts.signal?.aborted) throw e
411
- if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
412
423
  this.onFail?.(e, { provider: this.provider, model: this.model }) // counted for orchestration
413
- if (this.retryIf && !this.retryIf(e, attempt)) throw e
414
- // A bad request (400…) with a list of models: the next one may take it (it's often what the
415
- // conversation holds from another model). Two more at most, then it stops.
416
- if (!outage(e) && !unusable(e) && this.rotation && this.rotation.on !== 'any' && refusedBy < Math.min(2, this.rotation.list.length - 1)) {
417
- refusedBy++
418
- this.#next(`refused (${e.status})`)
419
- this.onRetry(e, 0, attempt + 1, this.target)
420
- continue
421
- }
422
- // A bad request (400…): the same request fails the same way every time, so it isn't sent
423
- // again. It stops here, unless the model list moves on at any error (the settings page). A
424
- // model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
425
- // in the list; a whole round of those stops too, rather than going round.
426
- 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
427
428
  hard &&= !outage(e)
428
429
  tries++
429
- if (this.#rotate(e, tries)) {
430
+ if (this.#rotate(e, tries, () => this.#pick(e, tried, down))) {
430
431
  tries = 0
431
432
  // ponytail: a 1s pause after a whole round fails (no connection at all: not thousands of
432
433
  // requests a minute); the user chose "round again at once"
433
434
  if (++round >= this.rotation.list.length) {
434
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
435
437
  hard = true
436
438
  round = 0
437
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 fallbacks when one fails (also: no model given).
76
- // Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
77
- export const DEFAULT_MODEL = 'default'
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 !== DEFAULT_MODEL) Object.assign(cfg, { model, fixed: true })
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
- async query(question, opts = {}, tries = 0) {
229
- try {
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