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/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 wrong model name, key or request (404, 401, 400…): the same request fails the same way
361
- // every time, so it isn't sent again. It stops here, unless the model list moves on at any
362
- // error (the settings page); and a whole round of those stops too, rather than going round.
363
- if (!outage(e) && this.rotation?.on !== 'any') throw e
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
- if (e.status === 400 && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
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 (e.status === 400 && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
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
- return count(done(msg.content ?? '', msg.reasoning_content ?? msg.reasoning ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
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
- const r = d.reasoning_content || d.reasoning
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) => (t.startsWith('mcp__') ? 'mcp' : (CLAUDE_TOOLS[t] ?? 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 chat = s.client.chat.bind(s.client)
99
- s.client.chat = async (...a) => {
100
- const r = await chat(...a)
101
- requests++
102
- usage.input_tokens += r.usage?.prompt_tokens ?? 0
103
- usage.output_tokens += r.usage?.completion_tokens ?? 0
104
- return r
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: usage.input_tokens, outputTokens: usage.output_tokens } }, session_id }
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
- export function pruneLogs(days = DAYS) {
32
- const before = Date.now() - days * 86_400_000
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 || fs.statSync(at).mtimeMs >= before) continue
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 starts the first time the model uses it, and stops when HeartCode exits.
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: '0.1.0' } }, this.url ? 600_000 : 120_000, signal) // remote: time to sign in
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).pathname
15
+ const BUILTIN = fileURLToPath(new URL('../prompts/', import.meta.url))
15
16
  let project = null
16
17
  const cache = new Map()
17
18