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.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/SKILL.md +179 -0
- package/bin/heart.js +78 -0
- package/bin/heartcode.js +1572 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +348 -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 +697 -0
- package/src/client.js +629 -0
- package/src/config.js +109 -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 +218 -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 +376 -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 +104 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1618 -0
- package/src/settings.js +554 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +865 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +149 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
|
@@ -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.
|
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
|
+
|