heartcode 0.1.0 → 0.1.1

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/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.
@@ -119,8 +119,9 @@ export async function runHeadless(app, opts) {
119
119
  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
120
  }
121
121
  }
122
+ if (s.strat.actions === 'tools' && s.mcp?.names.length) await loadMcpTools(s) // their tools are in the list, as in Claude Code's
122
123
  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' })
124
+ 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
125
  }
125
126
 
126
127
  let current = null // the message running now: { ctl }
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
 
package/src/settings.html CHANGED
@@ -145,7 +145,7 @@
145
145
 
146
146
  /* Providers & models: every provider's models on the left (pick one), yours in order on the right;
147
147
  both fill the screen's height and scroll inside */
148
- .work { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 20px; }
148
+ .work { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; } /* the catalog and your models, half and half */
149
149
  .work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
150
150
  .cat-top { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; align-items: center; }
151
151
  .cat-top input { flex: 1; min-width: 0; }
@@ -206,10 +206,10 @@
206
206
  .cat-note { padding: 12px 16px; color: var(--muted); font-size: 12.5px; }
207
207
  .byname { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); }
208
208
  .byname input { height: 32px; font-family: var(--mono); font-size: 12.5px; }
209
- /* two lines' room, never more: a test's progress doesn't move the cards below */
210
- .olist .test { font-size: 11.5px; line-height: 1.45; margin-top: 3px; color: var(--faint); display: flex; align-items: flex-start; gap: 6px; height: calc(2 * 1.45em + 2px); }
211
- .olist .test .msg { flex: 1; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
212
- .olist .test .spin { margin-top: 3px; flex: none; }
209
+ /* one line: what models.dev says (cut short, all of it on hover), its buttons on the right */
210
+ .olist .test { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
211
+ .olist .test .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
212
+ .olist .test .spin { flex: none; }
213
213
  .olist .test button { flex: none; }
214
214
  .olist .test.ok { color: var(--ok); }
215
215
  .olist .test.warn { color: var(--accent); }
@@ -224,13 +224,16 @@
224
224
  .spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
225
225
  @keyframes spin { to { transform: rotate(360deg); } }
226
226
  .mine-top { padding: 16px 18px 6px; }
227
- .mine-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
227
+ .mine-title { display: flex; align-items: center; justify-content: space-between; gap: 6px 8px; flex-wrap: wrap; }
228
228
  .mine-title h2 { margin: 0 0 6px; flex: 1; white-space: nowrap; }
229
229
  .mine-title button { white-space: nowrap; }
230
- .olist { list-style: none; margin: 0; padding: 6px 12px 12px; flex: 1; overflow-y: auto; }
231
- .olist li { display: flex; align-items: center; gap: 8px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; margin-bottom: 8px; background: var(--panel); }
232
- .olist li.in { border-color: var(--brand); background: var(--brand-soft); }
233
- .olist li.next { border-style: dashed; background: none; color: var(--faint); font-size: 12.5px; justify-content: center; padding: 14px 10px; }
230
+ /* your models as a table: a header, then one line a model */
231
+ .ohead, .olist li.in { display: grid; grid-template-columns: 14px 30px minmax(0, 1.7fr) minmax(0, 0.6fr) minmax(0, 1.5fr) 26px; align-items: center; column-gap: 8px; padding: 7px 16px; }
232
+ .ohead { font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding-top: 8px; padding-bottom: 8px; }
233
+ .olist { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
234
+ .olist li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; }
235
+ .olist li.in:first-child { background: var(--brand-soft); }
236
+ .olist li.next { border-bottom: 0; background: none; color: var(--faint); font-size: 12.5px; text-align: center; padding: 14px 10px; }
234
237
  .olist li.in { cursor: grab; user-select: none; transition: transform .16s ease, box-shadow .16s ease; position: relative; }
235
238
  .olist.sorting li.in { transition: transform .16s ease; }
236
239
  .olist li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: 0 10px 28px rgba(0, 0, 0, .35), 0 0 0 2px var(--brand); }
@@ -243,8 +246,8 @@
243
246
  .mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--brand); background: var(--brand-soft); border-radius: 999px; padding: 1px 4px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
244
247
  .mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
245
248
  .mt-scroll { overflow-x: auto; }
246
- .mt { min-width: 1080px; }
247
- .mt-h, .mtable li { display: grid; grid-template-columns: 16px 30px minmax(170px, 3fr) minmax(80px, 1.2fr) 76px 82px 56px 62px 54px 60px 64px minmax(200px, 2fr) 30px; align-items: center; column-gap: 8px; padding: 8px 18px; }
249
+ .mt { min-width: 1090px; }
250
+ .mt-h, .mtable li { display: grid; grid-template-columns: 16px 28px minmax(150px, 3fr) minmax(70px, 1.1fr) 66px 66px 42px 60px 50px 54px 60px minmax(166px, 1.5fr) minmax(120px, 1.3fr) 28px; align-items: center; column-gap: 6px; padding: 8px 16px; }
248
251
  .mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
249
252
  .mt-h button { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; }
250
253
  .mt-h button:hover { color: var(--text); }
@@ -266,13 +269,19 @@
266
269
  .mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
267
270
  .mtable .src.warn { color: var(--accent); }
268
271
  .mtable button { padding: 1px 7px; font-size: 11.5px; }
272
+ .mtable .try { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
273
+ .mtable .try .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
274
+ .mtable .try.ok { color: var(--ok); }
275
+ .mtable .try.bad { color: var(--err); }
276
+ .mtable .try.going { color: var(--accent); }
269
277
  .mt-empty { padding: 28px 18px; color: var(--muted); }
270
278
  .olist .grip { cursor: grab; color: var(--faint); padding: 0 2px; user-select: none; }
271
279
  .olist .n { font-weight: 800; color: var(--brand); min-width: 24px; }
272
- .olist .what { flex: 1; min-width: 0; }
273
- .olist .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }
274
- .olist .p { color: var(--muted); font-size: 12px; }
280
+ .olist .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
281
+ .olist .p { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
282
+ .olist .p.use { color: var(--brand); font-weight: 600; }
275
283
  .olist button { padding: 2px 8px; font-size: 12px; }
284
+ .olist button.danger { padding: 1px 6px; }
276
285
  .mine-rules { border-top: 1px solid var(--line); padding: 4px 18px 16px; }
277
286
  .mine-rules .hint { min-height: 2.9em; } /* the hints differ in length: the next setting stays put */
278
287
  .mine-empty { padding: 30px 22px; text-align: center; color: var(--muted); }
@@ -796,9 +805,75 @@ function testLine(t) {
796
805
  const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
797
806
  const [cls, text, action] =
798
807
  !x ? ['none', 'looking it up in models.dev…', null]
799
- : x.source === 'assumed' ? ['warn', 'not in models.dev: set up what it can do', [retry(t), edit('Set it up')]]
808
+ : x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Retry all
800
809
  : ['ok', `✓ ${testedText(x)} · ${Object.keys(x.set ?? {}).length ? 'yours' : FROM[x.source]}`, edit('Edit')]
801
- return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text }), action)
810
+ const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
811
+ return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
812
+ }
813
+
814
+ // Test: one short message to the model, to see that it really answers (the key, access to it, its
815
+ // name, the server). The result stays under it until the page is reloaded.
816
+ const TRIES = {} // "provider/model" → { going } | { ok, ms, reply } | { ok: false, error }
817
+ const keyOf = (t) => `${t.provider}/${t.model}`
818
+ // The Works? cell: the last result (cut short, all of it on hover) and Test.
819
+ function triedCell(t) {
820
+ const r = TRIES[keyOf(t)]
821
+ const secs = r?.ms != null ? ` · ${(r.ms / 1000).toFixed(1)}s` : ''
822
+ const [cls, text, about] = !r ? ['', 'not tested', 'send it a short message to see that it really answers (your key, access to it, its name)']
823
+ : r.going ? ['going', 'testing…', 'sending it a short message']
824
+ : r.ok ? ['ok', `✓ ${(r.ms / 1000).toFixed(1)}s`, `works: it answered "${r.reply}"${secs}`]
825
+ : ['bad', `✗ ${r.status ?? (/nothing/.test(r.error) ? 'empty' : /within/.test(r.error) ? 'no answer' : 'failed')}`, `doesn't work: ${r.error}${secs}`] // the reason on hover
826
+ return h('div', { className: 'try ' + cls },
827
+ h('span', { className: 'msg', textContent: text, title: about }),
828
+ h('button', { className: 'small', textContent: 'Test', disabled: Boolean(r?.going), title: 'send it a short message to see that it really answers', 'aria-label': `test ${t.model}`, onclick: () => tryModel(t) }))
829
+ }
830
+ function showTried(t) {
831
+ document.querySelector(`#mt li[data-key="${CSS.escape(keyOf(t))}"] .try`)?.replaceWith(triedCell(t))
832
+ if (SORT.some((x) => x.key === 'works') && !TRIES[keyOf(t)]?.going) drawMine(S) // sorted by it: its place may change
833
+ }
834
+ async function tryModel(t) {
835
+ TRIES[keyOf(t)] = { going: true }
836
+ showTried(t)
837
+ const r = (await act('/api/try', { provider: t.provider, model: t.model })) ?? { ok: false, error: 'the settings page did not answer' }
838
+ TRIES[keyOf(t)] = r
839
+ showTried(t)
840
+ return r
841
+ }
842
+ let testingAll = false
843
+ async function tryAll() {
844
+ if (testingAll) return
845
+ testingAll = true
846
+ const list = [...(S?.order ?? [])]
847
+ let good = 0
848
+ toast(`testing ${list.length} model${list.length === 1 ? '' : 's'}…`)
849
+ // several at once (6), but at most 2 per provider: more would trip its rate limit and show
850
+ // working models as failing
851
+ for (const t of list) TRIES[keyOf(t)] = { going: true }
852
+ list.forEach(showTried)
853
+ const queue = [...list]
854
+ const busy = {}
855
+ const worker = async () => {
856
+ while (queue.length) {
857
+ const i = queue.findIndex((t) => (busy[t.provider] ?? 0) < 2)
858
+ if (i === -1) {
859
+ await new Promise((r) => setTimeout(r, 100)) // every waiting one's provider is busy
860
+ continue
861
+ }
862
+ const [t] = queue.splice(i, 1)
863
+ busy[t.provider] = (busy[t.provider] ?? 0) + 1
864
+ try {
865
+ if ((await tryModel(t)).ok) good++
866
+ } finally {
867
+ busy[t.provider]--
868
+ }
869
+ }
870
+ }
871
+ try {
872
+ await Promise.all(Array.from({ length: Math.min(6, list.length) }, worker))
873
+ } finally {
874
+ testingAll = false
875
+ }
876
+ toast(`${good} of ${list.length} work${good === list.length ? '' : `; ${list.length - good} don't (Works? says why, on hover)`}`, good < list.length)
802
877
  }
803
878
 
804
879
  // models.dev looked up again: one model (its Retry), or all of yours (Retry all)
@@ -921,16 +996,16 @@ function drawProviders(s) {
921
996
  h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
922
997
  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') })),
923
998
  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
+ 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,
924
1000
  order.length ? h('ol', { className: 'olist' }, order.map((t, i) => h('li', {
925
1001
  className: 'in', 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
926
1002
  onkeydown: (e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), move(i, Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowUp' ? -1 : 1))))),
927
1003
  },
928
- order.length > 1 ? h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true' }) : null,
929
- h('span', { className: 'n', textContent: `#${i + 1}` }),
930
- h('div', { className: 'what' },
931
- h('div', { className: 'm', textContent: t.model }),
932
- h('div', { className: 'p', textContent: `${t.provider} · ${i === 0 ? 'in use' : 'fallback'}` }),
933
- testLine(t)),
1004
+ h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
1005
+ h('span', { className: 'n', textContent: `#${i + 1}`, title: i === 0 ? 'in use: the one HeartCode uses' : 'a fallback: tried, in order, when the ones above fail' }),
1006
+ h('span', { className: 'm', textContent: t.model, title: t.model }),
1007
+ h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
1008
+ testLine(t),
934
1009
  h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) })),
935
1010
  ), h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` }))
936
1011
  : h('div', { className: 'mine-empty' }, h('b', { textContent: 'No model chosen yet' }), `Click a model ${side}: it becomes #1, the one HeartCode uses.`),
@@ -940,7 +1015,7 @@ function drawProviders(s) {
940
1015
  h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
941
1016
  [['failure', 'at its first failure'], ['retries', 'after some tries of the same model'], ['any', 'at any error']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
942
1017
  s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
943
- h('div', { className: 'hint', textContent: { failure: 'A rate limit, server error, timeout or lost connection moves on at once. Errors like "model not found" or a wrong key stop.', retries: 'The same model is tried again (with a short wait) that many times first.', any: 'Also errors like "model not found" or a bad request move on.' }[s.rotation.on] }),
1018
+ h('div', { className: 'hint', textContent: { failure: 'A rate limit, server error, timeout or lost connection moves on at once, and so does a model it can\'t use (a wrong key, no access, "model not found"). Other errors, like a bad request, stop.', retries: 'The same model is tried again (with a short wait) that many times first.', any: 'Also errors like "model not found" or a bad request move on.' }[s.rotation.on] }),
944
1019
  h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
945
1020
  h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
946
1021
  [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
@@ -1122,6 +1197,7 @@ const COLS = {
1122
1197
  images: { label: 'Images', get: (t) => can(t).images },
1123
1198
  input: { label: 'In $/M', how: 'cheap', get: (t) => t.price?.input ?? null },
1124
1199
  output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
1200
+ works: { label: 'Works?', get: (t) => { const r = TRIES[keyOf(t)]; return !r || r.going ? 1 : r.ok ? 2 : 0 } }, // tested and working first; not tested; not working
1125
1201
  }
1126
1202
  let SORT = (() => {
1127
1203
  try {
@@ -1157,7 +1233,7 @@ function sorted(order) {
1157
1233
  }
1158
1234
  return order.map((t, i) => ({ t, i })).sort((a, b) => SORT.reduce((r, s) => r || cmp(a.t, b.t, s), 0) || a.i - b.i).map((x) => x.t)
1159
1235
  }
1160
- const dirText = (key, dir) => (COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
1236
+ const dirText = (key, dir) => (key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
1161
1237
  function drawMine(s) {
1162
1238
  const order = s.order
1163
1239
  $('n-mine').textContent = order.length || ''
@@ -1198,10 +1274,11 @@ function drawMine(s) {
1198
1274
  : 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.' : ''),
1199
1275
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1200
1276
  SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
1201
- order.length ? h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', onclick: () => lookUp() }) : null),
1277
+ order.length ? h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', onclick: () => lookUp() }) : null,
1278
+ 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),
1202
1279
  order.length
1203
1280
  ? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
1204
- h('div', { className: 'mt-h' }, h('span'), h('span', { textContent: '#' }), head('model'), head('provider'), head('context', true), head('abilities', true), head('tools'), head('thinking'), head('images'), head('input', true), head('output', true), h('span', { textContent: 'From' }), h('span')),
1281
+ h('div', { className: 'mt-h' }, h('span'), h('span', { textContent: '#' }), head('model'), head('provider'), head('context', true), head('abilities', true), head('tools'), head('thinking'), head('images'), head('input', true), head('output', true), h('span', { textContent: 'From' }), head('works'), h('span')),
1205
1282
  h('ol', { className: 'mtable' + (order.length < 2 ? '' : ' drag') }, view.map((t) => {
1206
1283
  const c = can(t)
1207
1284
  const ctx = COLS.context.get(t)
@@ -1216,6 +1293,7 @@ function drawMine(s) {
1216
1293
  h('span', { className: 'num', textContent: t.price?.input != null ? usd(t.price.input) : '–' }),
1217
1294
  h('span', { className: 'num', textContent: t.price?.output != null ? usd(t.price.output) : '–' }),
1218
1295
  src(t),
1296
+ triedCell(t),
1219
1297
  h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
1220
1298
  }))))
1221
1299
  : h('div', { className: 'mt-empty' }, 'No models in your list yet. ', h('a', { href: '#providers', textContent: 'Choose some under Providers & models' }), '.'))
package/src/settings.js CHANGED
@@ -12,6 +12,7 @@ import { HOME, saveConfig, readJson, writeJson } from './config.js'
12
12
  import { DEFAULT_SERVERS } from './mcp.js'
13
13
  import { sections, BUILTIN_PROMPTS } from './prompts.js'
14
14
  import { Client } from './client.js'
15
+ import { clip } from './tools.js'
15
16
  import { loadProfile, strategy, profilePath, profileFrom } from './probe.js'
16
17
  import { loadPrices, priceOf, limitsOf } from './usage.js'
17
18
  import { THEMES, PERMISSIONS } from './ui.js'
@@ -156,6 +157,17 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
156
157
  }
157
158
 
158
159
 
160
+ // A failed request in a few words: its status and what the server said (its JSON's message).
161
+ const whyFailed = (e) => {
162
+ let said = String(e.body ?? '').trim()
163
+ try {
164
+ const j = [JSON.parse(said)].flat()[0]
165
+ said = j?.error?.message ?? (typeof j?.error === 'string' ? j.error : null) ?? (typeof j?.detail === 'string' ? j.detail : null) ?? j?.message ?? j?.title ?? said
166
+ } catch {}
167
+ if (!said || /^\s*</.test(said)) said = e.message.replace(/^\d+ from \S+: /, '')
168
+ return clip(`${e.status ? `${e.status}: ` : ''}${said}`, 300)
169
+ }
170
+
159
171
  const state = () => ({
160
172
  providers: Object.entries(cfg.providers)
161
173
  .filter(([n]) => n !== cfg.adhoc)
@@ -296,6 +308,22 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
296
308
  // models.dev again (one model, or all of yours): its list fetched anew, and each model's profile
297
309
  // takes what it says now (one it didn't know before becomes "from models.dev"; your own settings
298
310
  // for it stay above). A running HeartCode takes it at once.
311
+ // Test: one short message to the model, to see that it answers (key, access, name, the server
312
+ // itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
313
+ 'POST /api/try': async ({ provider, model } = {}) => {
314
+ const c = clientFor({ provider, model: String(model ?? '') })
315
+ if (!c || !model) return { ok: false, error: `no provider "${provider}"` }
316
+ c.purpose = 'test'
317
+ const t0 = Date.now()
318
+ try {
319
+ const r = await c.chat({ messages: [{ role: 'user', content: 'Reply with just the word OK.' }], stream: true }, { signal: AbortSignal.timeout(90_000) })
320
+ const reply = (r.content || '').trim()
321
+ if (!reply && !r.toolCalls.length) return { ok: false, ms: Date.now() - t0, error: `it answered with nothing${r.usage?.completion_tokens ? ` (${r.usage.completion_tokens} tokens it said it wrote)` : ''}` }
322
+ return { ok: true, ms: Date.now() - t0, reply: reply.slice(0, 80) }
323
+ } catch (e) {
324
+ return { ok: false, ms: Date.now() - t0, status: e.status ?? null, error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : whyFailed(e) }
325
+ }
326
+ },
299
327
  'POST /api/models-dev': async ({ provider, model } = {}) => {
300
328
  await loadPrices({ force: true })
301
329
  const list = provider && model ? [{ provider, model: String(model) }] : state().order.map(({ provider, model }) => ({ provider, model }))
package/src/tools.js CHANGED
@@ -4,6 +4,7 @@ import { slug } from './memory.js'
4
4
  import { fillArgs } from './skills.js'
5
5
  import { prompt } from './prompts.js'
6
6
  import path from 'node:path'
7
+ import os from 'node:os'
7
8
  import { spawn, execFileSync } from 'node:child_process'
8
9
  import { applyEdit } from './edit.js'
9
10
  import { readJson } from './config.js'
@@ -19,10 +20,27 @@ const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'cover
19
20
  export const clip = (s, n) => (String(s).length > n ? String(s).slice(0, n) + '…' : String(s))
20
21
  export const tail = (s, n) => (s.length > n ? '…' + s.slice(-n) : s)
21
22
 
23
+ // Tool output too long for the conversation is saved here (one folder per chat), where read_file
24
+ // and search can read it although it's outside the project.
25
+ export const OUTPUTS = path.join(os.tmpdir(), 'heartcode-outputs')
26
+ export function saveOutput(chatId, name, text) {
27
+ try {
28
+ const dir = path.join(OUTPUTS, String(chatId ?? 'chat'))
29
+ fs.mkdirSync(dir, { recursive: true })
30
+ const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}.txt`)
31
+ fs.writeFileSync(file, text)
32
+ return file
33
+ } catch (e) {
34
+ log('tool', `could not save the output: ${e.message}`)
35
+ return ''
36
+ }
37
+ }
38
+
22
39
  export function resolveIn(root, p) {
23
40
  const clean = String(p ?? '').trim().replace(/^["'`]+|["'`]+$/g, '')
24
41
  if (!clean) throw new Error('no path given')
25
42
  let abs = path.resolve(root, clean)
43
+ if (abs.startsWith(OUTPUTS + path.sep)) return abs // a saved tool output
26
44
  // "num.js:" or "num.js," from a sloppy model: the file without the stray mark, if that one exists
27
45
  const trimmed = path.resolve(root, clean.replace(/[:;,.]+$/, ''))
28
46
  if (trimmed !== abs && !fs.existsSync(abs) && fs.existsSync(trimmed)) abs = trimmed
@@ -105,13 +123,15 @@ export function searchFiles(root, query, sub = '', limit = 50) {
105
123
  } catch {
106
124
  re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'i' : '')
107
125
  }
108
- const prefix = sub ? path.relative(root, resolveIn(root, sub)) : ''
126
+ const abs = sub ? resolveIn(root, sub) : ''
127
+ const saved = abs.startsWith(OUTPUTS + path.sep) // a tool output saved outside the project
128
+ const prefix = sub && !saved ? path.relative(root, abs) : ''
109
129
  const hits = []
110
- for (const f of listFiles(root)) {
130
+ for (const f of saved ? [abs] : listFiles(root)) {
111
131
  if (prefix && f !== prefix && !f.startsWith(prefix + '/')) continue
112
132
  let buf
113
133
  try {
114
- buf = fs.readFileSync(path.join(root, f))
134
+ buf = fs.readFileSync(path.resolve(root, f))
115
135
  } catch {
116
136
  continue
117
137
  }
@@ -388,12 +408,51 @@ export function readOnlyCommand(cmd) {
388
408
  return c.split(/\s*(?:&&|\|\||;|\|)\s*/).every((part) => READ_ONLY.test(part))
389
409
  }
390
410
 
391
- // The one tool for all MCP servers, and the skill loader; each only when there's something to offer.
411
+ // MCP servers' tools as the model sees them: each its own tool, mcp__server__tool (Claude Code's
412
+ // names), with its description and arguments, the way the model would see them in Claude Code.
413
+ // Servers whose tools aren't known yet (still starting, failed), or too many tools at once, go
414
+ // through the one mcp tool instead. Sets s.mcpDirect (name → { server, tool, def }), s.mcpMeta
415
+ // (servers behind the mcp tool) and s.mcpInstructions (what the servers say about using them).
416
+ export const MAX_MCP_TOOLS = 100
417
+ export async function loadMcpTools(s, signal) {
418
+ if (!s.mcp?.names.length) return Object.assign(s, { mcpDirect: new Map(), mcpMeta: [], mcpInstructions: '' })
419
+ const catalog = await s.mcp.catalog(signal)
420
+ // the servers with the fewest tools first, while they fit
421
+ const known = [...catalog].sort((a, b) => a[1].tools.length - b[1].tools.length)
422
+ const direct = new Map()
423
+ const notes = []
424
+ for (const [server, { tools, instructions }] of known) {
425
+ if (direct.size + tools.length > MAX_MCP_TOOLS) continue
426
+ for (const t of tools) direct.set(mcpName(server, t.name), { server, tool: t.name, def: t })
427
+ if (instructions) notes.push(`### ${server}\n${instructions}`)
428
+ }
429
+ const shown = new Set([...direct.values()].map((d) => d.server))
430
+ return Object.assign(s, { mcpDirect: direct, mcpMeta: s.mcp.names.filter((n) => !shown.has(n)), mcpInstructions: notes.join('\n\n'), mcpCatalog: catalog })
431
+ }
432
+ // Tool names may only have letters, digits, _ and - (64 at most).
433
+ export const mcpName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^\w-]/g, '_').slice(0, 64)
434
+ // A schema as providers take it: an object, without the keys some refuse ($schema).
435
+ function cleanSchema(x) {
436
+ if (Array.isArray(x)) return x.map(cleanSchema)
437
+ if (!x || typeof x !== 'object') return x
438
+ return Object.fromEntries(Object.entries(x).filter(([k]) => k !== '$schema').map(([k, v]) => [k, cleanSchema(v)]))
439
+ }
440
+ function mcpDefs(s) {
441
+ return [...(s.mcpDirect?.values() ?? [])].map(({ server, tool, def }) => {
442
+ const schema = cleanSchema(def.inputSchema ?? {})
443
+ return { name: mcpName(server, tool), description: clip(String(def.description ?? `${tool} (${server})`).trim(), 2000), parameters: { ...schema, type: 'object', properties: schema.properties ?? {} } }
444
+ })
445
+ }
446
+
447
+ // The MCP servers' tools, the one tool for the rest, and the skill loader; each only when there's
448
+ // something to offer.
392
449
  export function extraTools(s) {
393
- const out = []
394
- if (s.mcp?.names.length) {
395
- const servers = s.mcp.names.map((n) => `${n}: ${s.mcp.get(n).cfg.description || 'no description'}`).join('\n')
396
- out.push({ name: 'mcp', description: prompt('agent/tool-mcp', { servers }), parameters: obj({ server: str, tool: str, arguments: { type: 'object' } }, ['server', 'tool']) })
450
+ const out = mcpDefs(s)
451
+ const meta = s.mcpMeta ?? s.mcp?.names ?? []
452
+ if (meta.length) {
453
+ // no description: the names of its tools, when known, say what it's for
454
+ const about = (n) => s.mcp.get(n).cfg.description || (s.mcpCatalog?.get(n) ? `tools: ${clip(s.mcpCatalog.get(n).tools.map((t) => t.name).join(', '), 300)}` : 'no description')
455
+ out.push({ name: 'mcp', description: prompt('agent/tool-mcp', { servers: meta.map((n) => `${n}: ${about(n)}`).join('\n') }), parameters: obj({ server: str, tool: str, arguments: { type: 'object' } }, ['server', 'tool']) })
397
456
  }
398
457
  if (s.memory) {
399
458
  out.push({
@@ -410,7 +469,7 @@ export function extraTools(s) {
410
469
  // the patch format OpenAI's models are trained on (they also write it as a shell command, below)
411
470
  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']) })
412
471
  if (!s.isSub && s.subagent) out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str }, ['description', 'prompt']) })
413
- return [...out, ...(s.director?.toolDefs(s) ?? [])] // manage (director.js)
472
+ return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
414
473
  }
415
474
 
416
475
  // todo: the model's checklist for this message, shown to the user each time it changes.
@@ -533,8 +592,17 @@ async function mcpTool(s, args, signal) {
533
592
  const t = tools.find((x) => x.name === a.name)
534
593
  return t ? `${t.name}: ${t.description ?? ''}\nArguments (JSON schema): ${JSON.stringify(t.inputSchema ?? {})}` : `error: ${server.name} has no tool "${a.name}"; tool "list" shows its tools`
535
594
  }
536
- const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
537
- if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
595
+ return callMcp(s, server, tool, a, signal)
596
+ }
597
+
598
+ // One MCP tool call. Asked about like a command, unless the server marks the tool read-only.
599
+ async function callMcp(s, server, tool, a, signal) {
600
+ if (!allowed(s, mcpName(server.name, tool))) return `error: the tool ${mcpName(server.name, tool)} is not allowed here`
601
+ const def = (server.tools ?? s.mcpCatalog?.get(server.name)?.tools)?.find((t) => t.name === tool)
602
+ if (!def?.annotations?.readOnlyHint) {
603
+ const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
604
+ if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
605
+ }
538
606
  s.ui.spinner(`${server.name} · ${tool}`)
539
607
  const r = await server.call(tool, a, signal)
540
608
  const parts = r.content ?? []
@@ -714,8 +782,28 @@ function normalize(args) {
714
782
  return out
715
783
  }
716
784
 
785
+ // The MCP tool a name means: mcp__server__tool (offered, or a server behind the mcp tool), or a
786
+ // bare tool name only one server has ("click").
787
+ function mcpTarget(s, name) {
788
+ const d = s.mcpDirect?.get(name)
789
+ if (d) return { server: s.mcp.get(d.server), tool: d.tool }
790
+ const m = /^mcp__(.+?)__(.+)$/.exec(name)
791
+ if (m && s.mcp?.get(m[1])) return { server: s.mcp.get(m[1]), tool: m[2] }
792
+ const same = [...(s.mcpDirect?.values() ?? [])].filter((x) => x.tool === name)
793
+ return same.length === 1 ? { server: s.mcp.get(same[0].server), tool: name } : null
794
+ }
795
+
796
+ // Tools the caller allowed (--allowedTools) or took away (--disallowedTools); all by default. An
797
+ // MCP tool goes by its own name, its server's (mcp__server, as in Claude Code) or "mcp" (all of them).
798
+ export function allowed(s, name) {
799
+ const names = name.startsWith('mcp__') ? [name, name.split('__').slice(0, 2).join('__'), 'mcp'] : [name]
800
+ if (names.some((n) => s.denyTools?.has(n))) return false
801
+ if (!s.allowTools) return true
802
+ return names.some((n) => s.allowTools.has(n)) || (name === 'mcp' && [...s.allowTools].some((n) => n.startsWith('mcp__')))
803
+ }
804
+
717
805
  async function tool(name, args, s, signal) {
718
- args = normalize(args)
806
+ if (!name.startsWith('mcp__') && !mcpTarget(s, name)) args = normalize(args) // an MCP tool's arguments are its own
719
807
  try {
720
808
  const { root } = s
721
809
  if (s.ui.permission === 'plan') {
@@ -764,8 +852,11 @@ async function tool(name, args, s, signal) {
764
852
  const k = s.skills?.get(String(args.name ?? ''))
765
853
  return k && k.use !== 'me' ? fillArgs(k.body, '') : `error: no skill "${args.name}". Skills: ${[...(s.skills?.values() ?? [])].filter((x) => x.use !== 'me').map((x) => x.name).join(', ') || 'none'}`
766
854
  }
767
- default:
855
+ default: {
856
+ const m = mcpTarget(s, name)
857
+ if (m) return await callMcp(s, m.server, m.tool, args, signal)
768
858
  return (await s.director?.tool(name, args, s, signal)) ?? `error: there is no tool named ${name}`
859
+ }
769
860
  }
770
861
  } catch (e) {
771
862
  if (e.name === 'AbortError') throw e