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 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
- ![HeartCode in the terminal](docs/images/heartcode.png)
10
+ ![HeartCode in the terminal](https://res.cloudinary.com/m0jnrul4/image/upload/v1793165332/Screenshot_2026-10-28_at_9.28.26_AM.png)
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
- ![HeartCode's first screen](docs/images/start.png)
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
- ![The settings page](docs/images/settings.png)
68
+ ![The settings page](https://res.cloudinary.com/m0jnrul4/image/upload/v1793165673/Dark_HeartCode_Models_Dashboard.png)
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
- ![Providers and models](docs/images/providers.png)
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
- ![Your models](docs/images/your-models.png)
87
+ ![Your models](https://res.cloudinary.com/m0jnrul4/image/upload/v1793165634/HeartCode-Settings-10-28-2026_09_33_AM.png)
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
- ![Logging](docs/images/logging.png)
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
- ![The settings dialog](docs/images/settings-dialog.png)
104
+ ![The settings dialog](https://res.cloudinary.com/m0jnrul4/image/upload/v1793165735/Screenshot_2026-10-28_at_9.35.28_AM.png)
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
- ![The first reply](docs/images/hi.png)
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). An error waiting can't
56
- fix (a model name the server doesn't know, a wrong key, a request it refuses) is never sent again:
57
- the run stops with that error and exit code `1`. With the default list it moves on to the next
58
- model only if the user set its fallback rule to "at any error".
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>"` | added to HeartCode's system prompt for this run |
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`, then `assistant` (text, thinking, `tool_use`),
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).pathname, ...args]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
5
  "type": "module",
6
6
  "bin": {
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
- You are HeartCode, a coding assistant working in a terminal on the user's project.
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 res = await this.client.chat({ messages, tools }, { signal })
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.director && s.director.app.cfg.director !== false && prompt('agent/director'),
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, // from the caller (--append-system-prompt)
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: clip(out, Math.min(12_000, strat.context)) }
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
- // Tools the caller allowed (--allowedTools) or took away (--disallowedTools); all by default.
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 wrong model name, key or request (404, 401, 400…): the same request fails the same way
361
- // every time, so it isn't sent again. It stops here, unless the model list moves on at any
362
- // error (the settings page); and a whole round of those stops too, rather than going round.
363
- if (!outage(e) && this.rotation?.on !== 'any') throw e
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
- if (e.status === 400 && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
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 (e.status === 400 && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
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
- return count(done(msg.content ?? '', msg.reasoning_content ?? msg.reasoning ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
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
- const r = d.reasoning_content || d.reasoning
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 }