heartcode 0.1.6 → 0.1.7

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
@@ -51,15 +51,19 @@ reached has an `error` and is skipped; the command still exits `0`.
51
51
  | `default`, or no `-m` | the user's list: #1, and the next one in order when it fails (rate limits, outages…). Use this unless you need one particular model. |
52
52
  | a model from the list: `-m "<provider> <model>"` (`-m "groq openai/gpt-oss-120b"`), or `-m <model>` alone | that model alone: never another one, also when it fails (a rate limit or an outage is tried again, up to `--max-retries`) |
53
53
 
54
- **Failures:** a rate limit, a server error, a timeout or a lost connection is waited out and tried
54
+ **Failures:** a rate limit, a server error, a timeout, a reply that stops coming (nothing for 150s),
55
+ a lost connection, or an error page from a proxy in front of the provider is waited out and tried
55
56
  again (or handed to the next model in the list), up to `--max-retries` (10). With the default
56
57
  list, a model that can't be used (a wrong key, no access to it, a name the server doesn't know:
57
58
  401, 403, 404) also makes way for the next one, and so does one that keeps replying with nothing.
58
- The run stops with that error and exit code `1` when every model in the list fails that way, when
59
- a single model (`-m <model>`) does, or at a request the server refuses (400), which is never sent
60
- again (unless the user set the fallback rule to "at any error"). A provider that refuses a part
59
+ The run stops with that error and exit code `1` when every model in the list fails that way, or
60
+ when a single model (`-m <model>`) does. A request a server refuses (400) is sent as it is to two
61
+ more models of the list at most, then the run stops (unless the user set the fallback rule to "at
62
+ any error"). A provider that refuses a part
61
63
  of the request HeartCode can do without (the model's thinking sent back, keys in the tools'
62
- schemas) gets it again without that part.
64
+ schemas, images) gets it again without that part, and that is remembered for the model for a day.
65
+ Each request is fitted to the model it goes to: one that can't see gets a note in place of each
66
+ image, one that can't call tools gets the tool calls as text.
63
67
 
64
68
  `--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
65
69
 
@@ -74,6 +78,9 @@ heart -p --output-format json -C /path/to/project "fix the failing test in math.
74
78
 
75
79
  - `-C <folder>`: the project to work in (default: the current folder). A folder, not a file.
76
80
  - The prompt is the last argument, or stdin: `echo "explain src/agent.js" | heart -p`.
81
+ - An image goes with the task as `@path/to/shot.png` or a full path (`/Users/me/shot.png`) in the
82
+ prompt, or as an image block in stream-json. A file name only mentioned ("rename logo.png") isn't
83
+ attached. A model that can't see gets it looked at by one of the user's models that can.
77
84
  - Exit code: `0` it finished, `1` it failed (no model, the model kept failing, the server refused
78
85
  the request (wrong model name, bad key), `--max-turns` hit). The error is in `result` (JSON) or on stderr.
79
86
 
@@ -101,7 +108,9 @@ heart -p --continue "…" # or: the last session in this folder
101
108
  ## Keep it safe
102
109
 
103
110
  Headless never asks anyone. What the permission mode would ask about is declined, and the
104
- model is told why. An MCP tool its server marks read-only is never asked about.
111
+ model is told why. An MCP tool its server marks read-only is never asked about. The model can't
112
+ give itself a freer permission mode (its `manage` tool asks the user, so headless refuses it), and
113
+ can't switch the model you chose.
105
114
 
106
115
  | You want | Flags |
107
116
  |---|---|
@@ -136,7 +145,8 @@ The model in use knows the user's other models (what each can do, how often it f
136
145
  session) and works with them through its `agent` tool: it asks a model that can for what it
137
146
  can't do itself (a model that can't see asks one that can about a screenshot: "which tiles show a
138
147
  chair?"), several at once when there are many images or parts, and a helper that fails is handed to
139
- the next capable model until one does the job. Their tokens are in `usage`, and each model's in
148
+ the next capable model until one does the job. A helper takes 25 steps at most, then answers with
149
+ what it has. Their tokens are in `usage`, and each model's in
140
150
  `modelUsage`.
141
151
 
142
152
  ## Setting up a model
@@ -174,7 +184,8 @@ heart -p --input-format stream-json --output-format stream-json
174
184
  ```
175
185
 
176
186
  Stdin: one message per line, `{"type":"user","message":{"role":"user","content":"…"}}`.
177
- Stop the running one with `{"type":"control_request","request_id":"1","request":{"subtype":"interrupt"}}`.
187
+ Stop the running one with `{"type":"control_request","request_id":"1","request":{"subtype":"interrupt"}}`;
188
+ any other control request gets a `control_response` with `subtype: "error"` (not supported).
178
189
  Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP servers' tools,
179
190
  `mcp_servers` says which are `connected`), then `assistant` (text, thinking, `tool_use`),
180
191
  `user` (`tool_result`), and a `result` after each message. It runs until stdin closes.
@@ -182,8 +193,11 @@ Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP se
182
193
  A helper's steps (the `agent` tool: another of the user's models, or the same one, doing a job;
183
194
  several can run at once) come as `assistant` and `user` lines too, as Claude Code sends a
184
195
  subagent's: `parent_tool_use_id` is the id of the `agent` call they belong to (`null` for the main
185
- conversation's), and `message.model` says which model it is (`provider/model`). The helper's last
186
- reply is that `agent` call's `tool_result`.
196
+ conversation's). The helper's last reply is that `agent` call's `tool_result`.
197
+
198
+ Every `assistant` line's `message.model` is the model that wrote it (`provider/model`): after a
199
+ fallback, the one that took over, not the one the run started with (the `init` line's `model`).
200
+ `modelUsage` in the `result` has each model that answered.
187
201
 
188
202
  ## Don't
189
203
 
package/bin/heartcode.js CHANGED
@@ -4,7 +4,7 @@ import { parseArgs } from 'node:util'
4
4
  import { Client } from '../src/client.js'
5
5
  import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, DEFAULT_MODEL, VERSION } from '../src/config.js'
6
6
  import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
7
- import { learnFromUsage, RATE_CAP_MS } from '../src/context.js'
7
+ import { learnFromUsage, RATE_CAP_MS, 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'
@@ -160,6 +160,7 @@ class App {
160
160
  this.memory = new Memory(root) // .heartcode/memory: notes linked with [[name]]
161
161
  this.director = new Director(this) // the manage tool: skills, MCP servers and settings by asking
162
162
  this.later = [] // setting changes that wait until the current message is done
163
+ this.failures = {} // "provider model" → failed requests this session (engine: onFail)
163
164
  }
164
165
 
165
166
  newMcp() {
@@ -209,10 +210,13 @@ class App {
209
210
  const bad = (choices) => `error: ${name} must be ${choices}`
210
211
  const save = (msg) => (saveConfig(cfg), log('director', `setting ${name} = ${v}`), msg)
211
212
  switch (name) {
212
- case 'permissions':
213
+ case 'permissions': {
213
214
  if (!PERMISSIONS.some(([p]) => p === v)) return bad('all, commands, ask or plan')
214
- ui.setPermission(v)
215
- return `permissions: ${v}`
215
+ // allowing more than now: only if you say yes (the model can't give itself more on its own)
216
+ const free = (p) => ['plan', 'ask', 'commands', 'all'].indexOf(p)
217
+ if (free(v) <= free(ui.permission)) return (ui.setPermission(v), `permissions: ${v}`)
218
+ return ui.confirm(`The model wants to change permissions from "${ui.permission}" to "${v}". Allow?`, 'command').then((ok) => (ok === true ? (ui.setPermission(v), `permissions: ${v}`) : `The user didn't allow it; permissions stay "${ui.permission}".`))
219
+ }
216
220
  case 'effort':
217
221
  if (!ARGS.set_effort.some(([o]) => o === v)) return bad('default, low, medium or high')
218
222
  cfg.effort = v === 'default' ? undefined : v
@@ -228,6 +232,8 @@ class App {
228
232
  let [p, ...m] = v.split(/\s+/)
229
233
  if (!cfg.providers[p]) [p, m] = [cfg.provider, [v]]
230
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(', ')}`
235
+ // headless (-p) and editors (ACP): the program that started HeartCode chooses the model
236
+ if (this.ui instanceof HeadlessUI) return 'error: the model can\'t be switched here: the program that started HeartCode chooses it'
231
237
  this.later.push(() => this.use(p, m.join(' ')).catch((e) => ui.warn(e.message)))
232
238
  return `switching to ${p} · ${m.join(' ')} after this reply`
233
239
  }
@@ -237,41 +243,69 @@ class App {
237
243
  }
238
244
 
239
245
  // The model, its probe and strategy for a provider and model (probed once, then cached).
240
- async engine(provider, model, ui, signal) {
246
+ // quiet: for a helper (orchestration): nothing printed about the model, the settings left alone.
247
+ async engine(provider, model, ui, signal, { quiet = false } = {}) {
241
248
  const { cfg } = this
242
249
  const client = this.client(provider, model)
250
+ let price = null
243
251
  // tokens and cost, into the chat (also a helper's, the probe's, compaction's)
244
- let price = priceOf(provider, client.baseUrl, model)
245
- loadPrices().then(() => ((price = priceOf(provider, client.baseUrl, model)), ui.render?.()))
246
252
  client.price = () => price
247
253
  client.onSpend = (u) => addUsage((this.chat.usage ??= {}), u, price)
248
254
  client.onRetry = (err, ms, attempt, next) => ui.retrying(err, ms, attempt, next) // keep trying until Esc (also while probing)
249
- // the model list (settings page): another model answered; its price from now on
255
+ // failures per model this session: the main model sees them (Your other models) and helpers
256
+ // are tried in that order
257
+ client.onFail = (e, t) => {
258
+ if (e.name === 'AbortError') return
259
+ const k = `${t.provider} ${t.model}`
260
+ this.failures[k] = (this.failures[k] ?? 0) + 1
261
+ }
262
+ // the model list (settings page): another model answered
250
263
  client.onSwitch = (t) => {
251
- price = priceOf(t.provider, t.baseUrl, t.model)
252
264
  const i = client.rotation.list.indexOf(t)
253
265
  ui.print(c.dim(`↻ ${t.provider} · ${t.model} answered${i ? ` (#${i + 1} in your model list)` : ' (#1 again)'}`))
254
266
  }
255
267
  if (cfg.maxRetries !== undefined) client.retryIf = (e, attempt) => attempt < cfg.maxRetries // headless: then give up
256
- let profile = await getProfile(client, ui, cfg.force, signal)
257
- cfg.force = false
258
- if (strategy(profile, cfg).thinking) client.effort = cfg.effort
259
- const strat = strategy(profile, cfg)
260
- // Another model in the list is asked from now on: its own abilities (sees images? its window…)
261
- // and profile, not the first one's (whose profile was once saved under the next one's name).
268
+ let profile = await getProfile(client, ui, !quiet && cfg.force, signal, quiet)
269
+ if (!quiet) cfg.force = false
270
+ const strat = {}
271
+ // The model asked from now on (at first, and when the model list moves on): its own profile,
272
+ // abilities (sees images? calls tools? its window), thinking effort, price, and what its
273
+ // provider refuses. client.can: what the conversation is fitted to before each request.
274
+ const apply = () => {
275
+ for (const k of Object.keys(strat)) delete strat[k]
276
+ Object.assign(strat, strategy(profile, cfg))
277
+ Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0, can: strat, effort: strat.thinking ? cfg.effort : undefined })
278
+ for (const [q, at] of Object.entries(profile.quirks ?? {})) if (Date.now() - at < LIMIT_MS) client.quirks[q] = true
279
+ price = priceOf(client.provider, client.baseUrl, client.model)
280
+ if (this.s?.client === client) this.s.profile = profile // /model_info, the settings page
281
+ }
282
+ apply()
262
283
  client.onUse = (t) => {
263
284
  profile = loadProfile(client) || profileFrom(client, limitsOf(t.provider, client.baseUrl, t.model))
264
- Object.assign(strat, strategy(profile, cfg))
265
- Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
285
+ apply()
266
286
  log('rotate', `${t.provider} · ${t.model}: ${strat.actions}${strat.vision ? ', images' : ''}, ~${strat.context} tokens`)
267
287
  }
288
+ // Saved with what another connection to the same model (a helper on it) learned meanwhile: the
289
+ // provider's refusals of both are kept (each writes its whole profile).
290
+ const save = () => {
291
+ const disk = readJson(profilePath(client), null)
292
+ if (disk?.model === profile.model) profile.quirks = { ...disk.quirks, ...profile.quirks }
293
+ writeJson(profilePath(client), profile)
294
+ }
268
295
  // What the window is, learned while working (context.js); kept in the profile for next time.
269
296
  const learned = () => {
270
297
  Object.assign(strat, (({ context, contextSure, ratio, reply }) => ({ context, contextSure, ratio, reply }))(strategy(profile, cfg)))
271
298
  Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
272
- writeJson(profilePath(client), profile)
299
+ save()
300
+ }
301
+ // A part of the request this model's provider refused and the request worked without: kept for
302
+ // a day (images: the model is treated as not seeing them).
303
+ client.onLearn = (quirk) => {
304
+ profile.quirks = { ...profile.quirks, [quirk]: Date.now() }
305
+ if (quirk === 'noImages') strat.vision = false
306
+ save()
307
+ log('quirk', `${client.model}: ${quirk} (kept for a day)`)
273
308
  }
274
- Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
275
309
  client.onUsage = (used, estimate) => {
276
310
  const was = profile.largestAccepted
277
311
  const change = learnFromUsage(profile, used, estimate)
@@ -285,10 +319,11 @@ class App {
285
319
  if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
286
320
  else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
287
321
  learned()
288
- ui.warn(rate ? `${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`)
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`)
289
323
  }
290
324
  // models.dev's published size for the model (a day old at most): used when the server doesn't say
291
325
  loadPrices().then(() => {
326
+ price = priceOf(client.provider, client.baseUrl, client.model)
292
327
  const known = limitsOf(provider, client.baseUrl, model) ?? undefined
293
328
  if (profile.model !== model || JSON.stringify(known) === JSON.stringify(profile.known)) return // (a switched-to model: its own)
294
329
  profile.known = known
@@ -601,7 +636,8 @@ class App {
601
636
  Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
602
637
  Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
603
638
  this.s.team = () => this.team() // your other models: a job this one can't do goes to one of them
604
- this.s.engineFor = (t) => this.engine(t.provider, t.model, ui)
639
+ this.s.engineFor = (t) => this.engine(t.provider, t.model, ui, undefined, { quiet: true })
640
+ this.s.failures = this.failures
605
641
  if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
606
642
  if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
607
643
  if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
@@ -1002,7 +1038,7 @@ function copyText(text) {
1002
1038
  // A model's profile: the saved one; else what models.dev says (nothing is sent to the model to
1003
1039
  // find out); a model it doesn't know: plain assumptions, until you set it up on the settings page.
1004
1040
  // force (/test_model, heart probe): test it now instead, with real requests.
1005
- async function getProfile(client, ui, force, signal) {
1041
+ async function getProfile(client, ui, force, signal, quiet = false) {
1006
1042
  let p = !force && loadProfile(client)
1007
1043
  if (!p && !force) {
1008
1044
  await loadPrices()
@@ -1017,7 +1053,7 @@ async function getProfile(client, ui, force, signal) {
1017
1053
  if (known) writeJson(profilePath(client), (p = { ...p, known, source: 'models.dev' }))
1018
1054
  }
1019
1055
  if (p) {
1020
- if (p.source === 'assumed') ui.warn(`${client.model} isn't in models.dev: assuming it calls tools and streams, with a ~128k window. Set what it can do on /settings`)
1056
+ if (p.source === 'assumed' && !quiet) ui.warn(`${client.model} isn't in models.dev: assuming it calls tools and streams, with a ~128k window. Set what it can do on /settings`)
1021
1057
  return p
1022
1058
  }
1023
1059
  ui.print(c.dim(`Testing ${client.model}…`))
@@ -1235,7 +1271,6 @@ async function slash(app, line) {
1235
1271
  }
1236
1272
  const s = await app.session()
1237
1273
  if (!s) return
1238
- const restrat = () => (s.strat = strategy(s.profile, cfg))
1239
1274
  switch (cmd) {
1240
1275
  case 'compact': {
1241
1276
  const done = await s.compact({ signal: app.signal, focus: args.join(' ') })
@@ -1245,11 +1280,13 @@ async function slash(app, line) {
1245
1280
  }
1246
1281
  case 'model_info':
1247
1282
  return ui.print(`${c.cyan(s.client.model)}\n${describe(s.profile, s.strat)}`)
1248
- case 'test_model':
1249
- s.profile = await getProfile(s.client, ui, true, app.signal)
1250
- restrat()
1283
+ case 'test_model': {
1284
+ const tested = await getProfile(app.client(s.client.provider, s.client.model), ui, true, app.signal) // tested on a fresh connection (requests not fitted to what it was thought to do), and saved
1251
1285
  s.reset()
1252
- return ui.print(describe(s.profile, s.strat))
1286
+ app.s = null // built again from the saved profile (one profile and strategy, kept up to date as the model list moves on)
1287
+ await app.session()
1288
+ return ui.print(`${c.cyan(tested.model)}\n${describe(tested, strategy(tested, cfg))}`)
1289
+ }
1253
1290
  }
1254
1291
  }
1255
1292
 
@@ -1390,7 +1427,9 @@ async function main() {
1390
1427
  const argv = process.argv.slice(2)
1391
1428
  const print = argv.includes('-p') || argv.includes('--print') // headless: an id after --resume is always an id
1392
1429
  for (let i = 0; i < argv.length; i++) if (argv[i] === '-w' || argv[i] === '--worktree') argv[i] = '--worktree=' // a name only as --worktree=name
1393
- for (let i = 0; i < argv.length; i++) if ((argv[i] === '--resume' || argv[i] === '-r') && /^[\w.-]{6,}$/.test(argv[i + 1] ?? '') && (print || loadChat(argv[i + 1]))) argv[i] = '--resume-id'
1430
+ // an id: a saved session's, or (headless) shaped like one: HeartCode's (20261028091723-f44o) or a UUID
1431
+ const idShaped = (x) => /^\d{14}-[a-z0-9]+$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(x)
1432
+ for (let i = 0; i < argv.length; i++) if ((argv[i] === '--resume' || argv[i] === '-r') && /^[\w.-]{6,}$/.test(argv[i + 1] ?? '') && (loadChat(argv[i + 1]) || (print && idShaped(argv[i + 1])))) argv[i] = '--resume-id'
1394
1433
  const { values: f, positionals } = parseArgs({
1395
1434
  args: argv,
1396
1435
  allowPositionals: true,
@@ -1484,7 +1523,8 @@ async function main() {
1484
1523
  if (f.acp) {
1485
1524
  // each editor session gets its own App, for its folder and its MCP servers
1486
1525
  cfg.maxRetries ??= 10
1487
- const makeApp = (root, ui, mcp) => new App(mcp ? { ...cfg, mcp: { extra: [mcp] } } : cfg, ui, root)
1526
+ // 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)
1488
1528
  process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
1489
1529
  }
1490
1530
  const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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
@@ -107,21 +107,6 @@ Change an existing file: replace `search`, copied exactly from the file (includi
107
107
 
108
108
  Create a file, or overwrite one with its full content. With append: true, add to its end instead (to write a long file in parts).
109
109
 
110
- ## tool-apply_patch
111
-
112
- Change, add, delete or move files in one go, with a patch in `input`:
113
- *** Begin Patch
114
- *** Update File: path/to/file.js
115
- @@ a line just above the change (optional)
116
- context line
117
- -old line
118
- +new line
119
- *** Add File: new.js
120
- +its content
121
- *** Delete File: old.js
122
- *** End Patch
123
- (*** Move to: new/path after *** Update File: moves the file.) Give a few unchanged lines around each change so it matches one place.
124
-
125
110
  ## tool-run_command
126
111
 
127
112
  Run a shell command in the project folder; returns the exit code and output.
@@ -191,9 +176,9 @@ Work with them through the agent tool: "model" picks one (as "provider model"; l
191
176
  - 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.
192
177
  - 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.
193
178
  - 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.
194
- - 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: never two helpers on the same browser or the same files at once.
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.
195
180
  - 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.
196
- - Check what a helper reports before acting on it.
181
+ - 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.
197
182
 
198
183
  ## you-see
199
184
 
@@ -222,7 +207,7 @@ To act on what's in them, ask a model that can see: the agent tool with these in
222
207
  ## subagent
223
208
 
224
209
  <!-- Added to a helper agent's system prompt. -->
225
- You are a helper agent: the main agent gave you one job, and your final reply goes back to it (not to the user). Do the job, then reply with what it needs: findings with file paths and line numbers, what you changed, or what failed. Be complete but brief.
210
+ You are a helper agent: the main agent gave you one job, and your final reply goes back to it (not to the user). Do the job, then reply with what it needs: findings with file paths and line numbers, what you changed, or what failed. Be complete but brief. Do no more than the job needs: a question you can answer from what you know or were given gets its answer right away, without searching. You have at most 25 steps.
226
211
 
227
212
  ## plan-mode
228
213
 
@@ -311,10 +296,20 @@ The user added this while you were working:
311
296
 
312
297
  Take it into account and carry on.
313
298
 
299
+ ## garbled-call
300
+
301
+ <!-- Sent when the model's reply had a tool call written out as text (template markup like <tool_call> or <arg_key>) instead of made. -->
302
+ (Your last reply had a tool call written out as text, so nothing was done and it can't be shown as an answer. To use a tool, call it; if you're done, reply in plain words.)
303
+
304
+ ## repeating
305
+
306
+ <!-- Added to a tool's result when the model made exactly the same call {{n}} times in a row. At five, HeartCode stops. -->
307
+ (You've made exactly this call {{n}} times in a row now. Doing it again won't change anything: try another way, or say what's in the way.)
308
+
314
309
  ## tool-images
315
310
 
316
- <!-- Comes right after tool results that produced images (screenshots), which can only be sent as a message. -->
317
- (The images from the tool calls above.)
311
+ <!-- Comes right after tool results that produced images (screenshots), which can only be sent as a message. {{files}}: where they're saved. -->
312
+ (The images from the tool calls above: look at them here. They're also saved, only for giving them to a helper with the agent tool's images: {{files}})
318
313
 
319
314
  ## memory
320
315
 
@@ -344,16 +339,6 @@ Tags in use: {{tags}}
344
339
  {{#if notes}}Related notes:
345
340
  {{notes}}{{/if}}
346
341
 
347
- ## memory-exchange
348
-
349
- <!-- The exchange to look at for memories. {{task}}: what the user said. {{answer}}: the reply. -->
350
- Below is an exchange that already happened. Don't do what it asks and don't answer it: only save, with the memory tool, what in it is worth remembering, or reply NONE.
351
-
352
- <exchange>
353
- User: {{task}}
354
- Assistant: {{answer}}
355
- </exchange>
356
-
357
342
  ## memory-tidy
358
343
 
359
344
  <!-- /tidy_memory. {{notes}}: every note, with its scope. {{broken}}: links to notes that don't exist (may be empty). -->
package/prompts/guided.md CHANGED
@@ -61,7 +61,7 @@ Reply with only yes or no.
61
61
 
62
62
  ## intent
63
63
 
64
- <!-- Asked only when the wording of the task doesn't say whether it needs changes. -->
64
+ <!-- Asked at the start of each task: does it need changes? -->
65
65
  Does the TASK require changing existing code or creating files (not just answering a question)?
66
66
 
67
67
  ## pick-file
@@ -113,6 +113,11 @@ Here is the current content of {{file}}:
113
113
  Rewrite this file with the change needed for the TASK. Reply with the COMPLETE new file content and nothing else. Keep everything that should not change. Never use placeholders like "rest of code here".{{#if extra}}
114
114
  {{extra}}{{/if}}
115
115
 
116
+ ## meant-to-remove
117
+
118
+ <!-- Asked when an edit still removes definitions after a retry. {{names}}: what it removes. -->
119
+ Your edit removes {{names}}. Does the TASK ask for that to be removed or renamed?
120
+
116
121
  ## keep-names
117
122
 
118
123
  <!-- An edit deleted definitions the task didn't ask to remove. {{names}}: them. {{them}}: "it" or "them". -->
@@ -187,8 +192,3 @@ There are no files in the project.
187
192
  ## note-unknown-file
188
193
 
189
194
  Could not work out which file was meant.
190
-
191
- ## note-tests-are-right
192
-
193
- <!-- A weak model tried to edit a failing test instead of the code it tests. {{test}}, {{code}}: the files. -->
194
- The tests in {{test}} are correct; fix {{code}}, the code they test.
package/src/acp.js CHANGED
@@ -9,7 +9,7 @@ import readline from 'node:readline'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import path from 'node:path'
11
11
  import { log } from './log.js'
12
- import { clip } from './tools.js'
12
+ import { clip, runningCall, failed as toolFailed } from './tools.js'
13
13
  import { HeadlessUI } from './headless.js'
14
14
  import { PERMISSIONS } from './ui.js'
15
15
  import { loadChat } from './sessions.js'
@@ -36,7 +36,7 @@ class AcpUI extends HeadlessUI {
36
36
  }
37
37
  async confirm(question, kind = 'edit') {
38
38
  if ((this.permission === 'all' || (this.permission === 'commands' && kind === 'edit') || (this.permission === 'plan' && kind === 'read'))) return true
39
- const toolCallId = this.calls[0] ?? `ask_${Date.now()}`
39
+ const toolCallId = runningCall.getStore() ?? this.calls[0] ?? `ask_${Date.now()}` // the call asking
40
40
  const r = await this.conn
41
41
  .request('session/request_permission', {
42
42
  sessionId: this.sessionId,
@@ -206,7 +206,7 @@ function events(ui, s, e) {
206
206
  }
207
207
  } else if (e.type === 'tool') {
208
208
  ui.calls = ui.calls.filter((id) => id !== e.id)
209
- const failed = /^error/.test(e.output)
209
+ const failed = toolFailed(e.output)
210
210
  ui.update({ sessionUpdate: 'tool_call_update', toolCallId: e.id, status: failed ? 'failed' : 'completed', content: [{ type: 'content', content: { type: 'text', text: clip(plain(e.output), 4000) } }] })
211
211
  if (e.name === 'todo') ui.update({ sessionUpdate: 'plan', entries: (s.chat.todos ?? []).map((t) => ({ content: t.content, priority: 'medium', status: t.status })) })
212
212
  }