heartcode 0.1.5 → 0.1.6
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 +12 -4
- package/bin/heartcode.js +19 -6
- package/package.json +27 -1
- package/prompts/agent.md +29 -4
- package/src/acp.js +1 -1
- package/src/agent.js +84 -18
- package/src/client.js +3 -0
- package/src/headless.js +5 -4
- package/src/probe.js +2 -1
- package/src/tools.js +22 -8
package/SKILL.md
CHANGED
|
@@ -132,10 +132,12 @@ nothing is sent to the model to find out. A model models.dev doesn't know works
|
|
|
132
132
|
assumptions, and the user can set it up on the settings page. Models that can't call tools still
|
|
133
133
|
work, in a slower guided mode.
|
|
134
134
|
|
|
135
|
-
The model in use knows the user's other models
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
The model in use knows the user's other models (what each can do, how often it failed this
|
|
136
|
+
session) and works with them through its `agent` tool: it asks a model that can for what it
|
|
137
|
+
can't do itself (a model that can't see asks one that can about a screenshot: "which tiles show a
|
|
138
|
+
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
|
|
140
|
+
`modelUsage`.
|
|
139
141
|
|
|
140
142
|
## Setting up a model
|
|
141
143
|
|
|
@@ -177,6 +179,12 @@ Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP se
|
|
|
177
179
|
`mcp_servers` says which are `connected`), then `assistant` (text, thinking, `tool_use`),
|
|
178
180
|
`user` (`tool_result`), and a `result` after each message. It runs until stdin closes.
|
|
179
181
|
|
|
182
|
+
A helper's steps (the `agent` tool: another of the user's models, or the same one, doing a job;
|
|
183
|
+
several can run at once) come as `assistant` and `user` lines too, as Claude Code sends a
|
|
184
|
+
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`.
|
|
187
|
+
|
|
180
188
|
## Don't
|
|
181
189
|
|
|
182
190
|
- 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
|
@@ -253,10 +253,18 @@ class App {
|
|
|
253
253
|
ui.print(c.dim(`↻ ${t.provider} · ${t.model} answered${i ? ` (#${i + 1} in your model list)` : ' (#1 again)'}`))
|
|
254
254
|
}
|
|
255
255
|
if (cfg.maxRetries !== undefined) client.retryIf = (e, attempt) => attempt < cfg.maxRetries // headless: then give up
|
|
256
|
-
|
|
256
|
+
let profile = await getProfile(client, ui, cfg.force, signal)
|
|
257
257
|
cfg.force = false
|
|
258
258
|
if (strategy(profile, cfg).thinking) client.effort = cfg.effort
|
|
259
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).
|
|
262
|
+
client.onUse = (t) => {
|
|
263
|
+
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 })
|
|
266
|
+
log('rotate', `${t.provider} · ${t.model}: ${strat.actions}${strat.vision ? ', images' : ''}, ~${strat.context} tokens`)
|
|
267
|
+
}
|
|
260
268
|
// What the window is, learned while working (context.js); kept in the profile for next time.
|
|
261
269
|
const learned = () => {
|
|
262
270
|
Object.assign(strat, (({ context, contextSure, ratio, reply }) => ({ context, contextSure, ratio, reply }))(strategy(profile, cfg)))
|
|
@@ -267,7 +275,7 @@ class App {
|
|
|
267
275
|
client.onUsage = (used, estimate) => {
|
|
268
276
|
const was = profile.largestAccepted
|
|
269
277
|
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`)
|
|
278
|
+
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
279
|
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
280
|
if (change) learned()
|
|
273
281
|
}
|
|
@@ -277,12 +285,12 @@ class App {
|
|
|
277
285
|
if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
|
|
278
286
|
else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
|
|
279
287
|
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`)
|
|
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`)
|
|
281
289
|
}
|
|
282
290
|
// models.dev's published size for the model (a day old at most): used when the server doesn't say
|
|
283
291
|
loadPrices().then(() => {
|
|
284
292
|
const known = limitsOf(provider, client.baseUrl, model) ?? undefined
|
|
285
|
-
if (JSON.stringify(known) === JSON.stringify(profile.known)) return
|
|
293
|
+
if (profile.model !== model || JSON.stringify(known) === JSON.stringify(profile.known)) return // (a switched-to model: its own)
|
|
286
294
|
profile.known = known
|
|
287
295
|
if (known && profile.source === 'assumed') profile.source = 'models.dev' // it's listed now
|
|
288
296
|
learned()
|
|
@@ -509,7 +517,7 @@ class App {
|
|
|
509
517
|
modelChoices(typed, start, end) {
|
|
510
518
|
const mine = this.myModels()
|
|
511
519
|
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:
|
|
520
|
+
const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: this.modelDesc(t, i), start, end }))
|
|
513
521
|
return rank(items, typed).slice(0, 200)
|
|
514
522
|
}
|
|
515
523
|
|
|
@@ -637,6 +645,11 @@ class App {
|
|
|
637
645
|
return { tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context, guessed: p.source === 'assumed' && !p.set }
|
|
638
646
|
}
|
|
639
647
|
|
|
648
|
+
// A row of the model lists (the picker, the /select_model suggestions): "openrouter · #1 · in use · tools · 1M".
|
|
649
|
+
modelDesc(t, i) {
|
|
650
|
+
return `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''} · ${this.canText(t)}`
|
|
651
|
+
}
|
|
652
|
+
|
|
640
653
|
// "tools · images · thinking · 1M", for the model lists.
|
|
641
654
|
canText(t) {
|
|
642
655
|
const a = this.abilities(t)
|
|
@@ -668,7 +681,7 @@ class App {
|
|
|
668
681
|
if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
|
|
669
682
|
return ui.print(NO_MODELS)
|
|
670
683
|
}
|
|
671
|
-
const items = mine.map((t, i) => ({ label: t.model,
|
|
684
|
+
const items = mine.map((t, i) => ({ label: t.model, t, current: i === 0, desc: this.modelDesc(t, i) }))
|
|
672
685
|
const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
|
|
673
686
|
if (it && !it.current) await this.useMine(it.t)
|
|
674
687
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heartcode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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
|
@@ -175,17 +175,42 @@ Several agent calls in one reply run at the same time. Use that to split indepen
|
|
|
175
175
|
## tool-agent-model
|
|
176
176
|
|
|
177
177
|
<!-- The agent tool's "model", there when the user has other models. -->
|
|
178
|
-
|
|
178
|
+
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
179
|
|
|
180
180
|
## team
|
|
181
181
|
|
|
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. -->
|
|
182
|
+
<!-- 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
183
|
# Your other models
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
Besides you (tool calls, {{you}}), the user set up these models:
|
|
186
186
|
{{models}}
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
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.
|
|
189
|
+
|
|
190
|
+
- 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.
|
|
191
|
+
- 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
|
+
- 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
|
+
- 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.
|
|
195
|
+
- 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.
|
|
197
|
+
|
|
198
|
+
## you-see
|
|
199
|
+
|
|
200
|
+
<!-- In the system prompt of a model that can see images (models that can often say they can't). -->
|
|
201
|
+
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).
|
|
202
|
+
|
|
203
|
+
## tool-agent-images
|
|
204
|
+
|
|
205
|
+
<!-- The agent tool's "images", there when the user has other models. -->
|
|
206
|
+
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).
|
|
207
|
+
|
|
208
|
+
## images-unseen
|
|
209
|
+
|
|
210
|
+
<!-- After a tool's result that had images (a browser screenshot) when this model can't see them. {{files}}: where they were saved. -->
|
|
211
|
+
(You can't see the image(s) this tool returned. Saved as:
|
|
212
|
+
{{files}}
|
|
213
|
+
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
214
|
|
|
190
215
|
## images-described
|
|
191
216
|
|
package/src/acp.js
CHANGED
|
@@ -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,
|
package/src/agent.js
CHANGED
|
@@ -6,13 +6,38 @@ import { newChat } from './sessions.js'
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { est, Client } from './client.js'
|
|
8
8
|
import { runGuided } from './guided.js'
|
|
9
|
-
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput } from './tools.js'
|
|
9
|
+
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput, OUTPUTS } from './tools.js'
|
|
10
10
|
import { loadHooks, runHooks } from './hooks.js'
|
|
11
11
|
import { takeConfigErrors } from './config.js'
|
|
12
12
|
import { c } from './ui.js'
|
|
13
13
|
|
|
14
14
|
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
15
15
|
|
|
16
|
+
// Images for a helper (the agent tool's "images"), as data: URLs: image URLs (fetched), data: URLs,
|
|
17
|
+
// or files: in the project, saved screenshots (OUTPUTS), or a path the user gave in a message.
|
|
18
|
+
async function loadImages(list, s, signal) {
|
|
19
|
+
const out = []
|
|
20
|
+
for (const x of [].concat(list ?? []).map((v) => String(v).trim()).filter(Boolean)) {
|
|
21
|
+
if (x.startsWith('data:image/')) {
|
|
22
|
+
out.push(x)
|
|
23
|
+
} else if (/^https?:\/\//i.test(x)) {
|
|
24
|
+
const res = await fetch(x, { signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) })
|
|
25
|
+
const type = res.headers.get('content-type')?.split(';')[0].trim() ?? ''
|
|
26
|
+
if (!res.ok || !type.startsWith('image/')) throw new Error(`${x} is not an image (${res.status}${type ? `, ${type}` : ''})`)
|
|
27
|
+
out.push(`data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`)
|
|
28
|
+
} else {
|
|
29
|
+
const abs = path.resolve(s.root, x.replace(/^~(?=\/)/, os.homedir()))
|
|
30
|
+
const ext = path.extname(abs).slice(1).toLowerCase()
|
|
31
|
+
if (!MIME[ext]) throw new Error(`${x} is not an image file (png, jpg, gif, webp)`)
|
|
32
|
+
if (!fs.existsSync(abs)) throw new Error(`${x} does not exist`)
|
|
33
|
+
const inside = (dir) => abs.startsWith(dir + path.sep)
|
|
34
|
+
if (!inside(s.root) && !inside(OUTPUTS) && !s.toolImages?.has(abs) && !s.chat.turns.some((t) => t.task.includes(x) || t.task.includes(abs))) throw new Error(`${x} is outside the project; only images in it, saved screenshots and ones the user named can be sent`)
|
|
35
|
+
out.push(`data:${MIME[ext]};base64,${fs.readFileSync(abs).toString('base64')}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
// An image the message names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
|
|
17
42
|
// ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
|
|
18
43
|
const IMAGE_PATH = /(^|\s)@?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\2|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
|
|
@@ -157,27 +182,63 @@ export class Session {
|
|
|
157
182
|
// The agent tool: a helper with a fresh conversation and the same tools (but no helpers of its
|
|
158
183
|
// own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
|
|
159
184
|
// under the main conversation's, its file changes count for /rewind like the main agent's.
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
185
|
+
// model: one of your models (this one when left out); images: for it to look at (it must see).
|
|
186
|
+
// A helper that fails or replies with nothing: the job goes to the next of your models that can
|
|
187
|
+
// do it, until one does. id: the agent tool call's; the helper's steps go to onEvent (headless,
|
|
188
|
+
// ACP) as that call's.
|
|
189
|
+
async subagent(args, signal, id) {
|
|
163
190
|
const task = String(args.prompt ?? args.task ?? '').trim()
|
|
164
191
|
if (!task) return 'error: give the helper its job in "prompt"'
|
|
165
|
-
let
|
|
192
|
+
let images
|
|
193
|
+
try {
|
|
194
|
+
images = await loadImages(args.images, this, signal)
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (e.name === 'AbortError') throw e
|
|
197
|
+
return `error: ${e.message}`
|
|
198
|
+
}
|
|
199
|
+
const name = (t) => `${t.provider} ${t.model}`
|
|
200
|
+
const self = { provider: this.client.provider, model: this.client.model, images: this.strat.vision, self: true }
|
|
201
|
+
const all = [self, ...(this.team?.() ?? [])]
|
|
166
202
|
const want = String(args.model ?? '').trim()
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
203
|
+
const first = !want ? self : (all.find((t) => name(t) === want) ?? all.find((t) => t.model === want || want.endsWith(` ${t.model}`)))
|
|
204
|
+
if (!first) return `error: no model "${want}". Yours: ${all.map(name).join(', ')}`
|
|
205
|
+
const able = (t) => !images.length || t.images
|
|
206
|
+
const order = [first, ...all.filter((t) => t !== first)].filter(able)
|
|
207
|
+
if (!order.length) return 'error: none of your models can see images'
|
|
208
|
+
const failed = []
|
|
209
|
+
for (const [i, t] of order.entries()) {
|
|
210
|
+
let answer = ''
|
|
211
|
+
try {
|
|
212
|
+
answer = (await this.#helper(t, task, images, args.description, id, signal))?.trim()
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (e.name === 'AbortError' || signal?.aborted) throw e
|
|
215
|
+
failed.push(`${t.model}: ${clip(e.message, 150)}`)
|
|
216
|
+
}
|
|
217
|
+
if (answer) return t === first ? answer : `(${name(t)} did it${failed.length ? `; before it: ${failed.join('; ')}` : ''})\n\n${answer}`
|
|
218
|
+
if (failed.length < i + 1) failed.push(`${t.model}: no reply`)
|
|
219
|
+
this.failures = { ...this.failures, [name(t)]: (this.failures?.[name(t)] ?? 0) + 1 } // shown to the model (Your other models)
|
|
220
|
+
if (order[i + 1]) this.ui.warn(`${t.model} couldn't do it (${failed.at(-1).slice(t.model.length + 2)}); handing it to ${order[i + 1].model}`)
|
|
172
221
|
}
|
|
173
|
-
|
|
222
|
+
return `error: none of your models that can do it managed: ${failed.join('; ')}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// One helper on one model (t: this one, or another of yours).
|
|
226
|
+
async #helper(t, task, images, description, id, signal) {
|
|
227
|
+
let { client, profile, strat } = this
|
|
228
|
+
if (!t.self) {
|
|
229
|
+
;({ client, profile, strat } = await this.engineFor(t))
|
|
230
|
+
const was = client.retryIf
|
|
231
|
+
client.retryIf = (e, attempt) => attempt < 3 && (!was || was(e, attempt)) // then the next model gets the job
|
|
232
|
+
}
|
|
233
|
+
const label = clip(`${client.model}: ${String(description ?? '').trim() || task.split('\n')[0]}`, 60)
|
|
174
234
|
const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
|
|
175
235
|
Object.assign(helper, { isSub: true, mcp: this.mcp, skills: this.skills, hooks: this.hooks, allowTools: this.allowTools, denyTools: this.denyTools, maxTurns: this.maxTurns, autoCompact: this.autoCompact, appendSystem: this.appendSystem, fileCache: this.fileCache, noFormat: this.noFormat, noLsp: this.noLsp })
|
|
176
236
|
helper.track = (rel, before) => this.track(rel, before)
|
|
177
|
-
|
|
178
|
-
|
|
237
|
+
if (this.onEvent) helper.onEvent = (e) => this.onEvent({ ...e, parent: id, helper: label })
|
|
238
|
+
log('agent', `helper "${label}" (${images.length} image(s)): ${task}`)
|
|
239
|
+
const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, images, signal)
|
|
179
240
|
log('agent', `helper "${label}" done`, answer)
|
|
180
|
-
return answer
|
|
241
|
+
return answer
|
|
181
242
|
}
|
|
182
243
|
|
|
183
244
|
// Images for a model that can't see them: another of yours (seer) describes each, for the task.
|
|
@@ -309,6 +370,7 @@ const SYSTEM = (s) => {
|
|
|
309
370
|
prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
|
|
310
371
|
instructions && prompt('agent/instructions', { instructions }),
|
|
311
372
|
s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
|
|
373
|
+
s.strat.vision && prompt('agent/you-see'), // models that can see often say they can't
|
|
312
374
|
s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
|
|
313
375
|
s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
|
|
314
376
|
!s.isSub && teamPrompt(s),
|
|
@@ -324,7 +386,8 @@ const SYSTEM = (s) => {
|
|
|
324
386
|
const teamPrompt = (s) => {
|
|
325
387
|
const team = s.team?.() ?? []
|
|
326
388
|
if (!team.length) return ''
|
|
327
|
-
const
|
|
389
|
+
const fails = (t) => s.failures?.[`${t.provider} ${t.model}`] && `failed ${s.failures[`${t.provider} ${t.model}`]}× this session`
|
|
390
|
+
const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`, fails(t)].filter(Boolean).join(', ')
|
|
328
391
|
const you = [s.strat.vision ? 'images' : 'no images', s.strat.thinking && 'thinking'].filter(Boolean).join(', ')
|
|
329
392
|
return prompt('agent/team', { models: team.map((t) => `- ${t.provider} ${t.model}: ${can(t)}`).join('\n'), you })
|
|
330
393
|
}
|
|
@@ -542,6 +605,9 @@ async function runNative(s, task, images, signal) {
|
|
|
542
605
|
log('task', 'the user added while working:', added)
|
|
543
606
|
s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
|
|
544
607
|
}
|
|
608
|
+
// the model answering can change mid-message (your model list): the system prompt follows it
|
|
609
|
+
const now = SYSTEM(s)
|
|
610
|
+
if (s.messages[0]?.content !== now && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: now }
|
|
545
611
|
fitMessages(s.messages, budget())
|
|
546
612
|
pairTools(s.messages)
|
|
547
613
|
ui.spinner('thinking')
|
|
@@ -575,7 +641,7 @@ async function runNative(s, task, images, signal) {
|
|
|
575
641
|
res.content = ''
|
|
576
642
|
s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
|
|
577
643
|
}
|
|
578
|
-
s.onEvent?.({ type: 'assistant', content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
|
|
644
|
+
s.onEvent?.({ type: 'assistant', model: `${client.provider}/${client.model}`, content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
|
|
579
645
|
if (!res.toolCalls.length && !res.content?.trim()) {
|
|
580
646
|
// Nothing at all (seen after a failed tool call): not an answer. The same request again
|
|
581
647
|
// first (often fine the second time), then asked to go on; three in a row ends the task.
|
|
@@ -618,7 +684,7 @@ async function runNative(s, task, images, signal) {
|
|
|
618
684
|
const args = argsOf(call)
|
|
619
685
|
if (call.function.name !== 'agent' || !args || !allowed(s, 'agent') || res.toolCalls.filter((x) => x.function.name === 'agent').length < 2) continue
|
|
620
686
|
ui.step('agent', clip([args.model && `${args.model}:`, args.description].filter(Boolean).join(' '), 80))
|
|
621
|
-
together.set(call.id, runTool('agent', args, s, signal))
|
|
687
|
+
together.set(call.id, runTool('agent', args, s, signal, call.id))
|
|
622
688
|
}
|
|
623
689
|
for (const call of res.toolCalls) {
|
|
624
690
|
const name = call.function.name
|
|
@@ -637,7 +703,7 @@ async function runNative(s, task, images, signal) {
|
|
|
637
703
|
? ''
|
|
638
704
|
: (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
|
|
639
705
|
if (name !== 'run_command' && !together.has(call.id)) ui.step(name, clip(detail, 80))
|
|
640
|
-
const out = together.has(call.id) ? await together.get(call.id) : !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal) : 'error: the tool arguments were not valid JSON'
|
|
706
|
+
const out = together.has(call.id) ? await together.get(call.id) : !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal, call.id) : 'error: the tool arguments were not valid JSON'
|
|
641
707
|
if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
|
|
642
708
|
else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
|
|
643
709
|
else if (out.startsWith('error')) ui.result(clip(out, 120))
|
package/src/client.js
CHANGED
|
@@ -161,9 +161,12 @@ export class Client {
|
|
|
161
161
|
const r = this.rotation
|
|
162
162
|
if (r && r.next === 'first' && r.at) this.#use(r.list[(r.at = 0)])
|
|
163
163
|
}
|
|
164
|
+
// onUse(target): the model the next request goes to changed (its abilities and profile follow)
|
|
164
165
|
#use(t) {
|
|
166
|
+
const changed = this.model !== t.model || this.baseUrl !== normalizeBaseUrl(t.baseUrl)
|
|
165
167
|
Object.assign(this, { baseUrl: normalizeBaseUrl(t.baseUrl), apiKey: t.apiKey, model: t.model, provider: t.provider })
|
|
166
168
|
this.quirks = t.quirks ?? (t.quirks = {})
|
|
169
|
+
if (changed) this.onUse?.(t)
|
|
167
170
|
}
|
|
168
171
|
// After a failure: go on to the next model? (then it's tried at once)
|
|
169
172
|
#rotate(e, tries) {
|
package/src/headless.js
CHANGED
|
@@ -127,10 +127,11 @@ export async function runHeadless(app, opts) {
|
|
|
127
127
|
...(e.content ? [{ type: 'text', text: e.content }] : []),
|
|
128
128
|
...e.toolCalls.map((t) => ({ type: 'tool_use', id: t.id, name: t.function.name, input: parseArgs(t.function.arguments) })),
|
|
129
129
|
]
|
|
130
|
-
if (e.content) said = true
|
|
131
|
-
|
|
130
|
+
if (e.content && !e.parent) said = true
|
|
131
|
+
// a helper's (agent) steps too, as Claude Code sends a subagent's: parent_tool_use_id is its agent call
|
|
132
|
+
if (content.length) emit({ type: 'assistant', message: { role: 'assistant', model: e.parent ? e.model : model, content }, parent_tool_use_id: e.parent ?? null, session_id })
|
|
132
133
|
} else if (e.type === 'tool') {
|
|
133
|
-
emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, session_id })
|
|
134
|
+
emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, parent_tool_use_id: e.parent ?? null, session_id })
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
if (s.strat.actions === 'tools' && s.mcp?.names.length) await loadMcpTools(s) // their tools are in the list, as in Claude Code's
|
|
@@ -165,7 +166,7 @@ export async function runHeadless(app, opts) {
|
|
|
165
166
|
current = null
|
|
166
167
|
const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: 0, outputTokens: 0 }, ...byModel }, session_id }
|
|
167
168
|
if (out === 'stream-json') {
|
|
168
|
-
if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, session_id }) // guided mode
|
|
169
|
+
if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, parent_tool_use_id: null, session_id }) // guided mode
|
|
169
170
|
emit(result)
|
|
170
171
|
} else if (out === 'json') emit(result)
|
|
171
172
|
else if (error) process.stderr.write(`heartcode: ${error}\n`)
|
package/src/probe.js
CHANGED
|
@@ -21,7 +21,8 @@ export const profilePath = (client) => {
|
|
|
21
21
|
const PROBE_VERSION = 2
|
|
22
22
|
export const loadProfile = (client) => {
|
|
23
23
|
const p = readJson(profilePath(client), null)
|
|
24
|
-
|
|
24
|
+
// another model's (a model list switch once saved the first model's profile under the next one's name)
|
|
25
|
+
return p?.version === PROBE_VERSION && (!p.model || p.model === client.model) ? p : null
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// --- tiny PNG encoder, for the vision probe ---
|
package/src/tools.js
CHANGED
|
@@ -23,12 +23,13 @@ export const tail = (s, n) => (s.length > n ? '…' + s.slice(-n) : s)
|
|
|
23
23
|
// Tool output too long for the conversation is saved here (one folder per chat), where read_file
|
|
24
24
|
// and search can read it although it's outside the project.
|
|
25
25
|
export const OUTPUTS = path.join(os.tmpdir(), 'heartcode-outputs')
|
|
26
|
-
|
|
26
|
+
// data: text, or an image's bytes (ext: png…)
|
|
27
|
+
export function saveOutput(chatId, name, data, ext = 'txt') {
|
|
27
28
|
try {
|
|
28
29
|
const dir = path.join(OUTPUTS, String(chatId ?? 'chat'))
|
|
29
30
|
fs.mkdirSync(dir, { recursive: true })
|
|
30
|
-
const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}
|
|
31
|
-
fs.writeFileSync(file,
|
|
31
|
+
const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}.${ext}`)
|
|
32
|
+
fs.writeFileSync(file, data)
|
|
32
33
|
return file
|
|
33
34
|
} catch (e) {
|
|
34
35
|
log('tool', `could not save the output: ${e.message}`)
|
|
@@ -487,7 +488,8 @@ export function extraTools(s) {
|
|
|
487
488
|
if (!s.isSub && s.subagent) {
|
|
488
489
|
const team = s.team?.() ?? []
|
|
489
490
|
const model = { type: 'string', description: prompt('agent/tool-agent-model') }
|
|
490
|
-
|
|
491
|
+
const images = { type: 'array', items: str, description: prompt('agent/tool-agent-images') }
|
|
492
|
+
out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model, images }) }, ['description', 'prompt']) })
|
|
491
493
|
}
|
|
492
494
|
return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
|
|
493
495
|
}
|
|
@@ -628,10 +630,21 @@ async function callMcp(s, server, tool, a, signal) {
|
|
|
628
630
|
const parts = r.content ?? []
|
|
629
631
|
const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
|
|
630
632
|
const images = parts.filter((p) => p.type === 'image' && p.data)
|
|
633
|
+
// image files the tool says it saved ("Saved screenshot to /…/shot.webp"): as if returned
|
|
634
|
+
const saved = [...new Set([...text.matchAll(/(?:^|[\s"'`(])((?:\/|~\/)[^\s"'`)]+\.(?:png|jpe?g|gif|webp))\b/gi)].map((m) => m[1].replace(/^~(?=\/)/, os.homedir())))].filter((f) => fs.existsSync(f))
|
|
635
|
+
if (saved.length) {
|
|
636
|
+
s.toolImages ??= new Set()
|
|
637
|
+
for (const f of saved) s.toolImages.add(f) // may be sent to a helper (agent images)
|
|
638
|
+
if (s.strat?.vision) images.push(...saved.map((f) => ({ data: fs.readFileSync(f).toString('base64'), mimeType: `image/${path.extname(f).slice(1).toLowerCase().replace('jpg', 'jpeg')}`, file: f })))
|
|
639
|
+
}
|
|
631
640
|
let note = ''
|
|
632
641
|
if (images.length && s.strat?.vision) {
|
|
633
642
|
;(s.pendingImages ??= []).push(...images.map((p) => `data:${p.mimeType ?? 'image/png'};base64,${p.data}`))
|
|
634
643
|
note = `\n(${images.length} image(s) attached in the next message)`
|
|
644
|
+
} else if ((images.length || saved.length) && s.team?.().some((t) => t.images)) {
|
|
645
|
+
// saved, for a model that can see to be asked about (the agent tool's images)
|
|
646
|
+
const files = [...images.map((p, i) => saveOutput(s.chat?.id, `${tool}-${i + 1}`, Buffer.from(p.data, 'base64'), (p.mimeType ?? 'image/png').split('/')[1].replace('jpeg', 'jpg'))).filter(Boolean), ...saved]
|
|
647
|
+
note = `\n${prompt('agent/images-unseen', { files: files.join('\n') })}`
|
|
635
648
|
} else if (images.length) note = `\n(${images.length} image(s) not shown: this model can't see images)`
|
|
636
649
|
return `${r.isError ? 'error: ' : ''}${text || '(no text)'}${note}`
|
|
637
650
|
}
|
|
@@ -782,11 +795,12 @@ async function applyPatch(s, text) {
|
|
|
782
795
|
}
|
|
783
796
|
|
|
784
797
|
// Every tool call goes through the user's PreToolUse and PostToolUse hooks (hooks.js).
|
|
785
|
-
|
|
798
|
+
// id: the tool call's (a helper's steps are sent as its, for other apps to show under it)
|
|
799
|
+
export async function runTool(name, args, s, signal, id) {
|
|
786
800
|
log('tool', name, args)
|
|
787
801
|
const pre = await runHooks(s, 'PreToolUse', { tool_name: name, tool_input: args }, signal)
|
|
788
802
|
if (pre.block) return prompt('agent/hook-blocked-tool', { reason: pre.block })
|
|
789
|
-
let out = await tool(name, args, s, signal)
|
|
803
|
+
let out = await tool(name, args, s, signal, id)
|
|
790
804
|
const post = await runHooks(s, 'PostToolUse', { tool_name: name, tool_input: args, tool_response: out }, signal)
|
|
791
805
|
if (post.block) out += `\n\n${prompt('agent/hook-after-tool', { reason: post.block })}`
|
|
792
806
|
log('tool', `${name} result:`, clip(out, 4000))
|
|
@@ -822,7 +836,7 @@ export function allowed(s, name) {
|
|
|
822
836
|
return names.some((n) => s.allowTools.has(n)) || (name === 'mcp' && [...s.allowTools].some((n) => n.startsWith('mcp__')))
|
|
823
837
|
}
|
|
824
838
|
|
|
825
|
-
async function tool(name, args, s, signal) {
|
|
839
|
+
async function tool(name, args, s, signal, id) {
|
|
826
840
|
if (!name.startsWith('mcp__') && !mcpTarget(s, name)) args = normalize(args) // an MCP tool's arguments are its own
|
|
827
841
|
try {
|
|
828
842
|
const { root } = s
|
|
@@ -865,7 +879,7 @@ async function tool(name, args, s, signal) {
|
|
|
865
879
|
case 'web_fetch':
|
|
866
880
|
return await webTool(s, name, args, signal)
|
|
867
881
|
case 'agent':
|
|
868
|
-
return await s.subagent(args, signal)
|
|
882
|
+
return await s.subagent(args, signal, id)
|
|
869
883
|
case 'ask_user':
|
|
870
884
|
return await askUser(s, args)
|
|
871
885
|
case 'skill': {
|