heartcode 0.1.0 → 0.1.1
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 +19 -10
- package/bin/heart.js +2 -1
- package/bin/heartcode.js +1 -2
- package/package.json +1 -1
- package/prompts/agent.md +32 -4
- package/src/agent.js +67 -12
- package/src/client.js +96 -12
- package/src/config.js +4 -0
- package/src/headless.js +4 -3
- package/src/mcp.js +49 -3
- package/src/prompts.js +2 -1
- package/src/settings.html +105 -27
- package/src/settings.js +28 -0
- package/src/tools.js +104 -13
- 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
|
|
|
@@ -139,14 +145,16 @@ HEART_API_KEY=… heart -u https://api.groq.com/openai/v1 -m openai/gpt-oss-120b
|
|
|
139
145
|
```
|
|
140
146
|
|
|
141
147
|
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
|
|
148
|
+
found on its own. The user can also set everything up in a browser: `heart settings`, where
|
|
149
|
+
**Test** (or **Test all**) under *Your models* sends a model a short message to show it really
|
|
150
|
+
answers. A run that fails with a wrong key or an unknown model: ask the user to test it there.
|
|
143
151
|
|
|
144
152
|
## More context for the task
|
|
145
153
|
|
|
146
154
|
| Flag | |
|
|
147
155
|
|---|---|
|
|
148
|
-
| `--append-system-prompt "<text>"` |
|
|
149
|
-
| `--mcp-config <file or JSON>` | more MCP servers (repeatable); `--strict-mcp-config`: only those |
|
|
156
|
+
| `--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 |
|
|
157
|
+
| `--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
158
|
| `--skills-dir <folder>` | more skills (repeatable) |
|
|
151
159
|
| `--no-memory` | don't read or write HeartCode's memory notes |
|
|
152
160
|
|
|
@@ -160,7 +168,8 @@ heart -p --input-format stream-json --output-format stream-json
|
|
|
160
168
|
|
|
161
169
|
Stdin: one message per line, `{"type":"user","message":{"role":"user","content":"…"}}`.
|
|
162
170
|
Stop the running one with `{"type":"control_request","request_id":"1","request":{"subtype":"interrupt"}}`.
|
|
163
|
-
Stdout: one JSON event per line, `system`/`init
|
|
171
|
+
Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP servers' tools,
|
|
172
|
+
`mcp_servers` says which are `connected`), then `assistant` (text, thinking, `tool_use`),
|
|
164
173
|
`user` (`tool_result`), and a `result` after each message. It runs until stdin closes.
|
|
165
174
|
|
|
166
175
|
## 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
|
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
|
|
|
@@ -274,8 +298,12 @@ Tags in use: {{tags}}
|
|
|
274
298
|
## memory-exchange
|
|
275
299
|
|
|
276
300
|
<!-- The exchange to look at for memories. {{task}}: what the user said. {{answer}}: the reply. -->
|
|
301
|
+
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.
|
|
302
|
+
|
|
303
|
+
<exchange>
|
|
277
304
|
User: {{task}}
|
|
278
305
|
Assistant: {{answer}}
|
|
306
|
+
</exchange>
|
|
279
307
|
|
|
280
308
|
## memory-tidy
|
|
281
309
|
|
package/src/agent.js
CHANGED
|
@@ -5,7 +5,7 @@ import { newChat } from './sessions.js'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { est, Client } from './client.js'
|
|
7
7
|
import { runGuided } from './guided.js'
|
|
8
|
-
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip } from './tools.js'
|
|
8
|
+
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput } from './tools.js'
|
|
9
9
|
import { loadHooks, runHooks } from './hooks.js'
|
|
10
10
|
import { takeConfigErrors } from './config.js'
|
|
11
11
|
import { c } from './ui.js'
|
|
@@ -144,7 +144,7 @@ export class Session {
|
|
|
144
144
|
const answer = this.strat.actions === 'tools' ? await runNative(this, text, images, signal) : await runGuided(this, text, images, signal)
|
|
145
145
|
log('task', 'answer:', answer)
|
|
146
146
|
this.history.push({ task: text, answer })
|
|
147
|
-
if (this.memory && this.strat.actions === 'tools') await this.afterTask(text, answer, signal)
|
|
147
|
+
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
148
|
return answer
|
|
149
149
|
}
|
|
150
150
|
|
|
@@ -208,7 +208,7 @@ export class Session {
|
|
|
208
208
|
try {
|
|
209
209
|
if (!writes && REMEMBER.test(task)) {
|
|
210
210
|
this.ui.spinner('remembering')
|
|
211
|
-
await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task, answer: clip(answer, 2000) }), signal)
|
|
211
|
+
await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task: clip(task, 4000), answer: clip(answer, 2000) }), signal)
|
|
212
212
|
}
|
|
213
213
|
} catch (e) {
|
|
214
214
|
if (e.name === 'AbortError') throw e
|
|
@@ -228,7 +228,14 @@ export class Session {
|
|
|
228
228
|
const tools = extraTools({ memory: this.memory }).map((f) => ({ type: 'function', function: f }))
|
|
229
229
|
const messages = [{ role: 'system', content: system }, { role: 'user', content: user }]
|
|
230
230
|
for (let step = 0; step < steps; step++) {
|
|
231
|
-
const
|
|
231
|
+
const was = this.client.purpose
|
|
232
|
+
this.client.purpose = 'memory' // in the requests log
|
|
233
|
+
let res
|
|
234
|
+
try {
|
|
235
|
+
res = await this.client.chat({ messages, tools }, { signal })
|
|
236
|
+
} finally {
|
|
237
|
+
this.client.purpose = was
|
|
238
|
+
}
|
|
232
239
|
messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
|
|
233
240
|
if (!res.toolCalls.length) return res.content.trim()
|
|
234
241
|
for (const call of res.toolCalls) {
|
|
@@ -264,15 +271,20 @@ export class Session {
|
|
|
264
271
|
// The user's HEARTCODE.md / AGENTS.md instructions are added when there are any.
|
|
265
272
|
// In director mode the director part is added.
|
|
266
273
|
// No list of the project's files: the model looks up what the task needs (list_files, search).
|
|
274
|
+
// A program's own instructions (--append-system-prompt: Nibedita, a script) come last, and HeartCode's
|
|
275
|
+
// own say less then (caller): no role of its own, no "stay in the folder", no way to finish of its
|
|
276
|
+
// own, no director; they're told to follow the program's where they differ.
|
|
267
277
|
const SYSTEM = (s) => {
|
|
268
278
|
const instructions = userInstructions(s.root)
|
|
269
279
|
return [
|
|
270
|
-
prompt('agent/system', { root: s.root, files: '', more: '' }),
|
|
280
|
+
prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
|
|
271
281
|
instructions && prompt('agent/instructions', { instructions }),
|
|
272
282
|
s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
|
|
273
|
-
s.
|
|
283
|
+
s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
|
|
284
|
+
s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
|
|
274
285
|
s.isSub && prompt('agent/subagent'),
|
|
275
|
-
s.appendSystem
|
|
286
|
+
s.appendSystem && prompt('agent/caller'),
|
|
287
|
+
s.appendSystem,
|
|
276
288
|
]
|
|
277
289
|
.filter(Boolean)
|
|
278
290
|
.join('\n\n')
|
|
@@ -280,7 +292,8 @@ const SYSTEM = (s) => {
|
|
|
280
292
|
|
|
281
293
|
const sizeOf = (m) =>
|
|
282
294
|
(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)
|
|
295
|
+
(m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
|
|
296
|
+
(m.reasoning_content ? est(m.reasoning_content) : 0)
|
|
284
297
|
export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
|
|
285
298
|
// Words that suggest the user said something to remember (then HeartCode asks the model to save it).
|
|
286
299
|
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 +423,13 @@ export function pairTools(messages) {
|
|
|
410
423
|
|
|
411
424
|
async function runNative(s, task, images, signal) {
|
|
412
425
|
const { client, strat, ui } = s
|
|
426
|
+
if (s.mcp?.names.length) {
|
|
427
|
+
ui.spinner('getting the MCP servers\' tools')
|
|
428
|
+
await loadMcpTools(s, signal) // before the system prompt: it has their instructions
|
|
429
|
+
}
|
|
430
|
+
// The model's thinking goes back with its steps while it works on a message (it keeps its plan
|
|
431
|
+
// from one tool call to the next), not into later messages.
|
|
432
|
+
for (const m of s.messages ?? []) delete m.reasoning_content
|
|
413
433
|
// The system prompt is rebuilt for every message: files come and go, and a resumed chat's is
|
|
414
434
|
// from back then (it once told the model the project was empty after it had made files).
|
|
415
435
|
// Unchanged, it stays the same text, so a provider's prompt cache still holds.
|
|
@@ -457,6 +477,7 @@ async function runNative(s, task, images, signal) {
|
|
|
457
477
|
let nudged = 0 // reminders sent this message about open todo items or a Stop hook
|
|
458
478
|
|
|
459
479
|
let empty = 0 // replies in a row with neither text nor tool calls
|
|
480
|
+
let movedOn = 0 // models given up on for that, in this message
|
|
460
481
|
// No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
|
|
461
482
|
for (let step = 0; ; step++) {
|
|
462
483
|
if (s.maxTurns && step >= s.maxTurns) {
|
|
@@ -506,7 +527,7 @@ async function runNative(s, task, images, signal) {
|
|
|
506
527
|
out.text(res.content)
|
|
507
528
|
}
|
|
508
529
|
out.end()
|
|
509
|
-
s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
|
|
530
|
+
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
531
|
// a tool call written out as text instead of made (some models do, after a long conversation): made
|
|
511
532
|
const leak = !res.toolCalls.length && leakedCall(res.content)
|
|
512
533
|
if (leak && tools.some((t) => t.function.name === leak.name)) {
|
|
@@ -525,6 +546,14 @@ async function runNative(s, task, images, signal) {
|
|
|
525
546
|
if (empty > 1 && s.messages.at(-1)?.content !== prompt('agent/empty-reply')) s.messages.push({ role: 'user', content: prompt('agent/empty-reply') })
|
|
526
547
|
continue
|
|
527
548
|
}
|
|
549
|
+
// still nothing: the next model in your list carries on (each one once), before giving up
|
|
550
|
+
const was = client.model
|
|
551
|
+
if (movedOn < (client.rotation?.list.length ?? 1) - 1 && client.moveOn(`${was}: ${empty} empty replies in a row`)) {
|
|
552
|
+
movedOn++
|
|
553
|
+
empty = 0
|
|
554
|
+
ui.warn(`${was} keeps replying with nothing; going on with ${client.model}`)
|
|
555
|
+
continue
|
|
556
|
+
}
|
|
528
557
|
return finalAnswer(s, signal)
|
|
529
558
|
}
|
|
530
559
|
empty = 0
|
|
@@ -560,11 +589,12 @@ async function runNative(s, task, images, signal) {
|
|
|
560
589
|
if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
|
|
561
590
|
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
591
|
else if (out.startsWith('error')) ui.result(clip(out, 120))
|
|
563
|
-
const msg = { role: 'tool', tool_call_id: call.id, content:
|
|
592
|
+
const msg = { role: 'tool', tool_call_id: call.id, content: fitResult(s, name, out) }
|
|
564
593
|
if (name === 'read_file' && args && !args.start_line && !args.end_line && !out.startsWith('error')) replaceCopy(s, args.path, msg)
|
|
565
594
|
s.messages.push(msg)
|
|
566
595
|
s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
|
|
567
596
|
}
|
|
597
|
+
trimOldResults(s)
|
|
568
598
|
// screenshots from MCP tools: tool results are text only, so images follow as a message
|
|
569
599
|
if (s.pendingImages?.length) {
|
|
570
600
|
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 +629,36 @@ function helperUI(ui, label) {
|
|
|
599
629
|
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
630
|
}
|
|
601
631
|
|
|
602
|
-
|
|
603
|
-
export const allowed = (s, name) => (!s.allowTools || s.allowTools.has(name)) && !s.denyTools?.has(name)
|
|
632
|
+
export { allowed }
|
|
604
633
|
|
|
605
634
|
const shortened = new WeakSet() // kept outside the messages: providers reject unknown message fields
|
|
606
635
|
|
|
636
|
+
// How long a tool result may be in the conversation: a tenth of the window, 4k to 100k characters
|
|
637
|
+
// (Claude Code allows about 25k tokens). A longer one: its start, and the whole of it saved to a
|
|
638
|
+
// file the model can read in parts (read_file, search), as Claude Code does.
|
|
639
|
+
export function fitResult(s, name, out) {
|
|
640
|
+
const max = Math.max(4000, Math.min(100_000, Math.floor((s.strat.context * 3.5 * 0.1) / (s.strat.ratio ?? 1))))
|
|
641
|
+
if (out.length <= max) return out
|
|
642
|
+
const file = saveOutput(s.chat?.id, name, out)
|
|
643
|
+
log('tool', `${name}: ${out.length} characters, ${max} shown${file ? `; all of it in ${file}` : ''}`)
|
|
644
|
+
return `${out.slice(0, max)}\n…\n${prompt('agent/output-cut', { shown: max, total: out.length, file })}`
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// A long task's conversation fills up with old tool output (a browser's page snapshots, each out of
|
|
648
|
+
// date by the next). Past 12 long results, all but the newest 4 are cut to their start, each saved
|
|
649
|
+
// to a file first. All at once, so a provider's prompt cache (the conversation's unchanged start)
|
|
650
|
+
// is broken rarely, not at every step.
|
|
651
|
+
export function trimOldResults(s, keep = 4, most = 12) {
|
|
652
|
+
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))
|
|
653
|
+
if (long.length <= most) return
|
|
654
|
+
for (const m of long.slice(0, -keep)) {
|
|
655
|
+
const file = saveOutput(s.chat?.id, 'older', m.content)
|
|
656
|
+
m.content = `${prompt('agent/output-old', { total: m.content.length, file })}\n${m.content.slice(0, 600)}\n…`
|
|
657
|
+
shortened.add(m)
|
|
658
|
+
}
|
|
659
|
+
log('tool', `older tool output cut: ${long.length - keep} results`)
|
|
660
|
+
}
|
|
661
|
+
|
|
607
662
|
// A file read again: the older copy in the conversation becomes a one-line note.
|
|
608
663
|
function replaceCopy(s, file, msg) {
|
|
609
664
|
const key = path.normalize(String(file))
|
package/src/client.js
CHANGED
|
@@ -6,6 +6,9 @@ export const est = (text) => Math.ceil(String(text ?? '').length / 3.5)
|
|
|
6
6
|
// An error that may go away by itself: a rate limit, a server error or overload, a timeout, no
|
|
7
7
|
// connection (no status). Anything else (a wrong model name, key or request) won't.
|
|
8
8
|
export const outage = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500
|
|
9
|
+
// The model can't be used as it's set up (a wrong key, no access to it, no such model): waiting
|
|
10
|
+
// won't help, but the next one in the list may work.
|
|
11
|
+
export const unusable = (e) => [401, 403, 404].includes(e.status)
|
|
9
12
|
// A request's messages, estimated: an image's data isn't text (about 800 tokens, not its base64)
|
|
10
13
|
export function estMessages(messages) {
|
|
11
14
|
let images = 0
|
|
@@ -143,6 +146,16 @@ export class Client {
|
|
|
143
146
|
get target() {
|
|
144
147
|
return this.rotation?.list[this.rotation.at] ?? null
|
|
145
148
|
}
|
|
149
|
+
// The model in use answers but gives nothing usable (agent.js: empty replies, again and again):
|
|
150
|
+
// the next one in the list takes over. False when there's no list.
|
|
151
|
+
moveOn(why) {
|
|
152
|
+
const r = this.rotation
|
|
153
|
+
if (!r) return false
|
|
154
|
+
r.at = (r.at + 1) % r.list.length
|
|
155
|
+
log('rotate', `${why} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
156
|
+
this.#use(r.list[r.at])
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
146
159
|
// A new message: back to #1 (unless it stays on the one that worked)
|
|
147
160
|
newMessage() {
|
|
148
161
|
const r = this.rotation
|
|
@@ -156,7 +169,7 @@ export class Client {
|
|
|
156
169
|
#rotate(e, tries) {
|
|
157
170
|
const r = this.rotation
|
|
158
171
|
if (!r) return false
|
|
159
|
-
if (!(r.on === 'any' || (r.on === 'failure' && outage(e)) || (r.on === 'retries' && tries >= r.retries))) return false
|
|
172
|
+
if (!(r.on === 'any' || (r.on === 'failure' && (outage(e) || unusable(e))) || (r.on === 'retries' && (tries >= r.retries || unusable(e))))) return false
|
|
160
173
|
r.at = (r.at + 1) % r.list.length
|
|
161
174
|
log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
162
175
|
this.#use(r.list[r.at])
|
|
@@ -186,7 +199,7 @@ export class Client {
|
|
|
186
199
|
usage: reply?.usage,
|
|
187
200
|
finishReason: reply?.finishReason,
|
|
188
201
|
request: req,
|
|
189
|
-
reply: reply && { content: reply.content, reasoning: reply.reasoning, toolCalls: reply.toolCalls },
|
|
202
|
+
reply: reply && { content: reply.content, reasoning: reply.reasoning, toolCalls: reply.toolCalls, ...(reply.raw && { raw: reply.raw }) }, // raw: the stream as it came, for a reply with nothing in it
|
|
190
203
|
})
|
|
191
204
|
}
|
|
192
205
|
|
|
@@ -357,10 +370,11 @@ export class Client {
|
|
|
357
370
|
if (e.name === 'AbortError' || opts.signal?.aborted) throw e
|
|
358
371
|
if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
|
|
359
372
|
if (this.retryIf && !this.retryIf(e, attempt)) throw e
|
|
360
|
-
// A
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
373
|
+
// A bad request (400…): the same request fails the same way every time, so it isn't sent
|
|
374
|
+
// again. It stops here, unless the model list moves on at any error (the settings page). A
|
|
375
|
+
// model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
|
|
376
|
+
// in the list; a whole round of those stops too, rather than going round.
|
|
377
|
+
if (!outage(e) && !(unusable(e) && this.rotation) && this.rotation?.on !== 'any') throw e
|
|
364
378
|
hard &&= !outage(e)
|
|
365
379
|
tries++
|
|
366
380
|
if (this.#rotate(e, tries)) {
|
|
@@ -400,6 +414,14 @@ export class Client {
|
|
|
400
414
|
// One request. Without `once`, rate limits and server errors get three quiet retries.
|
|
401
415
|
async #chat(body, { onToken, onReasoning, onToolDelta, signal, once } = {}) {
|
|
402
416
|
const req = { model: this.model, ...body }
|
|
417
|
+
// The model's thinking on its earlier steps (agent.js keeps it as reasoning_content): sent by the
|
|
418
|
+
// name this provider sends it by (reasoning_content: DeepSeek, vLLM…; reasoning: OpenRouter,
|
|
419
|
+
// Ollama), or not at all if it refused it once.
|
|
420
|
+
if (req.messages?.some((m) => m.reasoning_content)) {
|
|
421
|
+
const field = this.quirks.noReasoningBack ? null : (this.quirks.reasoningField ?? 'reasoning_content')
|
|
422
|
+
req.messages = req.messages.map(({ reasoning_content, ...m }) => (reasoning_content && field ? { ...m, [field]: reasoning_content } : m))
|
|
423
|
+
}
|
|
424
|
+
if (this.quirks.simpleSchemas && req.tools) req.tools = req.tools.map((t) => ({ ...t, function: { ...t.function, parameters: simpleSchema(t.function.parameters) } }))
|
|
403
425
|
if (this.quirks.noTemperature) delete req.temperature
|
|
404
426
|
if (this.effort && !this.quirks.noEffort) req.reasoning_effort = this.effort
|
|
405
427
|
if (req.stream && !this.quirks.noStreamUsage) req.stream_options = { include_usage: true } // token counts for the status bar
|
|
@@ -423,19 +445,38 @@ export class Client {
|
|
|
423
445
|
}
|
|
424
446
|
}
|
|
425
447
|
} catch (e) {
|
|
426
|
-
// Some APIs reject parameters older servers need; learn once and retry.
|
|
427
|
-
|
|
448
|
+
// Some APIs reject parameters older servers need; learn once and retry. (Refused: 400, or 422
|
|
449
|
+
// from the ones that check requests against a schema, like Mistral.)
|
|
450
|
+
const refused = e.status === 400 || e.status === 422
|
|
451
|
+
const said = e.body ?? e.message
|
|
452
|
+
if (refused && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
|
|
428
453
|
this.quirks.noTemperature = true
|
|
429
454
|
log('quirk', 'server rejects temperature')
|
|
430
455
|
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
431
456
|
}
|
|
432
457
|
for (const [quirk, param] of [['noEffort', 'reasoning_effort'], ['noStreamUsage', 'stream_options']]) {
|
|
433
|
-
if (
|
|
458
|
+
if (refused && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
|
|
434
459
|
this.quirks[quirk] = true
|
|
435
460
|
log('quirk', `server rejects ${param}; dropping it`)
|
|
436
461
|
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
437
462
|
}
|
|
438
463
|
}
|
|
464
|
+
// The thinking sent back, or the tools' arguments (JSON schemas with keys a provider doesn't
|
|
465
|
+
// take: Gemini's "Unknown name additionalProperties"), refused: sent without it, or with
|
|
466
|
+
// simpler schemas, from now on with this model.
|
|
467
|
+
const reasoningSent = !this.quirks.noReasoningBack && body.messages?.some((m) => m.reasoning_content)
|
|
468
|
+
const noReasoning = () => {
|
|
469
|
+
this.quirks.noReasoningBack = true
|
|
470
|
+
log('quirk', 'server rejects the thinking sent back; not sending it')
|
|
471
|
+
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
472
|
+
}
|
|
473
|
+
if (refused && reasoningSent && /reasoning(?!_effort)/i.test(said)) return noReasoning()
|
|
474
|
+
if (refused && body.tools?.length && !this.quirks.simpleSchemas && /schema|function_declarations|parameters|additionalProperties|exclusiveM|minItems|unknown name|invalid.{0,40}(tool|function)|tools\[/i.test(said)) {
|
|
475
|
+
this.quirks.simpleSchemas = true
|
|
476
|
+
log('quirk', 'server rejects the tools\' schemas; sending simpler ones')
|
|
477
|
+
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
478
|
+
}
|
|
479
|
+
if (refused && reasoningSent && /extra|unknown|unrecognized|not permitted|additional propert/i.test(said)) return noReasoning()
|
|
439
480
|
throw e
|
|
440
481
|
}
|
|
441
482
|
// Tokens the last request used (prompt + reply): what the status bar shows against the context size.
|
|
@@ -458,7 +499,8 @@ export class Client {
|
|
|
458
499
|
const data = await res.json()
|
|
459
500
|
const choice = data.choices?.[0] ?? {}
|
|
460
501
|
const msg = choice.message ?? {}
|
|
461
|
-
|
|
502
|
+
this.#reasoningField(msg)
|
|
503
|
+
return count(done(textOf(msg.content), msg.reasoning_content ?? msg.reasoning ?? msg.thinking ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
|
|
462
504
|
}
|
|
463
505
|
|
|
464
506
|
const reader = res.body.getReader()
|
|
@@ -471,6 +513,7 @@ export class Client {
|
|
|
471
513
|
let usage
|
|
472
514
|
let finishReason
|
|
473
515
|
const calls = []
|
|
516
|
+
let raw = '' // the stream as it came (its start), kept for a reply that turns out empty
|
|
474
517
|
const emit = (v) => {
|
|
475
518
|
if (onToken && v.startsWith(emitted) && v.length > emitted.length) {
|
|
476
519
|
onToken(v.slice(emitted.length))
|
|
@@ -486,6 +529,7 @@ export class Client {
|
|
|
486
529
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
487
530
|
const line = buf.slice(0, nl).trim()
|
|
488
531
|
buf = buf.slice(nl + 1)
|
|
532
|
+
if (raw.length < 100_000 && line) raw += line + '\n'
|
|
489
533
|
if (!line.startsWith('data:')) continue
|
|
490
534
|
const payload = line.slice(5).trim()
|
|
491
535
|
if (payload === '[DONE]') continue
|
|
@@ -500,8 +544,10 @@ export class Client {
|
|
|
500
544
|
const ch = chunk.choices?.[0]
|
|
501
545
|
if (!ch) continue
|
|
502
546
|
if (ch.finish_reason) finishReason = ch.finish_reason
|
|
503
|
-
const d = ch.delta ?? {}
|
|
504
|
-
|
|
547
|
+
const d = ch.delta ?? ch.message ?? {} // some proxies send the whole message in a chunk
|
|
548
|
+
this.#reasoningField(d)
|
|
549
|
+
if (Array.isArray(d.content)) d.content = textOf(d.content)
|
|
550
|
+
const r = d.reasoning_content || d.reasoning || d.thinking
|
|
505
551
|
if (r) {
|
|
506
552
|
reasoning += r
|
|
507
553
|
onReasoning?.(r)
|
|
@@ -527,10 +573,48 @@ export class Client {
|
|
|
527
573
|
}
|
|
528
574
|
const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason)
|
|
529
575
|
emit(result.content)
|
|
576
|
+
if (!result.content.trim() && !result.toolCalls.length && !result.reasoning) {
|
|
577
|
+
result.raw = raw // nothing read from it: what came, for the requests log
|
|
578
|
+
log('reply', `${this.model}: an empty reply${usage?.completion_tokens ? ` of ${usage.completion_tokens} tokens` : ''}; the stream:`, raw.slice(0, 4000))
|
|
579
|
+
}
|
|
530
580
|
return count(result)
|
|
531
581
|
}
|
|
582
|
+
|
|
583
|
+
// The name this provider gives the model's thinking, to send it back by the same one.
|
|
584
|
+
#reasoningField(d) {
|
|
585
|
+
if (this.quirks.reasoningField) return
|
|
586
|
+
if (d.reasoning_content) this.quirks.reasoningField = 'reasoning_content'
|
|
587
|
+
else if (typeof d.reasoning === 'string' && d.reasoning) this.quirks.reasoningField = 'reasoning'
|
|
588
|
+
}
|
|
532
589
|
}
|
|
533
590
|
|
|
591
|
+
// A tool's arguments as the most limited providers take them (Gemini's function declarations):
|
|
592
|
+
// only types, properties, required, items, enum and descriptions; a choice of types (anyOf, oneOf,
|
|
593
|
+
// ["string", "null"]) becomes its first real one.
|
|
594
|
+
const KEEP = new Set(['type', 'properties', 'required', 'items', 'enum', 'description'])
|
|
595
|
+
export function simpleSchema(x) {
|
|
596
|
+
if (!x || typeof x !== 'object' || Array.isArray(x)) return x
|
|
597
|
+
const alt = x.anyOf ?? x.oneOf
|
|
598
|
+
if (alt?.length) {
|
|
599
|
+
const pick = alt.find((a) => a?.type !== 'null') ?? alt[0]
|
|
600
|
+
return simpleSchema({ ...pick, ...(x.description && { description: x.description }) })
|
|
601
|
+
}
|
|
602
|
+
const out = {}
|
|
603
|
+
for (const [k, v] of Object.entries(x)) {
|
|
604
|
+
if (!KEEP.has(k)) continue
|
|
605
|
+
if (k === 'properties') out.properties = Object.fromEntries(Object.entries(v ?? {}).map(([n, s]) => [n, simpleSchema(s)]))
|
|
606
|
+
else if (k === 'items') out.items = simpleSchema(Array.isArray(v) ? v[0] : v)
|
|
607
|
+
else if (k === 'type' && Array.isArray(v)) out.type = v.find((y) => y !== 'null') ?? 'string'
|
|
608
|
+
else out[k] = v
|
|
609
|
+
}
|
|
610
|
+
out.type ??= out.properties ? 'object' : 'string'
|
|
611
|
+
if (Array.isArray(out.required) && out.properties) out.required = out.required.filter((r) => r in out.properties)
|
|
612
|
+
return out
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Message content as text: a string, or a list of parts ({ type: 'text', text }).
|
|
616
|
+
const textOf = (c) => (Array.isArray(c) ? c.map((p) => (typeof p === 'string' ? p : (p?.text ?? ''))).join('') : (c ?? ''))
|
|
617
|
+
|
|
534
618
|
function done(raw, reasoning, toolCalls, usage, finishReason) {
|
|
535
619
|
const calls = toolCalls.filter((c) => c?.function?.name)
|
|
536
620
|
calls.forEach((c, i) => (c.id ||= `call_${Date.now()}_${i}`))
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
4
5
|
|
|
5
6
|
export const HOME = process.env.HEART_HOME || path.join(os.homedir(), '.heart')
|
|
6
7
|
export const CONFIG_FILE = path.join(HOME, 'config.json')
|
|
@@ -16,6 +17,9 @@ export function readJson(file, fallback) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
// HeartCode's version, from package.json (npm version changes it there).
|
|
21
|
+
export const VERSION = readJson(fileURLToPath(new URL('../package.json', import.meta.url)), {}).version ?? '0.0.0'
|
|
22
|
+
|
|
19
23
|
// A config file the user writes (hooks.json, mcp.json): one that doesn't parse is skipped, but
|
|
20
24
|
// said (takeConfigErrors, shown before the next message), never silently ignored.
|
|
21
25
|
const configErrors = new Map() // file → { message, stamp, shown }
|