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/src/guided.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
// Guided mode, for models without (reliable) tool calls. HeartCode drives the
|
|
2
|
+
// task as a series of single questions; every answer has one trivial shape
|
|
3
|
+
// (a number, a path, or "the whole reply is code") and is checked against
|
|
4
|
+
// reality before use. With constrained output, the server enforces the shape.
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import { log } from './log.js'
|
|
7
|
+
import { prompt, userInstructions } from './prompts.js'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { est } from './client.js'
|
|
10
|
+
import { applyEdit, parseBlocks, cleanCode, lostNames, reindent } from './edit.js'
|
|
11
|
+
import { listFiles, namedFiles, readFileTool, searchFiles, numberLines, proposeWrite, runConfirmed, suggestCommands, resolveIn, writeResult, clip } from './tools.js'
|
|
12
|
+
import { c } from './ui.js'
|
|
13
|
+
|
|
14
|
+
// The steps, as "name: description" lines in prompts/guided/actions.md.
|
|
15
|
+
const ACTIONS = prompt('guided/actions')
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map((l) => l.split(/:\s*/, 2))
|
|
18
|
+
.filter(([n, d]) => n && d)
|
|
19
|
+
const DESC = Object.fromEntries(ACTIONS)
|
|
20
|
+
const CHANGE_WORDS = /\b(fix|add|change|implement|create|write|make|rename|refactor|update|remove|delete|replace|edit|convert|move|improve)\b/i
|
|
21
|
+
const RUN_WORDS = /\b(tests?|run|build|lint|compile)\b/i
|
|
22
|
+
const FAIL_WORDS = /\b(fail\w*|broken|breaks?|errors?|crash\w*)\b/i
|
|
23
|
+
const TEST_FILE = /(^|[/._-])(tests?|spec|__tests__)([/._-]|$)/i
|
|
24
|
+
const TESTS_ARE_TARGET = /\b(add|write|update|change|fix|edit)\s+(the\s+|a\s+|some\s+)?(unit\s+)?tests?\b/i
|
|
25
|
+
const QUESTION = /^\s*(what|how|why|where|which|who|when|explain|describe|summari[sz]e|tell|show|list|is|are|does|do|can|could|should)\b|\?\s*$/i
|
|
26
|
+
|
|
27
|
+
// Wording first; the model only breaks a tie (small models answer yes/no unreliably).
|
|
28
|
+
export function intent(task) {
|
|
29
|
+
const change = CHANGE_WORDS.test(task)
|
|
30
|
+
const question = QUESTION.test(task)
|
|
31
|
+
return change && !question ? true : question && !change ? false : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A path in the task that doesn't exist yet, e.g. "create src/slug.js".
|
|
35
|
+
export function newFileIn(task, files) {
|
|
36
|
+
if (!/\b(create|new|add|write|make|generate)\b/i.test(task)) return null
|
|
37
|
+
for (const m of task.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/)*[\w-]+\.[a-z0-9]{1,6})(?=$|[\s`'"),:;]|\.(?:\s|$))/gi)) {
|
|
38
|
+
const f = m[1].replace(/^\.\//, '')
|
|
39
|
+
if (!files.includes(f) && !/^(node|next|vue|react|nuxt|express|three|d3)\.js$/i.test(f)) return f
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Obvious next steps are taken without asking: a weak model's effort goes into
|
|
45
|
+
// writing code, not into navigating. Returns an action, or null to ask.
|
|
46
|
+
export function autoAction(st) {
|
|
47
|
+
if (st.newFile && st.wantsChange) return 'create'
|
|
48
|
+
if (st.failing && st.canRun && !st.runs && !st.edits) return 'run' // see the failure first
|
|
49
|
+
if (st.lastRun !== null && st.lastRun !== 0 && !st.editsSinceRun && st.wantsChange && st.fixes < 3) return 'edit'
|
|
50
|
+
if (st.wantsChange && st.reads && !st.edits) return 'edit'
|
|
51
|
+
if (st.editsSinceRun && st.canRun && st.wantsRun) return 'run'
|
|
52
|
+
if (st.lastRun === 0 && st.edits && !st.editsSinceRun) return 'finish'
|
|
53
|
+
if (st.wantsRun && !st.runs && !st.wantsChange) return 'run'
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// When HeartCode does ask, small models still lean to the first option, so the
|
|
58
|
+
// likeliest step goes first.
|
|
59
|
+
export function nextActions(st) {
|
|
60
|
+
let first
|
|
61
|
+
if (!st.reads && st.hasFiles) first = ['read', 'search']
|
|
62
|
+
else if (st.wantsChange && !st.edits) first = st.hasFiles ? ['edit', 'create', 'read'] : ['create']
|
|
63
|
+
else first = ['finish', 'edit', 'run']
|
|
64
|
+
return [...new Set([...first, ...ACTIONS.map(([n]) => n)])]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A reply may be plain text or (when enforced) JSON like {"path": "a.js"}.
|
|
68
|
+
export function answerText(reply) {
|
|
69
|
+
const t = String(reply).trim()
|
|
70
|
+
if (t.startsWith('{')) {
|
|
71
|
+
try {
|
|
72
|
+
const v = Object.values(JSON.parse(t))[0]
|
|
73
|
+
if (v !== undefined) return String(v)
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return t
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parsePick(reply, names) {
|
|
80
|
+
const t = answerText(reply).toLowerCase()
|
|
81
|
+
const n = Number(/\d+/.exec(t)?.[0])
|
|
82
|
+
if (n >= 1 && n <= names.length) return names[n - 1]
|
|
83
|
+
const found = names.map((name) => [name, t.search(new RegExp(`\\b${name}\\b`))]).filter(([, i]) => i !== -1)
|
|
84
|
+
return found.sort((a, b) => a[1] - b[1])[0]?.[0] ?? null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseRange(reply, max) {
|
|
88
|
+
const t = String(reply).trim()
|
|
89
|
+
let a
|
|
90
|
+
let b
|
|
91
|
+
try {
|
|
92
|
+
const o = JSON.parse(t)
|
|
93
|
+
;[a, b] = [Number(o.start), Number(o.end)]
|
|
94
|
+
} catch {
|
|
95
|
+
const m = /(\d+)\s*(?:-|–|to|,|:|\.\.)\s*(\d+)/i.exec(t) ?? /(\d+)/.exec(t)
|
|
96
|
+
if (m) [a, b] = [Number(m[1]), Number(m[2] ?? m[1])]
|
|
97
|
+
}
|
|
98
|
+
if (!(a > 0)) return null
|
|
99
|
+
if (!(b > 0)) b = a
|
|
100
|
+
if (a > b) [a, b] = [b, a]
|
|
101
|
+
return [Math.min(a, max), Math.min(b, max)]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const STOP = new Set('the and for with that this from into instead should would could file code function change make sure fix add use not but its it'.split(' '))
|
|
105
|
+
|
|
106
|
+
// The project's files worth showing a model for this task, instead of all of them: a small project
|
|
107
|
+
// whole (40 files or fewer); else the files already read, then those whose path has the task's words
|
|
108
|
+
// (most matches first), up to 30; with nothing related, the top level of the project, to start from.
|
|
109
|
+
export function relevantFiles(task, files, read = [], max = 30) {
|
|
110
|
+
if (files.length <= 40) return files
|
|
111
|
+
const words = [...new Set(String(task).toLowerCase().match(/[a-z0-9_]{3,}/g) ?? [])].filter((w) => !STOP.has(w))
|
|
112
|
+
const score = (f) => words.reduce((n, w) => n + (f.toLowerCase().includes(w) ? 1 : 0), 0)
|
|
113
|
+
const hits = files.map((f) => [f, score(f)]).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1] || a[0].length - b[0].length).map(([f]) => f)
|
|
114
|
+
const picked = [...new Set([...read.filter((f) => files.includes(f)), ...hits])].slice(0, max)
|
|
115
|
+
if (picked.length) return picked
|
|
116
|
+
return [...new Set(files.map((f) => f.split('/')[0] + (f.includes('/') ? '/' : '')))].slice(0, max) // the top level
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// For files too big to show whole: the window of lines most related to `hint`.
|
|
120
|
+
export function focusWindow(lines, hint, maxChars) {
|
|
121
|
+
const words = [...new Set(String(hint).toLowerCase().match(/[a-z_$][\w$]{2,}/g) ?? [])].filter((w) => !STOP.has(w))
|
|
122
|
+
let best = -1
|
|
123
|
+
let score = 0
|
|
124
|
+
lines.forEach((l, i) => {
|
|
125
|
+
const low = l.toLowerCase()
|
|
126
|
+
const sc = words.filter((w) => low.includes(w)).length
|
|
127
|
+
if (sc > score) [best, score] = [i, sc]
|
|
128
|
+
})
|
|
129
|
+
if (best < 0) return null
|
|
130
|
+
let a = best
|
|
131
|
+
let b = best + 1
|
|
132
|
+
let size = lines[best].length
|
|
133
|
+
while ((a > 0 || b < lines.length) && size < maxChars) {
|
|
134
|
+
if (a > 0) size += lines[--a].length + 8
|
|
135
|
+
if (b < lines.length && size < maxChars) size += lines[b++].length + 8
|
|
136
|
+
}
|
|
137
|
+
return [a, b] // 0-based, end exclusive
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const unquote = (s) => s.replace(/^[`'"*(]+|[`'"*).,:;]+$/g, '').replace(/^\.\//, '').replace(/\\/g, '/')
|
|
141
|
+
const shortest = (list) => list.sort((a, b) => a.length - b.length)[0]
|
|
142
|
+
|
|
143
|
+
// Map whatever the model said onto a real file.
|
|
144
|
+
export function matchFile(files, answer) {
|
|
145
|
+
const line = String(answer).split('\n').map((l) => l.trim()).find(Boolean) ?? ''
|
|
146
|
+
const tokens = [unquote(line), ...line.split(/\s+/).map(unquote)].filter(Boolean)
|
|
147
|
+
for (const t of tokens) {
|
|
148
|
+
if (files.includes(t)) return t
|
|
149
|
+
const tailHits = files.filter((f) => f.endsWith('/' + t))
|
|
150
|
+
if (tailHits.length) return shortest(tailHits)
|
|
151
|
+
const baseHits = files.filter((f) => path.basename(f).toLowerCase() === t.toLowerCase())
|
|
152
|
+
if (baseHits.length) return shortest(baseHits)
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function closest(files, answer, n) {
|
|
158
|
+
const parts = String(answer).toLowerCase().split(/[^a-z0-9]+/).filter((p) => p.length > 1)
|
|
159
|
+
const score = (f) => parts.filter((p) => f.toLowerCase().includes(p)).length
|
|
160
|
+
return [...files].sort((a, b) => score(b) - score(a) || a.length - b.length).slice(0, n)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const fence = (t) => {
|
|
164
|
+
const f = '`'.repeat(Math.max(3, ...[...t.matchAll(/`{3,}/g)].map((m) => m[0].length + 1)))
|
|
165
|
+
return `${f}\n${t}\n${f}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Project files a file imports by relative path ('./num.js', '../lib/x').
|
|
169
|
+
export function localImports(file, text, files) {
|
|
170
|
+
const out = []
|
|
171
|
+
for (const m of text.matchAll(/(?:from|import|require\(|import\()\s*['"](\.{1,2}\/[^'"]+)['"]/g)) {
|
|
172
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(file), m[1]))
|
|
173
|
+
const hit = ['', '.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx', '/index.js', '/index.ts'].map((x) => base + x).find((x) => files.includes(x))
|
|
174
|
+
if (hit && !out.includes(hit)) out.push(hit)
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function cleanCommand(reply) {
|
|
180
|
+
const line = String(reply).split('\n').map((l) => l.trim()).find((l) => l && !/^(`{3,}|~{3,})/.test(l)) ?? ''
|
|
181
|
+
return line.replace(/^\$\s+/, '').replace(/^`+|`+$/g, '').trim()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function runGuided(s, task, images, signal) {
|
|
185
|
+
return new Guided(s, task, signal).start(images)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
class Guided {
|
|
189
|
+
constructor(s, task, signal) {
|
|
190
|
+
this.s = s
|
|
191
|
+
this.task = task
|
|
192
|
+
this.signal = signal
|
|
193
|
+
this.notes = []
|
|
194
|
+
this.changed = new Set()
|
|
195
|
+
this.files = listFiles(s.root)
|
|
196
|
+
this.st = { reads: 0, edits: 0, editsSinceRun: 0, runs: 0, fixes: 0, lastRun: null, hasFiles: this.files.length > 0, canRun: suggestCommands(s.root).length > 0 }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async yesNo(question, fallback) {
|
|
200
|
+
const schema = { type: 'object', properties: { answer: { type: 'string', enum: ['yes', 'no'] } }, required: ['answer'], additionalProperties: false }
|
|
201
|
+
const res = await this.query(prompt('guided/yes-no', { question }), { room: 20, schema, oneLine: true })
|
|
202
|
+
const m = /\b(yes|no)\b/i.exec(answerText(res.content))
|
|
203
|
+
return m ? m[1].toLowerCase() === 'yes' : fallback
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get ui() {
|
|
207
|
+
return this.s.ui
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
note(key, title, body) {
|
|
211
|
+
if (key) this.notes = this.notes.filter((n) => n.key !== key)
|
|
212
|
+
this.notes.push({ key, title, body })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Everything the model needs to know, fitted into the context window.
|
|
216
|
+
context(reserve, skipKey) {
|
|
217
|
+
const budget = Math.max(1500, (this.s.strat.context - reserve - 200) * 3.5)
|
|
218
|
+
const history = this.s.history
|
|
219
|
+
.slice(-3)
|
|
220
|
+
.map((h) => `User: ${clip(h.task, 300)}\nYou: ${clip(h.answer, 500)}`)
|
|
221
|
+
.join('\n')
|
|
222
|
+
// the files that matter for this task (not the whole project): see relevantFiles
|
|
223
|
+
const picked = relevantFiles(this.task, this.files, this.notes.filter((n) => n.key?.startsWith('read:')).map((n) => n.key.slice(5)))
|
|
224
|
+
let list = ''
|
|
225
|
+
for (const f of picked) {
|
|
226
|
+
if (list.length + f.length > budget * 0.2) break
|
|
227
|
+
list += f + '\n'
|
|
228
|
+
}
|
|
229
|
+
const more = this.files.length - list.split('\n').filter(Boolean).length
|
|
230
|
+
const files = this.files.length ? `\n${list}${more ? `(${this.files.length} files in all: the ones related to the task are shown; use search to find others)` : ''}`.trimEnd() : ' (none, the project is empty)'
|
|
231
|
+
const vars = { task: this.task, history, files }
|
|
232
|
+
let left = budget - prompt('guided/context', { ...vars, done: '' }).length
|
|
233
|
+
const parts = []
|
|
234
|
+
const notes = this.notes.filter((n) => !skipKey || n.key !== skipKey)
|
|
235
|
+
for (let i = notes.length - 1; i >= 0; i--) {
|
|
236
|
+
const full = `--- ${notes[i].title} ---\n${notes[i].body}`.trimEnd()
|
|
237
|
+
if (full.length <= left) {
|
|
238
|
+
parts.unshift(full)
|
|
239
|
+
left -= full.length
|
|
240
|
+
} else if (left > 400) {
|
|
241
|
+
parts.unshift(full.slice(0, left - 40) + '\n[… cut to fit]')
|
|
242
|
+
left = 0
|
|
243
|
+
} else {
|
|
244
|
+
parts.unshift(`(${i + 1} earlier step(s) not shown)`)
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return prompt('guided/context', { ...vars, done: parts.join('\n\n') }).trimEnd()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `answer: true` streams the reply to the user as the answer; otherwise only thinking is shown.
|
|
252
|
+
// room: tokens of context to keep free for the reply (only for fitting the context; never sent).
|
|
253
|
+
async query(question, opts = {}, tries = 0) {
|
|
254
|
+
try {
|
|
255
|
+
return await this.ask1(question, opts, tries)
|
|
256
|
+
} catch (e) {
|
|
257
|
+
// too large for the model: strat.context has dropped to its limit, so the context shrinks
|
|
258
|
+
if (!e.inputLimit || tries >= 3) throw e
|
|
259
|
+
return this.query(question, opts, tries + 1)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async ask1(question, { room = 32, schema, oneLine, onToken, answer, skipKey } = {}, tries = 0) {
|
|
264
|
+
const { client, strat } = this.s
|
|
265
|
+
const think = strat.thinking ? 4096 : 0
|
|
266
|
+
const user = `${this.context(est(question) + room + think + tries * 600, skipKey)}\n\n${question}`
|
|
267
|
+
this.instructions ??= userInstructions(this.s.root)
|
|
268
|
+
const system = [prompt('guided/system'), this.instructions && prompt('agent/instructions', { instructions: this.instructions }), this.s.appendSystem].filter(Boolean).join('\n\n')
|
|
269
|
+
const messages = strat.mergeSystem
|
|
270
|
+
? [{ role: 'user', content: `${system}\n\n${user}` }]
|
|
271
|
+
: [
|
|
272
|
+
{ role: 'system', content: system },
|
|
273
|
+
{ role: 'user', content: user },
|
|
274
|
+
]
|
|
275
|
+
const out = this.ui.response()
|
|
276
|
+
if (answer) onToken = out.text
|
|
277
|
+
const stream = Boolean(strat.stream && (onToken || strat.thinking))
|
|
278
|
+
const body = { messages, temperature: 0, stream } // no max_tokens: the model decides how long
|
|
279
|
+
if (schema && strat.enforce) body.response_format = { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema } }
|
|
280
|
+
else if (oneLine && strat.stop) body.stop = ['\n\n']
|
|
281
|
+
const res = await client.chat(body, { signal: this.signal, onToken, onReasoning: out.think })
|
|
282
|
+
if (!stream) out.think(res.reasoning)
|
|
283
|
+
if (answer && !stream) out.text(res.content)
|
|
284
|
+
out.end()
|
|
285
|
+
log('guided', `asked: ${question}\n replied: ${JSON.stringify(res.content)}`)
|
|
286
|
+
return res
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
progress(label) {
|
|
290
|
+
let n = 0
|
|
291
|
+
this.ui.spinner(label)
|
|
292
|
+
return (t) => this.ui.spinner(`${label} · ${(n += t.length)} chars`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async start(images) {
|
|
296
|
+
if (images.length) await this.describeImages(images)
|
|
297
|
+
this.autoRead()
|
|
298
|
+
this.ui.spinner('reading the task')
|
|
299
|
+
this.st.wantsChange = intent(this.task) ?? (await this.yesNo(prompt('guided/intent'), false))
|
|
300
|
+
this.st.newFile = newFileIn(this.task, this.files)
|
|
301
|
+
this.st.wantsRun = RUN_WORDS.test(this.task)
|
|
302
|
+
this.st.failing = FAIL_WORDS.test(this.task)
|
|
303
|
+
let last = ''
|
|
304
|
+
let repeats = 0
|
|
305
|
+
// No step limit (Esc stops it); a model doing the same thing three times in a row is stopped.
|
|
306
|
+
for (let step = 0; !this.s.maxTurns || step < this.s.maxTurns; step++) {
|
|
307
|
+
this.ui.spinner('deciding next step')
|
|
308
|
+
const auto = autoAction(this.st)
|
|
309
|
+
const action = auto ?? (await this.pickAction())
|
|
310
|
+
log('guided', `step ${step + 1}: ${action}${auto ? ' (autopilot)' : ''}`, this.st)
|
|
311
|
+
if (auto === 'edit' && this.st.runs) this.st.fixes++
|
|
312
|
+
if (action === 'finish') break
|
|
313
|
+
let sig
|
|
314
|
+
try {
|
|
315
|
+
sig = await this[action]()
|
|
316
|
+
} catch (e) {
|
|
317
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError' || e.status || /cannot reach/.test(e.message)) throw e
|
|
318
|
+
this.ui.result(c.red(e.message))
|
|
319
|
+
this.note(null, `${action} failed`, e.message)
|
|
320
|
+
sig = `${action}:error`
|
|
321
|
+
}
|
|
322
|
+
repeats = sig === last ? repeats + 1 : 0
|
|
323
|
+
last = sig
|
|
324
|
+
if (repeats >= 2) {
|
|
325
|
+
this.ui.warn(`stopped: ${this.s.client.model} did the same thing three times in a row`)
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
if (repeats === 1) this.note(null, 'warning', prompt('guided/note-repeated'))
|
|
329
|
+
}
|
|
330
|
+
return this.finish()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async describeImages(images) {
|
|
334
|
+
for (const [i, url] of images.entries()) {
|
|
335
|
+
this.ui.spinner(`looking at image ${i + 1}`)
|
|
336
|
+
const text = prompt('guided/describe-image', { task: this.task })
|
|
337
|
+
const res = await this.s.client.chat(
|
|
338
|
+
{ messages: [{ role: 'user', content: [{ type: 'text', text }, { type: 'image_url', image_url: { url } }] }] },
|
|
339
|
+
{ signal: this.signal },
|
|
340
|
+
)
|
|
341
|
+
this.note(`image:${i}`, `attached image ${i + 1}`, res.content)
|
|
342
|
+
this.ui.step('image', `described image ${i + 1}`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Files named in the task are read up front: one less decision for a weak model.
|
|
347
|
+
autoRead() {
|
|
348
|
+
for (const f of namedFiles(this.task, this.files).slice(0, 3)) this.readNote(f)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
readNote(f, quiet = false) {
|
|
352
|
+
const body = readFileTool(this.s.root, f, 1, undefined, 2000)
|
|
353
|
+
this.note(`read:${f}`, `contents of ${f}`, clip(body, Math.floor(this.s.strat.context * 3.5 * 0.4)))
|
|
354
|
+
if (!quiet) this.ui.step('read', f)
|
|
355
|
+
this.st.reads++
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async pickAction() {
|
|
359
|
+
const names = nextActions(this.st)
|
|
360
|
+
const menu = names.map((n, i) => `${i + 1}. ${n} - ${DESC[n]}`).join('\n')
|
|
361
|
+
let q = prompt('guided/next-action', { menu })
|
|
362
|
+
const schema = { type: 'object', properties: { choice: { type: 'integer', enum: names.map((_, i) => i + 1) } }, required: ['choice'], additionalProperties: false }
|
|
363
|
+
for (let i = 0; i < 2; i++) {
|
|
364
|
+
const res = await this.query(q, { room: 16, schema, oneLine: true })
|
|
365
|
+
const a = parsePick(res.content, names)
|
|
366
|
+
if (a) return a
|
|
367
|
+
q += '\n\n' + prompt('guided/next-action-retry', { reply: clip(res.content, 40), count: names.length })
|
|
368
|
+
}
|
|
369
|
+
return 'finish'
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async pickFile(question, candidates) {
|
|
373
|
+
if (!candidates.length) {
|
|
374
|
+
this.note(null, 'note', prompt('guided/note-no-files'))
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
const schema =
|
|
378
|
+
candidates.length <= 300 ? { type: 'object', properties: { path: { type: 'string', enum: candidates } }, required: ['path'], additionalProperties: false } : undefined
|
|
379
|
+
let q = prompt('guided/pick-file', { question })
|
|
380
|
+
for (let i = 0; i < 2; i++) {
|
|
381
|
+
const res = await this.query(q, { room: 60, schema, oneLine: true })
|
|
382
|
+
const said = answerText(res.content)
|
|
383
|
+
const f = matchFile(candidates, said)
|
|
384
|
+
if (f) return f
|
|
385
|
+
q = prompt('guided/pick-file-retry', { question, said: clip(said, 60), choices: closest(candidates, said, 15).join('\n') })
|
|
386
|
+
}
|
|
387
|
+
this.note(null, 'note', prompt('guided/note-unknown-file'))
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async read() {
|
|
392
|
+
const f = await this.pickFile(prompt('guided/which-file-to-read'), this.files)
|
|
393
|
+
if (f) this.readNote(f)
|
|
394
|
+
return `read:${f}`
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async search() {
|
|
398
|
+
const res = await this.query(prompt('guided/search'), { room: 40, oneLine: true })
|
|
399
|
+
const q = unquote(answerText(res.content).split('\n')[0].trim())
|
|
400
|
+
if (!q) return 'search:'
|
|
401
|
+
this.ui.step('search', q)
|
|
402
|
+
const out = searchFiles(this.s.root, q)
|
|
403
|
+
this.ui.result(out.startsWith('no matches') ? 'no matches' : `${out.split('\n').length} matches`)
|
|
404
|
+
this.note(`search:${q}`, `search results for "${q}"`, out)
|
|
405
|
+
return `search:${q}`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async edit() {
|
|
409
|
+
const seen = this.notes.filter((n) => n.key?.startsWith('read:')).map((n) => n.key.slice(5)).reverse()
|
|
410
|
+
// Weak models pick the first option: offer the code under test before the tests themselves.
|
|
411
|
+
let files = [...new Set([...seen, ...this.files])]
|
|
412
|
+
const testsAreTarget = TESTS_ARE_TARGET.test(this.task)
|
|
413
|
+
if (!testsAreTarget) files = [...files.filter((x) => !TEST_FILE.test(x)), ...files.filter((x) => TEST_FILE.test(x))]
|
|
414
|
+
let f = await this.pickFile(prompt('guided/which-file-to-edit'), files)
|
|
415
|
+
if (!f) return 'edit:'
|
|
416
|
+
if (this.st.lastRun && TEST_FILE.test(f) && !testsAreTarget) f = this.codeUnderTest(f)
|
|
417
|
+
const old = fs.readFileSync(resolveIn(this.s.root, f), 'utf8')
|
|
418
|
+
this.ui.step('edit', f)
|
|
419
|
+
let next = null
|
|
420
|
+
if (this.s.strat.edits === 'search_replace') next = await this.editBlocks(f, old)
|
|
421
|
+
if (next === null) next = await this.editRewrite(f, old)
|
|
422
|
+
if (next === null) return `edit:${f}`
|
|
423
|
+
const lost = lostNames(old, next, this.task)
|
|
424
|
+
if (lost.length) {
|
|
425
|
+
this.ui.result(c.yellow(`that edit deleted ${lost.join(', ')}; retrying`))
|
|
426
|
+
const again = await this.editRewrite(f, old, prompt('guided/keep-names', { names: lost.join(', '), them: lost.length > 1 ? 'them' : 'it' }))
|
|
427
|
+
if (again !== null && !lostNames(old, again, this.task).length) next = again
|
|
428
|
+
else this.ui.warn(`this edit removes ${lost.join(', ')}; check the diff`)
|
|
429
|
+
}
|
|
430
|
+
await this.apply(f, old, next)
|
|
431
|
+
return `edit:${f}`
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Weak models "fix" failing tests by editing the tests. Unless the task asks for test changes,
|
|
435
|
+
// the edit goes to the code the test imports.
|
|
436
|
+
codeUnderTest(test) {
|
|
437
|
+
const code = localImports(test, fs.readFileSync(resolveIn(this.s.root, test), 'utf8'), this.files).find((x) => !TEST_FILE.test(x))
|
|
438
|
+
if (code) this.note(null, 'note', prompt('guided/note-tests-are-right', { test, code }))
|
|
439
|
+
return code ?? test
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async editBlocks(f, old) {
|
|
443
|
+
const q = prompt('guided/edit-blocks', { file: f, content: fence(old) })
|
|
444
|
+
const res = await this.query(q, { room: Math.min(est(old) + 800, 4096), onToken: this.progress(`editing ${f}`), skipKey: `read:${f}` })
|
|
445
|
+
const blocks = parseBlocks(res.content)
|
|
446
|
+
let text = old
|
|
447
|
+
for (const b of blocks) {
|
|
448
|
+
const r = applyEdit(text, b.search, b.replace)
|
|
449
|
+
if (r.error) {
|
|
450
|
+
this.ui.result(`edit block did not apply (${clip(r.error, 80)}); rewriting instead`)
|
|
451
|
+
return null
|
|
452
|
+
}
|
|
453
|
+
text = r.text
|
|
454
|
+
}
|
|
455
|
+
return blocks.length ? text : null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async editRewrite(f, old, extra = '') {
|
|
459
|
+
const skipKey = `read:${f}`
|
|
460
|
+
if (est(old) * 2.3 + 1000 < this.s.strat.context) {
|
|
461
|
+
const q = prompt('guided/rewrite', { file: f, content: fence(old), extra })
|
|
462
|
+
const res = await this.query(q, { room: Math.ceil(est(old) * 1.5) + 600, onToken: this.progress(`rewriting ${f}`), skipKey })
|
|
463
|
+
if (res.finishReason === 'length') return this.failed(f, 'the reply was cut off before the end of the file')
|
|
464
|
+
return cleanCode(res.content, old)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Too big to rewrite whole: find the right area, pick a line range, rewrite just that.
|
|
468
|
+
const lines = old.split('\n')
|
|
469
|
+
const room = Math.floor(this.s.strat.context * 3.5 * 0.4)
|
|
470
|
+
let win = focusWindow(lines, this.task, room)
|
|
471
|
+
if (!win) {
|
|
472
|
+
const res = await this.query(prompt('guided/find-place', { file: f }), {
|
|
473
|
+
room: 30,
|
|
474
|
+
oneLine: true,
|
|
475
|
+
skipKey,
|
|
476
|
+
})
|
|
477
|
+
win = focusWindow(lines, answerText(res.content), room) ?? [0, lines.length]
|
|
478
|
+
}
|
|
479
|
+
const view = clip(numberLines(lines.slice(win[0], win[1]), win[0] + 1), room)
|
|
480
|
+
const schema = { type: 'object', properties: { start: { type: 'integer' }, end: { type: 'integer' } }, required: ['start', 'end'], additionalProperties: false }
|
|
481
|
+
const part = win[0] > 0 || win[1] < lines.length ? ` (lines ${win[0] + 1}-${win[1]} of ${lines.length})` : ''
|
|
482
|
+
const rres = await this.query(prompt('guided/pick-range', { file: f, part, view: fence(view) }), {
|
|
483
|
+
room: 20,
|
|
484
|
+
schema,
|
|
485
|
+
oneLine: true,
|
|
486
|
+
skipKey,
|
|
487
|
+
})
|
|
488
|
+
const range = parseRange(rres.content, lines.length)
|
|
489
|
+
if (!range) return this.failed(f, `could not understand the line range "${clip(rres.content, 40)}"`)
|
|
490
|
+
const [a, b] = range
|
|
491
|
+
const chunk = lines.slice(a - 1, b).join('\n')
|
|
492
|
+
const q = prompt('guided/rewrite-range', {
|
|
493
|
+
file: f,
|
|
494
|
+
a,
|
|
495
|
+
b,
|
|
496
|
+
chunk: fence(chunk),
|
|
497
|
+
before: fence(lines.slice(Math.max(0, a - 6), a - 1).join('\n')),
|
|
498
|
+
after: fence(lines.slice(b, b + 4).join('\n')),
|
|
499
|
+
extra,
|
|
500
|
+
})
|
|
501
|
+
const res = await this.query(q, { room: est(chunk) * 2 + 500, onToken: this.progress(`rewriting ${f}:${a}-${b}`), skipKey })
|
|
502
|
+
if (res.finishReason === 'length') return this.failed(f, 'the reply was cut off')
|
|
503
|
+
const repl = reindent(lines.slice(a - 1, b), cleanCode(res.content, chunk).split('\n'))
|
|
504
|
+
return [...lines.slice(0, a - 1), ...repl, ...lines.slice(b)].join('\n')
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
failed(f, why) {
|
|
508
|
+
this.ui.result(c.red(`edit failed: ${why}`))
|
|
509
|
+
this.note(null, `editing ${f} failed`, why)
|
|
510
|
+
return null
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async apply(f, old, next) {
|
|
514
|
+
this.ui.stopSpin()
|
|
515
|
+
const r = await proposeWrite(this.s, f, old, next)
|
|
516
|
+
this.note(null, `${old ? 'edit' : 'create'} ${f}`, writeResult(f, r) + (r.diff ? '\n' + r.diff : ''))
|
|
517
|
+
if (r.status === 'applied') {
|
|
518
|
+
this.st.edits++
|
|
519
|
+
this.changed.add(f)
|
|
520
|
+
this.st.editsSinceRun++
|
|
521
|
+
this.st.hasFiles = true
|
|
522
|
+
if (!this.files.includes(f)) this.files.push(f)
|
|
523
|
+
if (this.notes.some((n) => n.key === `read:${f}`)) this.readNote(f, true)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async create() {
|
|
528
|
+
let rel = this.st.newFile
|
|
529
|
+
this.st.newFile = null
|
|
530
|
+
if (!rel) {
|
|
531
|
+
const res = await this.query(prompt('guided/new-file-path'), { room: 40, oneLine: true })
|
|
532
|
+
rel = unquote(answerText(res.content).split('\n')[0].trim())
|
|
533
|
+
}
|
|
534
|
+
if (!rel) return 'create:'
|
|
535
|
+
const abs = resolveIn(this.s.root, rel)
|
|
536
|
+
const f = path.relative(this.s.root, abs)
|
|
537
|
+
this.ui.step('create', f)
|
|
538
|
+
if (fs.existsSync(abs)) {
|
|
539
|
+
const old = fs.readFileSync(abs, 'utf8')
|
|
540
|
+
const next = await this.editRewrite(f, old)
|
|
541
|
+
if (next !== null) await this.apply(f, old, next)
|
|
542
|
+
return `create:${f}`
|
|
543
|
+
}
|
|
544
|
+
const r = await this.query(prompt('guided/new-file', { file: f }), {
|
|
545
|
+
room: Math.min(4096, Math.floor(this.s.strat.context / 2)),
|
|
546
|
+
onToken: this.progress(`writing ${f}`),
|
|
547
|
+
})
|
|
548
|
+
if (r.finishReason === 'length') this.failed(f, 'the reply was cut off')
|
|
549
|
+
else await this.apply(f, '', cleanCode(r.content))
|
|
550
|
+
return `create:${f}`
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async run() {
|
|
554
|
+
const sugg = suggestCommands(this.s.root)
|
|
555
|
+
const commands = sugg.map((x, i) => `${i + 1}. ${x}`).join('\n')
|
|
556
|
+
const res = await this.query(prompt('guided/run', { commands }), { room: 60, oneLine: true })
|
|
557
|
+
const t = answerText(res.content).trim()
|
|
558
|
+
const n = sugg[Number(/^(\d+)[.):]?(\s|$)/.exec(t)?.[1]) - 1] // "2", "2.", "2. npm test"
|
|
559
|
+
const cmd = n ?? cleanCommand(t)
|
|
560
|
+
if (!cmd) return 'run:'
|
|
561
|
+
const out = await runConfirmed(this.s, cmd, this.signal)
|
|
562
|
+
const code = /^exit code (\S+)/.exec(out)?.[1]
|
|
563
|
+
this.st.runs++
|
|
564
|
+
this.st.editsSinceRun = 0
|
|
565
|
+
this.st.lastRun = code === undefined ? null : code === '0' ? 0 : code
|
|
566
|
+
this.note(null, `ran: ${cmd}`, out)
|
|
567
|
+
if (this.st.lastRun) this.readFailing(out)
|
|
568
|
+
return `run:${cmd}`
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// A failure usually names a test file; the bug tends to be in what it imports. Show both.
|
|
572
|
+
readFailing(out) {
|
|
573
|
+
const seen = new Set(this.notes.filter((n) => n.key?.startsWith('read:')).map((n) => n.key.slice(5)))
|
|
574
|
+
const esc = (f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
575
|
+
const named = this.files.filter((f) => new RegExp(`(^|[\\s/('"])${esc(f)}($|[\\s:)'"])`, 'm').test(out)).slice(0, 2)
|
|
576
|
+
const want = [...named, ...named.flatMap((f) => localImports(f, fs.readFileSync(resolveIn(this.s.root, f), 'utf8'), this.files))]
|
|
577
|
+
for (const f of [...new Set(want)].filter((f) => !seen.has(f)).slice(0, 3)) this.readNote(f)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async ask() {
|
|
581
|
+
const res = await this.query(prompt('guided/ask-user'), { room: 120 })
|
|
582
|
+
const q = res.content.trim()
|
|
583
|
+
const a = await this.ui.ask(`${c.cyan('heartcode asks:')} ${q}\n${c.magenta('> ')}`)
|
|
584
|
+
this.note(null, `you asked the user: ${q}`, `The user answered: ${a ?? '(no answer)'}`)
|
|
585
|
+
return `ask:${q}`
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async finish() {
|
|
589
|
+
this.ui.spinner('writing answer')
|
|
590
|
+
let res
|
|
591
|
+
try {
|
|
592
|
+
res = await this.query(prompt('guided/finish'), {
|
|
593
|
+
room: 1024,
|
|
594
|
+
answer: true,
|
|
595
|
+
})
|
|
596
|
+
} catch (e) {
|
|
597
|
+
// The work is done; a failed summary shouldn't fail the task.
|
|
598
|
+
if (e.name === 'AbortError' || !this.st.edits) throw e
|
|
599
|
+
this.ui.stopSpin()
|
|
600
|
+
this.ui.warn(`could not get a summary from the model (${clip(e.message, 100)})`)
|
|
601
|
+
const run = this.st.lastRun === null ? '' : ` Last command: exit ${this.st.lastRun}.`
|
|
602
|
+
const text = `Changed ${[...this.changed].join(', ')}.${run}`
|
|
603
|
+
this.ui.print(`${c.bold('●')} ${text}`)
|
|
604
|
+
return text
|
|
605
|
+
}
|
|
606
|
+
this.ui.stopSpin()
|
|
607
|
+
return res.content
|
|
608
|
+
}
|
|
609
|
+
}
|