heartcode 0.1.7 → 0.1.9

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/SKILL.md CHANGED
@@ -55,7 +55,11 @@ reached has an `error` and is skipped; the command still exits `0`.
55
55
  a lost connection, or an error page from a proxy in front of the provider is waited out and tried
56
56
  again (or handed to the next model in the list), up to `--max-retries` (10). With the default
57
57
  list, a model that can't be used (a wrong key, no access to it, a name the server doesn't know:
58
- 401, 403, 404) also makes way for the next one, and so does one that keeps replying with nothing.
58
+ 401, 403, 404) also makes way for the next one, and so does one that keeps replying with nothing. When a
59
+ provider answers with an error (status 400 or above), the next model tried is from another
60
+ provider; that provider's other models are tried only after every provider has had a go.
61
+ A model that takes over with a smaller context window gets the conversation summarized first, if it
62
+ wouldn't fit.
59
63
  The run stops with that error and exit code `1` when every model in the list fails that way, or
60
64
  when a single model (`-m <model>`) does. A request a server refuses (400) is sent as it is to two
61
65
  more models of the list at most, then the run stops (unless the user set the fallback rule to "at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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/prompts/agent.md CHANGED
@@ -14,6 +14,7 @@ what the task needs. {{files}} and {{more}} are still filled in, empty, for olde
14
14
  come at the end: then this says less, so it doesn't contradict them. -->
15
15
  {{#if caller}}You are running in HeartCode, an agent in a terminal. Your role and your instructions are at the end of this prompt.{{else}}You are HeartCode, a coding assistant working in a terminal on the user's project.{{/if}}
16
16
  Use the tools to look at files, change them and run commands. Find what the task needs with list_files and search rather than guessing paths; read a file before editing it, and prefer small edits with edit_file.
17
+ Check your work by what it produced, not by the absence of an error: read back the file you changed, run the test, look at the output. When something is wrong, find out why before trying again; don't repeat the same attempt. A task that repeats over many items: do one first, check it, then the rest. Before you finish, compare the result with what was asked.
17
18
  {{#if caller}}Commands run in the folder {{root}}, and relative paths are relative to it.{{else}}Paths are relative to the project folder, and commands already run in it. The project folder is {{root}} (for file:// URLs in the browser); stay inside it: don't search the rest of the disk.
18
19
  When you are done, reply with a short summary of what you did (or the answer to the question) and stop calling tools.{{/if}}
19
20
 
@@ -170,13 +171,13 @@ Which of your models does the job (from "Your other models" in the system prompt
170
171
  Besides you (tool calls, {{you}}), the user set up these models:
171
172
  {{models}}
172
173
 
173
- Work with them through the agent tool: "model" picks one (as "provider model"; left out, a helper on your own model), "images" gives it images to look at (image files, saved screenshots, image URLs). Several agent calls in one reply run at the same time.
174
+ Work with them through the agent tool: "model" picks one, "images" gives it images to look at.
174
175
 
175
176
  - Never decline, guess or work around something because you can't do it (see an image, think it through, read more than fits your context): ask a model that can. Choose by what the job needs: the ability first; a faster one for a simple job, the strongest for a hard one; not one that keeps failing.
176
177
  - Ask for what you need in order to act, not for a description. Not "describe this screenshot" but "Which of these 9 tiles (numbered 1-9, left to right, top to bottom) show a chair? Reply with the numbers only." or "Where is the Submit button? Reply with its exact text and its position (top/middle/bottom, left/center/right)." Say what to answer and in what form, and nothing more.
177
178
  - Many images, or a job in independent parts: one agent call per image or part, all in the same reply (the same model several times, or different ones). Then put the answers together and decide the next step yourself.
178
179
  - A loop that needs the ability at every step (look, act, look again, until done) goes whole to a model that has it: the goal, how to tell it's done, what to report. Better than asking about each step.
179
- - A helper starts fresh: it knows only your prompt and images. Give it all it needs: the goal, what's known (paths, URLs, the page the browser is on, what was tried), what to reply. It has your tools, the same browser and MCP servers too: helpers that need the same MCP server (a browser) take turns (HeartCode makes the next one wait), so jobs that use it don't gain from running together; and never give two helpers the same files at once.
180
+ - Give a helper all it needs: the goal, what's known (paths, URLs, the page the browser is on, what was tried), what to reply. It has your tools, the same browser and MCP servers too: helpers that need the same MCP server (a browser) take turns (HeartCode makes the next one wait), so jobs that use it don't gain from running together; and never give two helpers the same files at once.
180
181
  - A helper that fails or replies with nothing is handed on to your next model that can do the job, on its own. An answer that doesn't settle it (wrong, unsure, part of it): ask again, a sharper question or another capable model, until it's done.
181
182
  - Trust a helper's answer to what you asked; question it only when it looks doubtful (it contradicts what you know, or it's vague) or acting on it can't be undone. Don't redo its work yourself.
182
183
 
@@ -188,7 +189,7 @@ You can see images: those in the user's messages, those tools return (a browser
188
189
  ## tool-agent-images
189
190
 
190
191
  <!-- The agent tool's "images", there when the user has other models. -->
191
- Images for the helper to look at: image file paths (in the project, saved screenshots, ones the user gave) or image URLs. The model must see images (leave "model" out to get one that can).
192
+ Images for the helper to look at: image file paths (in the project, saved screenshots, ones the user gave) or image URLs. The model must see images.
192
193
 
193
194
  ## images-unseen
194
195
 
@@ -324,7 +325,7 @@ Update a note rather than adding a duplicate, and link related notes with [[name
324
325
  ## tool-memory
325
326
 
326
327
  <!-- The memory tool's description. -->
327
- Memory notes. action "write": name, description (one line), type (user, project, feedback or reference), tags (1-3), scope ("project", the default, or "global" for the user's preferences in every project), content (markdown; link notes with [[name]]). "read": name. "search": query (words, or tag:name). "delete": name (and scope if the name is in both).
328
+ Memory notes. action "write": name, description (one line), type (user, project, feedback or reference), tags (1-3), scope ("project", the default, or "global"), content (markdown; link notes with [[name]]). "read": name. "search": query (words, or tag:name). "delete": name (and scope if the name is in both).
328
329
 
329
330
  ## memory-extract
330
331
 
package/src/agent.js CHANGED
@@ -695,9 +695,12 @@ async function runNative(s, task, images, signal) {
695
695
  // shorter conversation fits (a per-minute cap smaller than that), so not tried again
696
696
  const fixed = Math.round((est(JSON.stringify(tools)) + sizeOf(s.messages[0])) * (strat.ratio ?? 1))
697
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
698
  out.end()
700
- shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
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
+ }
701
704
  step--
702
705
  continue
703
706
  }
package/src/client.js CHANGED
@@ -184,12 +184,34 @@ export class Client {
184
184
  this.#next(why)
185
185
  return true
186
186
  }
187
- #next(why) {
187
+ #next(why, pick) {
188
188
  const r = this.rotation
189
- r.at = (r.at + 1) % r.list.length
189
+ r.at = pick ? pick() : (r.at + 1) % r.list.length
190
190
  log('rotate', `${why} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
191
191
  this.#use(r.list[r.at])
192
192
  }
193
+ // Which model of the list to go on to after this one failed, for one request (tried: the places
194
+ // tried, down: the providers that answered with an error, 400 or above). A provider that answered
195
+ // with an error isn't asked again through its next model: the next one is from another provider.
196
+ // When every provider has failed, the models not tried yet, in order; when all have been tried,
197
+ // simply the next one (round again).
198
+ #pick(e, tried, down) {
199
+ const r = this.rotation
200
+ tried.add(r.at)
201
+ if (e?.status >= 400) down.add(r.list[r.at].provider)
202
+ const n = r.list.length
203
+ const after = (ok) => {
204
+ for (let k = 1; k <= n; k++) if (ok((r.at + k) % n)) return (r.at + k) % n
205
+ return -1
206
+ }
207
+ let i = after((i) => !tried.has(i) && !down.has(r.list[i].provider))
208
+ if (i < 0) i = after((i) => !tried.has(i))
209
+ if (i < 0) {
210
+ tried.clear() // all tried: round again
211
+ i = (r.at + 1) % n
212
+ }
213
+ return i
214
+ }
193
215
  // A new message: back to #1 (unless it stays on the one that worked)
194
216
  newMessage() {
195
217
  const r = this.rotation
@@ -203,11 +225,11 @@ export class Client {
203
225
  if (changed) this.onUse?.(t)
204
226
  }
205
227
  // After a failure: go on to the next model? (then it's tried at once)
206
- #rotate(e, tries) {
228
+ #rotate(e, tries, pick) {
207
229
  const r = this.rotation
208
230
  if (!r) return false
209
231
  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
+ r.at = pick ? pick() : (r.at + 1) % r.list.length
211
233
  log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
212
234
  this.#use(r.list[r.at])
213
235
  return true
@@ -284,6 +306,8 @@ export class Client {
284
306
  // The server's own entries for its models ({ id, owned_by, context_length, pricing… }, as it sends them).
285
307
  async modelEntries(timeout = 10_000) {
286
308
  const data = await this.#json(this.baseUrl + '/models', undefined, timeout)
309
+ // { result: [...] } (Cloudflare's shape): there the id is an internal one and the name is the model's
310
+ if (Array.isArray(data.result)) return data.result.filter((m) => m?.name).map((m) => ({ ...m, id: m.name }))
287
311
  return (data.data ?? data.models ?? []).filter((m) => m && (m.id ?? m.name))
288
312
  }
289
313
 
@@ -399,7 +423,17 @@ export class Client {
399
423
  let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
400
424
  let hard = true // every failure in this round was one that waiting can't fix (see outage)
401
425
  let refusedBy = 0 // models that refused this request (a bad request), tried in turn
426
+ const tried = new Set() // places in the model list tried for this request
427
+ const down = new Set() // providers that answered it with an error (400 or above): their other models are skipped
428
+ const first = this.target
402
429
  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
+ }
403
437
  try {
404
438
  const r = await this.#timed(body, { ...opts, once: true })
405
439
  if (attempt) this.onRetry(null)
@@ -415,7 +449,7 @@ export class Client {
415
449
  // conversation holds from another model). Two more at most, then it stops.
416
450
  if (!outage(e) && !unusable(e) && this.rotation && this.rotation.on !== 'any' && refusedBy < Math.min(2, this.rotation.list.length - 1)) {
417
451
  refusedBy++
418
- this.#next(`refused (${e.status})`)
452
+ this.#next(`refused (${e.status})`, () => this.#pick(e, tried, down))
419
453
  this.onRetry(e, 0, attempt + 1, this.target)
420
454
  continue
421
455
  }
@@ -426,7 +460,7 @@ export class Client {
426
460
  if (!outage(e) && !(unusable(e) && this.rotation) && this.rotation?.on !== 'any') throw e
427
461
  hard &&= !outage(e)
428
462
  tries++
429
- if (this.#rotate(e, tries)) {
463
+ if (this.#rotate(e, tries, () => this.#pick(e, tried, down))) {
430
464
  tries = 0
431
465
  // ponytail: a 1s pause after a whole round fails (no connection at all: not thousands of
432
466
  // requests a minute); the user chose "round again at once"
package/src/config.js CHANGED
@@ -76,9 +76,12 @@ function fromFile() {
76
76
  // Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
77
77
  export const DEFAULT_MODEL = 'default'
78
78
 
79
+ // providers by name, A to Z: every list of them (settings page, `heart providers`, pickers) follows
80
+ export const byName = (providers) => Object.fromEntries(Object.entries(providers ?? {}).sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' })))
81
+
79
82
  export function loadConfig(flags = {}) {
80
83
  const file = fromFile()
81
- const cfg = { providers: file.providers, provider: file.provider, model: file.model, adhoc: null }
84
+ const cfg = { providers: byName(file.providers), provider: file.provider, model: file.model, adhoc: null }
82
85
  for (const k of SETTINGS) cfg[k] = flags[k] ?? file[k]
83
86
  if (cfg.context) cfg.context = Number(cfg.context)
84
87
 
@@ -100,7 +103,7 @@ export function loadConfig(flags = {}) {
100
103
  }
101
104
 
102
105
  export function saveConfig(cfg) {
103
- const providers = { ...cfg.providers }
106
+ const providers = byName(cfg.providers)
104
107
  if (cfg.adhoc) delete providers[cfg.adhoc]
105
108
  const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
106
109
  const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }