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/agent.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { log } from './log.js'
|
|
3
|
+
import { prompt, userInstructions } from './prompts.js'
|
|
4
|
+
import { newChat } from './sessions.js'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { est, Client } from './client.js'
|
|
7
|
+
import { runGuided } from './guided.js'
|
|
8
|
+
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip } from './tools.js'
|
|
9
|
+
import { loadHooks, runHooks } from './hooks.js'
|
|
10
|
+
import { takeConfigErrors } from './config.js'
|
|
11
|
+
import { c } from './ui.js'
|
|
12
|
+
|
|
13
|
+
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
14
|
+
|
|
15
|
+
// "@shot.png" in a task attaches that image.
|
|
16
|
+
export function extractImages(task, root) {
|
|
17
|
+
const images = []
|
|
18
|
+
const text = task.replace(/(^|\s)@(\S+\.(png|jpe?g|gif|webp))\b/gi, (m, pre, file, ext) => {
|
|
19
|
+
const abs = path.resolve(root, file)
|
|
20
|
+
if (!fs.existsSync(abs)) return m
|
|
21
|
+
images.push(`data:${MIME[ext.toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
|
|
22
|
+
return `${pre}${file}`
|
|
23
|
+
})
|
|
24
|
+
return { text, images }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One model working on one chat. The chat (sessions.js) outlives it: switching models keeps it.
|
|
28
|
+
export class Session {
|
|
29
|
+
constructor({ client, profile, strat, root, ui, chat }) {
|
|
30
|
+
Object.assign(this, { client, profile, strat, root, ui, chat: chat ?? newChat(root) })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get messages() {
|
|
34
|
+
return this.chat.messages
|
|
35
|
+
}
|
|
36
|
+
set messages(v) {
|
|
37
|
+
this.chat.messages = v
|
|
38
|
+
}
|
|
39
|
+
get history() {
|
|
40
|
+
return this.chat.history
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Forget what the model has seen (the transcript and rewind points stay).
|
|
44
|
+
reset() {
|
|
45
|
+
this.chat.messages = null
|
|
46
|
+
this.chat.history.length = 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Called before HeartCode changes a file: keeps its first version in this turn, for rewinds.
|
|
50
|
+
track(rel, before) {
|
|
51
|
+
const turn = this.chat.turns.at(-1)
|
|
52
|
+
if (turn && !(rel in turn.files)) turn.files[rel] = before
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Replaces the conversation so far with a summary the model writes (prompts/compact/*.md).
|
|
56
|
+
// midTask: compacting in the middle of a task, so the model is told to carry on with `task`
|
|
57
|
+
// (the user's request, kept word for word).
|
|
58
|
+
// The conversation before is kept per compaction ("epoch"), so /rewind can still go back.
|
|
59
|
+
async compact({ signal, focus = '', midTask = false, task = '' } = {}) {
|
|
60
|
+
const { chat, client, strat, ui } = this
|
|
61
|
+
const native = Boolean(chat.messages?.length)
|
|
62
|
+
const head = native ? chat.messages.slice(0, chat.messages[0].role === 'system' ? 1 : 2) : [] // the system prompt stays
|
|
63
|
+
const body = native ? chat.messages.slice(head.length) : []
|
|
64
|
+
const conversation = native ? transcript(body) : chat.history.map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n')
|
|
65
|
+
if (!conversation.trim()) return null
|
|
66
|
+
let room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
|
|
67
|
+
const cut = () => (conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation)
|
|
68
|
+
const before = native ? size(body) : est(conversation)
|
|
69
|
+
ui.spinner(midTask ? 'context almost full: compacting the conversation' : 'compacting the conversation')
|
|
70
|
+
// A model shown its own work tends to carry on with it (calling tools, or writing tool calls
|
|
71
|
+
// as text) instead of summarizing: so it's told what it is, and a reply that isn't a summary
|
|
72
|
+
// is asked for again once; then the compaction fails (the conversation is trimmed instead).
|
|
73
|
+
const request = (extra) => prompt('compact/summarize', { conversation: cut(), focus }) + extra
|
|
74
|
+
const send = (extra) =>
|
|
75
|
+
client.chat({ messages: strat.mergeSystem ? [{ role: 'user', content: `${prompt('compact/summarizer')}\n\n${request(extra)}` }] : [{ role: 'system', content: prompt('compact/summarizer') }, { role: 'user', content: request(extra) }] }, { signal })
|
|
76
|
+
// too large even for the summary request: send less of it (the newest part) until it fits
|
|
77
|
+
const ask = async (extra = '') => {
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
return await send(extra)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (!e.inputLimit || room < 2000) throw e
|
|
83
|
+
room = Math.floor(Math.min(room, (e.inputLimit * 3.5) / (strat.ratio ?? 1)) * 0.7)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let summary = notSummary((await ask()).content)
|
|
88
|
+
if (!summary) {
|
|
89
|
+
log('compact', 'the reply was not a summary; asking again')
|
|
90
|
+
summary = notSummary((await ask(`\n\n${prompt('compact/summarize-again')}`)).content)
|
|
91
|
+
}
|
|
92
|
+
if (!summary) throw new Error('the model did not write a summary')
|
|
93
|
+
const epoch = chat.epoch ?? 0
|
|
94
|
+
;(chat.snapshots ??= [])[epoch] = { messages: chat.messages, history: [...chat.history] }
|
|
95
|
+
chat.sentNotes = [] // the summary replaces the messages the notes came with
|
|
96
|
+
chat.epoch = epoch + 1
|
|
97
|
+
const recap = prompt('compact/summary', { summary, requests: requestsOf(chat) })
|
|
98
|
+
if (native) {
|
|
99
|
+
chat.messages = midTask
|
|
100
|
+
? [...head, { role: 'user', content: `${recap}\n\n${prompt('compact/continue', { task })}` }]
|
|
101
|
+
: [...head, { role: 'user', content: recap }, { role: 'assistant', content: prompt('compact/acknowledged') }]
|
|
102
|
+
}
|
|
103
|
+
chat.history.splice(0, chat.history.length, { task: '(the conversation before this point, summarized)', answer: summary })
|
|
104
|
+
const after = native ? size(chat.messages.slice(head.length)) : est(summary)
|
|
105
|
+
log('compact', `${midTask ? 'auto' : 'manual'}: ~${before} → ~${after} tokens`, summary)
|
|
106
|
+
ui.stopSpin()
|
|
107
|
+
ui.print(`${c.faint('✻')} ${c.muted(`Compacted the conversation: ~${kTokens(before)} → ~${kTokens(after)} tokens`)}`)
|
|
108
|
+
return summary
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// images: data: URLs from the caller (headless input), besides @shot.png in the task.
|
|
112
|
+
async run(task, signal, extra = []) {
|
|
113
|
+
if (!this.isSub) this.client.newMessage?.() // the model list: from #1 again (unless set to stay)
|
|
114
|
+
const { chat, ui } = this
|
|
115
|
+
repairSummary(chat)
|
|
116
|
+
if (this.hooks !== false) this.hooks = loadHooks(this.root) // edits to hooks.json apply right away
|
|
117
|
+
if (!this.isSub) for (const e of takeConfigErrors()) ui.warn(e) // a hooks.json or mcp.json that doesn't parse
|
|
118
|
+
const hook = await runHooks(this, 'UserPromptSubmit', { prompt: task }, signal)
|
|
119
|
+
if (hook.block) {
|
|
120
|
+
const answer = prompt('agent/hook-blocked-prompt', { reason: hook.block })
|
|
121
|
+
ui.warn(answer)
|
|
122
|
+
return answer
|
|
123
|
+
}
|
|
124
|
+
if (hook.context) task = `${task}\n\n${hook.context}`
|
|
125
|
+
// guided mode only shows the last few exchanges: fold older ones into a summary first
|
|
126
|
+
if (this.strat.actions !== 'tools' && this.autoCompact && chat.history.length > 5) {
|
|
127
|
+
await this.compact({ signal }).catch((e) => e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`))
|
|
128
|
+
}
|
|
129
|
+
const epoch = chat.epoch ?? 0
|
|
130
|
+
chat.turns.push({ id: Math.random().toString(36).slice(2, 10), task, at: new Date().toISOString(), epoch, msgIndex: chat.messages?.length ?? 0, histIndex: chat.history.length, logIndex: ui.inputMark ?? Math.max(0, ui.log.length - 1), files: {} })
|
|
131
|
+
this.onTurn?.() // saved now: a message sent and not answered yet (quit, crash) can still be continued
|
|
132
|
+
// a summary of the branch the user left (/tree) goes in before this message
|
|
133
|
+
if (chat.branchNote && this.strat.actions !== 'tools') {
|
|
134
|
+
chat.history.push({ task: prompt('compact/branch-note-task'), answer: chat.branchNote })
|
|
135
|
+
delete chat.branchNote
|
|
136
|
+
}
|
|
137
|
+
let { text, images } = extractImages(task, this.root)
|
|
138
|
+
images = [...images, ...extra]
|
|
139
|
+
if (images.length && !this.strat.vision) {
|
|
140
|
+
this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
|
|
141
|
+
images = []
|
|
142
|
+
}
|
|
143
|
+
log('task', `${text} (${this.strat.actions} mode, ${images.length} image(s))`)
|
|
144
|
+
const answer = this.strat.actions === 'tools' ? await runNative(this, text, images, signal) : await runGuided(this, text, images, signal)
|
|
145
|
+
log('task', 'answer:', answer)
|
|
146
|
+
this.history.push({ task: text, answer })
|
|
147
|
+
if (this.memory && this.strat.actions === 'tools') await this.afterTask(text, answer, signal)
|
|
148
|
+
return answer
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The agent tool: a helper with a fresh conversation and the same tools (but no helpers of its
|
|
152
|
+
// own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
|
|
153
|
+
// under the main conversation's, its file changes count for /rewind like the main agent's.
|
|
154
|
+
// ponytail: helpers run one after another; run several at once if waiting on them gets long.
|
|
155
|
+
async subagent(args, signal) {
|
|
156
|
+
const task = String(args.prompt ?? args.task ?? '').trim()
|
|
157
|
+
if (!task) return 'error: give the helper its job in "prompt"'
|
|
158
|
+
const label = clip(String(args.description ?? '').trim() || task.split('\n')[0], 40)
|
|
159
|
+
const helper = new Session({ client: this.client, profile: this.profile, strat: this.strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
|
|
160
|
+
Object.assign(helper, { isSub: true, mcp: this.mcp, skills: this.skills, hooks: this.hooks, allowTools: this.allowTools, denyTools: this.denyTools, maxTurns: this.maxTurns, autoCompact: this.autoCompact, appendSystem: this.appendSystem, fileCache: this.fileCache, noFormat: this.noFormat, noLsp: this.noLsp })
|
|
161
|
+
helper.track = (rel, before) => this.track(rel, before)
|
|
162
|
+
log('agent', `helper "${label}": ${task}`)
|
|
163
|
+
const answer = await runNative(helper, task, [], signal)
|
|
164
|
+
log('agent', `helper "${label}" done`, answer)
|
|
165
|
+
return answer?.trim() || '(the helper finished without a reply)'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// /btw: a side question, answered from the conversation so far. Nothing goes into the
|
|
169
|
+
// conversation and no tools are offered, so it can run while a task works: on its own client,
|
|
170
|
+
// so the task's request timer and retries are left alone (its cost still counts).
|
|
171
|
+
async side(question, signal) {
|
|
172
|
+
const { client, strat, chat } = this
|
|
173
|
+
const ask = prompt('agent/btw', { question })
|
|
174
|
+
let messages
|
|
175
|
+
if (chat.messages?.length) {
|
|
176
|
+
messages = chat.messages.map((m) => ({ ...m })) // copies: trimming them leaves the real ones alone
|
|
177
|
+
pairTools(messages) // a tool still running has no result yet
|
|
178
|
+
messages.push({ role: 'user', content: ask })
|
|
179
|
+
fitMessages(messages, Math.floor((strat.context * 0.75) / (strat.ratio ?? 1)) - 800)
|
|
180
|
+
} else {
|
|
181
|
+
const past = chat.history.map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n').slice(-Math.floor(strat.context * 2))
|
|
182
|
+
messages = [{ role: 'user', content: past ? `${prompt('agent/btw-history', { conversation: past })}\n\n${ask}` : ask }]
|
|
183
|
+
}
|
|
184
|
+
const side = new Client({ baseUrl: client.baseUrl, apiKey: client.apiKey, model: client.model, provider: client.provider })
|
|
185
|
+
Object.assign(side, { effort: client.effort, quirks: client.quirks, tokenRatio: client.tokenRatio, onSpend: client.onSpend, purpose: 'btw' })
|
|
186
|
+
log('btw', question)
|
|
187
|
+
const res = await side.chat({ messages }, { signal })
|
|
188
|
+
log('btw', 'answer:', res.content)
|
|
189
|
+
return res.content?.trim() ?? ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A summary of the part of a branch the user is leaving (/tree), for the branch they go to.
|
|
193
|
+
async summarizeBranch(conversation, files, signal) {
|
|
194
|
+
const { client, strat } = this
|
|
195
|
+
const room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1))
|
|
196
|
+
const text = conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation
|
|
197
|
+
const request = prompt('compact/branch-summarize', { conversation: text, files: files.join(', ') })
|
|
198
|
+
const messages = strat.mergeSystem ? [{ role: 'user', content: `${prompt('compact/summarizer')}\n\n${request}` }] : [{ role: 'system', content: prompt('compact/summarizer') }, { role: 'user', content: request }]
|
|
199
|
+
const summary = notSummary((await client.chat({ messages }, { signal })).content)
|
|
200
|
+
if (!summary) throw new Error('the model did not write a summary')
|
|
201
|
+
return summary
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// After a task: if the user said something worth keeping and the model didn't save it, ask it to
|
|
205
|
+
// (one small request, with only the related notes). Never fails the task.
|
|
206
|
+
async afterTask(task, answer, signal) {
|
|
207
|
+
const writes = this.memoryWrites ?? 0
|
|
208
|
+
try {
|
|
209
|
+
if (!writes && REMEMBER.test(task)) {
|
|
210
|
+
this.ui.spinner('remembering')
|
|
211
|
+
await this.memoryTask(this.#extractPrompt(`${task}\n${answer}`), prompt('agent/memory-exchange', { task, answer: clip(answer, 2000) }), signal)
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (e.name === 'AbortError') throw e
|
|
215
|
+
this.ui.warn(`memory: ${e.message}`)
|
|
216
|
+
} finally {
|
|
217
|
+
this.memoryWrites = 0
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#extractPrompt(about) {
|
|
222
|
+
const notes = this.memory.relevant(about, 10).map((n) => `[[${n.name}]] — ${n.description} (${[n.scope, ...n.tags].join(', ')})`).join('\n')
|
|
223
|
+
return prompt('agent/memory-extract', { tags: this.memory.tagSummary() || '(none yet)', notes })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A small tool loop with only the memory tool (saving and tidying notes).
|
|
227
|
+
async memoryTask(system, user, signal, steps = 12) {
|
|
228
|
+
const tools = extraTools({ memory: this.memory }).map((f) => ({ type: 'function', function: f }))
|
|
229
|
+
const messages = [{ role: 'system', content: system }, { role: 'user', content: user }]
|
|
230
|
+
for (let step = 0; step < steps; step++) {
|
|
231
|
+
const res = await this.client.chat({ messages, tools }, { signal })
|
|
232
|
+
messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
|
|
233
|
+
if (!res.toolCalls.length) return res.content.trim()
|
|
234
|
+
for (const call of res.toolCalls) {
|
|
235
|
+
let args = {}
|
|
236
|
+
try {
|
|
237
|
+
args = JSON.parse(call.function.arguments || '{}')
|
|
238
|
+
} catch {}
|
|
239
|
+
const out = await runTool(call.function.name, args, this, signal)
|
|
240
|
+
if (args.action === 'write' || args.action === 'delete') this.ui.step('memory', `${args.action === 'write' ? 'saved' : 'deleted'} ${out.match(/\[\[[^\]]+\]\]/)?.[0] ?? ''}`)
|
|
241
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: out })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return ''
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// /tidy_memory: merge duplicates, drop what's outdated, link related notes.
|
|
248
|
+
async tidyMemory(signal) {
|
|
249
|
+
const notes = this.memory.list()
|
|
250
|
+
if (!notes.length) return 'No notes to tidy.'
|
|
251
|
+
this.ui.spinner('tidying the memory')
|
|
252
|
+
const room = Math.floor(this.strat.context * 0.5 * 3.5)
|
|
253
|
+
const text = notes.map((n) => `## [[${n.name}]] (${n.scope}, ${n.type}; tags: ${n.tags.join(', ') || 'none'}) — ${n.description}\n${n.body}`).join('\n\n').slice(0, room)
|
|
254
|
+
const broken = this.memory.brokenLinks().map((b) => `[[${b.to}]] in [[${b.from}]]`).join(', ')
|
|
255
|
+
const summary = await this.memoryTask(this.#extractPrompt(''), prompt('agent/memory-tidy', { notes: text, broken }), signal, 30 + notes.length * 3)
|
|
256
|
+
this.ui.stopSpin()
|
|
257
|
+
this.ui.print(`${c.faint('✻')} ${c.muted(`Tidied the memory${summary && summary !== 'NONE' ? `: ${clip(summary.split('\n')[0], 140)}` : ''}`)}`)
|
|
258
|
+
return summary
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The system prompt: prompts/tools/system.md. No absolute path in it: small models copy it into
|
|
263
|
+
// tool calls, garbled.
|
|
264
|
+
// The user's HEARTCODE.md / AGENTS.md instructions are added when there are any.
|
|
265
|
+
// In director mode the director part is added.
|
|
266
|
+
// No list of the project's files: the model looks up what the task needs (list_files, search).
|
|
267
|
+
const SYSTEM = (s) => {
|
|
268
|
+
const instructions = userInstructions(s.root)
|
|
269
|
+
return [
|
|
270
|
+
prompt('agent/system', { root: s.root, files: '', more: '' }),
|
|
271
|
+
instructions && prompt('agent/instructions', { instructions }),
|
|
272
|
+
s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
|
|
273
|
+
s.director && s.director.app.cfg.director !== false && prompt('agent/director'),
|
|
274
|
+
s.isSub && prompt('agent/subagent'),
|
|
275
|
+
s.appendSystem, // from the caller (--append-system-prompt)
|
|
276
|
+
]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join('\n\n')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const sizeOf = (m) =>
|
|
282
|
+
(Array.isArray(m.content) ? m.content.reduce((n, p) => n + (p.type === 'text' ? est(p.text) : 800), 0) : est(m.content)) +
|
|
283
|
+
(m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0)
|
|
284
|
+
export const size = (messages) => messages.reduce((n, m) => n + sizeOf(m), 0)
|
|
285
|
+
// Words that suggest the user said something to remember (then HeartCode asks the model to save it).
|
|
286
|
+
const REMEMBER = /\b(always|never|prefer|remember|don'?t|do not|instead of|from now on|we use|convention|make sure|keep in mind)\b/i
|
|
287
|
+
const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
|
|
288
|
+
|
|
289
|
+
// "assistant to=mcp({...})", "to=functions.read_file <|constrain|>json<|message|>{...}": a tool
|
|
290
|
+
// call written as text. Returns { name, arguments } (arguments as JSON text), or null.
|
|
291
|
+
export function leakedCall(text) {
|
|
292
|
+
const m = /^\s*(?:assistant\s*)?(?:<\|[^|]*\|>\s*)*to=(?:functions\.)?([\w-]+)[^{]*?(\{[\s\S]*\})\s*\)?\s*$/.exec(String(text ?? ''))
|
|
293
|
+
if (!m) return null
|
|
294
|
+
try {
|
|
295
|
+
JSON.parse(m[2])
|
|
296
|
+
} catch {
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
return { name: m[1], arguments: m[2] }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The user's messages in this chat, word for word, newest last (as many as fit in ~1500 tokens):
|
|
303
|
+
// they go with every summary, so what was asked is never lost, even when the last message is just
|
|
304
|
+
// "continue".
|
|
305
|
+
export function requestsOf(chat, room = 1500) {
|
|
306
|
+
const out = []
|
|
307
|
+
for (const t of [...chat.turns].reverse()) {
|
|
308
|
+
const line = `- ${t.task.trim()}`
|
|
309
|
+
if (est(out.join('\n') + line) > room && out.length) break
|
|
310
|
+
out.unshift(est(line) > room ? `${line.slice(0, room * 3)}…` : line)
|
|
311
|
+
}
|
|
312
|
+
return out.join('\n')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A chat compacted by an older HeartCode can hold a "summary" that isn't one (a tool call the
|
|
316
|
+
// model wrote as text). It's replaced with what's known for sure: the user's messages.
|
|
317
|
+
export function repairSummary(chat) {
|
|
318
|
+
const head = prompt('compact/summary', { summary: '\u0000', requests: '' }).split('\u0000')[0]
|
|
319
|
+
const i = chat.messages?.findIndex((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(head)) ?? -1
|
|
320
|
+
if (i === -1) return false
|
|
321
|
+
const rest = chat.messages[i].content.slice(head.length)
|
|
322
|
+
if (rest.trim() && !leakedCall(rest.split('\n\n')[0])) return false // a real summary (only a tool call written as text is broken)
|
|
323
|
+
log('compact', 'a broken summary in this chat was replaced with the user\'s messages')
|
|
324
|
+
chat.messages[i] = { ...chat.messages[i], content: prompt('compact/summary', { summary: prompt('compact/summary-lost'), requests: requestsOf(chat) }) }
|
|
325
|
+
if (chat.messages[i + 1]?.role === 'assistant' && leakedCall(chat.messages[i + 1].content)) chat.messages[i + 1] = { role: 'assistant', content: prompt('compact/acknowledged') }
|
|
326
|
+
return true
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The summary, or '' when the reply isn't one: empty, too short, or a tool call written as text.
|
|
330
|
+
export function notSummary(text) {
|
|
331
|
+
const t = String(text ?? '').trim()
|
|
332
|
+
return t.length < 10 || leakedCall(t) || /^\s*\{\s*"/.test(t) ? '' : t
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A conversation as plain text, for the model to summarize. Tool calls are described, not written
|
|
336
|
+
// as calls, so the summarizer doesn't imitate them.
|
|
337
|
+
export function transcript(messages) {
|
|
338
|
+
const text = (content) => (Array.isArray(content) ? content.map((p) => (p.type === 'text' ? p.text : '[image]')).join(' ') : String(content ?? ''))
|
|
339
|
+
return messages
|
|
340
|
+
.map((m) => {
|
|
341
|
+
if (m.role === 'tool') return `[tool result] ${clip(text(m.content), 1500)}`
|
|
342
|
+
const calls = (m.tool_calls ?? []).map((t) => `\n[used the ${t.function.name} tool with: ${clip(t.function.arguments, 400)}]`).join('')
|
|
343
|
+
return `${m.role === 'user' ? 'User' : 'Assistant'}: ${text(m.content)}${calls}`
|
|
344
|
+
})
|
|
345
|
+
.join('\n\n')
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Keep the conversation inside the context window: shrink old tool output first, then drop old turns.
|
|
349
|
+
export function fitMessages(messages, budget) {
|
|
350
|
+
const total = () => messages.reduce((n, m) => n + sizeOf(m), 0)
|
|
351
|
+
for (let i = 1; i < messages.length - 2 && total() > budget; i++) {
|
|
352
|
+
const m = messages[i]
|
|
353
|
+
if (m.role === 'tool' && m.content.length > 300) m.content = m.content.slice(0, 300) + '\n[older output trimmed]'
|
|
354
|
+
}
|
|
355
|
+
while (total() > budget) {
|
|
356
|
+
const next = messages.findIndex((m, i) => i > 1 && m.role === 'user')
|
|
357
|
+
if (next === -1) break
|
|
358
|
+
messages.splice(1, next - 1)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The model keeps replying with nothing (seen when it's done but its server fails to decode the
|
|
363
|
+
// last message): asked once more without tools, for the answer only. Never ends a task with
|
|
364
|
+
// nothing: failing that, HeartCode says itself what changed.
|
|
365
|
+
async function finalAnswer(s, signal) {
|
|
366
|
+
const { client, ui } = s
|
|
367
|
+
log('task', 'empty replies in a row; asking for the final answer without tools')
|
|
368
|
+
ui.spinner('asking for the final answer')
|
|
369
|
+
try {
|
|
370
|
+
const res = await client.chat({ messages: [...s.messages, { role: 'user', content: prompt('agent/final-answer') }] }, { signal })
|
|
371
|
+
const text = res.content?.trim()
|
|
372
|
+
if (text && !leakedCall(text)) {
|
|
373
|
+
s.messages.push({ role: 'user', content: prompt('agent/final-answer') }, { role: 'assistant', content: text })
|
|
374
|
+
ui.stopSpin()
|
|
375
|
+
const out = ui.response()
|
|
376
|
+
out.text(text)
|
|
377
|
+
out.end()
|
|
378
|
+
return text
|
|
379
|
+
}
|
|
380
|
+
} catch (e) {
|
|
381
|
+
if (e.name === 'AbortError') throw e
|
|
382
|
+
log('task', `final answer request failed: ${e.message}`)
|
|
383
|
+
}
|
|
384
|
+
ui.stopSpin()
|
|
385
|
+
const changed = Object.keys(s.chat.turns.at(-1)?.files ?? {})
|
|
386
|
+
const text = prompt('agent/no-final-answer', { model: client.model, changed: changed.join(', ') })
|
|
387
|
+
ui.warn(text)
|
|
388
|
+
return text
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Every tool call needs its result right after it, and every result its call, or the API rejects
|
|
392
|
+
// the request (and it would be retried forever). Esc during a tool leaves a call without a result;
|
|
393
|
+
// trimming can leave a result without its call. Mends both, in place.
|
|
394
|
+
export function pairTools(messages) {
|
|
395
|
+
for (let i = 0; i < messages.length; i++) {
|
|
396
|
+
const m = messages[i]
|
|
397
|
+
if (m.role === 'tool') {
|
|
398
|
+
const owner = messages.slice(0, i).findLast((x) => x.role !== 'tool')
|
|
399
|
+
if (!owner?.tool_calls?.some((t) => t.id === m.tool_call_id)) messages.splice(i--, 1)
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (!m.tool_calls?.length) continue
|
|
403
|
+
let j = i + 1
|
|
404
|
+
const have = new Set()
|
|
405
|
+
while (messages[j]?.role === 'tool') have.add(messages[j++].tool_call_id)
|
|
406
|
+
const missing = m.tool_calls.filter((t) => !have.has(t.id)).map((t) => ({ role: 'tool', tool_call_id: t.id, content: '(not run: the user stopped it)' }))
|
|
407
|
+
messages.splice(j, 0, ...missing)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function runNative(s, task, images, signal) {
|
|
412
|
+
const { client, strat, ui } = s
|
|
413
|
+
// The system prompt is rebuilt for every message: files come and go, and a resumed chat's is
|
|
414
|
+
// from back then (it once told the model the project was empty after it had made files).
|
|
415
|
+
// Unchanged, it stays the same text, so a provider's prompt cache still holds.
|
|
416
|
+
const sys = SYSTEM(s)
|
|
417
|
+
if (!s.messages) {
|
|
418
|
+
s.messages = strat.mergeSystem ? [{ role: 'user', content: sys }, { role: 'assistant', content: prompt('agent/system-acknowledged') }] : [{ role: 'system', content: sys }]
|
|
419
|
+
} else if (s.messages[0]?.content !== sys && (s.messages[0]?.role === 'system' || strat.mergeSystem)) {
|
|
420
|
+
s.messages[0] = { ...s.messages[0], content: sys }
|
|
421
|
+
}
|
|
422
|
+
if (s.chat.branchNote && !s.isSub) {
|
|
423
|
+
s.messages.push({ role: 'user', content: s.chat.branchNote }, { role: 'assistant', content: prompt('compact/acknowledged') })
|
|
424
|
+
delete s.chat.branchNote
|
|
425
|
+
}
|
|
426
|
+
let shrink = 1 // drops when the server says a request is too large
|
|
427
|
+
s.compactedAt = -1
|
|
428
|
+
// What may be sent: the window less a tenth (estimates are estimates) and less room for the
|
|
429
|
+
// reply (the longest the model gives, from models.dev, at most a quarter of the window; 8k when
|
|
430
|
+
// unknown) and the tools; trimming keeps to it. Auto-compact starts at 90% of it, so it comes
|
|
431
|
+
// before any trimming.
|
|
432
|
+
// s.sizeCap: "too large" without the server saying its limit: a guess for this conversation only
|
|
433
|
+
const windowSize = () => Math.min(strat.context, s.sizeCap ?? Infinity)
|
|
434
|
+
const reply = () => Math.min(strat.reply || 8192, Math.floor(windowSize() * 0.25))
|
|
435
|
+
const budget = () => Math.floor(((windowSize() * 0.9 - reply()) * shrink) / (strat.ratio ?? 1)) - 800 // in estimated tokens
|
|
436
|
+
const nearlyFull = () => s.autoCompact && s.messages.length > 3 && size(s.messages) > budget() * 0.9
|
|
437
|
+
// Already near full from earlier messages: compact first, so the new message stays word for word.
|
|
438
|
+
if (nearlyFull()) {
|
|
439
|
+
await s.compact({ signal }).catch((e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
|
|
440
|
+
}
|
|
441
|
+
// only the memory notes this message matches go with it (the system prompt has just the tags)
|
|
442
|
+
// each note once per conversation (again if it changed, or after compacting dropped it)
|
|
443
|
+
const sent = new Set(s.chat.sentNotes ?? [])
|
|
444
|
+
const key = (n) => `${n.scope}:${n.name}:${n.description}`
|
|
445
|
+
const notes = (s.memory?.relevant(task) ?? []).filter((n) => !sent.has(key(n)))
|
|
446
|
+
s.chat.sentNotes = [...sent, ...notes.map(key)]
|
|
447
|
+
const text = notes.length ? `${task}\n\n${prompt('agent/memory-relevant', { notes: notes.map((n) => `[[${n.name}]] — ${n.description}`).join('\n') })}` : task
|
|
448
|
+
const planning = ui.permission === 'plan'
|
|
449
|
+
const said = planning && !s.isSub ? `${text}\n\n${prompt('agent/plan-mode')}` : text
|
|
450
|
+
const content = images.length ? [{ type: 'text', text: said }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : said
|
|
451
|
+
s.messages.push({ role: 'user', content })
|
|
452
|
+
attachNamed(s, task) // files the message names: as if already read (saves a round trip each)
|
|
453
|
+
const tools = [...TOOL_DEFS, ...extraTools(s)].filter((f) => allowed(s, f.name) && !(planning && PLAN_HIDDEN.has(f.name)) && !(f.name === 'ask_user' && ui.noUser)).map((f) => ({ type: 'function', function: f }))
|
|
454
|
+
// A todo list left done from an earlier message is cleared; an unfinished one stays.
|
|
455
|
+
if (!s.isSub && s.chat.todos?.every((t) => t.status === 'completed')) s.chat.todos = []
|
|
456
|
+
s.todoTouched = false
|
|
457
|
+
let nudged = 0 // reminders sent this message about open todo items or a Stop hook
|
|
458
|
+
|
|
459
|
+
let empty = 0 // replies in a row with neither text nor tool calls
|
|
460
|
+
// No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
|
|
461
|
+
for (let step = 0; ; step++) {
|
|
462
|
+
if (s.maxTurns && step >= s.maxTurns) {
|
|
463
|
+
ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
|
|
464
|
+
throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
|
|
465
|
+
}
|
|
466
|
+
// after a failed compaction, the next try waits until the conversation grew by a fifth
|
|
467
|
+
if (nearlyFull() && s.messages.length > 4 && step !== s.compactedAt && size(s.messages) > (s.compactFailedAt ?? 0) * 1.2) {
|
|
468
|
+
s.compactedAt = step
|
|
469
|
+
await s.compact({ signal, midTask: true, task }).catch((e) => {
|
|
470
|
+
if (e.name === 'AbortError') throw e
|
|
471
|
+
// no summary: free the space anyway, by shortening older tool output and dropping the oldest turns
|
|
472
|
+
fitMessages(s.messages, Math.floor(budget() * 0.5))
|
|
473
|
+
s.compactFailedAt = size(s.messages)
|
|
474
|
+
ui.warn(`could not compact (${e.message}); shortened older tool output and messages instead`)
|
|
475
|
+
log('compact', `failed (${e.message}); trimmed to ~${s.compactFailedAt} tokens`)
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
// what the user typed while it worked goes in before the next step (steering)
|
|
479
|
+
const added = step && !s.isSub ? (ui.takeAhead?.() ?? []) : []
|
|
480
|
+
if (added.length) {
|
|
481
|
+
for (const l of added) ui.print(`${c.magenta('♥')} ${c.bright(l)}`)
|
|
482
|
+
log('task', 'the user added while working:', added)
|
|
483
|
+
s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
|
|
484
|
+
}
|
|
485
|
+
fitMessages(s.messages, budget())
|
|
486
|
+
pairTools(s.messages)
|
|
487
|
+
ui.spinner('thinking')
|
|
488
|
+
const out = ui.response()
|
|
489
|
+
const stream = strat.toolsStream
|
|
490
|
+
let res
|
|
491
|
+
try {
|
|
492
|
+
// A long tool call (a whole file) streams in silently: say what's being written.
|
|
493
|
+
const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
|
|
494
|
+
res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
495
|
+
} catch (e) {
|
|
496
|
+
if (!e.inputLimit || shrink < 0.3) throw e
|
|
497
|
+
s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
|
|
498
|
+
out.end()
|
|
499
|
+
shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
|
|
500
|
+
step--
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
ui.stopSpin()
|
|
504
|
+
if (!stream) {
|
|
505
|
+
out.think(res.reasoning)
|
|
506
|
+
out.text(res.content)
|
|
507
|
+
}
|
|
508
|
+
out.end()
|
|
509
|
+
s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }) })
|
|
510
|
+
// a tool call written out as text instead of made (some models do, after a long conversation): made
|
|
511
|
+
const leak = !res.toolCalls.length && leakedCall(res.content)
|
|
512
|
+
if (leak && tools.some((t) => t.function.name === leak.name)) {
|
|
513
|
+
log('tool', `tool call written as text, made for real: ${leak.name}`)
|
|
514
|
+
res.toolCalls = [{ id: `text_${Date.now().toString(36)}`, type: 'function', function: { name: leak.name, arguments: leak.arguments } }]
|
|
515
|
+
res.content = ''
|
|
516
|
+
s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
|
|
517
|
+
}
|
|
518
|
+
s.onEvent?.({ type: 'assistant', content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
|
|
519
|
+
if (!res.toolCalls.length && !res.content?.trim()) {
|
|
520
|
+
// Nothing at all (seen after a failed tool call): not an answer. The same request again
|
|
521
|
+
// first (often fine the second time), then asked to go on; three in a row ends the task.
|
|
522
|
+
s.messages.pop() // some APIs reject an empty assistant message
|
|
523
|
+
if (empty++ < 3) {
|
|
524
|
+
log('task', `empty reply ${empty}; ${empty === 1 ? 'asking again' : 'asking the model to continue'}`)
|
|
525
|
+
if (empty > 1 && s.messages.at(-1)?.content !== prompt('agent/empty-reply')) s.messages.push({ role: 'user', content: prompt('agent/empty-reply') })
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
return finalAnswer(s, signal)
|
|
529
|
+
}
|
|
530
|
+
empty = 0
|
|
531
|
+
if (!res.toolCalls.length) {
|
|
532
|
+
const more = nudged < 3 && !s.isSub && (await notDone(s, signal, nudged > 0))
|
|
533
|
+
if (!more) return res.content
|
|
534
|
+
nudged++
|
|
535
|
+
s.messages.push({ role: 'user', content: more })
|
|
536
|
+
continue
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
for (const call of res.toolCalls) {
|
|
540
|
+
const name = call.function.name
|
|
541
|
+
let args = null
|
|
542
|
+
try {
|
|
543
|
+
args = JSON.parse(call.function.arguments || '{}')
|
|
544
|
+
} catch {}
|
|
545
|
+
const detail = !args
|
|
546
|
+
? ''
|
|
547
|
+
: args.server
|
|
548
|
+
? `${args.server} · ${args.tool ?? ''}`
|
|
549
|
+
: name === 'memory'
|
|
550
|
+
? `${args.action} ${args.name ? `[[${args.name}]]` : (args.query ?? '')}`
|
|
551
|
+
: name === 'manage'
|
|
552
|
+
? [args.action, args.kind, args.name].filter(Boolean).join(' ')
|
|
553
|
+
: name === 'agent'
|
|
554
|
+
? (args.description ?? '')
|
|
555
|
+
: name === 'todo' || name === 'ask_user'
|
|
556
|
+
? ''
|
|
557
|
+
: (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
|
|
558
|
+
if (name !== 'run_command') ui.step(name, clip(detail, 80))
|
|
559
|
+
const out = !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal) : 'error: the tool arguments were not valid JSON'
|
|
560
|
+
if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
|
|
561
|
+
else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
|
|
562
|
+
else if (out.startsWith('error')) ui.result(clip(out, 120))
|
|
563
|
+
const msg = { role: 'tool', tool_call_id: call.id, content: clip(out, Math.min(12_000, strat.context)) }
|
|
564
|
+
if (name === 'read_file' && args && !args.start_line && !args.end_line && !out.startsWith('error')) replaceCopy(s, args.path, msg)
|
|
565
|
+
s.messages.push(msg)
|
|
566
|
+
s.onEvent?.({ type: 'tool', id: call.id, name, output: out })
|
|
567
|
+
}
|
|
568
|
+
// screenshots from MCP tools: tool results are text only, so images follow as a message
|
|
569
|
+
if (s.pendingImages?.length) {
|
|
570
|
+
s.messages.push({ role: 'user', content: [{ type: 'text', text: prompt('agent/tool-images') }, ...s.pendingImages.map((url) => ({ type: 'image_url', image_url: { url } }))] })
|
|
571
|
+
s.pendingImages = []
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// The model says it's done: is it? Not while its todo list (from this message) has open items, or
|
|
577
|
+
// while a Stop hook says otherwise. Returns what to tell it, or null. Each is asked a few times at
|
|
578
|
+
// most (the caller counts), so a model that can't finish still ends.
|
|
579
|
+
async function notDone(s, signal, again) {
|
|
580
|
+
const open = s.todoTouched && s.ui.permission !== 'plan' ? (s.chat.todos ?? []).filter((t) => t.status !== 'completed') : [] // plan mode: they wait for the approval
|
|
581
|
+
if (open.length) {
|
|
582
|
+
log('task', `stopped with ${open.length} open todo item(s); reminding`)
|
|
583
|
+
return prompt('agent/todo-unfinished', { items: open.map((t) => `- ${t.content}`).join('\n') })
|
|
584
|
+
}
|
|
585
|
+
const hook = await runHooks(s, 'Stop', { stop_hook_active: again }, signal)
|
|
586
|
+
return hook.block ? prompt('agent/hook-stop', { reason: hook.block }) : null
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// A helper agent's view of the screen: its steps are labelled, and its replies (which go to the
|
|
590
|
+
// main agent) aren't shown as answers. Everything else is the main UI's.
|
|
591
|
+
function helperUI(ui, label) {
|
|
592
|
+
const quiet = { think() {}, text() {}, end() {} }
|
|
593
|
+
const own = {
|
|
594
|
+
step: (name, detail) => ui.step(` ${label} › ${name}`, detail),
|
|
595
|
+
result: (text) => ui.result(` ${text}`),
|
|
596
|
+
response: () => quiet,
|
|
597
|
+
spinner: (text) => ui.spinner(`${label} · ${text}`),
|
|
598
|
+
}
|
|
599
|
+
return new Proxy(ui, { get: (t, k) => (k in own ? own[k] : typeof t[k] === 'function' ? t[k].bind(t) : t[k]), set: (t, k, v) => ((t[k] = v), true) })
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Tools the caller allowed (--allowedTools) or took away (--disallowedTools); all by default.
|
|
603
|
+
export const allowed = (s, name) => (!s.allowTools || s.allowTools.has(name)) && !s.denyTools?.has(name)
|
|
604
|
+
|
|
605
|
+
const shortened = new WeakSet() // kept outside the messages: providers reject unknown message fields
|
|
606
|
+
|
|
607
|
+
// A file read again: the older copy in the conversation becomes a one-line note.
|
|
608
|
+
function replaceCopy(s, file, msg) {
|
|
609
|
+
const key = path.normalize(String(file))
|
|
610
|
+
const older = (s.copies ??= new Map()).get(key)
|
|
611
|
+
if (older && !shortened.has(older)) {
|
|
612
|
+
older.content = prompt('agent/older-copy', { file })
|
|
613
|
+
shortened.add(older)
|
|
614
|
+
}
|
|
615
|
+
s.copies.set(key, msg)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Files the user's message names go in as if the model had already read them: one round trip less
|
|
619
|
+
// per file, and the model starts from the real content. Small text files only, at most 3.
|
|
620
|
+
function attachNamed(s, task) {
|
|
621
|
+
const files = namedFiles(task, listFiles(s.root)).slice(0, 3)
|
|
622
|
+
const calls = []
|
|
623
|
+
const results = []
|
|
624
|
+
for (const f of files) {
|
|
625
|
+
let body
|
|
626
|
+
try {
|
|
627
|
+
if (fs.statSync(path.join(s.root, f)).size > 40_000) continue
|
|
628
|
+
body = readFileTool(s.root, f)
|
|
629
|
+
} catch {
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
if (body.startsWith('error')) continue
|
|
633
|
+
const id = `named_${Date.now().toString(36)}_${calls.length}`
|
|
634
|
+
calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
|
|
635
|
+
const msg = { role: 'tool', tool_call_id: id, content: body }
|
|
636
|
+
replaceCopy(s, f, msg)
|
|
637
|
+
results.push(msg)
|
|
638
|
+
s.ui.step('read_file', f)
|
|
639
|
+
s.ui.result(`${body.split('\n').length} lines`)
|
|
640
|
+
}
|
|
641
|
+
if (calls.length) s.messages.push({ role: 'assistant', content: '', tool_calls: calls }, ...results)
|
|
642
|
+
}
|