heartcode 0.1.0 → 0.1.3
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/README.md +4 -16
- package/SKILL.md +24 -10
- package/bin/heart.js +2 -1
- package/bin/heartcode.js +25 -3
- package/package.json +1 -1
- package/prompts/agent.md +54 -4
- package/prompts/guided.md +1 -0
- package/src/agent.js +115 -21
- package/src/client.js +96 -12
- package/src/config.js +4 -0
- package/src/guided.js +13 -2
- package/src/headless.js +27 -11
- package/src/log.js +37 -3
- package/src/mcp.js +49 -3
- package/src/prompts.js +2 -1
- package/src/settings.html +144 -32
- package/src/settings.js +31 -1
- package/src/tools.js +126 -15
- package/src/ui.js +4 -3
- package/src/usage.js +16 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ behind your back: only Node.js (20 or newer) and HeartCode's own code. What you
|
|
|
7
7
|
what you can read.
|
|
8
8
|
|
|
9
9
|
<!-- image: HeartCode in the terminal, answering a message -->
|
|
10
|
-

|
|
11
11
|
|
|
12
12
|
## Why I made it
|
|
13
13
|
|
|
@@ -59,25 +59,19 @@ cd ~/code/my-app
|
|
|
59
59
|
heart
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
<!-- image: HeartCode's first screen -->
|
|
63
|
-

|
|
64
|
-
|
|
65
62
|
### 3. Open the settings page
|
|
66
63
|
|
|
67
64
|
Type `/settings`. The settings page opens in your browser. It runs on your machine only
|
|
68
65
|
(127.0.0.1, with a secret key in its link).
|
|
69
66
|
|
|
70
67
|
<!-- image: the settings page -->
|
|
71
|
-

|
|
72
69
|
|
|
73
70
|
### 4. Providers & models
|
|
74
71
|
|
|
75
72
|
1. **Add a provider**: its name, base URL and API key. Pick one of the presets to fill in the URL.
|
|
76
73
|
2. **Choose the models to use**: click a model to add it to your list. I use the free models.
|
|
77
74
|
|
|
78
|
-
<!-- image: Providers & models, with a provider added and models chosen -->
|
|
79
|
-

|
|
80
|
-
|
|
81
75
|
### 5. Your models
|
|
82
76
|
|
|
83
77
|
1. **Sort them by context and abilities.** Click the *Abilities* column, then *Context*: the
|
|
@@ -90,16 +84,13 @@ If a model says *not found* in models.dev, **Retry** looks it up again, or **Set
|
|
|
90
84
|
HeartCode what it can do.
|
|
91
85
|
|
|
92
86
|
<!-- image: Your models, sorted -->
|
|
93
|
-

|
|
94
88
|
|
|
95
89
|
### 6. Turn on logging (optional)
|
|
96
90
|
|
|
97
91
|
On the **Logging** page: a log of each run, and each request with its reply, in files of their
|
|
98
92
|
own. Logs older than 30 days are deleted (you can change that).
|
|
99
93
|
|
|
100
|
-
<!-- image: the Logging page -->
|
|
101
|
-

|
|
102
|
-
|
|
103
94
|
### 7. Back to HeartCode
|
|
104
95
|
|
|
105
96
|
Go back to the terminal. While the settings page is on, HeartCode shows a dialog and waits.
|
|
@@ -110,15 +101,12 @@ Your changes are already in.
|
|
|
110
101
|
**Turn off** is selected: press Enter. The page stops and HeartCode is yours again.
|
|
111
102
|
|
|
112
103
|
<!-- image: the "settings page is on" dialog -->
|
|
113
|
-

|
|
114
105
|
|
|
115
106
|
### 9. Send "Hi" as a test
|
|
116
107
|
|
|
117
108
|
Type `Hi` and press Enter. Wait for the reply: your model works.
|
|
118
109
|
|
|
119
|
-
<!-- image: the first reply -->
|
|
120
|
-

|
|
121
|
-
|
|
122
110
|
### 10. Start using it
|
|
123
111
|
|
|
124
112
|
Use HeartCode like any other coding agent: ask it to read, change, run and fix things in your
|
package/SKILL.md
CHANGED
|
@@ -52,10 +52,14 @@ reached has an `error` and is skipped; the command still exits `0`.
|
|
|
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
54
|
**Failures:** a rate limit, a server error, a timeout or a lost connection is waited out and tried
|
|
55
|
-
again (or handed to the next model in the list), up to `--max-retries` (10).
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
again (or handed to the next model in the list), up to `--max-retries` (10). With the default
|
|
56
|
+
list, a model that can't be used (a wrong key, no access to it, a name the server doesn't know:
|
|
57
|
+
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
|
|
61
|
+
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.
|
|
59
63
|
|
|
60
64
|
`--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
|
|
61
65
|
|
|
@@ -97,7 +101,7 @@ heart -p --continue "…" # or: the last session in this folder
|
|
|
97
101
|
## Keep it safe
|
|
98
102
|
|
|
99
103
|
Headless never asks anyone. What the permission mode would ask about is declined, and the
|
|
100
|
-
model is told why.
|
|
104
|
+
model is told why. An MCP tool its server marks read-only is never asked about.
|
|
101
105
|
|
|
102
106
|
| You want | Flags |
|
|
103
107
|
|---|---|
|
|
@@ -110,7 +114,9 @@ model is told why.
|
|
|
110
114
|
|
|
111
115
|
Tool names: HeartCode's own (`read_file`, `edit_file`, `write_file`, `run_command`, `search`,
|
|
112
116
|
`list_files`, `web_fetch`, `web_search`…) or Claude Code's (`Read`, `Edit`, `Write`, `Bash`,
|
|
113
|
-
`Grep`, `Glob`, `WebFetch`, `WebSearch`). `--disallowedTools Bash` takes one away.
|
|
117
|
+
`Grep`, `Glob`, `WebFetch`, `WebSearch`). `--disallowedTools Bash` takes one away. MCP servers'
|
|
118
|
+
tools are named as in Claude Code: `mcp__<server>__<tool>`; `mcp__<server>` means all of one
|
|
119
|
+
server's tools, `mcp` all of them.
|
|
114
120
|
|
|
115
121
|
## Choose the model
|
|
116
122
|
|
|
@@ -126,6 +132,11 @@ nothing is sent to the model to find out. A model models.dev doesn't know works
|
|
|
126
132
|
assumptions, and the user can set it up on the settings page. Models that can't call tools still
|
|
127
133
|
work, in a slower guided mode.
|
|
128
134
|
|
|
135
|
+
The model in use knows the user's other models and what each can do, and hands a job it can't do
|
|
136
|
+
(see an image, call tools…) to one that can: an attached image goes to a model that sees images, an
|
|
137
|
+
image's path to one through its `agent` tool, and a model without tool calls can hand the task to one
|
|
138
|
+
with them. Their tokens are in `usage`, and each model's in `modelUsage`.
|
|
139
|
+
|
|
129
140
|
## Setting up a model
|
|
130
141
|
|
|
131
142
|
Ask the user which provider and model to use, and for the key. Never invent one. Then:
|
|
@@ -139,14 +150,16 @@ HEART_API_KEY=… heart -u https://api.groq.com/openai/v1 -m openai/gpt-oss-120b
|
|
|
139
150
|
```
|
|
140
151
|
|
|
141
152
|
A model server on this machine (Ollama on :11434, LM Studio on :1234, llama.cpp on :8080) is
|
|
142
|
-
found on its own. The user can also set everything up in a browser: `heart settings
|
|
153
|
+
found on its own. The user can also set everything up in a browser: `heart settings`, where
|
|
154
|
+
**Test** (or **Test all**) under *Your models* sends a model a short message to show it really
|
|
155
|
+
answers. A run that fails with a wrong key or an unknown model: ask the user to test it there.
|
|
143
156
|
|
|
144
157
|
## More context for the task
|
|
145
158
|
|
|
146
159
|
| Flag | |
|
|
147
160
|
|---|---|
|
|
148
|
-
| `--append-system-prompt "<text>"` |
|
|
149
|
-
| `--mcp-config <file or JSON>` | more MCP servers (repeatable); `--strict-mcp-config`: only those |
|
|
161
|
+
| `--append-system-prompt "<text>"` | your instructions for this run, at the end of the system prompt. HeartCode's own part then says less (no role of its own, no "stay in the project folder", no way of finishing of its own) and tells the model to follow yours where they differ; its `manage` tool is left out, and nothing from the run's messages is saved to its memory |
|
|
162
|
+
| `--mcp-config <file or JSON>` | more MCP servers (repeatable); `--strict-mcp-config`: only those. Each server's tools are tools of their own for the model, `mcp__<server>__<tool>`, as in Claude Code, and what the server says about using them goes in the system prompt |
|
|
150
163
|
| `--skills-dir <folder>` | more skills (repeatable) |
|
|
151
164
|
| `--no-memory` | don't read or write HeartCode's memory notes |
|
|
152
165
|
|
|
@@ -160,7 +173,8 @@ heart -p --input-format stream-json --output-format stream-json
|
|
|
160
173
|
|
|
161
174
|
Stdin: one message per line, `{"type":"user","message":{"role":"user","content":"…"}}`.
|
|
162
175
|
Stop the running one with `{"type":"control_request","request_id":"1","request":{"subtype":"interrupt"}}`.
|
|
163
|
-
Stdout: one JSON event per line, `system`/`init
|
|
176
|
+
Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP servers' tools,
|
|
177
|
+
`mcp_servers` says which are `connected`), then `assistant` (text, thinking, `tool_use`),
|
|
164
178
|
`user` (`tool_result`), and a `result` after each message. It runs until stdin closes.
|
|
165
179
|
|
|
166
180
|
## Don't
|
package/bin/heart.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import os from 'node:os'
|
|
17
17
|
import path from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
18
19
|
|
|
19
20
|
const fail = (msg) => {
|
|
20
21
|
console.error(msg)
|
|
@@ -73,5 +74,5 @@ if (!process.env.HEART_LOG && !process.env.HEART_LOG_DIR && !args.some((a) => ['
|
|
|
73
74
|
process.env.HEART_LOG_RUN = logOn ? 'on' : 'off' // how it starts
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
process.argv = [process.argv[0], new URL('./heartcode.js', import.meta.url)
|
|
77
|
+
process.argv = [process.argv[0], fileURLToPath(new URL('./heartcode.js', import.meta.url)), ...args]
|
|
77
78
|
await import('./heartcode.js')
|
package/bin/heartcode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
import { Client } from '../src/client.js'
|
|
5
|
-
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, DEFAULT_MODEL } from '../src/config.js'
|
|
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
7
|
import { learnFromUsage, RATE_CAP_MS } from '../src/context.js'
|
|
8
8
|
import { Session, transcript } from '../src/agent.js'
|
|
@@ -28,7 +28,6 @@ import { Tabs } from '../src/tabs.js'
|
|
|
28
28
|
import { startSettings } from '../src/settings.js'
|
|
29
29
|
import { RESETS, reset, resetPaths, resetCode } from '../src/reset.js'
|
|
30
30
|
|
|
31
|
-
const VERSION = '0.1.0'
|
|
32
31
|
const LOCAL_SERVERS = ['http://localhost:11434/v1', 'http://localhost:1234/v1', 'http://localhost:8080/v1']
|
|
33
32
|
|
|
34
33
|
const HELP = `${c.magenta('♥')} ${c.bold('HeartCode')} — a coding CLI for any local or OpenAI-compatible model
|
|
@@ -453,6 +452,13 @@ class App {
|
|
|
453
452
|
return [...COMMANDS, ...mine]
|
|
454
453
|
}
|
|
455
454
|
|
|
455
|
+
// A command: "/name …" with the name of a built-in command or a skill you can type. Anything else
|
|
456
|
+
// starting with "/" (a path like /Users/me/shot.png) is a message for the model.
|
|
457
|
+
isCommand(line) {
|
|
458
|
+
const name = /^\/([\w-]+)(?=\s|$)/.exec(line)?.[1]
|
|
459
|
+
return Boolean(name) && (name === 'quit' || this.commands().some(([n]) => n === `/${name}`))
|
|
460
|
+
}
|
|
461
|
+
|
|
456
462
|
help() {
|
|
457
463
|
const mine = this.commands().slice(COMMANDS.length)
|
|
458
464
|
return helpText(mine.length ? [...SECTIONS, ['your skills', mine]] : SECTIONS)
|
|
@@ -586,6 +592,8 @@ class App {
|
|
|
586
592
|
this.useRotation()
|
|
587
593
|
Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
|
|
588
594
|
Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
|
|
595
|
+
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)
|
|
589
597
|
if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
|
|
590
598
|
if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
|
|
591
599
|
if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
|
|
@@ -612,6 +620,19 @@ class App {
|
|
|
612
620
|
await this.session()
|
|
613
621
|
}
|
|
614
622
|
|
|
623
|
+
// Your other models (not the one in use) and what each can do, from its saved profile or
|
|
624
|
+
// models.dev: nothing is sent to them to find out.
|
|
625
|
+
team() {
|
|
626
|
+
const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
|
|
627
|
+
return this.myModels()
|
|
628
|
+
.filter((t) => !(t.provider === now?.provider && t.model === now?.model))
|
|
629
|
+
.map((t) => {
|
|
630
|
+
const cl = this.client(t.provider, t.model)
|
|
631
|
+
const st = strategy(loadProfile(cl) || profileFrom(cl, limitsOf(t.provider, cl.baseUrl, t.model)))
|
|
632
|
+
return { ...t, tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context }
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
615
636
|
// Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
|
|
616
637
|
// provider's whole list is).
|
|
617
638
|
myModels() {
|
|
@@ -1236,6 +1257,7 @@ async function repl(app, f = {}) {
|
|
|
1236
1257
|
let ctl = null
|
|
1237
1258
|
ui.onEscape = () => ctl?.abort()
|
|
1238
1259
|
ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
|
|
1260
|
+
ui.isCommand = (l) => app.isCommand(l)
|
|
1239
1261
|
ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
|
|
1240
1262
|
// Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
|
|
1241
1263
|
// (a second Ctrl+C within 2s quits, also when something is stuck).
|
|
@@ -1304,7 +1326,7 @@ async function repl(app, f = {}) {
|
|
|
1304
1326
|
ctl = new AbortController()
|
|
1305
1327
|
app.signal = ctl.signal // Esc stops whatever this line started (a task, a probe, …)
|
|
1306
1328
|
ui.busy() // the spinner stays until this line's work is done
|
|
1307
|
-
if (
|
|
1329
|
+
if (app.isCommand(t)) {
|
|
1308
1330
|
try {
|
|
1309
1331
|
if ((await slash(app, t)) === 'exit') {
|
|
1310
1332
|
if (app.tabs) app.tabs.quitting = true
|
package/package.json
CHANGED
package/prompts/agent.md
CHANGED
|
@@ -9,11 +9,35 @@ its section into ~/.heart/prompts/agent.md (every project) or .heartcode/prompts
|
|
|
9
9
|
|
|
10
10
|
<!-- System prompt in tool mode (models with native tool calls).
|
|
11
11
|
{{root}}: the project folder's absolute path. (No list of the project's files: the model looks up
|
|
12
|
-
what the task needs. {{files}} and {{more}} are still filled in, empty, for older copies of this.)
|
|
13
|
-
|
|
12
|
+
what the task needs. {{files}} and {{more}} are still filled in, empty, for older copies of this.)
|
|
13
|
+
{{caller}}: a program started HeartCode with its own instructions (--append-system-prompt), which
|
|
14
|
+
come at the end: then this says less, so it doesn't contradict them. -->
|
|
15
|
+
{{#if caller}}You are running in HeartCode, an agent in a terminal. Your role and your instructions are at the end of this prompt.{{else}}You are HeartCode, a coding assistant working in a terminal on the user's project.{{/if}}
|
|
14
16
|
Use the tools to look at files, change them and run commands. Find what the task needs with list_files and search rather than guessing paths; read a file before editing it, and prefer small edits with edit_file.
|
|
15
|
-
Paths are relative to the project folder, and commands already run in it. The project folder is {{root}} (for file:// URLs in the browser); stay inside it: don't search the rest of the disk.
|
|
16
|
-
When you are done, reply with a short summary of what you did (or the answer to the question) and stop calling tools.
|
|
17
|
+
{{#if caller}}Commands run in the folder {{root}}, and relative paths are relative to it.{{else}}Paths are relative to the project folder, and commands already run in it. The project folder is {{root}} (for file:// URLs in the browser); stay inside it: don't search the rest of the disk.
|
|
18
|
+
When you are done, reply with a short summary of what you did (or the answer to the question) and stop calling tools.{{/if}}
|
|
19
|
+
|
|
20
|
+
## caller
|
|
21
|
+
|
|
22
|
+
<!-- Before the instructions a program gives HeartCode (--append-system-prompt), at the end of the system prompt. -->
|
|
23
|
+
The instructions below come from the program that started you. Where they differ from anything above (your role, where to work, paths, how to finish), follow them.
|
|
24
|
+
|
|
25
|
+
## mcp-instructions
|
|
26
|
+
|
|
27
|
+
<!-- In the system prompt: what the MCP servers say about using their tools (their "instructions"). {{instructions}}: "### server" and its text, for each server that has some. -->
|
|
28
|
+
How to use the MCP servers' tools, in their own words:
|
|
29
|
+
|
|
30
|
+
{{instructions}}
|
|
31
|
+
|
|
32
|
+
## output-cut
|
|
33
|
+
|
|
34
|
+
<!-- After the start of a tool result too long for the conversation. {{shown}}, {{total}}: characters; {{file}}: where all of it is (empty if it couldn't be saved). -->
|
|
35
|
+
[Output cut: the first {{shown}} of {{total}} characters are above.{{#if file}} All of it is in {{file}}: read the rest with read_file (start_line, end_line) or find what you need in it with search (path: that file).{{else}} Ask for less of it at a time.{{/if}}]
|
|
36
|
+
|
|
37
|
+
## output-old
|
|
38
|
+
|
|
39
|
+
<!-- Replaces an older tool result (all but the newest few long ones) when there are many. {{total}}: its length; {{file}}: where all of it is (may be empty). -->
|
|
40
|
+
[older output, cut to save room: it had {{total}} characters{{#if file}}; all of it is in {{file}}{{/if}}. If you need it, read that file or run the tool again: page snapshots are out of date by now anyway.]
|
|
17
41
|
|
|
18
42
|
## empty-reply
|
|
19
43
|
|
|
@@ -146,6 +170,28 @@ The user didn't answer. Decide yourself, carry on, and say in your reply what yo
|
|
|
146
170
|
<!-- Starts a helper agent (only in the main conversation, not in helpers). -->
|
|
147
171
|
Hand a side job to a helper agent with its own fresh context, so the details stay out of this conversation: searching or reading a lot of code to answer a question, researching on the web, reviewing changes. It has the same tools (no helpers of its own) and sees only your prompt, so say exactly what to do and what to report. Its final reply comes back as the result. description: 3-5 words for the user.
|
|
148
172
|
|
|
173
|
+
## tool-agent-model
|
|
174
|
+
|
|
175
|
+
<!-- The agent tool's "model", there when the user has other models. -->
|
|
176
|
+
Another of your models to do the job (from "Your other models" in the system prompt, as "provider model"), when it needs what you can't do. Leave out to do it with yourself.
|
|
177
|
+
|
|
178
|
+
## team
|
|
179
|
+
|
|
180
|
+
<!-- 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. -->
|
|
181
|
+
# Your other models
|
|
182
|
+
|
|
183
|
+
The user set up these models besides you (you: tool calls, {{you}}):
|
|
184
|
+
{{models}}
|
|
185
|
+
|
|
186
|
+
You can hand a job to one of them with the agent tool and its "model". Never decline a part of the task, or say you can't do it, because it needs something you can't do (see an image, call tools, think longer, read more than fits your context): give that part to a model here that can, with everything it needs in the prompt (file paths, what to look for, what to report), then go on with its answer. Answering yourself is better when you can.
|
|
187
|
+
|
|
188
|
+
## images-described
|
|
189
|
+
|
|
190
|
+
<!-- Added to the user's message when this model can't see images and another of theirs described them. {{model}}: that model. {{descriptions}}: what it saw. -->
|
|
191
|
+
(You can't see images, so {{model}} looked at the attached image(s) for you:
|
|
192
|
+
|
|
193
|
+
{{descriptions}})
|
|
194
|
+
|
|
149
195
|
## subagent
|
|
150
196
|
|
|
151
197
|
<!-- Added to a helper agent's system prompt. -->
|
|
@@ -274,8 +320,12 @@ Tags in use: {{tags}}
|
|
|
274
320
|
## memory-exchange
|
|
275
321
|
|
|
276
322
|
<!-- The exchange to look at for memories. {{task}}: what the user said. {{answer}}: the reply. -->
|
|
323
|
+
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.
|
|
324
|
+
|
|
325
|
+
<exchange>
|
|
277
326
|
User: {{task}}
|
|
278
327
|
Assistant: {{answer}}
|
|
328
|
+
</exchange>
|
|
279
329
|
|
|
280
330
|
## memory-tidy
|
|
281
331
|
|
package/prompts/guided.md
CHANGED
|
@@ -36,6 +36,7 @@ edit: change an existing file
|
|
|
36
36
|
create: make a new file
|
|
37
37
|
run: run a shell command (tests, build, scripts)
|
|
38
38
|
ask: ask the user a question
|
|
39
|
+
delegate: hand the task to another of your models, one that can use tools itself
|
|
39
40
|
finish: stop and give your final answer to the user
|
|
40
41
|
|
|
41
42
|
## next-action
|
package/src/agent.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import { log } from './log.js'
|
|
3
4
|
import { prompt, userInstructions } from './prompts.js'
|
|
4
5
|
import { newChat } from './sessions.js'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
import { est, Client } from './client.js'
|
|
7
8
|
import { runGuided } from './guided.js'
|
|
8
|
-
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip } from './tools.js'
|
|
9
|
+
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput } from './tools.js'
|
|
9
10
|
import { loadHooks, runHooks } from './hooks.js'
|
|
10
11
|
import { takeConfigErrors } from './config.js'
|
|
11
12
|
import { c } from './ui.js'
|
|
12
13
|
|
|
13
14
|
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
14
15
|
|
|
15
|
-
// "@shot.png"
|
|
16
|
+
// An image the message names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
|
|
17
|
+
// ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
|
|
18
|
+
const IMAGE_PATH = /(^|\s)@?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\2|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
|
|
16
19
|
export function extractImages(task, root) {
|
|
17
20
|
const images = []
|
|
18
|
-
const text = task.replace(
|
|
21
|
+
const text = task.replace(IMAGE_PATH, (m, pre, q, quoted, bare) => {
|
|
22
|
+
const file = (quoted ?? bare.replace(/\\ /g, ' ')).replace(/^~(?=\/)/, os.homedir())
|
|
19
23
|
const abs = path.resolve(root, file)
|
|
20
|
-
if (!fs.existsSync(abs)) return m
|
|
21
|
-
images.push(`data:${MIME[
|
|
24
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return m
|
|
25
|
+
images.push(`data:${MIME[path.extname(abs).slice(1).toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
|
|
22
26
|
return `${pre}${file}`
|
|
23
27
|
})
|
|
24
28
|
return { text, images }
|
|
@@ -137,14 +141,16 @@ export class Session {
|
|
|
137
141
|
let { text, images } = extractImages(task, this.root)
|
|
138
142
|
images = [...images, ...extra]
|
|
139
143
|
if (images.length && !this.strat.vision) {
|
|
140
|
-
this.
|
|
144
|
+
const seer = this.team?.().find((t) => t.images) // another of your models that can: it describes them
|
|
145
|
+
if (seer) text += await this.describeImages(images, seer, text, signal)
|
|
146
|
+
else this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
|
|
141
147
|
images = []
|
|
142
148
|
}
|
|
143
149
|
log('task', `${text} (${this.strat.actions} mode, ${images.length} image(s))`)
|
|
144
150
|
const answer = this.strat.actions === 'tools' ? await runNative(this, text, images, signal) : await runGuided(this, text, images, signal)
|
|
145
151
|
log('task', 'answer:', answer)
|
|
146
152
|
this.history.push({ task: text, answer })
|
|
147
|
-
if (this.memory && this.strat.actions === 'tools') await this.afterTask(text, answer, signal)
|
|
153
|
+
if (this.memory && this.strat.actions === 'tools' && !this.appendSystem) await this.afterTask(text, answer, signal) // a program's instructions (--append-system-prompt) aren't the user's to remember
|
|
148
154
|
return answer
|
|
149
155
|
}
|
|
150
156
|
|
|
@@ -152,19 +158,42 @@ export class Session {
|
|
|
152
158
|
// own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
|
|
153
159
|
// under the main conversation's, its file changes count for /rewind like the main agent's.
|
|
154
160
|
// ponytail: helpers run one after another; run several at once if waiting on them gets long.
|
|
161
|
+
// model: another of your models (team) does it, for what this one can't do (see images, call tools…).
|
|
155
162
|
async subagent(args, signal) {
|
|
156
163
|
const task = String(args.prompt ?? args.task ?? '').trim()
|
|
157
164
|
if (!task) return 'error: give the helper its job in "prompt"'
|
|
158
|
-
|
|
159
|
-
const
|
|
165
|
+
let { client, profile, strat } = this
|
|
166
|
+
const want = String(args.model ?? '').trim()
|
|
167
|
+
if (want) {
|
|
168
|
+
const team = this.team?.() ?? []
|
|
169
|
+
const t = team.find((x) => `${x.provider} ${x.model}` === want) ?? team.find((x) => x.model === want || want.endsWith(` ${x.model}`))
|
|
170
|
+
if (!t) return `error: no model "${want}". Yours: ${team.map((x) => `${x.provider} ${x.model}`).join(', ') || 'none besides this one'}`
|
|
171
|
+
;({ client, profile, strat } = await this.engineFor(t))
|
|
172
|
+
}
|
|
173
|
+
const label = clip((want ? `${client.model}: ` : '') + (String(args.description ?? '').trim() || task.split('\n')[0]), 60)
|
|
174
|
+
const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
|
|
160
175
|
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 })
|
|
161
176
|
helper.track = (rel, before) => this.track(rel, before)
|
|
162
177
|
log('agent', `helper "${label}": ${task}`)
|
|
163
|
-
const answer = await runNative(helper, task, [], signal)
|
|
178
|
+
const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, [], signal)
|
|
164
179
|
log('agent', `helper "${label}" done`, answer)
|
|
165
180
|
return answer?.trim() || '(the helper finished without a reply)'
|
|
166
181
|
}
|
|
167
182
|
|
|
183
|
+
// Images for a model that can't see them: another of yours (seer) describes each, for the task.
|
|
184
|
+
async describeImages(images, seer, task, signal) {
|
|
185
|
+
const { client } = await this.engineFor(seer)
|
|
186
|
+
const out = []
|
|
187
|
+
for (const [i, url] of images.entries()) {
|
|
188
|
+
this.ui.spinner(`${seer.model} is looking at image ${i + 1}`)
|
|
189
|
+
const res = await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('guided/describe-image', { task }) }, { type: 'image_url', image_url: { url } }] }] }, { signal })
|
|
190
|
+
out.push(`Image ${i + 1}:\n${res.content.trim()}`)
|
|
191
|
+
this.ui.step('image', `${seer.model} described image ${i + 1}`)
|
|
192
|
+
}
|
|
193
|
+
log('images', `described by ${seer.provider} ${seer.model}`, out)
|
|
194
|
+
return `\n\n${prompt('agent/images-described', { model: seer.model, descriptions: out.join('\n\n') })}`
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
// /btw: a side question, answered from the conversation so far. Nothing goes into the
|
|
169
198
|
// conversation and no tools are offered, so it can run while a task works: on its own client,
|
|
170
199
|
// so the task's request timer and retries are left alone (its cost still counts).
|
|
@@ -208,7 +237,7 @@ export class Session {
|
|
|
208
237
|
try {
|
|
209
238
|
if (!writes && REMEMBER.test(task)) {
|
|
210
239
|
this.ui.spinner('remembering')
|
|
211
|
-
await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task, answer: clip(answer, 2000) }), signal)
|
|
240
|
+
await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task: clip(task, 4000), answer: clip(answer, 2000) }), signal)
|
|
212
241
|
}
|
|
213
242
|
} catch (e) {
|
|
214
243
|
if (e.name === 'AbortError') throw e
|
|
@@ -228,7 +257,14 @@ export class Session {
|
|
|
228
257
|
const tools = extraTools({ memory: this.memory }).map((f) => ({ type: 'function', function: f }))
|
|
229
258
|
const messages = [{ role: 'system', content: system }, { role: 'user', content: user }]
|
|
230
259
|
for (let step = 0; step < steps; step++) {
|
|
231
|
-
const
|
|
260
|
+
const was = this.client.purpose
|
|
261
|
+
this.client.purpose = 'memory' // in the requests log
|
|
262
|
+
let res
|
|
263
|
+
try {
|
|
264
|
+
res = await this.client.chat({ messages, tools }, { signal })
|
|
265
|
+
} finally {
|
|
266
|
+
this.client.purpose = was
|
|
267
|
+
}
|
|
232
268
|
messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
|
|
233
269
|
if (!res.toolCalls.length) return res.content.trim()
|
|
234
270
|
for (const call of res.toolCalls) {
|
|
@@ -264,23 +300,39 @@ export class Session {
|
|
|
264
300
|
// The user's HEARTCODE.md / AGENTS.md instructions are added when there are any.
|
|
265
301
|
// In director mode the director part is added.
|
|
266
302
|
// No list of the project's files: the model looks up what the task needs (list_files, search).
|
|
303
|
+
// A program's own instructions (--append-system-prompt: Nibedita, a script) come last, and HeartCode's
|
|
304
|
+
// own say less then (caller): no role of its own, no "stay in the folder", no way to finish of its
|
|
305
|
+
// own, no director; they're told to follow the program's where they differ.
|
|
267
306
|
const SYSTEM = (s) => {
|
|
268
307
|
const instructions = userInstructions(s.root)
|
|
269
308
|
return [
|
|
270
|
-
prompt('agent/system', { root: s.root, files: '', more: '' }),
|
|
309
|
+
prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
|
|
271
310
|
instructions && prompt('agent/instructions', { instructions }),
|
|
272
311
|
s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
|
|
273
|
-
s.
|
|
312
|
+
s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
|
|
313
|
+
s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
|
|
314
|
+
!s.isSub && teamPrompt(s),
|
|
274
315
|
s.isSub && prompt('agent/subagent'),
|
|
275
|
-
s.appendSystem
|
|
316
|
+
s.appendSystem && prompt('agent/caller'),
|
|
317
|
+
s.appendSystem,
|
|
276
318
|
]
|
|
277
319
|
.filter(Boolean)
|
|
278
320
|
.join('\n\n')
|
|
279
321
|
}
|
|
280
322
|
|
|
323
|
+
// Your other models and what each can do, for the agent tool's model (only when there are any).
|
|
324
|
+
const teamPrompt = (s) => {
|
|
325
|
+
const team = s.team?.() ?? []
|
|
326
|
+
if (!team.length) return ''
|
|
327
|
+
const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`].filter(Boolean).join(', ')
|
|
328
|
+
const you = [s.strat.vision ? 'images' : 'no images', s.strat.thinking && 'thinking'].filter(Boolean).join(', ')
|
|
329
|
+
return prompt('agent/team', { models: team.map((t) => `- ${t.provider} ${t.model}: ${can(t)}`).join('\n'), you })
|
|
330
|
+
}
|
|
331
|
+
|
|
281
332
|
const sizeOf = (m) =>
|
|
282
333
|
(Array.isArray(m.content) ? m.content.reduce((n, p) => n + (p.type === 'text' ? est(p.text) : 800), 0) : est(m.content)) +
|
|
283
|
-
(m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0)
|
|
334
|
+
(m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
|
|
335
|
+
(m.reasoning_content ? est(m.reasoning_content) : 0)
|
|
284
336
|
export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
|
|
285
337
|
// Words that suggest the user said something to remember (then HeartCode asks the model to save it).
|
|
286
338
|
const REMEMBER = /\b(always|never|prefer|remember|don'?t|do not|instead of|from now on|we use|convention|make sure|keep in mind)\b/i
|
|
@@ -410,6 +462,13 @@ export function pairTools(messages) {
|
|
|
410
462
|
|
|
411
463
|
async function runNative(s, task, images, signal) {
|
|
412
464
|
const { client, strat, ui } = s
|
|
465
|
+
if (s.mcp?.names.length) {
|
|
466
|
+
ui.spinner('getting the MCP servers\' tools')
|
|
467
|
+
await loadMcpTools(s, signal) // before the system prompt: it has their instructions
|
|
468
|
+
}
|
|
469
|
+
// The model's thinking goes back with its steps while it works on a message (it keeps its plan
|
|
470
|
+
// from one tool call to the next), not into later messages.
|
|
471
|
+
for (const m of s.messages ?? []) delete m.reasoning_content
|
|
413
472
|
// The system prompt is rebuilt for every message: files come and go, and a resumed chat's is
|
|
414
473
|
// from back then (it once told the model the project was empty after it had made files).
|
|
415
474
|
// Unchanged, it stays the same text, so a provider's prompt cache still holds.
|
|
@@ -457,6 +516,7 @@ async function runNative(s, task, images, signal) {
|
|
|
457
516
|
let nudged = 0 // reminders sent this message about open todo items or a Stop hook
|
|
458
517
|
|
|
459
518
|
let empty = 0 // replies in a row with neither text nor tool calls
|
|
519
|
+
let movedOn = 0 // models given up on for that, in this message
|
|
460
520
|
// No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
|
|
461
521
|
for (let step = 0; ; step++) {
|
|
462
522
|
if (s.maxTurns && step >= s.maxTurns) {
|
|
@@ -506,7 +566,7 @@ async function runNative(s, task, images, signal) {
|
|
|
506
566
|
out.text(res.content)
|
|
507
567
|
}
|
|
508
568
|
out.end()
|
|
509
|
-
s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
|
|
569
|
+
s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }), ...(res.reasoning && res.toolCalls.length && { reasoning_content: res.reasoning }) }) // client.js sends it by the name the provider uses, or not at all
|
|
510
570
|
// a tool call written out as text instead of made (some models do, after a long conversation): made
|
|
511
571
|
const leak = !res.toolCalls.length && leakedCall(res.content)
|
|
512
572
|
if (leak && tools.some((t) => t.function.name === leak.name)) {
|
|
@@ -525,6 +585,14 @@ async function runNative(s, task, images, signal) {
|
|
|
525
585
|
if (empty > 1 && s.messages.at(-1)?.content !== prompt('agent/empty-reply')) s.messages.push({ role: 'user', content: prompt('agent/empty-reply') })
|
|
526
586
|
continue
|
|
527
587
|
}
|
|
588
|
+
// still nothing: the next model in your list carries on (each one once), before giving up
|
|
589
|
+
const was = client.model
|
|
590
|
+
if (movedOn < (client.rotation?.list.length ?? 1) - 1 && client.moveOn(`${was}: ${empty} empty replies in a row`)) {
|
|
591
|
+
movedOn++
|
|
592
|
+
empty = 0
|
|
593
|
+
ui.warn(`${was} keeps replying with nothing; going on with ${client.model}`)
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
528
596
|
return finalAnswer(s, signal)
|
|
529
597
|
}
|
|
530
598
|
empty = 0
|
|
@@ -560,11 +628,12 @@ async function runNative(s, task, images, signal) {
|
|
|
560
628
|
if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
|
|
561
629
|
else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
|
|
562
630
|
else if (out.startsWith('error')) ui.result(clip(out, 120))
|
|
563
|
-
const msg = { role: 'tool', tool_call_id: call.id, content:
|
|
631
|
+
const msg = { role: 'tool', tool_call_id: call.id, content: fitResult(s, name, out) }
|
|
564
632
|
if (name === 'read_file' && args && !args.start_line && !args.end_line && !out.startsWith('error')) replaceCopy(s, args.path, msg)
|
|
565
633
|
s.messages.push(msg)
|
|
566
634
|
s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
|
|
567
635
|
}
|
|
636
|
+
trimOldResults(s)
|
|
568
637
|
// screenshots from MCP tools: tool results are text only, so images follow as a message
|
|
569
638
|
if (s.pendingImages?.length) {
|
|
570
639
|
s.messages.push({ role: 'user', content: [{ type: 'text', text: prompt('agent/tool-images') }, ...s.pendingImages.map((url) => ({ type: 'image_url', image_url: { url } }))] })
|
|
@@ -599,11 +668,36 @@ function helperUI(ui, label) {
|
|
|
599
668
|
return new Proxy(ui, { get: (t, k) => (k in own ? own[k] : typeof t[k] === 'function' ? t[k].bind(t) : t[k]), set: (t, k, v) => ((t[k] = v), true) })
|
|
600
669
|
}
|
|
601
670
|
|
|
602
|
-
|
|
603
|
-
export const allowed = (s, name) => (!s.allowTools || s.allowTools.has(name)) && !s.denyTools?.has(name)
|
|
671
|
+
export { allowed }
|
|
604
672
|
|
|
605
673
|
const shortened = new WeakSet() // kept outside the messages: providers reject unknown message fields
|
|
606
674
|
|
|
675
|
+
// How long a tool result may be in the conversation: a tenth of the window, 4k to 100k characters
|
|
676
|
+
// (Claude Code allows about 25k tokens). A longer one: its start, and the whole of it saved to a
|
|
677
|
+
// file the model can read in parts (read_file, search), as Claude Code does.
|
|
678
|
+
export function fitResult(s, name, out) {
|
|
679
|
+
const max = Math.max(4000, Math.min(100_000, Math.floor((s.strat.context * 3.5 * 0.1) / (s.strat.ratio ?? 1))))
|
|
680
|
+
if (out.length <= max) return out
|
|
681
|
+
const file = saveOutput(s.chat?.id, name, out)
|
|
682
|
+
log('tool', `${name}: ${out.length} characters, ${max} shown${file ? `; all of it in ${file}` : ''}`)
|
|
683
|
+
return `${out.slice(0, max)}\n…\n${prompt('agent/output-cut', { shown: max, total: out.length, file })}`
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A long task's conversation fills up with old tool output (a browser's page snapshots, each out of
|
|
687
|
+
// date by the next). Past 12 long results, all but the newest 4 are cut to their start, each saved
|
|
688
|
+
// to a file first. All at once, so a provider's prompt cache (the conversation's unchanged start)
|
|
689
|
+
// is broken rarely, not at every step.
|
|
690
|
+
export function trimOldResults(s, keep = 4, most = 12) {
|
|
691
|
+
const long = s.messages.filter((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.length > 2000 && !shortened.has(m) && !/^\[older output/.test(m.content))
|
|
692
|
+
if (long.length <= most) return
|
|
693
|
+
for (const m of long.slice(0, -keep)) {
|
|
694
|
+
const file = saveOutput(s.chat?.id, 'older', m.content)
|
|
695
|
+
m.content = `${prompt('agent/output-old', { total: m.content.length, file })}\n${m.content.slice(0, 600)}\n…`
|
|
696
|
+
shortened.add(m)
|
|
697
|
+
}
|
|
698
|
+
log('tool', `older tool output cut: ${long.length - keep} results`)
|
|
699
|
+
}
|
|
700
|
+
|
|
607
701
|
// A file read again: the older copy in the conversation becomes a one-line note.
|
|
608
702
|
function replaceCopy(s, file, msg) {
|
|
609
703
|
const key = path.normalize(String(file))
|
|
@@ -629,7 +723,7 @@ function attachNamed(s, task) {
|
|
|
629
723
|
} catch {
|
|
630
724
|
continue
|
|
631
725
|
}
|
|
632
|
-
if (body.startsWith('error')) continue
|
|
726
|
+
if (body.startsWith('error') || body.startsWith(`${f} is a binary file`)) continue // an image: read_file shows it, or says who can see it
|
|
633
727
|
const id = `named_${Date.now().toString(36)}_${calls.length}`
|
|
634
728
|
calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
|
|
635
729
|
const msg = { role: 'tool', tool_call_id: id, content: body }
|