heartcode 0.0.0-stage → 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.
@@ -0,0 +1,193 @@
1
+ # guided.md
2
+
3
+ What weak models see in guided mode: one question at a time.
4
+ Each `## name` below is one prompt. `{{placeholders}}` are filled in by HeartCode;
5
+ a `<!-- … -->` note under a heading explains the prompt and is never sent. To change one, copy
6
+ its section into ~/.heart/prompts/guided.md (every project) or .heartcode/prompts/guided.md (one project).
7
+
8
+ ## system
9
+
10
+ <!-- System prompt in guided mode (weak models: one question at a time). -->
11
+ You are HeartCode, a careful coding assistant working inside the user's project.
12
+ You work in small steps. Each message asks you ONE question. Answer exactly that question, in the requested form, and nothing else.
13
+
14
+ ## context
15
+
16
+ <!-- What comes before every guided question.
17
+ {{task}}: the user's message. {{history}}: earlier messages and answers (may be empty).
18
+ {{files}}: the project's files (or a note that there are none). {{done}}: notes on the steps so far (may be empty). -->
19
+ TASK: {{task}}
20
+ {{#if history}}
21
+ EARLIER IN THIS CONVERSATION:
22
+ {{history}}
23
+ {{/if}}
24
+ PROJECT FILES:{{files}}
25
+ {{#if done}}
26
+ WHAT HAS BEEN DONE SO FAR:
27
+ {{done}}{{/if}}
28
+
29
+ ## actions
30
+
31
+ <!-- The steps guided mode can take, one per line as "name: what it does".
32
+ The names are fixed (HeartCode acts on them); the descriptions are what the model sees in the menu. -->
33
+ read: look at a file
34
+ search: find text in the project files
35
+ edit: change an existing file
36
+ create: make a new file
37
+ run: run a shell command (tests, build, scripts)
38
+ ask: ask the user a question
39
+ finish: stop and give your final answer to the user
40
+
41
+ ## next-action
42
+
43
+ <!-- The step menu. {{menu}}: numbered steps, likeliest first. -->
44
+ What should you do next for the TASK?
45
+ {{menu}}
46
+
47
+ Read a file before editing it. If the task is done, or it was only a question you can now answer, choose finish.
48
+ Reply with only the number.
49
+
50
+ ## next-action-retry
51
+
52
+ <!-- Added to the step menu when the reply wasn't a number. {{reply}}: what it said. {{count}}: how many steps. -->
53
+ Your reply "{{reply}}" is not a number from 1 to {{count}}. Reply with only the number.
54
+
55
+ ## yes-no
56
+
57
+ <!-- A yes/no question. {{question}}: the question. -->
58
+ {{question}}
59
+ Reply with only yes or no.
60
+
61
+ ## intent
62
+
63
+ <!-- Asked only when the wording of the task doesn't say whether it needs changes. -->
64
+ Does the TASK require changing existing code or creating files (not just answering a question)?
65
+
66
+ ## pick-file
67
+
68
+ <!-- Choosing a file. {{question}}: e.g. which-file-to-read. -->
69
+ {{question}}
70
+ Reply with only the file path.
71
+
72
+ ## pick-file-retry
73
+
74
+ <!-- The answer wasn't a project file. {{said}}: the answer. {{choices}}: the closest files, one per line. -->
75
+ {{question}}
76
+ Your answer "{{said}}" is not a file in the project. Choose one of these:
77
+ {{choices}}
78
+ Reply with only the file path.
79
+
80
+ ## which-file-to-read
81
+
82
+ Which file do you want to read?
83
+
84
+ ## which-file-to-edit
85
+
86
+ Which existing file should be changed?
87
+
88
+ ## search
89
+
90
+ What text should be searched for in the project files? Reply with only the search text.
91
+
92
+ ## edit-blocks
93
+
94
+ <!-- Editing with SEARCH/REPLACE blocks. {{file}}: path. {{content}}: the file, fenced. -->
95
+ Here is the current content of {{file}}:
96
+ {{content}}
97
+
98
+ Write one or more SEARCH/REPLACE blocks that make the change needed for the TASK. Use exactly this format:
99
+ <<<<<<< SEARCH
100
+ (lines copied exactly from the file)
101
+ =======
102
+ (the new lines)
103
+ >>>>>>> REPLACE
104
+ Reply with only the blocks.
105
+
106
+ ## rewrite
107
+
108
+ <!-- Rewriting a whole file. {{file}}: path. {{content}}: the file, fenced. {{extra}}: an extra instruction (may be empty). -->
109
+ Here is the current content of {{file}}:
110
+ {{content}}
111
+
112
+ Rewrite this file with the change needed for the TASK. Reply with the COMPLETE new file content and nothing else. Keep everything that should not change. Never use placeholders like "rest of code here".{{#if extra}}
113
+ {{extra}}{{/if}}
114
+
115
+ ## keep-names
116
+
117
+ <!-- An edit deleted definitions the task didn't ask to remove. {{names}}: them. {{them}}: "it" or "them". -->
118
+ Your previous attempt deleted {{names}}. The TASK does not ask for that, so keep {{them}} exactly as is.
119
+
120
+ ## find-place
121
+
122
+ <!-- The file is too long to show. {{file}}: path. -->
123
+ {{file}} is too long to show whole. What word or text in it marks the place that needs to change for the TASK? Reply with only that text.
124
+
125
+ ## pick-range
126
+
127
+ <!-- Choosing the lines to rewrite in a long file. {{file}}: path. {{part}}: " (lines 10-80 of 400)" or empty. {{view}}: numbered lines, fenced. -->
128
+ Here is part of {{file}}{{part}}, with line numbers:
129
+ {{view}}
130
+
131
+ Which lines need to change for the TASK? Reply with only the line range, like 12-30.
132
+
133
+ ## rewrite-range
134
+
135
+ <!-- Rewriting a line range. {{file}}, {{a}}-{{b}}: the range. {{chunk}}, {{before}}, {{after}}: fenced code. {{extra}}: an extra instruction (may be empty). -->
136
+ In {{file}}, these are lines {{a}}-{{b}}:
137
+ {{chunk}}
138
+
139
+ The lines just before them:
140
+ {{before}}
141
+ The lines just after them:
142
+ {{after}}
143
+
144
+ Write the new code that replaces lines {{a}}-{{b}} for the TASK (it can be more or fewer lines).{{#if extra}}
145
+ {{extra}}{{/if}} Reply with only that code.
146
+
147
+ ## new-file-path
148
+
149
+ What is the path of the new file, relative to the project root? Reply with only the path.
150
+
151
+ ## new-file
152
+
153
+ <!-- {{file}}: the new file's path. -->
154
+ Write the full content of the new file {{file}} for the TASK. Reply with only the file content.
155
+
156
+ ## run
157
+
158
+ <!-- Choosing a command. {{commands}}: the project's known commands, numbered (may be empty). -->
159
+ Which shell command should be run?
160
+ {{#if commands}}Commands this project has:
161
+ {{commands}}
162
+
163
+ Reply with a number from the list, or with a different shell command.{{else}}Reply with only the shell command.{{/if}}
164
+
165
+ ## ask-user
166
+
167
+ What question do you want to ask the user? Reply with only the question.
168
+
169
+ ## finish
170
+
171
+ Now write your final reply to the user: answer their request, or briefly summarize what was changed. Be clear and concise.
172
+
173
+ ## describe-image
174
+
175
+ <!-- For an image attached with @file.png. {{task}}: the user's message. -->
176
+ The user attached this image to the task: "{{task}}". Describe everything in it that matters for the task: text, code, error messages, layout. Be thorough but concise.
177
+
178
+ ## note-repeated
179
+
180
+ You just did the same thing twice. Choose something different, or finish if the task is done.
181
+
182
+ ## note-no-files
183
+
184
+ There are no files in the project.
185
+
186
+ ## note-unknown-file
187
+
188
+ Could not work out which file was meant.
189
+
190
+ ## note-tests-are-right
191
+
192
+ <!-- A weak model tried to edit a failing test instead of the code it tests. {{test}}, {{code}}: the files. -->
193
+ The tests in {{test}} are correct; fix {{code}}, the code they test.
@@ -0,0 +1,67 @@
1
+ # probe.md
2
+
3
+ The one-time test of a new model's abilities. Changing these changes how models are judged.
4
+ Each `## name` below is one prompt. `{{placeholders}}` are filled in by HeartCode;
5
+ a `<!-- … -->` note under a heading explains the prompt and is never sent. To change one, copy
6
+ its section into ~/.heart/prompts/probe.md (every project) or .heartcode/prompts/probe.md (one project).
7
+
8
+ ## capital
9
+
10
+ What is the capital of France? Answer in one word.
11
+
12
+ ## count
13
+
14
+ Count from 1 to 10, separated by spaces. Reply with only the numbers.
15
+
16
+ ## edit
17
+
18
+ <!-- The "edit skill" test. {{code}}: a small file. {{ask}}: the change to make. -->
19
+ Here is a file:
20
+ ```
21
+ {{code}}```
22
+
23
+ {{ask}}
24
+ Reply with only a SEARCH/REPLACE block in exactly this format:
25
+ <<<<<<< SEARCH
26
+ (exact lines to find)
27
+ =======
28
+ (replacement lines)
29
+ >>>>>>> REPLACE
30
+
31
+ ## exact-number
32
+
33
+ <!-- The "exact format" test. {{n}}: a number. -->
34
+ Reply with only the number {{n}}. No other words.
35
+
36
+ ## hello
37
+
38
+ Say hello in one short sentence.
39
+
40
+ ## image-color
41
+
42
+ What color is this image? Reply with one word.
43
+
44
+ ## menu
45
+
46
+ <!-- The "menu picking" test. {{what}}: e.g. "a fruit". {{list}}: numbered options. -->
47
+ Which of these is {{what}}?
48
+ {{list}}
49
+ Reply with only the number.
50
+
51
+ ## system
52
+
53
+ <!-- The system prompt of the "follows the system prompt" test; the model passes by replying BANANA. -->
54
+ Whatever the user says, reply with only the single word BANANA.
55
+
56
+ ## tool-description
57
+
58
+ Look up the secret code for a name.
59
+
60
+ ## tool-question
61
+
62
+ <!-- The tool-call test. {{name}}: the name to look up. -->
63
+ What is the secret code for "{{name}}"? Use the get_secret_code tool to find out.
64
+
65
+ ## tool-result
66
+
67
+ The secret code is ZX-4417.
package/src/acp.js ADDED
@@ -0,0 +1,248 @@
1
+ // heartcode --acp: the Agent Client Protocol (https://agentclientprotocol.com), for editors that
2
+ // run coding agents (Zed, JetBrains, Neovim's CodeCompanion, …). JSON-RPC 2.0 over stdin/stdout,
3
+ // one message per line. The editor starts sessions (session/new, session/load) and sends prompts
4
+ // (session/prompt); HeartCode streams its answer, thinking, tool calls and todo list back
5
+ // (session/update) and asks the editor before edits and commands (session/request_permission), as
6
+ // the permission mode says (the editor's mode picker: session/set_mode).
7
+ // In Zed's settings.json: "agent_servers": { "HeartCode": { "command": "heartcode", "args": ["--acp"] } }
8
+ import readline from 'node:readline'
9
+ import { fileURLToPath } from 'node:url'
10
+ import path from 'node:path'
11
+ import { log } from './log.js'
12
+ import { clip } from './tools.js'
13
+ import { HeadlessUI } from './headless.js'
14
+ import { PERMISSIONS } from './ui.js'
15
+ import { loadChat } from './sessions.js'
16
+
17
+ const KIND = { read_file: 'read', list_files: 'search', search: 'search', edit_file: 'edit', write_file: 'edit', run_command: 'execute', web_fetch: 'fetch', web_search: 'fetch', todo: 'think', agent: 'think' }
18
+ const plain = (s) => String(s ?? '').replace(/\x1b\[[0-9;]*m/g, '')
19
+
20
+ // The screen for one session: everything goes to the editor as session/update.
21
+ class AcpUI extends HeadlessUI {
22
+ constructor(conn, permission) {
23
+ super({ permission })
24
+ Object.assign(this, { conn, sessionId: null, calls: [] })
25
+ }
26
+ update(u) {
27
+ this.conn.notify('session/update', { sessionId: this.sessionId, update: u })
28
+ }
29
+ response() {
30
+ const chunk = (sessionUpdate) => (text) => text && this.update({ sessionUpdate, content: { type: 'text', text } })
31
+ return { text: chunk('agent_message_chunk'), think: chunk('agent_thought_chunk'), end() {} }
32
+ }
33
+ setPermission(mode) {
34
+ super.setPermission(mode)
35
+ if (this.sessionId) this.update({ sessionUpdate: 'current_mode_update', currentModeId: mode })
36
+ }
37
+ async confirm(question, kind = 'edit') {
38
+ if ((this.permission === 'all' || (this.permission === 'commands' && kind === 'edit') || (this.permission === 'plan' && kind === 'read'))) return true
39
+ const toolCallId = this.calls[0] ?? `ask_${Date.now()}`
40
+ const r = await this.conn
41
+ .request('session/request_permission', {
42
+ sessionId: this.sessionId,
43
+ toolCall: { toolCallId, title: plain(question) },
44
+ options: [
45
+ { optionId: 'allow', name: 'Allow', kind: 'allow_once' },
46
+ { optionId: 'all', name: 'Allow everything from now on', kind: 'allow_always' },
47
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' },
48
+ ],
49
+ })
50
+ .catch(() => null)
51
+ const id = r?.outcome?.outcome === 'selected' ? r.outcome.optionId : null
52
+ if (id === 'all') this.setPermission('all')
53
+ return id === 'allow' || id === 'all'
54
+ }
55
+ }
56
+
57
+ // JSON-RPC over stdio.
58
+ function connection(input, output) {
59
+ let next = 1
60
+ const pending = new Map()
61
+ const send = (msg) => output.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n')
62
+ return {
63
+ send,
64
+ pending,
65
+ notify: (method, params) => send({ method, params }),
66
+ request: (method, params) =>
67
+ new Promise((resolve, reject) => {
68
+ const id = `hc${next++}`
69
+ pending.set(id, { resolve, reject })
70
+ send({ id, method, params })
71
+ }),
72
+ }
73
+ }
74
+
75
+ // makeApp(root, ui, mcpServers) → an App (bin/heartcode.js) for a session's folder.
76
+ export async function runAcp({ makeApp, version, permission = 'ask', input = process.stdin, output = process.stdout }) {
77
+ const conn = connection(input, output)
78
+ const sessions = new Map() // id → { app, ui, ctl }
79
+ const modes = (current) => ({ currentModeId: current, availableModes: PERMISSIONS.map(([id, description]) => ({ id, name: id, description })) })
80
+
81
+ const open = async (cwd, mcpServers, chat) => {
82
+ const ui = new AcpUI(conn, permission)
83
+ const app = makeApp(path.resolve(cwd || process.cwd()), ui, toMcp(mcpServers))
84
+ if (chat) app.setChat(chat)
85
+ ui.sessionId = app.chat.id
86
+ const s = await app.session()
87
+ if (!s) throw Object.assign(new Error('no model set up: run heart settings to add a provider and choose a model'), { code: -32000 })
88
+ s.onEvent = (e) => events(ui, s, e)
89
+ sessions.set(ui.sessionId, { app, ui, ctl: null })
90
+ return { app, ui }
91
+ }
92
+
93
+ const methods = {
94
+ initialize: () => ({
95
+ protocolVersion: 1,
96
+ agentCapabilities: { loadSession: true, promptCapabilities: { image: true, audio: false, embeddedContext: true }, mcpCapabilities: { http: true, sse: true } },
97
+ authMethods: [],
98
+ agentInfo: { name: 'heartcode', title: 'HeartCode', version },
99
+ }),
100
+ 'session/new': async ({ cwd, mcpServers }) => {
101
+ const { ui } = await open(cwd, mcpServers)
102
+ return { sessionId: ui.sessionId, modes: modes(ui.permission) }
103
+ },
104
+ 'session/load': async ({ sessionId, cwd, mcpServers }) => {
105
+ const chat = loadChat(sessionId)
106
+ if (!chat) throw Object.assign(new Error(`no session ${sessionId}`), { code: -32002 })
107
+ const { ui } = await open(cwd ?? chat.root, mcpServers, chat)
108
+ for (const h of chat.history) {
109
+ ui.update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: h.task } })
110
+ ui.update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: h.answer } })
111
+ }
112
+ return { modes: modes(ui.permission) }
113
+ },
114
+ 'session/set_mode': ({ sessionId, modeId }) => {
115
+ const it = sessions.get(sessionId)
116
+ if (!it) throw Object.assign(new Error(`no session ${sessionId}`), { code: -32002 })
117
+ if (!PERMISSIONS.some(([p]) => p === modeId)) throw Object.assign(new Error(`no mode ${modeId}`), { code: -32602 })
118
+ it.ui.setPermission(modeId)
119
+ return {}
120
+ },
121
+ 'session/cancel': ({ sessionId }) => sessions.get(sessionId)?.ctl?.abort(),
122
+ 'session/prompt': async ({ sessionId, prompt }) => {
123
+ const it = sessions.get(sessionId)
124
+ if (!it) throw Object.assign(new Error(`no session ${sessionId}`), { code: -32002 })
125
+ const { app } = it
126
+ const { text, images } = promptOf(prompt, app.root)
127
+ it.ctl = new AbortController()
128
+ app.signal = it.ctl.signal
129
+ try {
130
+ const k = /^\/(\S+)\s*([\s\S]*)$/.exec(text)
131
+ const skill = k && app.skills.get(k[1])
132
+ await app.s.run(skill && skill.use !== 'model' ? await app.expandSkill(skill, k[2]) : text, it.ctl.signal, images)
133
+ return { stopReason: 'end_turn' }
134
+ } catch (e) {
135
+ if (it.ctl.signal.aborted) return { stopReason: 'cancelled' }
136
+ if (e.maxTurns) return { stopReason: 'max_turn_requests' }
137
+ throw e
138
+ } finally {
139
+ it.ctl = null
140
+ it.ui.calls = []
141
+ app.save()
142
+ }
143
+ },
144
+ }
145
+
146
+ const rl = readline.createInterface({ input, crlfDelay: Infinity })
147
+ const running = new Set()
148
+ for await (const line of rl) {
149
+ if (!line.trim()) continue
150
+ let msg
151
+ try {
152
+ msg = JSON.parse(line)
153
+ } catch {
154
+ conn.send({ id: null, error: { code: -32700, message: 'parse error' } })
155
+ continue
156
+ }
157
+ if (msg.method === undefined && msg.id !== undefined) {
158
+ // an answer to one of HeartCode's requests
159
+ const p = conn.pending.get(msg.id)
160
+ conn.pending.delete(msg.id)
161
+ msg.error ? p?.reject(new Error(msg.error.message)) : p?.resolve(msg.result)
162
+ continue
163
+ }
164
+ const fn = methods[msg.method]
165
+ log('acp', `← ${msg.method}`, clip(JSON.stringify(msg.params ?? {}), 2000))
166
+ const job = (async () => {
167
+ if (!fn) {
168
+ if (msg.id !== undefined) conn.send({ id: msg.id, error: { code: -32601, message: `HeartCode does not support ${msg.method}` } })
169
+ return
170
+ }
171
+ try {
172
+ const result = await fn(msg.params ?? {})
173
+ if (msg.id !== undefined) conn.send({ id: msg.id, result: result ?? null })
174
+ } catch (e) {
175
+ log('acp', `${msg.method} failed: ${e.stack}`)
176
+ if (msg.id !== undefined) conn.send({ id: msg.id, error: { code: e.code ?? -32603, message: e.message } })
177
+ }
178
+ })()
179
+ running.add(job)
180
+ job.finally(() => running.delete(job))
181
+ }
182
+ await Promise.all(running)
183
+ return 0
184
+ }
185
+
186
+ // Tool calls and results → the editor's tool call list; the todo list → its plan.
187
+ function events(ui, s, e) {
188
+ if (e.type === 'assistant') {
189
+ for (const t of e.toolCalls) {
190
+ let args = {}
191
+ try {
192
+ args = JSON.parse(t.function.arguments || '{}')
193
+ } catch {}
194
+ const name = t.function.name
195
+ const what = args.command ?? args.path ?? args.url ?? args.query ?? args.description ?? (args.server ? `${args.server} · ${args.tool}` : '')
196
+ ui.calls.push(t.id)
197
+ ui.update({
198
+ sessionUpdate: 'tool_call',
199
+ toolCallId: t.id,
200
+ title: clip(`${name}${what ? ` ${what}` : ''}`, 120),
201
+ kind: KIND[name] ?? 'other',
202
+ status: 'pending',
203
+ rawInput: args,
204
+ ...(args.path && { locations: [{ path: path.resolve(s.root, String(args.path)) }] }),
205
+ })
206
+ }
207
+ } else if (e.type === 'tool') {
208
+ ui.calls = ui.calls.filter((id) => id !== e.id)
209
+ const failed = /^error/.test(e.output)
210
+ ui.update({ sessionUpdate: 'tool_call_update', toolCallId: e.id, status: failed ? 'failed' : 'completed', content: [{ type: 'content', content: { type: 'text', text: clip(plain(e.output), 4000) } }] })
211
+ if (e.name === 'todo') ui.update({ sessionUpdate: 'plan', entries: (s.chat.todos ?? []).map((t) => ({ content: t.content, priority: 'medium', status: t.status })) })
212
+ }
213
+ }
214
+
215
+ // The prompt's content blocks: text, images, files (linked or with their text).
216
+ export function promptOf(blocks, root) {
217
+ const text = []
218
+ const images = []
219
+ for (const b of [blocks].flat()) {
220
+ if (!b) continue
221
+ if (b.type === 'text') text.push(b.text)
222
+ else if (b.type === 'image' && b.data) images.push(`data:${b.mimeType ?? 'image/png'};base64,${b.data}`)
223
+ else if (b.type === 'resource_link') text.push(rel(b.uri, root))
224
+ else if (b.type === 'resource' && b.resource) {
225
+ const r = b.resource
226
+ text.push(r.text !== undefined ? `${rel(r.uri, root)}:\n\`\`\`\n${r.text}\n\`\`\`` : rel(r.uri, root))
227
+ }
228
+ }
229
+ return { text: text.join('\n\n'), images }
230
+ }
231
+
232
+ const rel = (uri, root) => {
233
+ try {
234
+ return uri?.startsWith('file:') ? path.relative(root, fileURLToPath(uri)) || '.' : String(uri)
235
+ } catch {
236
+ return String(uri)
237
+ }
238
+ }
239
+
240
+ // The editor's MCP servers → HeartCode's config ({ name, command, args, env: [{name, value}] } or { type: http|sse, url, headers }).
241
+ function toMcp(list = []) {
242
+ const kv = (xs) => Object.fromEntries((xs ?? []).map((x) => [x.name, x.value]))
243
+ const servers = Object.fromEntries(
244
+ list.map((m) => [m.name, m.command ? { command: m.command, args: m.args ?? [], env: kv(m.env) } : { url: m.url, type: m.type === 'sse' ? 'sse' : 'http', headers: kv(m.headers) }]),
245
+ )
246
+ return list.length ? JSON.stringify({ mcpServers: servers }) : null
247
+ }
248
+