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/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
+ }