heartcode 0.0.0-stage → 0.1.0

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,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
+