heartcode 0.1.0 → 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/README.md +4 -16
- package/SKILL.md +24 -10
- package/bin/heart.js +2 -1
- package/bin/heartcode.js +25 -3
- package/package.json +1 -1
- package/prompts/agent.md +54 -4
- package/prompts/guided.md +1 -0
- package/src/agent.js +115 -21
- package/src/client.js +96 -12
- package/src/config.js +4 -0
- package/src/guided.js +13 -2
- package/src/headless.js +27 -11
- package/src/log.js +37 -3
- package/src/mcp.js +49 -3
- package/src/prompts.js +2 -1
- package/src/settings.html +144 -32
- package/src/settings.js +31 -1
- package/src/tools.js +126 -15
- package/src/ui.js +4 -3
- package/src/usage.js +16 -1
package/src/client.js
CHANGED
|
@@ -6,6 +6,9 @@ export const est = (text) => Math.ceil(String(text ?? '').length / 3.5)
|
|
|
6
6
|
// An error that may go away by itself: a rate limit, a server error or overload, a timeout, no
|
|
7
7
|
// connection (no status). Anything else (a wrong model name, key or request) won't.
|
|
8
8
|
export const outage = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500
|
|
9
|
+
// The model can't be used as it's set up (a wrong key, no access to it, no such model): waiting
|
|
10
|
+
// won't help, but the next one in the list may work.
|
|
11
|
+
export const unusable = (e) => [401, 403, 404].includes(e.status)
|
|
9
12
|
// A request's messages, estimated: an image's data isn't text (about 800 tokens, not its base64)
|
|
10
13
|
export function estMessages(messages) {
|
|
11
14
|
let images = 0
|
|
@@ -143,6 +146,16 @@ export class Client {
|
|
|
143
146
|
get target() {
|
|
144
147
|
return this.rotation?.list[this.rotation.at] ?? null
|
|
145
148
|
}
|
|
149
|
+
// The model in use answers but gives nothing usable (agent.js: empty replies, again and again):
|
|
150
|
+
// the next one in the list takes over. False when there's no list.
|
|
151
|
+
moveOn(why) {
|
|
152
|
+
const r = this.rotation
|
|
153
|
+
if (!r) return false
|
|
154
|
+
r.at = (r.at + 1) % r.list.length
|
|
155
|
+
log('rotate', `${why} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
156
|
+
this.#use(r.list[r.at])
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
146
159
|
// A new message: back to #1 (unless it stays on the one that worked)
|
|
147
160
|
newMessage() {
|
|
148
161
|
const r = this.rotation
|
|
@@ -156,7 +169,7 @@ export class Client {
|
|
|
156
169
|
#rotate(e, tries) {
|
|
157
170
|
const r = this.rotation
|
|
158
171
|
if (!r) return false
|
|
159
|
-
if (!(r.on === 'any' || (r.on === 'failure' && outage(e)) || (r.on === 'retries' && tries >= r.retries))) return false
|
|
172
|
+
if (!(r.on === 'any' || (r.on === 'failure' && (outage(e) || unusable(e))) || (r.on === 'retries' && (tries >= r.retries || unusable(e))))) return false
|
|
160
173
|
r.at = (r.at + 1) % r.list.length
|
|
161
174
|
log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
162
175
|
this.#use(r.list[r.at])
|
|
@@ -186,7 +199,7 @@ export class Client {
|
|
|
186
199
|
usage: reply?.usage,
|
|
187
200
|
finishReason: reply?.finishReason,
|
|
188
201
|
request: req,
|
|
189
|
-
reply: reply && { content: reply.content, reasoning: reply.reasoning, toolCalls: reply.toolCalls },
|
|
202
|
+
reply: reply && { content: reply.content, reasoning: reply.reasoning, toolCalls: reply.toolCalls, ...(reply.raw && { raw: reply.raw }) }, // raw: the stream as it came, for a reply with nothing in it
|
|
190
203
|
})
|
|
191
204
|
}
|
|
192
205
|
|
|
@@ -357,10 +370,11 @@ export class Client {
|
|
|
357
370
|
if (e.name === 'AbortError' || opts.signal?.aborted) throw e
|
|
358
371
|
if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
|
|
359
372
|
if (this.retryIf && !this.retryIf(e, attempt)) throw e
|
|
360
|
-
// A
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
373
|
+
// A bad request (400…): the same request fails the same way every time, so it isn't sent
|
|
374
|
+
// again. It stops here, unless the model list moves on at any error (the settings page). A
|
|
375
|
+
// model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
|
|
376
|
+
// in the list; a whole round of those stops too, rather than going round.
|
|
377
|
+
if (!outage(e) && !(unusable(e) && this.rotation) && this.rotation?.on !== 'any') throw e
|
|
364
378
|
hard &&= !outage(e)
|
|
365
379
|
tries++
|
|
366
380
|
if (this.#rotate(e, tries)) {
|
|
@@ -400,6 +414,14 @@ export class Client {
|
|
|
400
414
|
// One request. Without `once`, rate limits and server errors get three quiet retries.
|
|
401
415
|
async #chat(body, { onToken, onReasoning, onToolDelta, signal, once } = {}) {
|
|
402
416
|
const req = { model: this.model, ...body }
|
|
417
|
+
// The model's thinking on its earlier steps (agent.js keeps it as reasoning_content): sent by the
|
|
418
|
+
// name this provider sends it by (reasoning_content: DeepSeek, vLLM…; reasoning: OpenRouter,
|
|
419
|
+
// Ollama), or not at all if it refused it once.
|
|
420
|
+
if (req.messages?.some((m) => m.reasoning_content)) {
|
|
421
|
+
const field = this.quirks.noReasoningBack ? null : (this.quirks.reasoningField ?? 'reasoning_content')
|
|
422
|
+
req.messages = req.messages.map(({ reasoning_content, ...m }) => (reasoning_content && field ? { ...m, [field]: reasoning_content } : m))
|
|
423
|
+
}
|
|
424
|
+
if (this.quirks.simpleSchemas && req.tools) req.tools = req.tools.map((t) => ({ ...t, function: { ...t.function, parameters: simpleSchema(t.function.parameters) } }))
|
|
403
425
|
if (this.quirks.noTemperature) delete req.temperature
|
|
404
426
|
if (this.effort && !this.quirks.noEffort) req.reasoning_effort = this.effort
|
|
405
427
|
if (req.stream && !this.quirks.noStreamUsage) req.stream_options = { include_usage: true } // token counts for the status bar
|
|
@@ -423,19 +445,38 @@ export class Client {
|
|
|
423
445
|
}
|
|
424
446
|
}
|
|
425
447
|
} catch (e) {
|
|
426
|
-
// Some APIs reject parameters older servers need; learn once and retry.
|
|
427
|
-
|
|
448
|
+
// Some APIs reject parameters older servers need; learn once and retry. (Refused: 400, or 422
|
|
449
|
+
// from the ones that check requests against a schema, like Mistral.)
|
|
450
|
+
const refused = e.status === 400 || e.status === 422
|
|
451
|
+
const said = e.body ?? e.message
|
|
452
|
+
if (refused && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
|
|
428
453
|
this.quirks.noTemperature = true
|
|
429
454
|
log('quirk', 'server rejects temperature')
|
|
430
455
|
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
431
456
|
}
|
|
432
457
|
for (const [quirk, param] of [['noEffort', 'reasoning_effort'], ['noStreamUsage', 'stream_options']]) {
|
|
433
|
-
if (
|
|
458
|
+
if (refused && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
|
|
434
459
|
this.quirks[quirk] = true
|
|
435
460
|
log('quirk', `server rejects ${param}; dropping it`)
|
|
436
461
|
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
437
462
|
}
|
|
438
463
|
}
|
|
464
|
+
// The thinking sent back, or the tools' arguments (JSON schemas with keys a provider doesn't
|
|
465
|
+
// take: Gemini's "Unknown name additionalProperties"), refused: sent without it, or with
|
|
466
|
+
// simpler schemas, from now on with this model.
|
|
467
|
+
const reasoningSent = !this.quirks.noReasoningBack && body.messages?.some((m) => m.reasoning_content)
|
|
468
|
+
const noReasoning = () => {
|
|
469
|
+
this.quirks.noReasoningBack = true
|
|
470
|
+
log('quirk', 'server rejects the thinking sent back; not sending it')
|
|
471
|
+
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
472
|
+
}
|
|
473
|
+
if (refused && reasoningSent && /reasoning(?!_effort)/i.test(said)) return noReasoning()
|
|
474
|
+
if (refused && body.tools?.length && !this.quirks.simpleSchemas && /schema|function_declarations|parameters|additionalProperties|exclusiveM|minItems|unknown name|invalid.{0,40}(tool|function)|tools\[/i.test(said)) {
|
|
475
|
+
this.quirks.simpleSchemas = true
|
|
476
|
+
log('quirk', 'server rejects the tools\' schemas; sending simpler ones')
|
|
477
|
+
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
478
|
+
}
|
|
479
|
+
if (refused && reasoningSent && /extra|unknown|unrecognized|not permitted|additional propert/i.test(said)) return noReasoning()
|
|
439
480
|
throw e
|
|
440
481
|
}
|
|
441
482
|
// Tokens the last request used (prompt + reply): what the status bar shows against the context size.
|
|
@@ -458,7 +499,8 @@ export class Client {
|
|
|
458
499
|
const data = await res.json()
|
|
459
500
|
const choice = data.choices?.[0] ?? {}
|
|
460
501
|
const msg = choice.message ?? {}
|
|
461
|
-
|
|
502
|
+
this.#reasoningField(msg)
|
|
503
|
+
return count(done(textOf(msg.content), msg.reasoning_content ?? msg.reasoning ?? msg.thinking ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
|
|
462
504
|
}
|
|
463
505
|
|
|
464
506
|
const reader = res.body.getReader()
|
|
@@ -471,6 +513,7 @@ export class Client {
|
|
|
471
513
|
let usage
|
|
472
514
|
let finishReason
|
|
473
515
|
const calls = []
|
|
516
|
+
let raw = '' // the stream as it came (its start), kept for a reply that turns out empty
|
|
474
517
|
const emit = (v) => {
|
|
475
518
|
if (onToken && v.startsWith(emitted) && v.length > emitted.length) {
|
|
476
519
|
onToken(v.slice(emitted.length))
|
|
@@ -486,6 +529,7 @@ export class Client {
|
|
|
486
529
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
487
530
|
const line = buf.slice(0, nl).trim()
|
|
488
531
|
buf = buf.slice(nl + 1)
|
|
532
|
+
if (raw.length < 100_000 && line) raw += line + '\n'
|
|
489
533
|
if (!line.startsWith('data:')) continue
|
|
490
534
|
const payload = line.slice(5).trim()
|
|
491
535
|
if (payload === '[DONE]') continue
|
|
@@ -500,8 +544,10 @@ export class Client {
|
|
|
500
544
|
const ch = chunk.choices?.[0]
|
|
501
545
|
if (!ch) continue
|
|
502
546
|
if (ch.finish_reason) finishReason = ch.finish_reason
|
|
503
|
-
const d = ch.delta ?? {}
|
|
504
|
-
|
|
547
|
+
const d = ch.delta ?? ch.message ?? {} // some proxies send the whole message in a chunk
|
|
548
|
+
this.#reasoningField(d)
|
|
549
|
+
if (Array.isArray(d.content)) d.content = textOf(d.content)
|
|
550
|
+
const r = d.reasoning_content || d.reasoning || d.thinking
|
|
505
551
|
if (r) {
|
|
506
552
|
reasoning += r
|
|
507
553
|
onReasoning?.(r)
|
|
@@ -527,10 +573,48 @@ export class Client {
|
|
|
527
573
|
}
|
|
528
574
|
const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason)
|
|
529
575
|
emit(result.content)
|
|
576
|
+
if (!result.content.trim() && !result.toolCalls.length && !result.reasoning) {
|
|
577
|
+
result.raw = raw // nothing read from it: what came, for the requests log
|
|
578
|
+
log('reply', `${this.model}: an empty reply${usage?.completion_tokens ? ` of ${usage.completion_tokens} tokens` : ''}; the stream:`, raw.slice(0, 4000))
|
|
579
|
+
}
|
|
530
580
|
return count(result)
|
|
531
581
|
}
|
|
582
|
+
|
|
583
|
+
// The name this provider gives the model's thinking, to send it back by the same one.
|
|
584
|
+
#reasoningField(d) {
|
|
585
|
+
if (this.quirks.reasoningField) return
|
|
586
|
+
if (d.reasoning_content) this.quirks.reasoningField = 'reasoning_content'
|
|
587
|
+
else if (typeof d.reasoning === 'string' && d.reasoning) this.quirks.reasoningField = 'reasoning'
|
|
588
|
+
}
|
|
532
589
|
}
|
|
533
590
|
|
|
591
|
+
// A tool's arguments as the most limited providers take them (Gemini's function declarations):
|
|
592
|
+
// only types, properties, required, items, enum and descriptions; a choice of types (anyOf, oneOf,
|
|
593
|
+
// ["string", "null"]) becomes its first real one.
|
|
594
|
+
const KEEP = new Set(['type', 'properties', 'required', 'items', 'enum', 'description'])
|
|
595
|
+
export function simpleSchema(x) {
|
|
596
|
+
if (!x || typeof x !== 'object' || Array.isArray(x)) return x
|
|
597
|
+
const alt = x.anyOf ?? x.oneOf
|
|
598
|
+
if (alt?.length) {
|
|
599
|
+
const pick = alt.find((a) => a?.type !== 'null') ?? alt[0]
|
|
600
|
+
return simpleSchema({ ...pick, ...(x.description && { description: x.description }) })
|
|
601
|
+
}
|
|
602
|
+
const out = {}
|
|
603
|
+
for (const [k, v] of Object.entries(x)) {
|
|
604
|
+
if (!KEEP.has(k)) continue
|
|
605
|
+
if (k === 'properties') out.properties = Object.fromEntries(Object.entries(v ?? {}).map(([n, s]) => [n, simpleSchema(s)]))
|
|
606
|
+
else if (k === 'items') out.items = simpleSchema(Array.isArray(v) ? v[0] : v)
|
|
607
|
+
else if (k === 'type' && Array.isArray(v)) out.type = v.find((y) => y !== 'null') ?? 'string'
|
|
608
|
+
else out[k] = v
|
|
609
|
+
}
|
|
610
|
+
out.type ??= out.properties ? 'object' : 'string'
|
|
611
|
+
if (Array.isArray(out.required) && out.properties) out.required = out.required.filter((r) => r in out.properties)
|
|
612
|
+
return out
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Message content as text: a string, or a list of parts ({ type: 'text', text }).
|
|
616
|
+
const textOf = (c) => (Array.isArray(c) ? c.map((p) => (typeof p === 'string' ? p : (p?.text ?? ''))).join('') : (c ?? ''))
|
|
617
|
+
|
|
534
618
|
function done(raw, reasoning, toolCalls, usage, finishReason) {
|
|
535
619
|
const calls = toolCalls.filter((c) => c?.function?.name)
|
|
536
620
|
calls.forEach((c, i) => (c.id ||= `call_${Date.now()}_${i}`))
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
4
5
|
|
|
5
6
|
export const HOME = process.env.HEART_HOME || path.join(os.homedir(), '.heart')
|
|
6
7
|
export const CONFIG_FILE = path.join(HOME, 'config.json')
|
|
@@ -16,6 +17,9 @@ export function readJson(file, fallback) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
// HeartCode's version, from package.json (npm version changes it there).
|
|
21
|
+
export const VERSION = readJson(fileURLToPath(new URL('../package.json', import.meta.url)), {}).version ?? '0.0.0'
|
|
22
|
+
|
|
19
23
|
// A config file the user writes (hooks.json, mcp.json): one that doesn't parse is skipped, but
|
|
20
24
|
// said (takeConfigErrors, shown before the next message), never silently ignored.
|
|
21
25
|
const configErrors = new Map() // file → { message, stamp, shown }
|
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
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
// told to the model. Logs and warnings go to stderr (and HEART_LOG), never stdout.
|
|
13
13
|
import readline from 'node:readline'
|
|
14
14
|
import { log } from './log.js'
|
|
15
|
-
import { clip, TOOL_DEFS, extraTools } from './tools.js'
|
|
15
|
+
import { clip, TOOL_DEFS, extraTools, loadMcpTools } from './tools.js'
|
|
16
16
|
import { allowed } from './agent.js'
|
|
17
17
|
|
|
18
18
|
// Claude Code's tool names → HeartCode's (for --allowedTools / --disallowedTools).
|
|
19
19
|
const CLAUDE_TOOLS = { Read: 'read_file', Grep: 'search', Glob: 'list_files', LS: 'list_files', Edit: 'edit_file', MultiEdit: 'edit_file', Write: 'write_file', NotebookEdit: 'edit_file', Bash: 'run_command' }
|
|
20
20
|
export function toolNames(list) {
|
|
21
21
|
const names = list.flatMap((x) => String(x).split(/[,\s]+/)).filter(Boolean).map((t) => t.replace(/\(.*$/, '')) // ponytail: "Bash(git:*)" allows every command, not just git
|
|
22
|
-
return new Set(names.map((t) =>
|
|
22
|
+
return new Set(names.map((t) => CLAUDE_TOOLS[t] ?? t)) // mcp__server__tool and mcp__server as they are (tools.js allowed)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// --permission-mode, in HeartCode's terms or Claude Code's.
|
|
@@ -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
|
|
@@ -119,8 +133,9 @@ export async function runHeadless(app, opts) {
|
|
|
119
133
|
emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, session_id })
|
|
120
134
|
}
|
|
121
135
|
}
|
|
136
|
+
if (s.strat.actions === 'tools' && s.mcp?.names.length) await loadMcpTools(s) // their tools are in the list, as in Claude Code's
|
|
122
137
|
const tools = s.strat.actions === 'tools' ? [...TOOL_DEFS, ...extraTools(s)].map((f) => f.name).filter((n) => allowed(s, n)) : []
|
|
123
|
-
emit({ type: 'system', subtype: 'init', session_id, model, cwd: app.root, tools, mcp_servers: app.mcp.names.map((name) => ({ name, status: 'pending' })), permissionMode: app.ui.permission, apiKeySource: 'none' })
|
|
138
|
+
emit({ type: 'system', subtype: 'init', session_id, model, cwd: app.root, tools, mcp_servers: app.mcp.names.map((name) => ({ name, status: s.mcpCatalog?.has(name) ? 'connected' : 'pending' })), permissionMode: app.ui.permission, apiKeySource: 'none' })
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
let current = null // the message running now: { ctl }
|
|
@@ -128,6 +143,7 @@ export async function runHeadless(app, opts) {
|
|
|
128
143
|
const t0 = Date.now()
|
|
129
144
|
const spent = app.chat.usage?.cost ?? 0
|
|
130
145
|
usage = { input_tokens: 0, output_tokens: 0 }
|
|
146
|
+
byModel = {}
|
|
131
147
|
requests = 0
|
|
132
148
|
said = false
|
|
133
149
|
current = { ctl: new AbortController() }
|
|
@@ -147,7 +163,7 @@ export async function runHeadless(app, opts) {
|
|
|
147
163
|
}
|
|
148
164
|
const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
|
|
149
165
|
current = null
|
|
150
|
-
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 }
|
|
151
167
|
if (out === 'stream-json') {
|
|
152
168
|
if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, session_id }) // guided mode
|
|
153
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/mcp.js
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
// A local server talks over its stdin/stdout, one message per line. A remote one over Streamable
|
|
6
6
|
// HTTP (or the older HTTP+SSE: "type": "sse", a URL ending in /sse, or when HTTP is refused), and
|
|
7
7
|
// signs in with OAuth when it asks (oauth.js). ${VAR} in a URL or header is the environment's VAR.
|
|
8
|
-
// A server
|
|
8
|
+
// A server's tools are offered to the model as tools of their own (mcp__server__tool), from the
|
|
9
|
+
// list it gave the last time it ran (kept in ~/.heart/mcp-tools.json, with its instructions): so a
|
|
10
|
+
// server starts the first time the model uses it, or once to say its tools; it stops when
|
|
11
|
+
// HeartCode exits.
|
|
9
12
|
import { spawn } from 'node:child_process'
|
|
13
|
+
import crypto from 'node:crypto'
|
|
10
14
|
import path from 'node:path'
|
|
11
15
|
import { pathToFileURL } from 'node:url'
|
|
12
|
-
import { HOME, readJson, readConfig } from './config.js'
|
|
16
|
+
import { HOME, readJson, readConfig, writeJson, VERSION } from './config.js'
|
|
13
17
|
import { log } from './log.js'
|
|
14
18
|
import { savedToken, signIn } from './oauth.js'
|
|
15
19
|
|
|
@@ -75,7 +79,7 @@ class Server {
|
|
|
75
79
|
|
|
76
80
|
start(signal) {
|
|
77
81
|
this.ready ??= (async () => {
|
|
78
|
-
const init = () => this.request('initialize', { protocolVersion: '2025-06-18', capabilities: { roots: {} }, clientInfo: { name: 'heartcode', version:
|
|
82
|
+
const init = () => this.request('initialize', { protocolVersion: '2025-06-18', capabilities: { roots: {} }, clientInfo: { name: 'heartcode', version: VERSION } }, this.url ? 600_000 : 120_000, signal) // remote: time to sign in
|
|
79
83
|
let r
|
|
80
84
|
if (this.url) {
|
|
81
85
|
const type = this.cfg.type ?? this.cfg.transport
|
|
@@ -102,6 +106,7 @@ class Server {
|
|
|
102
106
|
}
|
|
103
107
|
} else r = await this.#spawn(init)
|
|
104
108
|
this.protocol = r?.protocolVersion
|
|
109
|
+
this.instructions = typeof r?.instructions === 'string' ? r.instructions.trim() : ''
|
|
105
110
|
this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
106
111
|
})().catch((e) => {
|
|
107
112
|
this.stop() // failed to start: the next use tries again
|
|
@@ -276,12 +281,14 @@ class Server {
|
|
|
276
281
|
this.tools.push(...(r.tools ?? []))
|
|
277
282
|
cursor = r.nextCursor
|
|
278
283
|
} while (cursor)
|
|
284
|
+
this.hub.remember(this)
|
|
279
285
|
}
|
|
280
286
|
return this.tools
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
async call(tool, args, signal) {
|
|
284
290
|
await this.start(signal)
|
|
291
|
+
if (!this.tools) this.listTools().catch(() => {}) // started from the saved list: what it has now, for next time
|
|
285
292
|
return this.request('tools/call', { name: tool, arguments: args ?? {} }, 300_000, signal)
|
|
286
293
|
}
|
|
287
294
|
|
|
@@ -301,6 +308,9 @@ class Server {
|
|
|
301
308
|
}
|
|
302
309
|
}
|
|
303
310
|
|
|
311
|
+
const TOOLS_FILE = path.join(HOME, 'mcp-tools.json')
|
|
312
|
+
const keyOf = (cfg) => crypto.createHash('sha256').update(JSON.stringify(cfg)).digest('hex').slice(0, 16)
|
|
313
|
+
|
|
304
314
|
// All configured servers for a project; nothing runs until a server is first used.
|
|
305
315
|
// onSignIn(name, url): set by the screen, to show a remote server's sign-in page (none headless).
|
|
306
316
|
export class Mcp {
|
|
@@ -324,6 +334,42 @@ export class Mcp {
|
|
|
324
334
|
return this.servers.get(name)
|
|
325
335
|
}
|
|
326
336
|
|
|
337
|
+
// Each server's { tools, instructions }, for the model: the list saved the last time it ran
|
|
338
|
+
// (with the same config), else asked for now, waiting at most `wait` ms; a server that hasn't
|
|
339
|
+
// answered by then (it may still be starting: npx downloads it the first time) or failed to
|
|
340
|
+
// start isn't in the result. It's tried again next time, and its list is saved once it answers.
|
|
341
|
+
async catalog(signal, wait = 15_000) {
|
|
342
|
+
const saved = readJson(TOOLS_FILE, {})
|
|
343
|
+
const out = new Map()
|
|
344
|
+
const asks = []
|
|
345
|
+
for (const [name, sv] of this.servers) {
|
|
346
|
+
const hit = sv.tools ? { tools: sv.tools, instructions: sv.instructions ?? '' } : saved[name]?.key === keyOf(sv.cfg) ? saved[name] : null
|
|
347
|
+
if (hit) out.set(name, { tools: hit.tools, instructions: hit.instructions ?? '' })
|
|
348
|
+
else if (!sv.failed) asks.push(sv.listTools(signal).then((tools) => out.set(name, { tools, instructions: sv.instructions ?? '' }), (e) => {
|
|
349
|
+
if (e.name === 'AbortError') return
|
|
350
|
+
sv.failed = true // not waited for again in this run; still usable through the mcp tool
|
|
351
|
+
log('mcp', `${name}: could not list its tools: ${e.message}`)
|
|
352
|
+
}))
|
|
353
|
+
}
|
|
354
|
+
if (asks.length) {
|
|
355
|
+
let timer
|
|
356
|
+
await Promise.race([Promise.all(asks), new Promise((r) => (timer = setTimeout(r, wait)))])
|
|
357
|
+
clearTimeout(timer)
|
|
358
|
+
}
|
|
359
|
+
return out
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A server's tools, saved for the next run (the config's hash, not the config: it may hold keys).
|
|
363
|
+
remember(sv) {
|
|
364
|
+
const saved = readJson(TOOLS_FILE, {})
|
|
365
|
+
saved[sv.name] = { key: keyOf(sv.cfg), tools: sv.tools, instructions: sv.instructions ?? '', at: new Date().toISOString() }
|
|
366
|
+
try {
|
|
367
|
+
writeJson(TOOLS_FILE, saved)
|
|
368
|
+
} catch (e) {
|
|
369
|
+
log('mcp', `could not save ${sv.name}'s tools: ${e.message}`)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
327
373
|
stopAll() {
|
|
328
374
|
for (const s of this.servers.values()) s.stop()
|
|
329
375
|
}
|
package/src/prompts.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// <!-- … --> a note right under the heading; never sent
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
12
13
|
import { HOME } from './config.js'
|
|
13
14
|
|
|
14
|
-
const BUILTIN = new URL('../prompts/', import.meta.url)
|
|
15
|
+
const BUILTIN = fileURLToPath(new URL('../prompts/', import.meta.url))
|
|
15
16
|
let project = null
|
|
16
17
|
const cache = new Map()
|
|
17
18
|
|