heartcode 0.1.5 → 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 +33 -11
- package/bin/heartcode.js +83 -30
- package/package.json +27 -1
- package/prompts/agent.md +42 -32
- package/prompts/guided.md +6 -6
- package/src/acp.js +4 -4
- package/src/agent.js +232 -66
- package/src/client.js +165 -81
- package/src/context.js +2 -2
- package/src/director.js +8 -5
- package/src/edit.js +5 -6
- package/src/format.js +18 -9
- package/src/guided.js +27 -55
- package/src/headless.js +20 -12
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +8 -4
- package/src/settings.js +19 -2
- package/src/tools.js +149 -38
- package/src/usage.js +9 -4
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
|
|
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,
|
|
59
|
-
a single model (`-m <model>`) does
|
|
60
|
-
|
|
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
|
|---|---|
|
|
@@ -132,10 +141,13 @@ nothing is sent to the model to find out. A model models.dev doesn't know works
|
|
|
132
141
|
assumptions, and the user can set it up on the settings page. Models that can't call tools still
|
|
133
142
|
work, in a slower guided mode.
|
|
134
143
|
|
|
135
|
-
The model in use knows the user's other models
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
The model in use knows the user's other models (what each can do, how often it failed this
|
|
145
|
+
session) and works with them through its `agent` tool: it asks a model that can for what it
|
|
146
|
+
can't do itself (a model that can't see asks one that can about a screenshot: "which tiles show a
|
|
147
|
+
chair?"), several at once when there are many images or parts, and a helper that fails is handed to
|
|
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
|
|
150
|
+
`modelUsage`.
|
|
139
151
|
|
|
140
152
|
## Setting up a model
|
|
141
153
|
|
|
@@ -172,11 +184,21 @@ heart -p --input-format stream-json --output-format stream-json
|
|
|
172
184
|
```
|
|
173
185
|
|
|
174
186
|
Stdin: one message per line, `{"type":"user","message":{"role":"user","content":"…"}}`.
|
|
175
|
-
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).
|
|
176
189
|
Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP servers' tools,
|
|
177
190
|
`mcp_servers` says which are `connected`), then `assistant` (text, thinking, `tool_use`),
|
|
178
191
|
`user` (`tool_result`), and a `result` after each message. It runs until stdin closes.
|
|
179
192
|
|
|
193
|
+
A helper's steps (the `agent` tool: another of the user's models, or the same one, doing a job;
|
|
194
|
+
several can run at once) come as `assistant` and `user` lines too, as Claude Code sends a
|
|
195
|
+
subagent's: `parent_tool_use_id` is the id of the `agent` call they belong to (`null` for the main
|
|
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.
|
|
201
|
+
|
|
180
202
|
## Don't
|
|
181
203
|
|
|
182
204
|
- Don't run `heart` without `-p` from an agent: it's a full-screen app and waits for a person.
|
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
|
-
|
|
215
|
-
|
|
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,37 +243,73 @@ class App {
|
|
|
237
243
|
}
|
|
238
244
|
|
|
239
245
|
// The model, its probe and strategy for a provider and model (probed once, then cached).
|
|
240
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
257
|
-
cfg.force = false
|
|
258
|
-
|
|
259
|
-
|
|
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()
|
|
283
|
+
client.onUse = (t) => {
|
|
284
|
+
profile = loadProfile(client) || profileFrom(client, limitsOf(t.provider, client.baseUrl, t.model))
|
|
285
|
+
apply()
|
|
286
|
+
log('rotate', `${t.provider} · ${t.model}: ${strat.actions}${strat.vision ? ', images' : ''}, ~${strat.context} tokens`)
|
|
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
|
+
}
|
|
260
295
|
// What the window is, learned while working (context.js); kept in the profile for next time.
|
|
261
296
|
const learned = () => {
|
|
262
297
|
Object.assign(strat, (({ context, contextSure, ratio, reply }) => ({ context, contextSure, ratio, reply }))(strategy(profile, cfg)))
|
|
263
298
|
Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
|
|
264
|
-
|
|
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)`)
|
|
265
308
|
}
|
|
266
|
-
Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
|
|
267
309
|
client.onUsage = (used, estimate) => {
|
|
268
310
|
const was = profile.largestAccepted
|
|
269
311
|
const change = learnFromUsage(profile, used, estimate)
|
|
270
|
-
if (change === 'cut') ui.warn(`${model} silently cut a request to ~${profile.inputLimit} tokens: that's its window here; keeping the conversation within it`)
|
|
312
|
+
if (change === 'cut') ui.warn(`${client.model} silently cut a request to ~${profile.inputLimit} tokens: that's its window here; keeping the conversation within it`)
|
|
271
313
|
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 })
|
|
272
314
|
if (change) learned()
|
|
273
315
|
}
|
|
@@ -277,12 +319,13 @@ class App {
|
|
|
277
319
|
if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
|
|
278
320
|
else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
|
|
279
321
|
learned()
|
|
280
|
-
ui.warn(rate ? `${provider} takes at most ~${n} tokens a minute on your account; shortening the conversation to fit (for today)` : `${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`)
|
|
281
323
|
}
|
|
282
324
|
// models.dev's published size for the model (a day old at most): used when the server doesn't say
|
|
283
325
|
loadPrices().then(() => {
|
|
326
|
+
price = priceOf(client.provider, client.baseUrl, client.model)
|
|
284
327
|
const known = limitsOf(provider, client.baseUrl, model) ?? undefined
|
|
285
|
-
if (JSON.stringify(known) === JSON.stringify(profile.known)) return
|
|
328
|
+
if (profile.model !== model || JSON.stringify(known) === JSON.stringify(profile.known)) return // (a switched-to model: its own)
|
|
286
329
|
profile.known = known
|
|
287
330
|
if (known && profile.source === 'assumed') profile.source = 'models.dev' // it's listed now
|
|
288
331
|
learned()
|
|
@@ -509,7 +552,7 @@ class App {
|
|
|
509
552
|
modelChoices(typed, start, end) {
|
|
510
553
|
const mine = this.myModels()
|
|
511
554
|
const twice = (m) => mine.filter((t) => t.model === m).length > 1
|
|
512
|
-
const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc:
|
|
555
|
+
const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: this.modelDesc(t, i), start, end }))
|
|
513
556
|
return rank(items, typed).slice(0, 200)
|
|
514
557
|
}
|
|
515
558
|
|
|
@@ -593,7 +636,8 @@ class App {
|
|
|
593
636
|
Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
|
|
594
637
|
Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
|
|
595
638
|
this.s.team = () => this.team() // your other models: a job this one can't do goes to one of them
|
|
596
|
-
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
|
|
597
641
|
if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
|
|
598
642
|
if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
|
|
599
643
|
if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
|
|
@@ -637,6 +681,11 @@ class App {
|
|
|
637
681
|
return { tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context, guessed: p.source === 'assumed' && !p.set }
|
|
638
682
|
}
|
|
639
683
|
|
|
684
|
+
// A row of the model lists (the picker, the /select_model suggestions): "openrouter · #1 · in use · tools · 1M".
|
|
685
|
+
modelDesc(t, i) {
|
|
686
|
+
return `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''} · ${this.canText(t)}`
|
|
687
|
+
}
|
|
688
|
+
|
|
640
689
|
// "tools · images · thinking · 1M", for the model lists.
|
|
641
690
|
canText(t) {
|
|
642
691
|
const a = this.abilities(t)
|
|
@@ -668,7 +717,7 @@ class App {
|
|
|
668
717
|
if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
|
|
669
718
|
return ui.print(NO_MODELS)
|
|
670
719
|
}
|
|
671
|
-
const items = mine.map((t, i) => ({ label: t.model,
|
|
720
|
+
const items = mine.map((t, i) => ({ label: t.model, t, current: i === 0, desc: this.modelDesc(t, i) }))
|
|
672
721
|
const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
|
|
673
722
|
if (it && !it.current) await this.useMine(it.t)
|
|
674
723
|
}
|
|
@@ -989,7 +1038,7 @@ function copyText(text) {
|
|
|
989
1038
|
// A model's profile: the saved one; else what models.dev says (nothing is sent to the model to
|
|
990
1039
|
// find out); a model it doesn't know: plain assumptions, until you set it up on the settings page.
|
|
991
1040
|
// force (/test_model, heart probe): test it now instead, with real requests.
|
|
992
|
-
async function getProfile(client, ui, force, signal) {
|
|
1041
|
+
async function getProfile(client, ui, force, signal, quiet = false) {
|
|
993
1042
|
let p = !force && loadProfile(client)
|
|
994
1043
|
if (!p && !force) {
|
|
995
1044
|
await loadPrices()
|
|
@@ -1004,7 +1053,7 @@ async function getProfile(client, ui, force, signal) {
|
|
|
1004
1053
|
if (known) writeJson(profilePath(client), (p = { ...p, known, source: 'models.dev' }))
|
|
1005
1054
|
}
|
|
1006
1055
|
if (p) {
|
|
1007
|
-
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`)
|
|
1008
1057
|
return p
|
|
1009
1058
|
}
|
|
1010
1059
|
ui.print(c.dim(`Testing ${client.model}…`))
|
|
@@ -1222,7 +1271,6 @@ async function slash(app, line) {
|
|
|
1222
1271
|
}
|
|
1223
1272
|
const s = await app.session()
|
|
1224
1273
|
if (!s) return
|
|
1225
|
-
const restrat = () => (s.strat = strategy(s.profile, cfg))
|
|
1226
1274
|
switch (cmd) {
|
|
1227
1275
|
case 'compact': {
|
|
1228
1276
|
const done = await s.compact({ signal: app.signal, focus: args.join(' ') })
|
|
@@ -1232,11 +1280,13 @@ async function slash(app, line) {
|
|
|
1232
1280
|
}
|
|
1233
1281
|
case 'model_info':
|
|
1234
1282
|
return ui.print(`${c.cyan(s.client.model)}\n${describe(s.profile, s.strat)}`)
|
|
1235
|
-
case 'test_model':
|
|
1236
|
-
|
|
1237
|
-
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
|
|
1238
1285
|
s.reset()
|
|
1239
|
-
|
|
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
|
+
}
|
|
1240
1290
|
}
|
|
1241
1291
|
}
|
|
1242
1292
|
|
|
@@ -1377,7 +1427,9 @@ async function main() {
|
|
|
1377
1427
|
const argv = process.argv.slice(2)
|
|
1378
1428
|
const print = argv.includes('-p') || argv.includes('--print') // headless: an id after --resume is always an id
|
|
1379
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
|
|
1380
|
-
|
|
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'
|
|
1381
1433
|
const { values: f, positionals } = parseArgs({
|
|
1382
1434
|
args: argv,
|
|
1383
1435
|
allowPositionals: true,
|
|
@@ -1471,7 +1523,8 @@ async function main() {
|
|
|
1471
1523
|
if (f.acp) {
|
|
1472
1524
|
// each editor session gets its own App, for its folder and its MCP servers
|
|
1473
1525
|
cfg.maxRetries ??= 10
|
|
1474
|
-
|
|
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)
|
|
1475
1528
|
process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
|
|
1476
1529
|
}
|
|
1477
1530
|
const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
|
package/package.json
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heartcode",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"cli",
|
|
8
|
+
"coding-agent",
|
|
9
|
+
"ai-agent",
|
|
10
|
+
"llm",
|
|
11
|
+
"openai-compatible",
|
|
12
|
+
"openrouter",
|
|
13
|
+
"groq",
|
|
14
|
+
"ollama",
|
|
15
|
+
"lm-studio",
|
|
16
|
+
"gemini",
|
|
17
|
+
"mcp",
|
|
18
|
+
"terminal",
|
|
19
|
+
"developer-tools",
|
|
20
|
+
"claude-code-alternative"
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://github.com/p32929/HeartCode#readme",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/p32929/HeartCode.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/p32929/HeartCode/issues"
|
|
29
|
+
},
|
|
30
|
+
"author": "p32929",
|
|
5
31
|
"type": "module",
|
|
6
32
|
"bin": {
|
|
7
33
|
"heart": "bin/heart.js",
|
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.
|
|
@@ -175,17 +160,42 @@ Several agent calls in one reply run at the same time. Use that to split indepen
|
|
|
175
160
|
## tool-agent-model
|
|
176
161
|
|
|
177
162
|
<!-- The agent tool's "model", there when the user has other models. -->
|
|
178
|
-
|
|
163
|
+
Which of your models does the job (from "Your other models" in the system prompt, as "provider model"): one that can do what it needs. Left out: your own model, or with images and you can't see them, the first of yours that can.
|
|
179
164
|
|
|
180
165
|
## team
|
|
181
166
|
|
|
182
|
-
<!-- In the system prompt when the user set up other models. {{models}}: one per line with what each can do. {{you}}: what this model can do. -->
|
|
167
|
+
<!-- In the system prompt when the user set up other models. {{models}}: one per line with what each can do (and failures this session). {{you}}: what this model can do. -->
|
|
183
168
|
# Your other models
|
|
184
169
|
|
|
185
|
-
|
|
170
|
+
Besides you (tool calls, {{you}}), the user set up these models:
|
|
186
171
|
{{models}}
|
|
187
172
|
|
|
188
|
-
|
|
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
|
+
|
|
175
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
- 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
|
+
## you-see
|
|
184
|
+
|
|
185
|
+
<!-- In the system prompt of a model that can see images (models that can often say they can't). -->
|
|
186
|
+
You can see images: those in the user's messages, those tools return (a browser screenshot), and image files you open with read_file. To know what's on a page or in a picture, look at it yourself and act on what you see; never say you can't see or interpret images, and never ask for an image as text (base64).
|
|
187
|
+
|
|
188
|
+
## tool-agent-images
|
|
189
|
+
|
|
190
|
+
<!-- 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
|
+
|
|
193
|
+
## images-unseen
|
|
194
|
+
|
|
195
|
+
<!-- After a tool's result that had images (a browser screenshot) when this model can't see them. {{files}}: where they were saved. -->
|
|
196
|
+
(You can't see the image(s) this tool returned. Saved as:
|
|
197
|
+
{{files}}
|
|
198
|
+
To act on what's in them, ask a model that can see: the agent tool with these in "images" and a precise question about what you need to do next.)
|
|
189
199
|
|
|
190
200
|
## images-described
|
|
191
201
|
|
|
@@ -197,7 +207,7 @@ You can hand a job to one of them with the agent tool and its "model", and to se
|
|
|
197
207
|
## subagent
|
|
198
208
|
|
|
199
209
|
<!-- Added to a helper agent's system prompt. -->
|
|
200
|
-
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.
|
|
201
211
|
|
|
202
212
|
## plan-mode
|
|
203
213
|
|
|
@@ -286,10 +296,20 @@ The user added this while you were working:
|
|
|
286
296
|
|
|
287
297
|
Take it into account and carry on.
|
|
288
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
|
+
|
|
289
309
|
## tool-images
|
|
290
310
|
|
|
291
|
-
<!-- Comes right after tool results that produced images (screenshots), which can only be sent as a message. -->
|
|
292
|
-
(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}})
|
|
293
313
|
|
|
294
314
|
## memory
|
|
295
315
|
|
|
@@ -319,16 +339,6 @@ Tags in use: {{tags}}
|
|
|
319
339
|
{{#if notes}}Related notes:
|
|
320
340
|
{{notes}}{{/if}}
|
|
321
341
|
|
|
322
|
-
## memory-exchange
|
|
323
|
-
|
|
324
|
-
<!-- The exchange to look at for memories. {{task}}: what the user said. {{answer}}: the reply. -->
|
|
325
|
-
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.
|
|
326
|
-
|
|
327
|
-
<exchange>
|
|
328
|
-
User: {{task}}
|
|
329
|
-
Assistant: {{answer}}
|
|
330
|
-
</exchange>
|
|
331
|
-
|
|
332
342
|
## memory-tidy
|
|
333
343
|
|
|
334
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
|
|
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,
|
|
@@ -197,7 +197,7 @@ function events(ui, s, e) {
|
|
|
197
197
|
ui.update({
|
|
198
198
|
sessionUpdate: 'tool_call',
|
|
199
199
|
toolCallId: t.id,
|
|
200
|
-
title: clip(`${name}${what ? ` ${what}` : ''}`, 120),
|
|
200
|
+
title: clip(`${e.helper ? `${e.helper} › ` : ''}${name}${what ? ` ${what}` : ''}`, 120), // a helper's steps: named
|
|
201
201
|
kind: KIND[name] ?? 'other',
|
|
202
202
|
status: 'pending',
|
|
203
203
|
rawInput: args,
|
|
@@ -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 =
|
|
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
|
}
|