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/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 { est, Client, writtenBy } from './client.js'
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
- async compact({ signal, focus = '', midTask = false, task = '' } = {}) {
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(midTask ? 'context almost full: compacting the conversation' : 'compacting the conversation')
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
- const first = !want ? self : (all.find((t) => name(t) === want) ?? all.find((t) => t.model === want || want.endsWith(` ${t.model}`)))
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: every 100 messages, the conversation is summarized (what fits each model is
598
- // models.dev's business and the model list's: one that fails, the next takes over)
599
- const COMPACT_AT = 100
600
- const nearlyFull = () => s.autoCompact && s.messages.length >= COMPACT_AT + (s.compactFailedAt ?? 0)
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 }).catch((e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
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
- const said = planning && !s.isSub ? `${text}\n\n${prompt('agent/plan-mode')}` : text
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 stuck = null // a tool called the same way five times this message: stop
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 for 20 more messages
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
- () => (s.compactFailedAt = 0),
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 = (s.compactFailedAt ?? 0) + 20
652
- log('compact', `failed (${e.message}); trying again 20 messages later`)
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
- const res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
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
- let out = 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'
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} made the same ${stuck} call five times in a row`)
786
- return finalAnswer(s, signal, `the same ${stuck} call five times`)
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
- const secs = ((Date.now() - t0) / 1000).toFixed(1)
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
  }