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.
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/prompts/probe.md
ADDED
|
@@ -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
|
+
|