heartcode 0.1.8 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/SKILL.md +45 -30
- package/bin/heartcode.js +56 -79
- package/package.json +1 -1
- package/prompts/agent.md +5 -4
- package/src/acp.js +7 -1
- package/src/agent.js +20 -51
- package/src/client.js +42 -40
- package/src/config.js +17 -4
- package/src/guided.js +2 -8
- package/src/headless.js +3 -0
- package/src/settings.html +120 -71
- package/src/settings.js +1 -1
package/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,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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
|
|
691
|
-
res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
692
|
-
} catch (e) {
|
|
693
|
-
if (!e.inputLimit || shrink < 0.3) throw e
|
|
694
|
-
// what every request carries (the system prompt and the tools) is over the limit already: no
|
|
695
|
-
// shorter conversation fits (a per-minute cap smaller than that), so not tried again
|
|
696
|
-
const fixed = Math.round((est(JSON.stringify(tools)) + sizeOf(s.messages[0])) * (strat.ratio ?? 1))
|
|
697
|
-
if (fixed >= e.inputLimit) throw Object.assign(e, { message: `${e.message} (the system prompt and tools alone are ~${fixed} tokens: this can't fit)` })
|
|
698
|
-
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
if (
|
|
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
|
|
76
|
-
// Any other model given for a run (-m, HEART_MODEL, a URL of its own)
|
|
77
|
-
|
|
75
|
+
// The model name that means your list: #1, then the next when one fails (also: no model given;
|
|
76
|
+
// "default" is its old name). Any other model given for a run (-m, HEART_MODEL, a URL of its own)
|
|
77
|
+
// is that model alone: fixed (--fallback-model adds others after it).
|
|
78
|
+
export const AUTO_MODEL = 'auto'
|
|
79
|
+
export const isAuto = (m) => m === AUTO_MODEL || m === 'default'
|
|
80
|
+
|
|
81
|
+
// A model as given (-m, --fallback-model, /model): "provider/model" (as opencode writes it; split at
|
|
82
|
+
// the first "/", provider names have none), the older "provider model", or a model of `current`.
|
|
83
|
+
export function parseModel(v, providers, current) {
|
|
84
|
+
v = String(v).trim()
|
|
85
|
+
const spaced = /^(\S+)\s+(.+)$/.exec(v)
|
|
86
|
+
if (spaced && providers[spaced[1]]) return { provider: spaced[1], model: spaced[2] }
|
|
87
|
+
const i = v.indexOf('/')
|
|
88
|
+
if (i > 0 && providers[v.slice(0, i)]) return { provider: v.slice(0, i), model: v.slice(i + 1) }
|
|
89
|
+
return { provider: current, model: v }
|
|
90
|
+
}
|
|
78
91
|
|
|
79
92
|
// providers by name, A to Z: every list of them (settings page, `heart providers`, pickers) follows
|
|
80
93
|
export const byName = (providers) => Object.fromEntries(Object.entries(providers ?? {}).sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' })))
|
|
@@ -98,7 +111,7 @@ export function loadConfig(flags = {}) {
|
|
|
98
111
|
cfg.providers[cfg.provider].apiKey = apiKey
|
|
99
112
|
}
|
|
100
113
|
const model = flags.model || process.env.HEART_MODEL
|
|
101
|
-
if (model && model
|
|
114
|
+
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
|
|
102
115
|
return cfg
|
|
103
116
|
}
|
|
104
117
|
|
package/src/guided.js
CHANGED
|
@@ -225,14 +225,8 @@ class Guided {
|
|
|
225
225
|
|
|
226
226
|
// `answer: true` streams the reply to the user as the answer; otherwise only thinking is shown.
|
|
227
227
|
// room: tokens of context to keep free for the reply (only for fitting the context; never sent).
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return await this.ask1(question, opts, tries)
|
|
231
|
-
} catch (e) {
|
|
232
|
-
// too large for the model: strat.context has dropped to its limit, so the context shrinks
|
|
233
|
-
if (!e.inputLimit || tries >= 3) throw e
|
|
234
|
-
return this.query(question, opts, tries + 1)
|
|
235
|
-
}
|
|
228
|
+
query(question, opts = {}) {
|
|
229
|
+
return this.ask1(question, opts)
|
|
236
230
|
}
|
|
237
231
|
|
|
238
232
|
async ask1(question, { room = 32, schema, oneLine, onToken, answer, skipKey } = {}, tries = 0) {
|
package/src/headless.js
CHANGED
|
@@ -150,6 +150,7 @@ export async function runHeadless(app, opts) {
|
|
|
150
150
|
said = false
|
|
151
151
|
current = { ctl: new AbortController() }
|
|
152
152
|
app.signal = current.ctl.signal
|
|
153
|
+
app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
|
|
153
154
|
let answer = ''
|
|
154
155
|
let error = null
|
|
155
156
|
try {
|
|
@@ -162,6 +163,8 @@ export async function runHeadless(app, opts) {
|
|
|
162
163
|
log('error', error, e.stack)
|
|
163
164
|
} finally {
|
|
164
165
|
app.save()
|
|
166
|
+
app.working = false
|
|
167
|
+
app.afterLine?.()
|
|
165
168
|
}
|
|
166
169
|
const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
|
|
167
170
|
current = null
|