heartcode 0.1.1 → 0.1.3
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/SKILL.md +5 -0
- package/bin/heartcode.js +24 -1
- package/package.json +1 -1
- package/prompts/agent.md +22 -0
- package/prompts/guided.md +1 -0
- package/src/agent.js +48 -9
- package/src/guided.js +13 -2
- package/src/headless.js +23 -8
- package/src/log.js +37 -3
- package/src/settings.html +41 -7
- package/src/settings.js +3 -1
- package/src/tools.js +22 -2
- package/src/ui.js +4 -3
package/SKILL.md
CHANGED
|
@@ -132,6 +132,11 @@ nothing is sent to the model to find out. A model models.dev doesn't know works
|
|
|
132
132
|
assumptions, and the user can set it up on the settings page. Models that can't call tools still
|
|
133
133
|
work, in a slower guided mode.
|
|
134
134
|
|
|
135
|
+
The model in use knows the user's other models and what each can do, and hands a job it can't do
|
|
136
|
+
(see an image, call tools…) to one that can: an attached image goes to a model that sees images, an
|
|
137
|
+
image's path to one through its `agent` tool, and a model without tool calls can hand the task to one
|
|
138
|
+
with them. Their tokens are in `usage`, and each model's in `modelUsage`.
|
|
139
|
+
|
|
135
140
|
## Setting up a model
|
|
136
141
|
|
|
137
142
|
Ask the user which provider and model to use, and for the key. Never invent one. Then:
|
package/bin/heartcode.js
CHANGED
|
@@ -452,6 +452,13 @@ class App {
|
|
|
452
452
|
return [...COMMANDS, ...mine]
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
// A command: "/name …" with the name of a built-in command or a skill you can type. Anything else
|
|
456
|
+
// starting with "/" (a path like /Users/me/shot.png) is a message for the model.
|
|
457
|
+
isCommand(line) {
|
|
458
|
+
const name = /^\/([\w-]+)(?=\s|$)/.exec(line)?.[1]
|
|
459
|
+
return Boolean(name) && (name === 'quit' || this.commands().some(([n]) => n === `/${name}`))
|
|
460
|
+
}
|
|
461
|
+
|
|
455
462
|
help() {
|
|
456
463
|
const mine = this.commands().slice(COMMANDS.length)
|
|
457
464
|
return helpText(mine.length ? [...SECTIONS, ['your skills', mine]] : SECTIONS)
|
|
@@ -585,6 +592,8 @@ class App {
|
|
|
585
592
|
this.useRotation()
|
|
586
593
|
Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
|
|
587
594
|
Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
|
|
595
|
+
this.s.team = () => this.team() // your other models: a job this one can't do goes to one of them
|
|
596
|
+
this.s.engineFor = (t) => this.engine(t.provider, t.model, ui)
|
|
588
597
|
if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
|
|
589
598
|
if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
|
|
590
599
|
if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
|
|
@@ -611,6 +620,19 @@ class App {
|
|
|
611
620
|
await this.session()
|
|
612
621
|
}
|
|
613
622
|
|
|
623
|
+
// Your other models (not the one in use) and what each can do, from its saved profile or
|
|
624
|
+
// models.dev: nothing is sent to them to find out.
|
|
625
|
+
team() {
|
|
626
|
+
const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
|
|
627
|
+
return this.myModels()
|
|
628
|
+
.filter((t) => !(t.provider === now?.provider && t.model === now?.model))
|
|
629
|
+
.map((t) => {
|
|
630
|
+
const cl = this.client(t.provider, t.model)
|
|
631
|
+
const st = strategy(loadProfile(cl) || profileFrom(cl, limitsOf(t.provider, cl.baseUrl, t.model)))
|
|
632
|
+
return { ...t, tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context }
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
614
636
|
// Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
|
|
615
637
|
// provider's whole list is).
|
|
616
638
|
myModels() {
|
|
@@ -1235,6 +1257,7 @@ async function repl(app, f = {}) {
|
|
|
1235
1257
|
let ctl = null
|
|
1236
1258
|
ui.onEscape = () => ctl?.abort()
|
|
1237
1259
|
ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
|
|
1260
|
+
ui.isCommand = (l) => app.isCommand(l)
|
|
1238
1261
|
ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
|
|
1239
1262
|
// Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
|
|
1240
1263
|
// (a second Ctrl+C within 2s quits, also when something is stuck).
|
|
@@ -1303,7 +1326,7 @@ async function repl(app, f = {}) {
|
|
|
1303
1326
|
ctl = new AbortController()
|
|
1304
1327
|
app.signal = ctl.signal // Esc stops whatever this line started (a task, a probe, …)
|
|
1305
1328
|
ui.busy() // the spinner stays until this line's work is done
|
|
1306
|
-
if (
|
|
1329
|
+
if (app.isCommand(t)) {
|
|
1307
1330
|
try {
|
|
1308
1331
|
if ((await slash(app, t)) === 'exit') {
|
|
1309
1332
|
if (app.tabs) app.tabs.quitting = true
|
package/package.json
CHANGED
package/prompts/agent.md
CHANGED
|
@@ -170,6 +170,28 @@ The user didn't answer. Decide yourself, carry on, and say in your reply what yo
|
|
|
170
170
|
<!-- Starts a helper agent (only in the main conversation, not in helpers). -->
|
|
171
171
|
Hand a side job to a helper agent with its own fresh context, so the details stay out of this conversation: searching or reading a lot of code to answer a question, researching on the web, reviewing changes. It has the same tools (no helpers of its own) and sees only your prompt, so say exactly what to do and what to report. Its final reply comes back as the result. description: 3-5 words for the user.
|
|
172
172
|
|
|
173
|
+
## tool-agent-model
|
|
174
|
+
|
|
175
|
+
<!-- The agent tool's "model", there when the user has other models. -->
|
|
176
|
+
Another of your models to do the job (from "Your other models" in the system prompt, as "provider model"), when it needs what you can't do. Leave out to do it with yourself.
|
|
177
|
+
|
|
178
|
+
## team
|
|
179
|
+
|
|
180
|
+
<!-- In the system prompt when the user set up other models. {{models}}: one per line with what each can do. {{you}}: what this model can do. -->
|
|
181
|
+
# Your other models
|
|
182
|
+
|
|
183
|
+
The user set up these models besides you (you: tool calls, {{you}}):
|
|
184
|
+
{{models}}
|
|
185
|
+
|
|
186
|
+
You can hand a job to one of them with the agent tool and its "model". Never decline a part of the task, or say you can't do it, because it needs something you can't do (see an image, call tools, think longer, read more than fits your context): give that part to a model here that can, with everything it needs in the prompt (file paths, what to look for, what to report), then go on with its answer. Answering yourself is better when you can.
|
|
187
|
+
|
|
188
|
+
## images-described
|
|
189
|
+
|
|
190
|
+
<!-- Added to the user's message when this model can't see images and another of theirs described them. {{model}}: that model. {{descriptions}}: what it saw. -->
|
|
191
|
+
(You can't see images, so {{model}} looked at the attached image(s) for you:
|
|
192
|
+
|
|
193
|
+
{{descriptions}})
|
|
194
|
+
|
|
173
195
|
## subagent
|
|
174
196
|
|
|
175
197
|
<!-- Added to a helper agent's system prompt. -->
|
package/prompts/guided.md
CHANGED
|
@@ -36,6 +36,7 @@ edit: change an existing file
|
|
|
36
36
|
create: make a new file
|
|
37
37
|
run: run a shell command (tests, build, scripts)
|
|
38
38
|
ask: ask the user a question
|
|
39
|
+
delegate: hand the task to another of your models, one that can use tools itself
|
|
39
40
|
finish: stop and give your final answer to the user
|
|
40
41
|
|
|
41
42
|
## next-action
|
package/src/agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import { log } from './log.js'
|
|
3
4
|
import { prompt, userInstructions } from './prompts.js'
|
|
4
5
|
import { newChat } from './sessions.js'
|
|
@@ -12,13 +13,16 @@ import { c } from './ui.js'
|
|
|
12
13
|
|
|
13
14
|
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
14
15
|
|
|
15
|
-
// "@shot.png"
|
|
16
|
+
// An image the message names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
|
|
17
|
+
// ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
|
|
18
|
+
const IMAGE_PATH = /(^|\s)@?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\2|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
|
|
16
19
|
export function extractImages(task, root) {
|
|
17
20
|
const images = []
|
|
18
|
-
const text = task.replace(
|
|
21
|
+
const text = task.replace(IMAGE_PATH, (m, pre, q, quoted, bare) => {
|
|
22
|
+
const file = (quoted ?? bare.replace(/\\ /g, ' ')).replace(/^~(?=\/)/, os.homedir())
|
|
19
23
|
const abs = path.resolve(root, file)
|
|
20
|
-
if (!fs.existsSync(abs)) return m
|
|
21
|
-
images.push(`data:${MIME[
|
|
24
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return m
|
|
25
|
+
images.push(`data:${MIME[path.extname(abs).slice(1).toLowerCase()]};base64,${fs.readFileSync(abs).toString('base64')}`)
|
|
22
26
|
return `${pre}${file}`
|
|
23
27
|
})
|
|
24
28
|
return { text, images }
|
|
@@ -137,7 +141,9 @@ export class Session {
|
|
|
137
141
|
let { text, images } = extractImages(task, this.root)
|
|
138
142
|
images = [...images, ...extra]
|
|
139
143
|
if (images.length && !this.strat.vision) {
|
|
140
|
-
this.
|
|
144
|
+
const seer = this.team?.().find((t) => t.images) // another of your models that can: it describes them
|
|
145
|
+
if (seer) text += await this.describeImages(images, seer, text, signal)
|
|
146
|
+
else this.ui.warn(`${this.client.model} can't read images (per its probe); ignoring ${images.length} image(s)`)
|
|
141
147
|
images = []
|
|
142
148
|
}
|
|
143
149
|
log('task', `${text} (${this.strat.actions} mode, ${images.length} image(s))`)
|
|
@@ -152,19 +158,42 @@ export class Session {
|
|
|
152
158
|
// own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
|
|
153
159
|
// under the main conversation's, its file changes count for /rewind like the main agent's.
|
|
154
160
|
// ponytail: helpers run one after another; run several at once if waiting on them gets long.
|
|
161
|
+
// model: another of your models (team) does it, for what this one can't do (see images, call tools…).
|
|
155
162
|
async subagent(args, signal) {
|
|
156
163
|
const task = String(args.prompt ?? args.task ?? '').trim()
|
|
157
164
|
if (!task) return 'error: give the helper its job in "prompt"'
|
|
158
|
-
|
|
159
|
-
const
|
|
165
|
+
let { client, profile, strat } = this
|
|
166
|
+
const want = String(args.model ?? '').trim()
|
|
167
|
+
if (want) {
|
|
168
|
+
const team = this.team?.() ?? []
|
|
169
|
+
const t = team.find((x) => `${x.provider} ${x.model}` === want) ?? team.find((x) => x.model === want || want.endsWith(` ${x.model}`))
|
|
170
|
+
if (!t) return `error: no model "${want}". Yours: ${team.map((x) => `${x.provider} ${x.model}`).join(', ') || 'none besides this one'}`
|
|
171
|
+
;({ client, profile, strat } = await this.engineFor(t))
|
|
172
|
+
}
|
|
173
|
+
const label = clip((want ? `${client.model}: ` : '') + (String(args.description ?? '').trim() || task.split('\n')[0]), 60)
|
|
174
|
+
const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
|
|
160
175
|
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
176
|
helper.track = (rel, before) => this.track(rel, before)
|
|
162
177
|
log('agent', `helper "${label}": ${task}`)
|
|
163
|
-
const answer = await runNative(helper, task, [], signal)
|
|
178
|
+
const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, [], signal)
|
|
164
179
|
log('agent', `helper "${label}" done`, answer)
|
|
165
180
|
return answer?.trim() || '(the helper finished without a reply)'
|
|
166
181
|
}
|
|
167
182
|
|
|
183
|
+
// Images for a model that can't see them: another of yours (seer) describes each, for the task.
|
|
184
|
+
async describeImages(images, seer, task, signal) {
|
|
185
|
+
const { client } = await this.engineFor(seer)
|
|
186
|
+
const out = []
|
|
187
|
+
for (const [i, url] of images.entries()) {
|
|
188
|
+
this.ui.spinner(`${seer.model} is looking at image ${i + 1}`)
|
|
189
|
+
const res = await client.chat({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('guided/describe-image', { task }) }, { type: 'image_url', image_url: { url } }] }] }, { signal })
|
|
190
|
+
out.push(`Image ${i + 1}:\n${res.content.trim()}`)
|
|
191
|
+
this.ui.step('image', `${seer.model} described image ${i + 1}`)
|
|
192
|
+
}
|
|
193
|
+
log('images', `described by ${seer.provider} ${seer.model}`, out)
|
|
194
|
+
return `\n\n${prompt('agent/images-described', { model: seer.model, descriptions: out.join('\n\n') })}`
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
// /btw: a side question, answered from the conversation so far. Nothing goes into the
|
|
169
198
|
// conversation and no tools are offered, so it can run while a task works: on its own client,
|
|
170
199
|
// so the task's request timer and retries are left alone (its cost still counts).
|
|
@@ -282,6 +311,7 @@ const SYSTEM = (s) => {
|
|
|
282
311
|
s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
|
|
283
312
|
s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
|
|
284
313
|
s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
|
|
314
|
+
!s.isSub && teamPrompt(s),
|
|
285
315
|
s.isSub && prompt('agent/subagent'),
|
|
286
316
|
s.appendSystem && prompt('agent/caller'),
|
|
287
317
|
s.appendSystem,
|
|
@@ -290,6 +320,15 @@ const SYSTEM = (s) => {
|
|
|
290
320
|
.join('\n\n')
|
|
291
321
|
}
|
|
292
322
|
|
|
323
|
+
// Your other models and what each can do, for the agent tool's model (only when there are any).
|
|
324
|
+
const teamPrompt = (s) => {
|
|
325
|
+
const team = s.team?.() ?? []
|
|
326
|
+
if (!team.length) return ''
|
|
327
|
+
const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`].filter(Boolean).join(', ')
|
|
328
|
+
const you = [s.strat.vision ? 'images' : 'no images', s.strat.thinking && 'thinking'].filter(Boolean).join(', ')
|
|
329
|
+
return prompt('agent/team', { models: team.map((t) => `- ${t.provider} ${t.model}: ${can(t)}`).join('\n'), you })
|
|
330
|
+
}
|
|
331
|
+
|
|
293
332
|
const sizeOf = (m) =>
|
|
294
333
|
(Array.isArray(m.content) ? m.content.reduce((n, p) => n + (p.type === 'text' ? est(p.text) : 800), 0) : est(m.content)) +
|
|
295
334
|
(m.tool_calls ? est(JSON.stringify(m.tool_calls)) : 0) +
|
|
@@ -684,7 +723,7 @@ function attachNamed(s, task) {
|
|
|
684
723
|
} catch {
|
|
685
724
|
continue
|
|
686
725
|
}
|
|
687
|
-
if (body.startsWith('error')) continue
|
|
726
|
+
if (body.startsWith('error') || body.startsWith(`${f} is a binary file`)) continue // an image: read_file shows it, or says who can see it
|
|
688
727
|
const id = `named_${Date.now().toString(36)}_${calls.length}`
|
|
689
728
|
calls.push({ id, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: f }) } })
|
|
690
729
|
const msg = { role: 'tool', tool_call_id: id, content: body }
|
package/src/guided.js
CHANGED
|
@@ -44,6 +44,7 @@ export function newFileIn(task, files) {
|
|
|
44
44
|
// Obvious next steps are taken without asking: a weak model's effort goes into
|
|
45
45
|
// writing code, not into navigating. Returns an action, or null to ask.
|
|
46
46
|
export function autoAction(st) {
|
|
47
|
+
if (st.delegated) return 'finish' // another model did the task: say what it did
|
|
47
48
|
if (st.newFile && st.wantsChange) return 'create'
|
|
48
49
|
if (st.failing && st.canRun && !st.runs && !st.edits) return 'run' // see the failure first
|
|
49
50
|
if (st.lastRun !== null && st.lastRun !== 0 && !st.editsSinceRun && st.wantsChange && st.fixes < 3) return 'edit'
|
|
@@ -61,7 +62,7 @@ export function nextActions(st) {
|
|
|
61
62
|
if (!st.reads && st.hasFiles) first = ['read', 'search']
|
|
62
63
|
else if (st.wantsChange && !st.edits) first = st.hasFiles ? ['edit', 'create', 'read'] : ['create']
|
|
63
64
|
else first = ['finish', 'edit', 'run']
|
|
64
|
-
return [...new Set([...first, ...ACTIONS.map(([n]) => n)])]
|
|
65
|
+
return [...new Set([...first, ...ACTIONS.map(([n]) => n)])].filter((n) => n !== 'delegate' || st.canDelegate)
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// A reply may be plain text or (when enforced) JSON like {"path": "a.js"}.
|
|
@@ -193,7 +194,7 @@ class Guided {
|
|
|
193
194
|
this.notes = []
|
|
194
195
|
this.changed = new Set()
|
|
195
196
|
this.files = listFiles(s.root)
|
|
196
|
-
this.st = { reads: 0, edits: 0, editsSinceRun: 0, runs: 0, fixes: 0, lastRun: null, hasFiles: this.files.length > 0, canRun: suggestCommands(s.root).length > 0 }
|
|
197
|
+
this.st = { reads: 0, edits: 0, editsSinceRun: 0, runs: 0, fixes: 0, lastRun: null, hasFiles: this.files.length > 0, canRun: suggestCommands(s.root).length > 0, canDelegate: Boolean(s.team?.().some((t) => t.tools)) }
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
async yesNo(question, fallback) {
|
|
@@ -585,6 +586,16 @@ class Guided {
|
|
|
585
586
|
return `ask:${q}`
|
|
586
587
|
}
|
|
587
588
|
|
|
589
|
+
// Another of your models, one with tool calls, does the whole task; what it did goes in the notes.
|
|
590
|
+
async delegate() {
|
|
591
|
+
const team = this.s.team().filter((t) => t.tools)
|
|
592
|
+
const t = (/\.(png|jpe?g|gif|webp)\b/i.test(this.task) && team.find((x) => x.images)) || team[0]
|
|
593
|
+
const done = await this.s.subagent({ description: 'the task', prompt: this.task, model: `${t.provider} ${t.model}` }, this.signal)
|
|
594
|
+
this.note('delegate', `what ${t.model} did for the task`, done)
|
|
595
|
+
this.st.delegated = true
|
|
596
|
+
return 'delegate'
|
|
597
|
+
}
|
|
598
|
+
|
|
588
599
|
async finish() {
|
|
589
600
|
this.ui.spinner('writing answer')
|
|
590
601
|
let res
|
package/src/headless.js
CHANGED
|
@@ -93,15 +93,29 @@ export async function runHeadless(app, opts) {
|
|
|
93
93
|
const model = `${app.cfg.provider}/${s.client.model}`
|
|
94
94
|
|
|
95
95
|
// usage and request count, over everything this message sends (tool loop, compaction, memory)
|
|
96
|
+
// (also your other models', when this one hands them a job: modelUsage has each)
|
|
96
97
|
let usage = { input_tokens: 0, output_tokens: 0 }
|
|
98
|
+
let byModel = {}
|
|
97
99
|
let requests = 0
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
const count = (client, key) => {
|
|
101
|
+
const chat = client.chat.bind(client)
|
|
102
|
+
client.chat = async (...a) => {
|
|
103
|
+
const r = await chat(...a)
|
|
104
|
+
const m = (byModel[key] ??= { inputTokens: 0, outputTokens: 0 })
|
|
105
|
+
if (client === s.client) requests++
|
|
106
|
+
usage.input_tokens += r.usage?.prompt_tokens ?? 0
|
|
107
|
+
usage.output_tokens += r.usage?.completion_tokens ?? 0
|
|
108
|
+
m.inputTokens += r.usage?.prompt_tokens ?? 0
|
|
109
|
+
m.outputTokens += r.usage?.completion_tokens ?? 0
|
|
110
|
+
return r
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
count(s.client, model)
|
|
114
|
+
const engineFor = s.engineFor
|
|
115
|
+
if (engineFor) s.engineFor = async (t) => {
|
|
116
|
+
const e = await engineFor(t)
|
|
117
|
+
count(e.client, `${t.provider}/${t.model}`)
|
|
118
|
+
return e
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
let said = false // the model's text was already sent as an assistant message
|
|
@@ -129,6 +143,7 @@ export async function runHeadless(app, opts) {
|
|
|
129
143
|
const t0 = Date.now()
|
|
130
144
|
const spent = app.chat.usage?.cost ?? 0
|
|
131
145
|
usage = { input_tokens: 0, output_tokens: 0 }
|
|
146
|
+
byModel = {}
|
|
132
147
|
requests = 0
|
|
133
148
|
said = false
|
|
134
149
|
current = { ctl: new AbortController() }
|
|
@@ -148,7 +163,7 @@ export async function runHeadless(app, opts) {
|
|
|
148
163
|
}
|
|
149
164
|
const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
|
|
150
165
|
current = null
|
|
151
|
-
const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens:
|
|
166
|
+
const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: 0, outputTokens: 0 }, ...byModel }, session_id }
|
|
152
167
|
if (out === 'stream-json') {
|
|
153
168
|
if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, session_id }) // guided mode
|
|
154
169
|
emit(result)
|
package/src/log.js
CHANGED
|
@@ -28,14 +28,23 @@ export function stamp(d = new Date()) {
|
|
|
28
28
|
}
|
|
29
29
|
// Logs older than `days` go: run folders, request folders, and the older one-file-per-run logs.
|
|
30
30
|
// Returns how many went.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// When a log was last written to: a folder's newest file (writing into a file leaves its folder's
|
|
32
|
+
// time as it was).
|
|
33
|
+
function lastWritten(at) {
|
|
34
|
+
const s = fs.statSync(at)
|
|
35
|
+
if (!s.isDirectory()) return s.mtimeMs
|
|
36
|
+
let t = s.mtimeMs
|
|
37
|
+
for (const f of fs.readdirSync(at)) t = Math.max(t, fs.statSync(path.join(at, f), { throwIfNoEntry: false })?.mtimeMs ?? 0)
|
|
38
|
+
return t
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pruneLogs(days = DAYS, before = Date.now() - days * 86_400_000) {
|
|
33
42
|
let gone = 0
|
|
34
43
|
for (const [dir, isLog] of [[path.join(LOGS, 'runs'), () => true], [REQUESTS, () => true], [LOGS, (f) => f.endsWith('.log')]]) {
|
|
35
44
|
try {
|
|
36
45
|
for (const f of fs.readdirSync(dir)) {
|
|
37
46
|
const at = path.join(dir, f)
|
|
38
|
-
if (!isLog(f) || at === LOG_DIR || at === requestLog.dir ||
|
|
47
|
+
if (!isLog(f) || at === LOG_DIR || at === requestLog.dir || lastWritten(at) >= before) continue
|
|
39
48
|
fs.rmSync(at, { recursive: true, force: true })
|
|
40
49
|
gone++
|
|
41
50
|
}
|
|
@@ -43,6 +52,31 @@ export function pruneLogs(days = DAYS) {
|
|
|
43
52
|
}
|
|
44
53
|
return gone
|
|
45
54
|
}
|
|
55
|
+
// The settings page's Delete all: every log but the ones being written now (this run's, and any
|
|
56
|
+
// touched in the last minute: another HeartCode's). Returns how many files and folders went.
|
|
57
|
+
export const clearLogs = () => pruneLogs(0, Date.now() - 60_000)
|
|
58
|
+
|
|
59
|
+
// How much room the logs take on disk, in bytes: { runs, requests, total } (runs: the run logs,
|
|
60
|
+
// and older single log files).
|
|
61
|
+
export function logSize() {
|
|
62
|
+
const sum = (dir) => {
|
|
63
|
+
let n = 0
|
|
64
|
+
try {
|
|
65
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
66
|
+
const at = path.join(dir, e.name)
|
|
67
|
+
n += e.isDirectory() ? sum(at) : (fs.statSync(at, { throwIfNoEntry: false })?.size ?? 0)
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
return n
|
|
71
|
+
}
|
|
72
|
+
let loose = 0
|
|
73
|
+
try {
|
|
74
|
+
for (const f of fs.readdirSync(LOGS)) if (f.endsWith('.log')) loose += fs.statSync(path.join(LOGS, f), { throwIfNoEntry: false })?.size ?? 0
|
|
75
|
+
} catch {}
|
|
76
|
+
const runs = sum(path.join(LOGS, 'runs')) + loose
|
|
77
|
+
const requests = sum(REQUESTS)
|
|
78
|
+
return { runs, requests, total: runs + requests }
|
|
79
|
+
}
|
|
46
80
|
export const logDays = (cfg) => (Number.isInteger(cfg.logDays) && cfg.logDays > 0 ? cfg.logDays : DAYS)
|
|
47
81
|
|
|
48
82
|
// Which tab this is running for (tabs.js runs each tab's work in it): its log file, its requests.
|
package/src/settings.html
CHANGED
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
.page { display: none; }
|
|
43
43
|
.page.on { display: block; }
|
|
44
44
|
.head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
|
|
45
|
+
.disk { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
|
46
|
+
.disk b { font-size: 22px; }
|
|
47
|
+
.disk .k { color: var(--muted); font-size: 12.5px; }
|
|
45
48
|
h1 { font-size: 24px; margin: 0 0 4px; }
|
|
46
49
|
.lead { color: var(--muted); margin: 0; max-width: 760px; }
|
|
47
50
|
h2 { font-size: 15px; margin: 0 0 4px; }
|
|
@@ -412,7 +415,7 @@
|
|
|
412
415
|
|
|
413
416
|
<!-- Logging -->
|
|
414
417
|
<section class="page" id="page-logging">
|
|
415
|
-
<div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div></div>
|
|
418
|
+
<div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div><div class="disk" id="l-disk"></div></div>
|
|
416
419
|
<div class="card">
|
|
417
420
|
<div class="log-opts">
|
|
418
421
|
<div>
|
|
@@ -606,6 +609,7 @@ function showPage() {
|
|
|
606
609
|
for (const p of PAGES) $('page-' + p).classList.toggle('on', p === page())
|
|
607
610
|
for (const a of document.querySelectorAll('nav a')) a.classList.toggle('on', a.dataset.page === page())
|
|
608
611
|
if (S) drawUsing(S)
|
|
612
|
+
if (page() === 'logging') drawDisk()
|
|
609
613
|
scrollTo(0, 0)
|
|
610
614
|
fillHeight()
|
|
611
615
|
document.querySelector('nav a.on')?.scrollIntoView({ inline: 'center', block: 'nearest' }) // on a phone the pages scroll sideways
|
|
@@ -805,7 +809,7 @@ function testLine(t) {
|
|
|
805
809
|
const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
|
|
806
810
|
const [cls, text, action] =
|
|
807
811
|
!x ? ['none', 'looking it up in models.dev…', null]
|
|
808
|
-
: x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and
|
|
812
|
+
: x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Refresh from models.dev
|
|
809
813
|
: ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
|
|
810
814
|
const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
|
|
811
815
|
return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
|
|
@@ -876,7 +880,7 @@ async function tryAll() {
|
|
|
876
880
|
toast(`${good} of ${list.length} work${good === list.length ? '' : `; ${list.length - good} don't (Works? says why, on hover)`}`, good < list.length)
|
|
877
881
|
}
|
|
878
882
|
|
|
879
|
-
// models.dev looked up again: one model (its Retry), or all of yours (
|
|
883
|
+
// models.dev looked up again: one model (its Retry), or all of yours (Refresh from models.dev)
|
|
880
884
|
const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
|
|
881
885
|
async function lookUp(t) {
|
|
882
886
|
toast(t ? `looking up ${t.model} in models.dev…` : 'looking up your models in models.dev…')
|
|
@@ -993,7 +997,7 @@ function drawProviders(s) {
|
|
|
993
997
|
h('div', { className: 'mine-title' },
|
|
994
998
|
h('h2', { textContent: 'Your models' }),
|
|
995
999
|
h('button', { className: 'small', textContent: 'Table', style: order.length ? '' : 'visibility:hidden', title: 'all of them in a table, to compare and sort', onclick: () => (location.hash = 'models') }),
|
|
996
|
-
h('button', { className: 'small', textContent: '
|
|
1000
|
+
h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
|
|
997
1001
|
h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
|
|
998
1002
|
h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
|
|
999
1003
|
order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
|
|
@@ -1274,7 +1278,7 @@ function drawMine(s) {
|
|
|
1274
1278
|
: order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
|
|
1275
1279
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1276
1280
|
SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
|
|
1277
|
-
order.length ? h('button', { className: 'small', textContent: '
|
|
1281
|
+
order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
|
|
1278
1282
|
order.length ? h('button', { className: 'small', textContent: 'Test all', title: 'send each a short message, several at once (2 per provider at most), to see which really answer', onclick: () => tryAll() }) : null),
|
|
1279
1283
|
order.length
|
|
1280
1284
|
? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
|
|
@@ -1350,7 +1354,36 @@ $('v-save').onclick = () => act('/api/voice', { text: $('v-text').value }, 'voic
|
|
|
1350
1354
|
const logSwitch = (s, k, title, about) => h('label', { className: 'opt' },
|
|
1351
1355
|
h('div', { className: 't' }, h('b', { textContent: title }), h('span', { textContent: about })),
|
|
1352
1356
|
sw({ checked: s.settings[k], 'aria-label': title, onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${title}: ${e.target.checked ? 'on' : 'off'}`) }))
|
|
1357
|
+
// How much room the logs take: asked again when the page shows, when it's redrawn, and (at most every
|
|
1358
|
+
// few seconds) while requests are being logged.
|
|
1359
|
+
const bytes = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e8 ? 0 : 1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} kB` : `${n} bytes`)
|
|
1360
|
+
let diskAt = 0
|
|
1361
|
+
let diskLater = null
|
|
1362
|
+
async function drawDisk() {
|
|
1363
|
+
if (Date.now() - diskAt < 3000) return void (diskLater ??= setTimeout(() => ((diskLater = null), drawDisk()), 3000))
|
|
1364
|
+
diskAt = Date.now()
|
|
1365
|
+
try {
|
|
1366
|
+
showDisk(await api('/api/log-size'))
|
|
1367
|
+
} catch {}
|
|
1368
|
+
}
|
|
1369
|
+
function showDisk(d) {
|
|
1370
|
+
put($('l-disk'),
|
|
1371
|
+
h('div', { className: 'row', style: 'gap:10px;align-items:center' },
|
|
1372
|
+
h('span', {}, h('span', { className: 'k', textContent: 'Logs on disk ' }), h('b', { textContent: bytes(d.total) })),
|
|
1373
|
+
d.total ? h('button', { className: 'small danger', textContent: 'Delete all', title: 'delete every log (the ones being written right now stay)', onclick: clearAll }) : null),
|
|
1374
|
+
h('div', { className: 'k', textContent: `requests ${bytes(d.requests)} · runs ${bytes(d.runs)}` }))
|
|
1375
|
+
}
|
|
1376
|
+
async function clearAll() {
|
|
1377
|
+
if (!confirm('Delete all logs, of every run and every request? This can\'t be undone. (The ones being written right now stay.)')) return
|
|
1378
|
+
const r = await act('/api/clear-logs', {})
|
|
1379
|
+
if (!r) return
|
|
1380
|
+
showDisk(r.size)
|
|
1381
|
+
diskAt = Date.now()
|
|
1382
|
+
api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => {})
|
|
1383
|
+
toast(`deleted: logs on disk now ${bytes(r.size.total)}`)
|
|
1384
|
+
}
|
|
1353
1385
|
function drawLogging(s) {
|
|
1386
|
+
drawDisk()
|
|
1354
1387
|
put($('l-run'), logSwitch(s, 'log', 'Keep a log of every run', `From now on: a folder per run in ${s.logs.runs}, a file per tab (tab-1.log, tab-2.log…).`))
|
|
1355
1388
|
put($('l-req'), logSwitch(s, 'logRequests', 'Log every request and reply', `Right away: a file per request in ${s.logs.requests}, a folder per run.`))
|
|
1356
1389
|
$('l-detail').value = s.settings.logDetail
|
|
@@ -1388,11 +1421,11 @@ const whyOf = (r) => {
|
|
|
1388
1421
|
return r.error
|
|
1389
1422
|
}
|
|
1390
1423
|
const resultTag = (r) => h('span', { className: `tag ${r.result === 'ok' ? 'ok' : r.result === 'failed' ? 'err' : ''}`, textContent: r.result === 'ok' ? 'ok' : r.result === 'failed' ? `failed${r.status ? ` ${r.status}` : ''}` : 'stopped' })
|
|
1391
|
-
const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) :
|
|
1424
|
+
const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : null) // #1: nothing to say
|
|
1392
1425
|
function reqRow(r) {
|
|
1393
1426
|
const key = `${r.run}/${r.file}`
|
|
1394
1427
|
return h('button', { className: `rq ${r.result}${reqOpen === key ? ' on' : ''}`, 'data-key': key, onclick: () => openReq(r) },
|
|
1395
|
-
h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model }),
|
|
1428
|
+
h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model })), // where it is in your list: in its details
|
|
1396
1429
|
h('div', { className: 'l2' },
|
|
1397
1430
|
h('span', { textContent: when(r.at) }), h('span', { textContent: r.provider ?? '' }), h('span', { textContent: secs(r.ms) }),
|
|
1398
1431
|
r.usage ? h('span', { textContent: tokensOf(r.usage) }) : null,
|
|
@@ -1597,6 +1630,7 @@ if (token) {
|
|
|
1597
1630
|
// a request logged: at the top of the list, nothing else redrawn
|
|
1598
1631
|
events.addEventListener('request', (e) => {
|
|
1599
1632
|
const r = JSON.parse(e.data)
|
|
1633
|
+
if (page() === 'logging') drawDisk() // the logs grew
|
|
1600
1634
|
if (!REQS) return
|
|
1601
1635
|
REQS.unshift(r)
|
|
1602
1636
|
if (REQS.length === 1 || !reqShown(r)) return drawReqs()
|
package/src/settings.js
CHANGED
|
@@ -17,7 +17,7 @@ import { loadProfile, strategy, profilePath, profileFrom } from './probe.js'
|
|
|
17
17
|
import { loadPrices, priceOf, limitsOf } from './usage.js'
|
|
18
18
|
import { THEMES, PERMISSIONS } from './ui.js'
|
|
19
19
|
import { loadSkills, frontMatter } from './skills.js'
|
|
20
|
-
import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays } from './log.js'
|
|
20
|
+
import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays, logSize, clearLogs } from './log.js'
|
|
21
21
|
|
|
22
22
|
const PAGE = new URL('./settings.html', import.meta.url)
|
|
23
23
|
const EFFORTS = ['default', 'low', 'medium', 'high']
|
|
@@ -189,6 +189,8 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
189
189
|
|
|
190
190
|
const routes = {
|
|
191
191
|
'GET /api/state': () => state(),
|
|
192
|
+
'GET /api/log-size': () => logSize(),
|
|
193
|
+
'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
|
|
192
194
|
'POST /api/provider': ({ name, baseUrl, apiKey, keepKey, was }) => {
|
|
193
195
|
name = safeName(name)
|
|
194
196
|
if (!name) throw new Error('a name is needed')
|
package/src/tools.js
CHANGED
|
@@ -115,6 +115,22 @@ export function readFileTool(root, p, start, end, maxLines = 400) {
|
|
|
115
115
|
return numberLines(lines.slice(s - 1, e), s) + more
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// An image file: shown to a model that can see images (after the tool results, as MCP screenshots
|
|
119
|
+
// are); for one that can't, where to send it.
|
|
120
|
+
const IMAGE = /\.(png|jpe?g|gif|webp)$/i
|
|
121
|
+
function readImage(s, p) {
|
|
122
|
+
const abs = resolveIn(s.root, p)
|
|
123
|
+
const rel = path.relative(s.root, abs) || p
|
|
124
|
+
if (!fs.existsSync(abs)) return readFileTool(s.root, p)
|
|
125
|
+
if (s.strat?.vision) {
|
|
126
|
+
const ext = path.extname(abs).slice(1).toLowerCase()
|
|
127
|
+
;(s.pendingImages ??= []).push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${fs.readFileSync(abs).toString('base64')}`)
|
|
128
|
+
return `${rel}: the image is attached in the next message`
|
|
129
|
+
}
|
|
130
|
+
const seer = s.team?.().find((t) => t.images)
|
|
131
|
+
return `${rel} is an image, and you can't see images.${seer ? ` Ask a model that can: the agent tool with model "${seer.provider} ${seer.model}", and the image's path in the prompt.` : ''}`
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
export function searchFiles(root, query, sub = '', limit = 50) {
|
|
119
135
|
const ci = query === query.toLowerCase()
|
|
120
136
|
let re
|
|
@@ -468,7 +484,11 @@ export function extraTools(s) {
|
|
|
468
484
|
}
|
|
469
485
|
// the patch format OpenAI's models are trained on (they also write it as a shell command, below)
|
|
470
486
|
if (/gpt|codex|\bo\d/i.test(s.client?.model ?? '')) out.push({ name: 'apply_patch', description: prompt('agent/tool-apply_patch'), parameters: obj({ input: str }, ['input']) })
|
|
471
|
-
if (!s.isSub && s.subagent)
|
|
487
|
+
if (!s.isSub && s.subagent) {
|
|
488
|
+
const team = s.team?.() ?? []
|
|
489
|
+
const model = { type: 'string', description: prompt('agent/tool-agent-model') }
|
|
490
|
+
out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model }) }, ['description', 'prompt']) })
|
|
491
|
+
}
|
|
472
492
|
return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
|
|
473
493
|
}
|
|
474
494
|
|
|
@@ -811,7 +831,7 @@ async function tool(name, args, s, signal) {
|
|
|
811
831
|
}
|
|
812
832
|
switch (name) {
|
|
813
833
|
case 'read_file':
|
|
814
|
-
return readFileTool(root, args.path, args.start_line, args.end_line)
|
|
834
|
+
return IMAGE.test(String(args.path ?? '')) ? readImage(s, args.path) : readFileTool(root, args.path, args.start_line, args.end_line)
|
|
815
835
|
case 'list_files': {
|
|
816
836
|
const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
|
|
817
837
|
return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
|
package/src/ui.js
CHANGED
|
@@ -376,6 +376,7 @@ export class UI {
|
|
|
376
376
|
this.out = output
|
|
377
377
|
this.queue = []
|
|
378
378
|
this.ahead = [] // messages typed (and sent with Enter) while HeartCode was working
|
|
379
|
+
this.isCommand = (l) => l.startsWith('/') // the app's: only real commands (a path like /Users/… is a message)
|
|
379
380
|
this.keys = { ...DEFAULT_KEYS, ...loadKeys() }
|
|
380
381
|
this.waiters = []
|
|
381
382
|
this.closed = false
|
|
@@ -736,7 +737,7 @@ export class UI {
|
|
|
736
737
|
if (/^\/btw\b/.test(line) && this.onBtw) this.onBtw(line.replace(/^\/btw\b/, '').trim()) // answered now, beside the task
|
|
737
738
|
else if (line) {
|
|
738
739
|
this.ahead.push(line)
|
|
739
|
-
this.print(`${c.faint(' ↳')} ${c.muted(line.split('\n')[0])} ${c.faint(
|
|
740
|
+
this.print(`${c.faint(' ↳')} ${c.muted(line.split('\n')[0])} ${c.faint(this.isCommand(line) ? '(runs when this is done)' : '(goes to the model at its next step, or runs next)')}`)
|
|
740
741
|
}
|
|
741
742
|
return this.render()
|
|
742
743
|
}
|
|
@@ -841,8 +842,8 @@ export class UI {
|
|
|
841
842
|
|
|
842
843
|
// Messages typed while working that can go to the running task now (not commands); taken.
|
|
843
844
|
takeAhead() {
|
|
844
|
-
const out = this.ahead.filter((l) => !
|
|
845
|
-
this.ahead = this.ahead.filter((l) =>
|
|
845
|
+
const out = this.ahead.filter((l) => !this.isCommand(l))
|
|
846
|
+
this.ahead = this.ahead.filter((l) => this.isCommand(l))
|
|
846
847
|
return out
|
|
847
848
|
}
|
|
848
849
|
|