heartcode 0.1.15 → 0.1.24
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/CHANGELOG.md +186 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +262 -131
- package/src/settings.js +11 -2
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
package/src/agent.js
CHANGED
|
@@ -4,12 +4,14 @@ import { log } from './log.js'
|
|
|
4
4
|
import { prompt, userInstructions } from './prompts.js'
|
|
5
5
|
import { newChat } from './sessions.js'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import {
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
import { est, Client, writtenBy, tokensOf, COMPACT_SHARE } from './client.js'
|
|
8
9
|
import { runGuided } from './guided.js'
|
|
9
|
-
import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput, OUTPUTS, imageUrl, MAX_IMAGE, failed } from './tools.js'
|
|
10
|
+
import { TOOL_DEFS, PLAN_HIDDEN, folderView, readOnlyCommand, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput, OUTPUTS, imageUrl, MAX_IMAGE, failed } from './tools.js'
|
|
11
|
+
import { serversNote } from './lsp.js'
|
|
10
12
|
import { loadHooks, runHooks } from './hooks.js'
|
|
11
13
|
import { takeConfigErrors } from './config.js'
|
|
12
|
-
import { c } from './ui.js'
|
|
14
|
+
import { c, duration } from './ui.js'
|
|
13
15
|
|
|
14
16
|
// Images for a helper (the agent tool's "images"), as data: URLs: image URLs (fetched), data: URLs,
|
|
15
17
|
// or files: in the project, saved screenshots (OUTPUTS), or a path the user gave in a message.
|
|
@@ -97,7 +99,8 @@ export class Session {
|
|
|
97
99
|
// midTask: compacting in the middle of a task, so the model is told to carry on with `task`
|
|
98
100
|
// (the user's request, kept word for word).
|
|
99
101
|
// The conversation before is kept per compaction ("epoch"), so /rewind can still go back.
|
|
100
|
-
|
|
102
|
+
// why: what the spinner says the compacting is for ("context 85% full (650k/768k)").
|
|
103
|
+
async compact({ signal, focus = '', midTask = false, task = '', why = '' } = {}) {
|
|
101
104
|
const { chat, client, strat, ui } = this
|
|
102
105
|
const native = Boolean(chat.messages?.length)
|
|
103
106
|
const head = native ? chat.messages.slice(0, chat.messages[0].role === 'system' ? 1 : 2) : [] // the system prompt stays
|
|
@@ -107,7 +110,7 @@ export class Session {
|
|
|
107
110
|
const room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
|
|
108
111
|
const cut = () => (conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation)
|
|
109
112
|
const before = native ? size(body) : est(conversation)
|
|
110
|
-
ui.spinner(
|
|
113
|
+
ui.spinner(why ? `${why}: compacting the conversation` : 'compacting the conversation')
|
|
111
114
|
// A model shown its own work tends to carry on with it (calling tools, or writing tool calls
|
|
112
115
|
// as text) instead of summarizing: so it's told what it is, and a reply that isn't a summary
|
|
113
116
|
// is asked for again once; then the compaction fails (the conversation is trimmed instead).
|
|
@@ -133,7 +136,9 @@ export class Session {
|
|
|
133
136
|
}
|
|
134
137
|
chat.history.splice(0, chat.history.length, { task: '(the conversation before this point, summarized)', answer: summary })
|
|
135
138
|
const after = native ? size(chat.messages.slice(head.length)) : est(summary)
|
|
136
|
-
log('compact', `${midTask ? 'auto' : 'manual'}: ~${before} → ~${after} tokens`, summary)
|
|
139
|
+
log('compact', `${midTask ? 'auto' : 'manual'}${why ? ` (${why})` : ''}: ~${before} → ~${after} tokens`, summary)
|
|
140
|
+
client.lastTokens = 0 // the server's count was for the conversation before: none yet for this one
|
|
141
|
+
this.counted = null
|
|
137
142
|
ui.stopSpin()
|
|
138
143
|
ui.print(`${c.faint('✻')} ${c.muted(`Compacted the conversation: ~${kTokens(before)} → ~${kTokens(after)} tokens`)}`)
|
|
139
144
|
return summary
|
|
@@ -202,7 +207,8 @@ export class Session {
|
|
|
202
207
|
const self = { provider: this.client.provider, model: this.client.model, images: this.strat.vision, tools: this.strat.actions === 'tools', self: true }
|
|
203
208
|
const all = [self, ...(this.team?.() ?? [])]
|
|
204
209
|
const want = String(args.model ?? '').trim()
|
|
205
|
-
|
|
210
|
+
// only one model available (a pinned model, or you have just one): helpers use it, whatever was asked
|
|
211
|
+
const first = !want || all.length === 1 ? self : (all.find((t) => name(t) === want) ?? all.find((t) => t.model === want || want.endsWith(` ${t.model}`)))
|
|
206
212
|
if (!first) return `error: no model "${want}". Yours: ${all.map(name).join(', ')}`
|
|
207
213
|
// the one asked for first (if it can do the job), then the others that can: those that call
|
|
208
214
|
// tools first, then the ones that failed least this session, in your list's order
|
|
@@ -402,10 +408,23 @@ export class Session {
|
|
|
402
408
|
// A program's own instructions (--append-system-prompt: Nibedita, a script) come last, and HeartCode's
|
|
403
409
|
// own say less then (caller): no role of its own, no "stay in the folder", no way to finish of its
|
|
404
410
|
// own, no director; they're told to follow the program's where they differ.
|
|
411
|
+
// One line of context about where HeartCode runs, so the model knows the OS, shell and tools to expect.
|
|
412
|
+
const OS_NAMES = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' }
|
|
413
|
+
const envLine = () => {
|
|
414
|
+
const shell = (process.env.SHELL || process.env.ComSpec || '').split(/[/\\]/).pop()
|
|
415
|
+
return `${OS_NAMES[process.platform] ?? process.platform} (${process.platform} ${os.release()}, ${process.arch})${shell ? `, shell ${shell}` : ''}`
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The project root's entries, every one (names only), for the system prompt.
|
|
419
|
+
const rootList = (root) => {
|
|
420
|
+
const v = folderView(root)
|
|
421
|
+
return v.error ? `(can't read it: ${v.error})` : v.entries.length ? v.entries.join(' · ') + (v.more ? ` · … ${v.more} more` : '') : '(empty)'
|
|
422
|
+
}
|
|
423
|
+
|
|
405
424
|
const SYSTEM = (s) => {
|
|
406
425
|
const instructions = userInstructions(s.root)
|
|
407
426
|
return [
|
|
408
|
-
prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
|
|
427
|
+
prompt('agent/system', { root: s.root, env: envLine(), today: new Date().toISOString().slice(0, 10), rootList: rootList(s.root), servers: serversNote(s.root), files: '', more: '', caller: Boolean(s.appendSystem) }),
|
|
409
428
|
instructions && prompt('agent/instructions', { instructions }),
|
|
410
429
|
s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
|
|
411
430
|
s.strat.vision && prompt('agent/you-see'), // models that can see often say they can't
|
|
@@ -594,13 +613,28 @@ async function runNative(s, task, images, signal) {
|
|
|
594
613
|
delete s.chat.branchNote
|
|
595
614
|
}
|
|
596
615
|
s.compactedAt = -1
|
|
597
|
-
// Auto-compact:
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
616
|
+
// Auto-compact: when the conversation takes 85% of the window of the model answering now (its
|
|
617
|
+
// window as set up, measured or learned; also when that changes mid-task, or the model list moves
|
|
618
|
+
// on to a model with a smaller one), so there's room left for the reply. Its size: what the server
|
|
619
|
+
// counted for this conversation's last request (s.counted) plus the estimate of what came since
|
|
620
|
+
// (the tool results), or the estimate of all of it when that's more (no count yet: a resumed
|
|
621
|
+
// session, just compacted). After a failed try: again once it has grown by another 5%.
|
|
622
|
+
const used = () => {
|
|
623
|
+
const ratio = strat.ratio ?? 1
|
|
624
|
+
const all = tokensOf(s.messages, [...TOOL_DEFS, ...extraTools(s)], ratio)
|
|
625
|
+
const k = s.counted
|
|
626
|
+
if (!k?.tokens || k.upTo > s.messages.length) return all
|
|
627
|
+
return Math.max(all, k.tokens + tokensOf(s.messages.slice(k.upTo), null, ratio))
|
|
628
|
+
}
|
|
629
|
+
const nearlyFull = () => s.autoCompact && strat.context > 0 && used() >= strat.context * COMPACT_SHARE && used() >= (s.compactFailedAt ?? 0)
|
|
630
|
+
const fullness = () => `context ${Math.round((used() / strat.context) * 100)}% full (~${kTokens(used())} of ${kTokens(strat.context)})`
|
|
631
|
+
// Still that full after compacting (what every request carries, the system prompt and the tools'
|
|
632
|
+
// schemas, takes most of the window): compacting again won't free anything, so not until it has
|
|
633
|
+
// grown by another 5% (not a summary request at every step).
|
|
634
|
+
const afterCompact = () => (s.compactFailedAt = used() >= strat.context * COMPACT_SHARE ? used() + Math.round(strat.context * 0.05) : 0)
|
|
601
635
|
// Already that long from earlier messages: compact first, so the new message stays word for word.
|
|
602
636
|
if (nearlyFull()) {
|
|
603
|
-
await s.compact({ signal }).
|
|
637
|
+
await s.compact({ signal, why: fullness() }).then(afterCompact, (e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
|
|
604
638
|
}
|
|
605
639
|
// only the memory notes this message matches go with it (the system prompt has just the tags)
|
|
606
640
|
// each note once per conversation (again if it changed, or after compacting dropped it)
|
|
@@ -610,24 +644,36 @@ async function runNative(s, task, images, signal) {
|
|
|
610
644
|
s.chat.sentNotes = [...sent, ...notes.map(key)]
|
|
611
645
|
const text = notes.length ? `${task}\n\n${prompt('agent/memory-relevant', { notes: notes.map((n) => `[[${n.name}]] — ${n.description}`).join('\n') })}` : task
|
|
612
646
|
const planning = ui.permission === 'plan'
|
|
613
|
-
|
|
647
|
+
// the research-first reminder: small models follow the user's message far more than the system
|
|
648
|
+
// prompt (not a helper's job, nor a program's own task with its own instructions)
|
|
649
|
+
const noted = !s.isSub && !s.appendSystem ? `${text}\n\n${prompt('agent/research-first', { today: new Date().toISOString().slice(0, 10) })}` : text
|
|
650
|
+
const said = planning && !s.isSub ? `${noted}\n\n${prompt('agent/plan-mode')}` : noted
|
|
614
651
|
const content = images.length ? [{ type: 'text', text: said }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] : said
|
|
615
652
|
s.messages.push({ role: 'user', content })
|
|
616
653
|
attachNamed(s, task) // files the message names: as if already read (saves a round trip each)
|
|
617
654
|
// the tools: built again each step (the model answering may change; client.js leaves them out for
|
|
618
655
|
// one that can't call tools)
|
|
619
656
|
const toolList = () => [...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 }))
|
|
657
|
+
const guard = newGuard() // calls made again with nothing changed: the earlier result instead
|
|
620
658
|
let lastCall = '' // the last call (tool and arguments), and how many times in a row
|
|
621
659
|
let inRow = 0
|
|
622
660
|
// A todo list left done from an earlier message is cleared; an unfinished one stays.
|
|
623
661
|
if (!s.isSub && s.chat.todos?.every((t) => t.status === 'completed')) s.chat.todos = []
|
|
624
662
|
s.todoTouched = false
|
|
663
|
+
s.task = task // the user's message, for the page review (lookAtUi)
|
|
664
|
+
s.uiTouched = new Set() // pages (and styles) written in this message: looked at before it ends
|
|
665
|
+
s.uiLooks = 0
|
|
666
|
+
s.uiErrorsPrev = [] // hard errors from the last look: when one is gone next look, it was fixed — learn from it
|
|
667
|
+
s.runFails = new Map() // a command that failed (by its tool) → its error; when the same tool runs clean after an edit, that's a verified fix to learn from (any language, not just web)
|
|
668
|
+
s.edits = 0 // successful file edits so far this message (to tell a real fix from a lucky rerun)
|
|
625
669
|
let nudged = 0 // reminders sent this message about open todo items or a Stop hook
|
|
626
670
|
|
|
627
671
|
let empty = 0 // replies in a row with neither text nor tool calls
|
|
628
|
-
let
|
|
672
|
+
let looping = 0 // replies cut for going into a loop, this message
|
|
673
|
+
let stuck = null // why it's stopped: going round in circles this message
|
|
629
674
|
let garbled = 0 // replies with a tool call written as text (template markup), this message
|
|
630
675
|
let movedOn = 0 // models given up on for that, in this message
|
|
676
|
+
let refitted = false // this step's conversation was just compacted for a smaller model the list moved on to
|
|
631
677
|
// No step limit: a long fix takes many steps, and Esc stops it. A caller can set one (--max-turns).
|
|
632
678
|
for (let step = 0; ; step++) {
|
|
633
679
|
const tools = toolList()
|
|
@@ -641,25 +687,18 @@ async function runNative(s, task, images, signal) {
|
|
|
641
687
|
ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
|
|
642
688
|
throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
|
|
643
689
|
}
|
|
644
|
-
// after a failed compaction, the next try waits
|
|
690
|
+
// after a failed compaction, the next try waits until the conversation has grown by 5% of the window
|
|
645
691
|
if (nearlyFull() && step !== s.compactedAt) {
|
|
646
692
|
s.compactedAt = step
|
|
647
|
-
await s.compact({ signal, midTask: true, task }).then(
|
|
648
|
-
|
|
693
|
+
await s.compact({ signal, midTask: true, task, why: fullness() }).then(
|
|
694
|
+
afterCompact,
|
|
649
695
|
(e) => {
|
|
650
696
|
if (e.name === 'AbortError') throw e
|
|
651
|
-
s.compactFailedAt = (
|
|
652
|
-
log('compact', `failed (${e.message}); trying again
|
|
697
|
+
s.compactFailedAt = used() + Math.round(strat.context * 0.05)
|
|
698
|
+
log('compact', `failed (${e.message}); trying again once it's ~${s.compactFailedAt} tokens`)
|
|
653
699
|
},
|
|
654
700
|
)
|
|
655
701
|
}
|
|
656
|
-
// what the user typed while it worked goes in before the next step (steering)
|
|
657
|
-
const added = step && !s.isSub ? (ui.takeAhead?.() ?? []) : []
|
|
658
|
-
if (added.length) {
|
|
659
|
-
for (const l of added) ui.print(`${c.magenta('♥')} ${c.bright(l)}`)
|
|
660
|
-
log('task', 'the user added while working:', added)
|
|
661
|
-
s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
|
|
662
|
-
}
|
|
663
702
|
// the model answering can change mid-message (your model list): the system prompt follows it
|
|
664
703
|
const now = SYSTEM(s)
|
|
665
704
|
if (s.messages[0]?.content !== now && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: now }
|
|
@@ -672,14 +711,45 @@ async function runNative(s, task, images, signal) {
|
|
|
672
711
|
// the prompts or HEARTCODE.md changed meanwhile (applySettings): the system prompt again, now
|
|
673
712
|
if (s.refreshSystem && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: SYSTEM(s) }
|
|
674
713
|
s.refreshSystem = false
|
|
675
|
-
|
|
714
|
+
let res
|
|
715
|
+
try {
|
|
716
|
+
res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta, fit: !refitted })
|
|
717
|
+
} catch (e) {
|
|
718
|
+
if (!e.tooBig) throw e
|
|
719
|
+
// The model list moved on (the one before failed) to a model whose window can't hold the
|
|
720
|
+
// conversation: compacted for it (by it, to its size), then the same step again. Once: still
|
|
721
|
+
// too big after that (its window can't even hold the start), it's sent anyway.
|
|
722
|
+
ui.stopSpin()
|
|
723
|
+
out.end()
|
|
724
|
+
log('compact', e.message)
|
|
725
|
+
refitted = true
|
|
726
|
+
await s.compact({ signal, midTask: true, task, why: `${client.model} took over and its window is smaller` }).catch((err) => {
|
|
727
|
+
if (err.name === 'AbortError') throw err
|
|
728
|
+
log('compact', `failed (${err.message}); sending it anyway`)
|
|
729
|
+
})
|
|
730
|
+
step--
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
refitted = false
|
|
676
734
|
ui.stopSpin()
|
|
677
735
|
if (!stream) {
|
|
678
736
|
out.think(res.reasoning)
|
|
679
737
|
out.text(res.content)
|
|
680
738
|
}
|
|
681
739
|
out.end()
|
|
740
|
+
// the reply went round and round (client.js cut it): told so, and asked again
|
|
741
|
+
if (res.looped) {
|
|
742
|
+
if (++looping > 3) {
|
|
743
|
+
ui.warn(`stopped: ${client.model} keeps going into a loop`)
|
|
744
|
+
return finalAnswer(s, signal, 'its replies kept going into a loop')
|
|
745
|
+
}
|
|
746
|
+
ui.warn(`${client.model} went into a loop; cut, and asked again`)
|
|
747
|
+
if (res.content.trim()) s.messages.push({ role: 'assistant', content: res.content })
|
|
748
|
+
s.messages.push({ role: 'user', content: prompt('agent/looped', { sample: res.looped }) })
|
|
749
|
+
continue
|
|
750
|
+
}
|
|
682
751
|
s.messages.push({ role: 'assistant', content: res.content || '', ...(res.toolCalls.length && { tool_calls: res.toolCalls }), ...(res.reasoning && res.toolCalls.length && { reasoning_content: res.reasoning }) }) // client.js sends it by the name the provider uses, or not at all
|
|
752
|
+
s.counted = { tokens: client.lastTokens, upTo: s.messages.length } // the server's count: up to this reply (auto-compact)
|
|
683
753
|
writtenBy.set(s.messages.at(-1), client.model) // its thinking goes back to this model only
|
|
684
754
|
// a tool call written out as text instead of made (some models do, after a long conversation): made
|
|
685
755
|
const leak = !res.toolCalls.length && leakedCall(res.content)
|
|
@@ -725,6 +795,12 @@ async function runNative(s, task, images, signal) {
|
|
|
725
795
|
}
|
|
726
796
|
empty = 0
|
|
727
797
|
if (!res.toolCalls.length) {
|
|
798
|
+
// a page it built or restyled: HeartCode opens it and shows it the result, to check and fix
|
|
799
|
+
const look = !s.isSub && (await lookAtUi(s, signal))
|
|
800
|
+
if (look) {
|
|
801
|
+
s.messages.push({ role: 'user', content: look })
|
|
802
|
+
continue
|
|
803
|
+
}
|
|
728
804
|
const more = nudged < 3 && !s.isSub && (await notDone(s, signal, nudged > 0))
|
|
729
805
|
if (!more) return res.content
|
|
730
806
|
nudged++
|
|
@@ -748,6 +824,18 @@ async function runNative(s, task, images, signal) {
|
|
|
748
824
|
ui.step('agent', clip([args.model && `${args.model}:`, args.description].filter(Boolean).join(' '), 80))
|
|
749
825
|
together.set(call.id, runTool('agent', args, s, signal, call.id))
|
|
750
826
|
}
|
|
827
|
+
// lookups in one reply (searches, pages, files) run at the same time; the same one twice, once
|
|
828
|
+
const early = new Map() // call id → { refused } or { running }
|
|
829
|
+
const bySig = new Map()
|
|
830
|
+
for (const call of res.toolCalls) {
|
|
831
|
+
const name = call.function.name
|
|
832
|
+
const args = argsOf(call)
|
|
833
|
+
if (!LOOKUPS.has(name) || !args || !allowed(s, name)) continue
|
|
834
|
+
const sig = `${name} ${JSON.stringify(args)}`
|
|
835
|
+
const refused = guard.before(name, args)
|
|
836
|
+
if (refused) early.set(call.id, { refused })
|
|
837
|
+
else early.set(call.id, { running: bySig.get(sig) ?? bySig.set(sig, runTool(name, args, s, signal, call.id)).get(sig) })
|
|
838
|
+
}
|
|
751
839
|
for (const call of res.toolCalls) {
|
|
752
840
|
const name = call.function.name
|
|
753
841
|
const args = argsOf(call)
|
|
@@ -765,16 +853,22 @@ async function runNative(s, task, images, signal) {
|
|
|
765
853
|
? ''
|
|
766
854
|
: (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
|
|
767
855
|
if (name !== 'run_command' && !together.has(call.id)) ui.step(name, clip(detail, 80))
|
|
768
|
-
|
|
856
|
+
const pre = early.get(call.id)
|
|
857
|
+
const refused = pre ? (pre.refused ?? null) : !together.has(call.id) && args && allowed(s, name) ? guard.before(name, args) : null
|
|
858
|
+
let out = refused ?? (pre ? await pre.running : together.has(call.id) ? await together.get(call.id) : !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal, call.id) : 'error: the tool arguments were not valid JSON')
|
|
859
|
+
if (!refused && args) out += guard.after(name, args, out)
|
|
860
|
+
if (guard.stuck) stuck ??= guard.stuck
|
|
861
|
+
if (!s.isSub && s.memory && !refused && args) await noteRun(s, name, args, out, signal).catch((e) => log('learn', `run note: ${e.message}`)) // learn from any run that failed then passed
|
|
769
862
|
// the same call again and again in a row (a weak model going round): told at the third, stopped at the fifth
|
|
770
863
|
const sig = `${name} ${call.function.arguments}`
|
|
771
864
|
inRow = sig === lastCall ? inRow + 1 : 1
|
|
772
865
|
lastCall = sig
|
|
773
866
|
if (inRow >= 3) out += `\n\n${prompt('agent/repeating', { n: inRow })}`
|
|
774
|
-
if (inRow >= 5) stuck = name
|
|
867
|
+
if (inRow >= 5) stuck = `made the same ${name} call five times in a row`
|
|
775
868
|
if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
|
|
776
869
|
else if (name === 'agent' && !failed(out)) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
|
|
777
870
|
else if (failed(out)) ui.result(clip(out, 120))
|
|
871
|
+
client.refresh?.() // a setting changed while the tool ran (the window): its result is cut by the new one
|
|
778
872
|
const msg = { role: 'tool', tool_call_id: call.id, content: fitResult(s, name, out) }
|
|
779
873
|
if (name === 'read_file' && args && !args.start_line && !args.end_line && !failed(out)) replaceCopy(s, args.path, msg)
|
|
780
874
|
s.messages.push(msg)
|
|
@@ -782,8 +876,8 @@ async function runNative(s, task, images, signal) {
|
|
|
782
876
|
}
|
|
783
877
|
trimOldResults(s)
|
|
784
878
|
if (stuck) {
|
|
785
|
-
ui.warn(`stopped: ${client.model}
|
|
786
|
-
return finalAnswer(s, signal,
|
|
879
|
+
ui.warn(`stopped: ${client.model} ${stuck}`)
|
|
880
|
+
return finalAnswer(s, signal, stuck)
|
|
787
881
|
}
|
|
788
882
|
// screenshots from MCP tools: tool results are text only, so images follow as a message
|
|
789
883
|
if (s.pendingImages?.length) {
|
|
@@ -793,6 +887,297 @@ async function runNative(s, task, images, signal) {
|
|
|
793
887
|
}
|
|
794
888
|
}
|
|
795
889
|
|
|
890
|
+
// One message's calls, so a model going round in circles is caught even when the calls aren't in a
|
|
891
|
+
// row or differ only a little: the same search or page again, the same command, read or search
|
|
892
|
+
// again with nothing changed since (it gets the earlier result back instead), command after command
|
|
893
|
+
// failing. No limit on how much it looks up: new searches and pages always run; only going round
|
|
894
|
+
// (ten calls already made) stops it.
|
|
895
|
+
// before: what to send back instead of running it (the earlier result), or null; after: a note for its result.
|
|
896
|
+
const EDITS = new Set(['edit_file', 'write_file', 'append_file', 'apply_patch'])
|
|
897
|
+
// Tools that only look (nothing changes): several in one reply run at the same time.
|
|
898
|
+
const LOOKUPS = new Set(['web_search', 'web_fetch', 'read_file', 'search', 'list_files', 'find_command', 'lsp'])
|
|
899
|
+
export function newGuard() {
|
|
900
|
+
const seen = new Map() // what a call is → { out, at }
|
|
901
|
+
let epoch = 0 // goes up with each change (an edit, a command that can change things)
|
|
902
|
+
let failing = 0
|
|
903
|
+
let repeats = 0
|
|
904
|
+
const g = { stuck: null }
|
|
905
|
+
const words = (q) => [...new Set(String(q ?? '').toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])].sort().join(' ')
|
|
906
|
+
const keyOf = (name, a) => {
|
|
907
|
+
if (name === 'web_search') return `search: ${words(a.query)}`
|
|
908
|
+
if (name === 'web_fetch') return `page: ${String(a.url ?? '').toLowerCase().replace(/^https?:\/\/(www\.)?/, '').replace(/\/+$/, '')} @${a.start ?? 0}`
|
|
909
|
+
if (['run_command', 'read_file', 'search', 'list_files', 'find_command', 'lsp'].includes(name)) return `${name} ${JSON.stringify(a)} @${epoch}`
|
|
910
|
+
return null
|
|
911
|
+
}
|
|
912
|
+
g.before = (name, a) => {
|
|
913
|
+
const k = keyOf(name, a)
|
|
914
|
+
const was = k && seen.get(k)
|
|
915
|
+
// a command may be waited on (a server starting): the same one again after a while runs
|
|
916
|
+
if (!was || (name === 'run_command' && Date.now() - was.at > 20_000)) return null
|
|
917
|
+
repeats++
|
|
918
|
+
if (repeats >= 10) g.stuck = 'kept making calls it had already made (10 repeats)' // going round, not finding anything new
|
|
919
|
+
return prompt('agent/already-done', { result: was.out }) + (repeats >= 3 ? `\n\n${prompt('agent/circling')}` : '')
|
|
920
|
+
}
|
|
921
|
+
g.after = (name, a, out) => {
|
|
922
|
+
let note = ''
|
|
923
|
+
if (name === 'run_command') {
|
|
924
|
+
const code = Number(/^exit code (-?\d+)/.exec(out)?.[1] ?? 0)
|
|
925
|
+
failing = code ? failing + 1 : 0
|
|
926
|
+
if (failing >= 4) note += `\n\n${prompt('agent/failing-in-row', { n: failing })}`
|
|
927
|
+
if (failing >= 8) g.stuck = 'had eight commands in a row fail'
|
|
928
|
+
if (!readOnlyCommand(String(a.command ?? ''))) epoch++ // it may have changed things: what came before can be done again
|
|
929
|
+
}
|
|
930
|
+
if (EDITS.has(name) && !failed(out)) epoch++
|
|
931
|
+
const k = keyOf(name, a) // after the command's own change: the same again right away is the same
|
|
932
|
+
if (k) seen.set(k, { out: clip(String(out), 6000), at: Date.now() })
|
|
933
|
+
return note
|
|
934
|
+
}
|
|
935
|
+
return g
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// The pages this message wrote or restyled (an .html written, or one using a .css written),
|
|
939
|
+
// opened in the built-in browser (chrome-devtools): a screenshot at desktop width (the whole page)
|
|
940
|
+
// and at phone width, given to the model to check against its brief and fix. Twice per message at
|
|
941
|
+
// most; nothing when there's no browser. A model that can't see gets them described by one of
|
|
942
|
+
// yours that can.
|
|
943
|
+
// Problems found in a built page without a browser: JavaScript that doesn't parse, and the Tailwind
|
|
944
|
+
// CDN used with @apply/@layer (it throws "Cannot apply unknown utility class" at runtime and the
|
|
945
|
+
// styling breaks). These are caught even when the browser can't be opened.
|
|
946
|
+
function staticChecks(root, rel) {
|
|
947
|
+
const out = []
|
|
948
|
+
let html
|
|
949
|
+
try {
|
|
950
|
+
html = fs.readFileSync(path.join(root, rel), 'utf8')
|
|
951
|
+
} catch {
|
|
952
|
+
return out
|
|
953
|
+
}
|
|
954
|
+
for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
955
|
+
const attrs = m[1]
|
|
956
|
+
if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["']?module/.test(attrs) || !m[2].trim()) continue // external or ESM: can't check this way
|
|
957
|
+
try {
|
|
958
|
+
new Function(m[2]) // compiles without running: throws on a syntax error (unclosed brace, typo)
|
|
959
|
+
} catch (e) {
|
|
960
|
+
out.push(`${rel}: the <script> doesn't parse — ${e.message}. The page's JavaScript won't run at all. Find and fix it (often an unclosed brace or bracket).`)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const tailwindCdn = /cdn\.tailwindcss\.com|@tailwindcss\/browser|type\s*=\s*["']text\/tailwindcss/.test(html)
|
|
964
|
+
if (tailwindCdn) {
|
|
965
|
+
const css = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n')
|
|
966
|
+
const defined = new Set([...css.matchAll(/(?:^|\})\s*\.([a-zA-Z][\w-]*)\s*\{/g)].map((m) => m[1])) // classes defined with a rule
|
|
967
|
+
const applied = new Set([...css.matchAll(/@apply\b([^;}{]*)/g)].flatMap((m) => m[1].split(/\s+/).map((t) => t.replace(/[^\w-].*$/, '')).filter(Boolean)))
|
|
968
|
+
const bad = [...applied].filter((c) => defined.has(c))
|
|
969
|
+
if (bad.length) out.push(`${rel}: the Tailwind browser build (CDN) throws "Cannot apply unknown utility class" here — you @apply a class you defined yourself (${bad.join(', ')}), which only a real build step allows. Put those utility classes directly in the HTML (class="…"), not behind @apply; or drop the @apply and write the rules in plain CSS.`)
|
|
970
|
+
}
|
|
971
|
+
return out
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// The tech a mistake is about, for tagging a lesson so it's recalled on later tasks of the same
|
|
975
|
+
// kind. Reads both the error and the command/file it came from; works for any language, not just web.
|
|
976
|
+
function techOf(context) {
|
|
977
|
+
const t = String(context).toLowerCase()
|
|
978
|
+
const tags = []
|
|
979
|
+
const add = (re, tag) => re.test(t) && tags.push(tag)
|
|
980
|
+
add(/tailwind/, 'tailwind')
|
|
981
|
+
add(/\btsc?\b|\.tsx?\b|typescript|ts-node/, 'typescript')
|
|
982
|
+
add(/\bnode\b|\.c?js\b|\.mjs\b|javascript|npm|pnpm|yarn|\bbun\b|vite|webpack|eslint|referenceerror|is not defined/, 'javascript')
|
|
983
|
+
add(/\breact\b|usestate|useeffect|\bjsx\b|next\.?js/, 'react')
|
|
984
|
+
add(/\bpython3?\b|\bpip3?\b|\bpytest\b|\buv\b|\.py\b|traceback|modulenotfound|indentationerror/, 'python')
|
|
985
|
+
add(/\bgo\b|\.go\b|golang|go\.mod/, 'go')
|
|
986
|
+
add(/\bcargo\b|\brustc\b|\.rs\b|rust/, 'rust')
|
|
987
|
+
add(/\bjava\b|\bmvn\b|gradle|\.java\b/, 'java')
|
|
988
|
+
add(/\bruby\b|\bgem\b|bundle|\.rb\b|rails/, 'ruby')
|
|
989
|
+
add(/\bphp\b|composer|\.php\b|laravel/, 'php')
|
|
990
|
+
add(/\bmake\b|cmake|\bgcc\b|clang|\.c\b|\.cpp\b/, 'c')
|
|
991
|
+
add(/\bdocker\b|dockerfile|kubectl|\bk8s\b/, 'docker')
|
|
992
|
+
add(/\bcurl\b|\bwget\b|http|endpoint|\bapi\b|\bfetch\b/, 'api')
|
|
993
|
+
add(/\.html\b|<script|<style|\bdom\b/, 'html')
|
|
994
|
+
add(/\.css\b|@apply|@layer|utility class|stylesheet/, 'css')
|
|
995
|
+
add(/\bsql\b|postgres|sqlite|mysql|database/, 'sql')
|
|
996
|
+
return tags.length ? [...new Set(tags)].slice(0, 4) : ['general']
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// A command's tool: its first word (npm, python3, go, cargo, make, curl…), paths and flags dropped,
|
|
1000
|
+
// so the same kind of command failing then passing is recognised across runs.
|
|
1001
|
+
const runTool_ = (cmd) => (String(cmd).trim().match(/^[\w./-]+/)?.[0] ?? '').split('/').pop()
|
|
1002
|
+
|
|
1003
|
+
// After a command: a failure (non-zero exit, or a tool error) is remembered by its tool; a later
|
|
1004
|
+
// clean run of the same tool, once files were edited, means it was fixed — learn from it. This is
|
|
1005
|
+
// how HeartCode learns from anything it runs, in any language, not only web pages.
|
|
1006
|
+
async function noteRun(s, name, args, out, signal) {
|
|
1007
|
+
if (name === 'edit_file' || name === 'write_file' || name === 'apply_patch') {
|
|
1008
|
+
if (!failed(out) && !/error:/i.test(out.slice(0, 40))) s.edits++
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
if (name !== 'run_command') return
|
|
1012
|
+
const tool = runTool_(args.command)
|
|
1013
|
+
if (!tool) return
|
|
1014
|
+
const code = Number(/^exit code (-?\d+)/.exec(out)?.[1] ?? (failed(out) ? 1 : 0))
|
|
1015
|
+
const bad = code !== 0 || /\b(error|traceback|exception|failed|cannot|not found|undefined)\b/i.test(out.split('\n').slice(0, 6).join(' '))
|
|
1016
|
+
if (bad) {
|
|
1017
|
+
const line = out.split('\n').find((l) => /\b(error|traceback|exception|failed|cannot|not found)\b/i.test(l)) ?? out.split('\n').find((l) => l.trim()) ?? ''
|
|
1018
|
+
s.runFails.set(tool, { error: `${args.command.split(/\s+/)[0]}: ${line.trim().slice(0, 160)}`, atEdits: s.edits, cmd: args.command })
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
// a clean run: did this tool fail earlier, and have files been edited since? then it's a real fix
|
|
1022
|
+
const prev = s.runFails.get(tool)
|
|
1023
|
+
if (prev && s.edits > prev.atEdits) {
|
|
1024
|
+
s.runFails.delete(tool)
|
|
1025
|
+
await learnFromFixes(s, [{ error: prev.error, context: `${prev.cmd} ${prev.error}` }], signal)
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Turn each fixed mistake into one reusable rule and save it (global memory, tagged by tech). The
|
|
1030
|
+
// model writes the rule from the error; a near-duplicate lesson is skipped. Recall is automatic
|
|
1031
|
+
// (memory.relevant attaches matching notes to a later message).
|
|
1032
|
+
async function learnFromFixes(s, fixed, signal) {
|
|
1033
|
+
for (const { error, context } of fixed.slice(0, 5)) {
|
|
1034
|
+
const tags = techOf(context ?? error)
|
|
1035
|
+
const near = s.memory.search(`tag:lesson ${tags.join(' ')}`).find((n) => n.body.toLowerCase().includes(error.replace(/^[^:]*:\s*/, '').toLowerCase().slice(0, 30)))
|
|
1036
|
+
if (near) continue // already learned
|
|
1037
|
+
let rule = ''
|
|
1038
|
+
try {
|
|
1039
|
+
rule = (await s.client.chat({ messages: [{ role: 'user', content: prompt('agent/lesson', { error }) }] }, { signal })).content.trim().split('\n')[0].replace(/^["'\s-]+|["']+$/g, '').slice(0, 200)
|
|
1040
|
+
} catch {}
|
|
1041
|
+
if (!rule || rule.length < 10) rule = error.replace(/^[^:]*:\s*/, '') // the model couldn't phrase it: keep the error's own wording
|
|
1042
|
+
const name = s.memory.write({ description: rule, type: 'reference', tags: ['lesson', ...tags], content: `${rule}\n\nLearned after this was caught and fixed while building: ${error}`, scope: 'global' })
|
|
1043
|
+
s.ui.step('learned', rule.slice(0, 90))
|
|
1044
|
+
log('learn', `saved lesson [[${name}]] (#${tags.join(' #')}): ${rule}`)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Injected into the page (evaluate_script): fills inputs, clicks buttons, toggles checkboxes, and
|
|
1049
|
+
// returns the errors any of them threw. Navigation is blocked so one click can't end the test.
|
|
1050
|
+
const SMOKE_TEST = `async () => {
|
|
1051
|
+
const errs = []
|
|
1052
|
+
const push = (m) => { const s = String(m && m.message ? m.message : m).slice(0, 180); if (s && !errs.includes(s)) errs.push(s) }
|
|
1053
|
+
addEventListener('error', (e) => push(e.message || (e.error && e.error.message) || e), true)
|
|
1054
|
+
addEventListener('unhandledrejection', (e) => push('unhandled: ' + ((e.reason && e.reason.message) || e.reason)), true)
|
|
1055
|
+
const ce = console.error; console.error = (...a) => { push(a.map(String).join(' ')); ce.apply(console, a) }
|
|
1056
|
+
document.addEventListener('submit', (e) => e.preventDefault(), true)
|
|
1057
|
+
document.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a'); const h = a && a.getAttribute('href'); if (h && h[0] !== '#') e.preventDefault() }, true)
|
|
1058
|
+
for (const inp of document.querySelectorAll('input:not([type=button]):not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=file]), textarea')) {
|
|
1059
|
+
try { inp.focus(); inp.value = 'Test 123'; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); for (const t of ['keydown','keypress','keyup']) inp.dispatchEvent(new KeyboardEvent(t, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })) } catch (e) { push('typing: ' + e.message) }
|
|
1060
|
+
}
|
|
1061
|
+
const btns = [...document.querySelectorAll('button, [role=button], input[type=button], input[type=submit], a[href^="#"], a:not([href]), [onclick]')]
|
|
1062
|
+
let controls = btns.length
|
|
1063
|
+
for (const el of btns.slice(0, 30)) { try { el.click() } catch (e) { push('click "' + ((el.textContent || el.getAttribute('aria-label') || el.value || '').trim().slice(0, 30)) + '": ' + e.message) } ; await new Promise((r) => setTimeout(r, 10)) }
|
|
1064
|
+
for (const cb of document.querySelectorAll('input[type=checkbox], input[type=radio]')) { controls++; try { cb.click() } catch (e) { push('toggle: ' + e.message) } }
|
|
1065
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
1066
|
+
return JSON.stringify({ errors: errs, controls })
|
|
1067
|
+
}`
|
|
1068
|
+
|
|
1069
|
+
async function lookAtUi(s, signal) {
|
|
1070
|
+
if (!s.uiTouched?.size || s.uiLooks >= 3 || s.ui.permission === 'plan') return null
|
|
1071
|
+
const touched = [...s.uiTouched]
|
|
1072
|
+
s.uiTouched.clear()
|
|
1073
|
+
const styles = touched.filter((f) => /\.css$/i.test(f)).map((f) => path.basename(f))
|
|
1074
|
+
const pages = touched.filter((f) => /\.html?$/i.test(f))
|
|
1075
|
+
if (styles.length) {
|
|
1076
|
+
for (const f of listFiles(s.root).filter((f) => /\.html?$/i.test(f))) {
|
|
1077
|
+
try {
|
|
1078
|
+
if (styles.some((c) => fs.readFileSync(path.join(s.root, f), 'utf8').includes(c))) pages.push(f)
|
|
1079
|
+
} catch {}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const shown = [...new Set(pages)].filter((f) => fs.existsSync(path.join(s.root, f))).slice(0, 2)
|
|
1083
|
+
if (!shown.length) return null
|
|
1084
|
+
s.uiLooks++
|
|
1085
|
+
const browser = s.mcp?.get?.('chrome-devtools')
|
|
1086
|
+
const errors = shown.flatMap((rel) => staticChecks(s.root, rel)) // works with no browser
|
|
1087
|
+
const shots = []
|
|
1088
|
+
let opened = 0 // pages actually opened in the browser (0: couldn't verify it runs)
|
|
1089
|
+
const text = (r) => (r.content ?? []).map((p) => p.text ?? '').join('\n')
|
|
1090
|
+
const done = browser ? await s.mcpGate?.() : null
|
|
1091
|
+
try {
|
|
1092
|
+
if (!browser) throw new Error('no browser')
|
|
1093
|
+
for (const rel of shown) {
|
|
1094
|
+
s.ui.spinner(`opening ${rel} in the browser`)
|
|
1095
|
+
const openedText = text(await browser.call('new_page', { url: pathToFileURL(path.join(s.root, rel)).href }, signal))
|
|
1096
|
+
opened++
|
|
1097
|
+
const id = Number(/(\d+):[^\n]*\[selected\]/.exec(openedText)?.[1]) || undefined // newer versions number the pages
|
|
1098
|
+
const page = id ? { pageId: id } : {}
|
|
1099
|
+
await new Promise((r) => setTimeout(r, 600)) // the scripts run before we read the console
|
|
1100
|
+
const con = await browser.call('list_console_messages', page, signal).catch(() => null)
|
|
1101
|
+
for (const line of (con ? text(con) : '').split('\n')) {
|
|
1102
|
+
if (!/\[error\]/i.test(line) || /favicon\.ico/i.test(line)) continue // a missing favicon isn't a real problem
|
|
1103
|
+
errors.push(`${rel}: ${line.replace(/^\s*msgid=\d+\s*\[error\]\s*/i, '').replace(/\s*\(\d+ args?\)\s*$/, '').trim()}`)
|
|
1104
|
+
}
|
|
1105
|
+
// Drive it like a user: fill the inputs, click every button, toggle every checkbox. An error a
|
|
1106
|
+
// control throws means it doesn't work (a dead button, a popup that fails) — caught here, not on a plain load.
|
|
1107
|
+
s.ui.spinner(`testing ${rel} (clicking through it)`)
|
|
1108
|
+
const ran = await browser.call('evaluate_script', { ...page, function: SMOKE_TEST }, signal).catch((e) => (log('look', `smoke test failed on ${rel}: ${e.message}`), null))
|
|
1109
|
+
const blob = ran && /\{"errors":\s*\[[\s\S]*?\],\s*"controls":\s*\d+\}/.exec(text(ran).replace(/\\n/g, ' ').replace(/\\"/g, '"').replace(/\\\\/g, '\\'))
|
|
1110
|
+
if (blob) {
|
|
1111
|
+
try {
|
|
1112
|
+
const r = JSON.parse(blob[0])
|
|
1113
|
+
for (const err of r.errors.slice(0, 8)) errors.push(`${rel}: a control is broken — ${err}`)
|
|
1114
|
+
log('look', `${rel}: exercised ${r.controls} control(s), ${r.errors.length} broke`)
|
|
1115
|
+
} catch (e) {
|
|
1116
|
+
log('look', `smoke parse: ${e.message}`)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
for (const [width, height, fullPage] of [[1280, 900, true], [390, 844, false]]) {
|
|
1120
|
+
await browser.call('resize_page', { ...page, width, height }, signal)
|
|
1121
|
+
const r = await browser.call('take_screenshot', { ...page, ...(fullPage && { fullPage }) }, signal)
|
|
1122
|
+
const img = r.content?.find((p) => p.type === 'image' && p.data)
|
|
1123
|
+
if (img) shots.push({ label: `${rel} at ${width} px wide${fullPage ? ' (the whole page)' : ''}`, url: `data:${img.mimeType ?? 'image/png'};base64,${img.data}` })
|
|
1124
|
+
}
|
|
1125
|
+
if (id) await browser.call('close_page', page, signal).catch(() => {})
|
|
1126
|
+
}
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
if (signal?.aborted) throw e
|
|
1129
|
+
log('look', `couldn't open ${shown.join(', ')} in the browser: ${e.message}`)
|
|
1130
|
+
} finally {
|
|
1131
|
+
done?.()
|
|
1132
|
+
s.ui.stopSpin()
|
|
1133
|
+
}
|
|
1134
|
+
if (errors.length) log('look', `problems found: ${errors.join(' | ')}`)
|
|
1135
|
+
// Learn: a hard error that was there last look and is gone now was fixed — save the lesson so it
|
|
1136
|
+
// isn't made again (recalled on later tasks about the same tech). Only in the main agent.
|
|
1137
|
+
if (!s.isSub && s.memory) {
|
|
1138
|
+
const key = (e) => e.replace(/^[^:]*:\s*/, '').toLowerCase().replace(/["'`]/g, '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
1139
|
+
const nowKeys = new Set(errors.map(key))
|
|
1140
|
+
const fixed = (s.uiErrorsPrev ?? []).filter((p) => !nowKeys.has(p.key))
|
|
1141
|
+
if (fixed.length) await learnFromFixes(s, fixed, signal).catch((e) => log('learn', `failed: ${e.message}`))
|
|
1142
|
+
s.uiErrorsPrev = errors.map((e) => ({ key: key(e), error: e }))
|
|
1143
|
+
}
|
|
1144
|
+
// Couldn't open it in a browser (none configured, or it failed): don't pass silently — the model
|
|
1145
|
+
// must verify it itself. Static checks still apply.
|
|
1146
|
+
const unverified = opened === 0 ? `${shown.join(', ')} could not be opened in a browser here, so HeartCode could not confirm it works. Before you finish, verify it yourself: open it and check it actually works — no errors in the console, every button and interaction does what it should.` : ''
|
|
1147
|
+
// JavaScript errors mean the page is broken however good it looks: fed back on their own (text,
|
|
1148
|
+
// no model that sees needed), and the visual review added when there is one.
|
|
1149
|
+
const brokenList = errors.length ? errors.map((e, i) => `${i + 1}. ${e}`).join('\n') : ''
|
|
1150
|
+
let found = ''
|
|
1151
|
+
if (shots.length) {
|
|
1152
|
+
log('look', shots.map((x) => x.label))
|
|
1153
|
+
// reviewed in a request of its own (the screenshots and the user's request, nothing else): a
|
|
1154
|
+
// model checking its own work in its own conversation tends to call it fine
|
|
1155
|
+
const self = { provider: s.client.provider, model: s.client.model, images: s.strat.vision }
|
|
1156
|
+
const reviewers = [...(self.images ? [self] : []), ...(s.team?.() ?? []).filter((t) => t.images && !(t.provider === self.provider && t.model === self.model))]
|
|
1157
|
+
const ask = prompt('agent/ui-review', { task: s.task ?? '', shots: shots.map((x, i) => `${i + 1}. ${x.label}`).join('\n') })
|
|
1158
|
+
for (const t of reviewers) {
|
|
1159
|
+
s.ui.spinner(`${t.model} is reviewing ${shown.join(', ')}`)
|
|
1160
|
+
try {
|
|
1161
|
+
const { client } = t === self ? { client: s.client } : await s.engineFor(t)
|
|
1162
|
+
found = (await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: ask }, ...shots.map((x) => ({ type: 'image_url', image_url: { url: x.url } }))] }] }, { signal })).content.trim()
|
|
1163
|
+
if (found) break
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
if (signal?.aborted) throw e
|
|
1166
|
+
log('look', `${t.model} couldn't review: ${e.message}`)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
s.ui.stopSpin()
|
|
1170
|
+
}
|
|
1171
|
+
const visual = found && !(/^\W*none\W*$/i.test(found) || !/\d+[.)]\s/.test(found)) ? found : ''
|
|
1172
|
+
if (!brokenList && !visual && !unverified) {
|
|
1173
|
+
if (shots.length) s.ui.step('look', `${shown.join(', ')}: works, nothing to fix`)
|
|
1174
|
+
return null
|
|
1175
|
+
}
|
|
1176
|
+
s.ui.step('look', `${shown.join(', ')}: ${[errors.length && `${errors.length} problem${errors.length > 1 ? 's' : ''}`, visual && 'review notes', unverified && 'verify it yourself'].filter(Boolean).join(', ')}`)
|
|
1177
|
+
const problems = [brokenList, visual, unverified].filter(Boolean).join('\n')
|
|
1178
|
+
return prompt('agent/look-at-ui', { files: shown.join(', '), problems })
|
|
1179
|
+
}
|
|
1180
|
+
|
|
796
1181
|
// The model says it's done: is it? Not while its todo list (from this message) has open items, or
|
|
797
1182
|
// while a Stop hook says otherwise. Returns what to tell it, or null. Each is asked a few times at
|
|
798
1183
|
// most (the caller counts), so a model that can't finish still ends.
|
|
@@ -820,8 +1205,7 @@ function helperUI(ui, label) {
|
|
|
820
1205
|
text() {},
|
|
821
1206
|
end() {
|
|
822
1207
|
if (!thought.trim()) return
|
|
823
|
-
|
|
824
|
-
ui.print(c.faint(c.italic(` ✻ ${label} thought for ${secs}s`)) + (ui.showThinking ? `\n${c.think(c.italic(thought.trim().replace(/^/gm, ' ')))}` : ''))
|
|
1208
|
+
ui.print(c.faint(c.italic(` ✻ ${label} thought for ${duration(Date.now() - t0)}`)) + (ui.showThinking ? `\n${c.think(c.italic(thought.trim().replace(/^/gm, ' ')))}` : ''))
|
|
825
1209
|
},
|
|
826
1210
|
}
|
|
827
1211
|
}
|