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/settings.html CHANGED
@@ -42,6 +42,9 @@
42
42
  .page { display: none; }
43
43
  .page.on { display: block; }
44
44
  .head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
45
+ .disk { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
46
+ .disk b { font-size: 22px; }
47
+ .disk .k { color: var(--muted); font-size: 12.5px; }
45
48
  h1 { font-size: 24px; margin: 0 0 4px; }
46
49
  .lead { color: var(--muted); margin: 0; max-width: 760px; }
47
50
  h2 { font-size: 15px; margin: 0 0 4px; }
@@ -145,7 +148,7 @@
145
148
 
146
149
  /* Providers & models: every provider's models on the left (pick one), yours in order on the right;
147
150
  both fill the screen's height and scroll inside */
148
- .work { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 20px; }
151
+ .work { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; } /* the catalog and your models, half and half */
149
152
  .work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
150
153
  .cat-top { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; align-items: center; }
151
154
  .cat-top input { flex: 1; min-width: 0; }
@@ -206,10 +209,10 @@
206
209
  .cat-note { padding: 12px 16px; color: var(--muted); font-size: 12.5px; }
207
210
  .byname { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); }
208
211
  .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; }
212
+ /* one line: what models.dev says (cut short, all of it on hover), its buttons on the right */
213
+ .olist .test { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
214
+ .olist .test .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
215
+ .olist .test .spin { flex: none; }
213
216
  .olist .test button { flex: none; }
214
217
  .olist .test.ok { color: var(--ok); }
215
218
  .olist .test.warn { color: var(--accent); }
@@ -224,13 +227,16 @@
224
227
  .spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
225
228
  @keyframes spin { to { transform: rotate(360deg); } }
226
229
  .mine-top { padding: 16px 18px 6px; }
227
- .mine-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
230
+ .mine-title { display: flex; align-items: center; justify-content: space-between; gap: 6px 8px; flex-wrap: wrap; }
228
231
  .mine-title h2 { margin: 0 0 6px; flex: 1; white-space: nowrap; }
229
232
  .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; }
233
+ /* your models as a table: a header, then one line a model */
234
+ .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; }
235
+ .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; }
236
+ .olist { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
237
+ .olist li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; }
238
+ .olist li.in:first-child { background: var(--brand-soft); }
239
+ .olist li.next { border-bottom: 0; background: none; color: var(--faint); font-size: 12.5px; text-align: center; padding: 14px 10px; }
234
240
  .olist li.in { cursor: grab; user-select: none; transition: transform .16s ease, box-shadow .16s ease; position: relative; }
235
241
  .olist.sorting li.in { transition: transform .16s ease; }
236
242
  .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 +249,8 @@
243
249
  .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
250
  .mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
245
251
  .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; }
252
+ .mt { min-width: 1090px; }
253
+ .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
254
  .mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
249
255
  .mt-h button { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; }
250
256
  .mt-h button:hover { color: var(--text); }
@@ -266,13 +272,19 @@
266
272
  .mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
267
273
  .mtable .src.warn { color: var(--accent); }
268
274
  .mtable button { padding: 1px 7px; font-size: 11.5px; }
275
+ .mtable .try { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
276
+ .mtable .try .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
277
+ .mtable .try.ok { color: var(--ok); }
278
+ .mtable .try.bad { color: var(--err); }
279
+ .mtable .try.going { color: var(--accent); }
269
280
  .mt-empty { padding: 28px 18px; color: var(--muted); }
270
281
  .olist .grip { cursor: grab; color: var(--faint); padding: 0 2px; user-select: none; }
271
282
  .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; }
283
+ .olist .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
284
+ .olist .p { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
285
+ .olist .p.use { color: var(--brand); font-weight: 600; }
275
286
  .olist button { padding: 2px 8px; font-size: 12px; }
287
+ .olist button.danger { padding: 1px 6px; }
276
288
  .mine-rules { border-top: 1px solid var(--line); padding: 4px 18px 16px; }
277
289
  .mine-rules .hint { min-height: 2.9em; } /* the hints differ in length: the next setting stays put */
278
290
  .mine-empty { padding: 30px 22px; text-align: center; color: var(--muted); }
@@ -403,7 +415,7 @@
403
415
 
404
416
  <!-- Logging -->
405
417
  <section class="page" id="page-logging">
406
- <div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div></div>
418
+ <div class="head"><div><h1>Logging</h1><p class="lead">What HeartCode writes down, for finding out what went wrong. Off unless you switch it on.</p></div><div class="disk" id="l-disk"></div></div>
407
419
  <div class="card">
408
420
  <div class="log-opts">
409
421
  <div>
@@ -597,6 +609,7 @@ function showPage() {
597
609
  for (const p of PAGES) $('page-' + p).classList.toggle('on', p === page())
598
610
  for (const a of document.querySelectorAll('nav a')) a.classList.toggle('on', a.dataset.page === page())
599
611
  if (S) drawUsing(S)
612
+ if (page() === 'logging') drawDisk()
600
613
  scrollTo(0, 0)
601
614
  fillHeight()
602
615
  document.querySelector('nav a.on')?.scrollIntoView({ inline: 'center', block: 'nearest' }) // on a phone the pages scroll sideways
@@ -796,12 +809,78 @@ function testLine(t) {
796
809
  const edit = (label) => h('button', { className: 'small', textContent: label, onclick: (e) => (e.stopPropagation(), openSetup(t)) })
797
810
  const [cls, text, action] =
798
811
  !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')]]
812
+ : x.source === 'assumed' ? ['warn', 'not in models.dev', edit('Set up')] // its Retry: on the Your models page, and Refresh from models.dev
800
813
  : ['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)
814
+ const about = x?.source === 'assumed' ? "not in models.dev: Retry, or set up what it can do" : text
815
+ return h('div', { className: 'test ' + cls }, h('span', { className: 'msg', textContent: text, title: about }), action)
802
816
  }
803
817
 
804
- // models.dev looked up again: one model (its Retry), or all of yours (Retry all)
818
+ // Test: one short message to the model, to see that it really answers (the key, access to it, its
819
+ // name, the server). The result stays under it until the page is reloaded.
820
+ const TRIES = {} // "provider/model" → { going } | { ok, ms, reply } | { ok: false, error }
821
+ const keyOf = (t) => `${t.provider}/${t.model}`
822
+ // The Works? cell: the last result (cut short, all of it on hover) and Test.
823
+ function triedCell(t) {
824
+ const r = TRIES[keyOf(t)]
825
+ const secs = r?.ms != null ? ` · ${(r.ms / 1000).toFixed(1)}s` : ''
826
+ 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)']
827
+ : r.going ? ['going', 'testing…', 'sending it a short message']
828
+ : r.ok ? ['ok', `✓ ${(r.ms / 1000).toFixed(1)}s`, `works: it answered "${r.reply}"${secs}`]
829
+ : ['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
830
+ return h('div', { className: 'try ' + cls },
831
+ h('span', { className: 'msg', textContent: text, title: about }),
832
+ 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) }))
833
+ }
834
+ function showTried(t) {
835
+ document.querySelector(`#mt li[data-key="${CSS.escape(keyOf(t))}"] .try`)?.replaceWith(triedCell(t))
836
+ if (SORT.some((x) => x.key === 'works') && !TRIES[keyOf(t)]?.going) drawMine(S) // sorted by it: its place may change
837
+ }
838
+ async function tryModel(t) {
839
+ TRIES[keyOf(t)] = { going: true }
840
+ showTried(t)
841
+ const r = (await act('/api/try', { provider: t.provider, model: t.model })) ?? { ok: false, error: 'the settings page did not answer' }
842
+ TRIES[keyOf(t)] = r
843
+ showTried(t)
844
+ return r
845
+ }
846
+ let testingAll = false
847
+ async function tryAll() {
848
+ if (testingAll) return
849
+ testingAll = true
850
+ const list = [...(S?.order ?? [])]
851
+ let good = 0
852
+ toast(`testing ${list.length} model${list.length === 1 ? '' : 's'}…`)
853
+ // several at once (6), but at most 2 per provider: more would trip its rate limit and show
854
+ // working models as failing
855
+ for (const t of list) TRIES[keyOf(t)] = { going: true }
856
+ list.forEach(showTried)
857
+ const queue = [...list]
858
+ const busy = {}
859
+ const worker = async () => {
860
+ while (queue.length) {
861
+ const i = queue.findIndex((t) => (busy[t.provider] ?? 0) < 2)
862
+ if (i === -1) {
863
+ await new Promise((r) => setTimeout(r, 100)) // every waiting one's provider is busy
864
+ continue
865
+ }
866
+ const [t] = queue.splice(i, 1)
867
+ busy[t.provider] = (busy[t.provider] ?? 0) + 1
868
+ try {
869
+ if ((await tryModel(t)).ok) good++
870
+ } finally {
871
+ busy[t.provider]--
872
+ }
873
+ }
874
+ }
875
+ try {
876
+ await Promise.all(Array.from({ length: Math.min(6, list.length) }, worker))
877
+ } finally {
878
+ testingAll = false
879
+ }
880
+ toast(`${good} of ${list.length} work${good === list.length ? '' : `; ${list.length - good} don't (Works? says why, on hover)`}`, good < list.length)
881
+ }
882
+
883
+ // models.dev looked up again: one model (its Retry), or all of yours (Refresh from models.dev)
805
884
  const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
806
885
  async function lookUp(t) {
807
886
  toast(t ? `looking up ${t.model} in models.dev…` : 'looking up your models in models.dev…')
@@ -918,19 +997,19 @@ function drawProviders(s) {
918
997
  h('div', { className: 'mine-title' },
919
998
  h('h2', { textContent: 'Your models' }),
920
999
  h('button', { className: 'small', textContent: 'Table', style: order.length ? '' : 'visibility:hidden', title: 'all of them in a table, to compare and sort', onclick: () => (location.hash = 'models') }),
921
- h('button', { className: 'small', textContent: 'Retry all', title: 'look all of them up in models.dev again', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
1000
+ h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
922
1001
  h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
923
1002
  h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
1003
+ order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
924
1004
  order.length ? h('ol', { className: 'olist' }, order.map((t, i) => h('li', {
925
1005
  className: 'in', 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
926
1006
  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
1007
  },
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)),
1008
+ h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
1009
+ 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' }),
1010
+ h('span', { className: 'm', textContent: t.model, title: t.model }),
1011
+ h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
1012
+ testLine(t),
934
1013
  h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) })),
935
1014
  ), h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` }))
936
1015
  : 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 +1019,7 @@ function drawProviders(s) {
940
1019
  h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
941
1020
  [['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
1021
  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] }),
1022
+ 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
1023
  h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
945
1024
  h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
946
1025
  [['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 +1201,7 @@ const COLS = {
1122
1201
  images: { label: 'Images', get: (t) => can(t).images },
1123
1202
  input: { label: 'In $/M', how: 'cheap', get: (t) => t.price?.input ?? null },
1124
1203
  output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
1204
+ 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
1205
  }
1126
1206
  let SORT = (() => {
1127
1207
  try {
@@ -1157,7 +1237,7 @@ function sorted(order) {
1157
1237
  }
1158
1238
  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
1239
  }
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')
1240
+ 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
1241
  function drawMine(s) {
1162
1242
  const order = s.order
1163
1243
  $('n-mine').textContent = order.length || ''
@@ -1198,10 +1278,11 @@ function drawMine(s) {
1198
1278
  : order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
1199
1279
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1200
1280
  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),
1281
+ order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
1282
+ order.length ? h('button', { className: 'small', textContent: 'Test all', title: 'send each a short message, several at once (2 per provider at most), to see which really answer', onclick: () => tryAll() }) : null),
1202
1283
  order.length
1203
1284
  ? 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')),
1285
+ 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
1286
  h('ol', { className: 'mtable' + (order.length < 2 ? '' : ' drag') }, view.map((t) => {
1206
1287
  const c = can(t)
1207
1288
  const ctx = COLS.context.get(t)
@@ -1216,6 +1297,7 @@ function drawMine(s) {
1216
1297
  h('span', { className: 'num', textContent: t.price?.input != null ? usd(t.price.input) : '–' }),
1217
1298
  h('span', { className: 'num', textContent: t.price?.output != null ? usd(t.price.output) : '–' }),
1218
1299
  src(t),
1300
+ triedCell(t),
1219
1301
  h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
1220
1302
  }))))
1221
1303
  : h('div', { className: 'mt-empty' }, 'No models in your list yet. ', h('a', { href: '#providers', textContent: 'Choose some under Providers & models' }), '.'))
@@ -1272,7 +1354,36 @@ $('v-save').onclick = () => act('/api/voice', { text: $('v-text').value }, 'voic
1272
1354
  const logSwitch = (s, k, title, about) => h('label', { className: 'opt' },
1273
1355
  h('div', { className: 't' }, h('b', { textContent: title }), h('span', { textContent: about })),
1274
1356
  sw({ checked: s.settings[k], 'aria-label': title, onchange: (e) => act('/api/setting', { name: k, value: e.target.checked }, `${title}: ${e.target.checked ? 'on' : 'off'}`) }))
1357
+ // How much room the logs take: asked again when the page shows, when it's redrawn, and (at most every
1358
+ // few seconds) while requests are being logged.
1359
+ const bytes = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e8 ? 0 : 1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} kB` : `${n} bytes`)
1360
+ let diskAt = 0
1361
+ let diskLater = null
1362
+ async function drawDisk() {
1363
+ if (Date.now() - diskAt < 3000) return void (diskLater ??= setTimeout(() => ((diskLater = null), drawDisk()), 3000))
1364
+ diskAt = Date.now()
1365
+ try {
1366
+ showDisk(await api('/api/log-size'))
1367
+ } catch {}
1368
+ }
1369
+ function showDisk(d) {
1370
+ put($('l-disk'),
1371
+ h('div', { className: 'row', style: 'gap:10px;align-items:center' },
1372
+ h('span', {}, h('span', { className: 'k', textContent: 'Logs on disk ' }), h('b', { textContent: bytes(d.total) })),
1373
+ d.total ? h('button', { className: 'small danger', textContent: 'Delete all', title: 'delete every log (the ones being written right now stay)', onclick: clearAll }) : null),
1374
+ h('div', { className: 'k', textContent: `requests ${bytes(d.requests)} · runs ${bytes(d.runs)}` }))
1375
+ }
1376
+ async function clearAll() {
1377
+ if (!confirm('Delete all logs, of every run and every request? This can\'t be undone. (The ones being written right now stay.)')) return
1378
+ const r = await act('/api/clear-logs', {})
1379
+ if (!r) return
1380
+ showDisk(r.size)
1381
+ diskAt = Date.now()
1382
+ api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => {})
1383
+ toast(`deleted: logs on disk now ${bytes(r.size.total)}`)
1384
+ }
1275
1385
  function drawLogging(s) {
1386
+ drawDisk()
1276
1387
  put($('l-run'), logSwitch(s, 'log', 'Keep a log of every run', `From now on: a folder per run in ${s.logs.runs}, a file per tab (tab-1.log, tab-2.log…).`))
1277
1388
  put($('l-req'), logSwitch(s, 'logRequests', 'Log every request and reply', `Right away: a file per request in ${s.logs.requests}, a folder per run.`))
1278
1389
  $('l-detail').value = s.settings.logDetail
@@ -1310,11 +1421,11 @@ const whyOf = (r) => {
1310
1421
  return r.error
1311
1422
  }
1312
1423
  const resultTag = (r) => h('span', { className: `tag ${r.result === 'ok' ? 'ok' : r.result === 'failed' ? 'err' : ''}`, textContent: r.result === 'ok' ? 'ok' : r.result === 'failed' ? `failed${r.status ? ` ${r.status}` : ''}` : 'stopped' })
1313
- const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : r.of > 1 ? h('span', { className: 'tag', textContent: `#1 of ${r.of}` }) : null)
1424
+ const placeTag = (r) => (r.place > 1 ? h('span', { className: 'tag brand', textContent: `#${r.place} of ${r.of} · fallback` }) : null) // #1: nothing to say
1314
1425
  function reqRow(r) {
1315
1426
  const key = `${r.run}/${r.file}`
1316
1427
  return h('button', { className: `rq ${r.result}${reqOpen === key ? ' on' : ''}`, 'data-key': key, onclick: () => openReq(r) },
1317
- h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model }), placeTag(r)),
1428
+ h('div', { className: 'l1' }, resultTag(r), h('span', { className: 'm', textContent: r.model })), // where it is in your list: in its details
1318
1429
  h('div', { className: 'l2' },
1319
1430
  h('span', { textContent: when(r.at) }), h('span', { textContent: r.provider ?? '' }), h('span', { textContent: secs(r.ms) }),
1320
1431
  r.usage ? h('span', { textContent: tokensOf(r.usage) }) : null,
@@ -1519,6 +1630,7 @@ if (token) {
1519
1630
  // a request logged: at the top of the list, nothing else redrawn
1520
1631
  events.addEventListener('request', (e) => {
1521
1632
  const r = JSON.parse(e.data)
1633
+ if (page() === 'logging') drawDisk() // the logs grew
1522
1634
  if (!REQS) return
1523
1635
  REQS.unshift(r)
1524
1636
  if (REQS.length === 1 || !reqShown(r)) return drawReqs()
package/src/settings.js CHANGED
@@ -12,11 +12,12 @@ 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'
18
19
  import { loadSkills, frontMatter } from './skills.js'
19
- import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays } from './log.js'
20
+ import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays, logSize, clearLogs } from './log.js'
20
21
 
21
22
  const PAGE = new URL('./settings.html', import.meta.url)
22
23
  const EFFORTS = ['default', 'low', 'medium', 'high']
@@ -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)
@@ -177,6 +189,8 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
177
189
 
178
190
  const routes = {
179
191
  'GET /api/state': () => state(),
192
+ 'GET /api/log-size': () => logSize(),
193
+ 'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
180
194
  'POST /api/provider': ({ name, baseUrl, apiKey, keepKey, was }) => {
181
195
  name = safeName(name)
182
196
  if (!name) throw new Error('a name is needed')
@@ -296,6 +310,22 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
296
310
  // models.dev again (one model, or all of yours): its list fetched anew, and each model's profile
297
311
  // takes what it says now (one it didn't know before becomes "from models.dev"; your own settings
298
312
  // for it stay above). A running HeartCode takes it at once.
313
+ // Test: one short message to the model, to see that it answers (key, access, name, the server
314
+ // itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
315
+ 'POST /api/try': async ({ provider, model } = {}) => {
316
+ const c = clientFor({ provider, model: String(model ?? '') })
317
+ if (!c || !model) return { ok: false, error: `no provider "${provider}"` }
318
+ c.purpose = 'test'
319
+ const t0 = Date.now()
320
+ try {
321
+ const r = await c.chat({ messages: [{ role: 'user', content: 'Reply with just the word OK.' }], stream: true }, { signal: AbortSignal.timeout(90_000) })
322
+ const reply = (r.content || '').trim()
323
+ 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)` : ''}` }
324
+ return { ok: true, ms: Date.now() - t0, reply: reply.slice(0, 80) }
325
+ } catch (e) {
326
+ return { ok: false, ms: Date.now() - t0, status: e.status ?? null, error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : whyFailed(e) }
327
+ }
328
+ },
299
329
  'POST /api/models-dev': async ({ provider, model } = {}) => {
300
330
  await loadPrices({ force: true })
301
331
  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
@@ -97,6 +115,22 @@ export function readFileTool(root, p, start, end, maxLines = 400) {
97
115
  return numberLines(lines.slice(s - 1, e), s) + more
98
116
  }
99
117
 
118
+ // An image file: shown to a model that can see images (after the tool results, as MCP screenshots
119
+ // are); for one that can't, where to send it.
120
+ const IMAGE = /\.(png|jpe?g|gif|webp)$/i
121
+ function readImage(s, p) {
122
+ const abs = resolveIn(s.root, p)
123
+ const rel = path.relative(s.root, abs) || p
124
+ if (!fs.existsSync(abs)) return readFileTool(s.root, p)
125
+ if (s.strat?.vision) {
126
+ const ext = path.extname(abs).slice(1).toLowerCase()
127
+ ;(s.pendingImages ??= []).push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${fs.readFileSync(abs).toString('base64')}`)
128
+ return `${rel}: the image is attached in the next message`
129
+ }
130
+ const seer = s.team?.().find((t) => t.images)
131
+ return `${rel} is an image, and you can't see images.${seer ? ` Ask a model that can: the agent tool with model "${seer.provider} ${seer.model}", and the image's path in the prompt.` : ''}`
132
+ }
133
+
100
134
  export function searchFiles(root, query, sub = '', limit = 50) {
101
135
  const ci = query === query.toLowerCase()
102
136
  let re
@@ -105,13 +139,15 @@ export function searchFiles(root, query, sub = '', limit = 50) {
105
139
  } catch {
106
140
  re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'i' : '')
107
141
  }
108
- const prefix = sub ? path.relative(root, resolveIn(root, sub)) : ''
142
+ const abs = sub ? resolveIn(root, sub) : ''
143
+ const saved = abs.startsWith(OUTPUTS + path.sep) // a tool output saved outside the project
144
+ const prefix = sub && !saved ? path.relative(root, abs) : ''
109
145
  const hits = []
110
- for (const f of listFiles(root)) {
146
+ for (const f of saved ? [abs] : listFiles(root)) {
111
147
  if (prefix && f !== prefix && !f.startsWith(prefix + '/')) continue
112
148
  let buf
113
149
  try {
114
- buf = fs.readFileSync(path.join(root, f))
150
+ buf = fs.readFileSync(path.resolve(root, f))
115
151
  } catch {
116
152
  continue
117
153
  }
@@ -388,12 +424,51 @@ export function readOnlyCommand(cmd) {
388
424
  return c.split(/\s*(?:&&|\|\||;|\|)\s*/).every((part) => READ_ONLY.test(part))
389
425
  }
390
426
 
391
- // The one tool for all MCP servers, and the skill loader; each only when there's something to offer.
427
+ // MCP servers' tools as the model sees them: each its own tool, mcp__server__tool (Claude Code's
428
+ // names), with its description and arguments, the way the model would see them in Claude Code.
429
+ // Servers whose tools aren't known yet (still starting, failed), or too many tools at once, go
430
+ // through the one mcp tool instead. Sets s.mcpDirect (name → { server, tool, def }), s.mcpMeta
431
+ // (servers behind the mcp tool) and s.mcpInstructions (what the servers say about using them).
432
+ export const MAX_MCP_TOOLS = 100
433
+ export async function loadMcpTools(s, signal) {
434
+ if (!s.mcp?.names.length) return Object.assign(s, { mcpDirect: new Map(), mcpMeta: [], mcpInstructions: '' })
435
+ const catalog = await s.mcp.catalog(signal)
436
+ // the servers with the fewest tools first, while they fit
437
+ const known = [...catalog].sort((a, b) => a[1].tools.length - b[1].tools.length)
438
+ const direct = new Map()
439
+ const notes = []
440
+ for (const [server, { tools, instructions }] of known) {
441
+ if (direct.size + tools.length > MAX_MCP_TOOLS) continue
442
+ for (const t of tools) direct.set(mcpName(server, t.name), { server, tool: t.name, def: t })
443
+ if (instructions) notes.push(`### ${server}\n${instructions}`)
444
+ }
445
+ const shown = new Set([...direct.values()].map((d) => d.server))
446
+ return Object.assign(s, { mcpDirect: direct, mcpMeta: s.mcp.names.filter((n) => !shown.has(n)), mcpInstructions: notes.join('\n\n'), mcpCatalog: catalog })
447
+ }
448
+ // Tool names may only have letters, digits, _ and - (64 at most).
449
+ export const mcpName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^\w-]/g, '_').slice(0, 64)
450
+ // A schema as providers take it: an object, without the keys some refuse ($schema).
451
+ function cleanSchema(x) {
452
+ if (Array.isArray(x)) return x.map(cleanSchema)
453
+ if (!x || typeof x !== 'object') return x
454
+ return Object.fromEntries(Object.entries(x).filter(([k]) => k !== '$schema').map(([k, v]) => [k, cleanSchema(v)]))
455
+ }
456
+ function mcpDefs(s) {
457
+ return [...(s.mcpDirect?.values() ?? [])].map(({ server, tool, def }) => {
458
+ const schema = cleanSchema(def.inputSchema ?? {})
459
+ return { name: mcpName(server, tool), description: clip(String(def.description ?? `${tool} (${server})`).trim(), 2000), parameters: { ...schema, type: 'object', properties: schema.properties ?? {} } }
460
+ })
461
+ }
462
+
463
+ // The MCP servers' tools, the one tool for the rest, and the skill loader; each only when there's
464
+ // something to offer.
392
465
  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']) })
466
+ const out = mcpDefs(s)
467
+ const meta = s.mcpMeta ?? s.mcp?.names ?? []
468
+ if (meta.length) {
469
+ // no description: the names of its tools, when known, say what it's for
470
+ 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')
471
+ 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
472
  }
398
473
  if (s.memory) {
399
474
  out.push({
@@ -409,8 +484,12 @@ export function extraTools(s) {
409
484
  }
410
485
  // the patch format OpenAI's models are trained on (they also write it as a shell command, below)
411
486
  if (/gpt|codex|\bo\d/i.test(s.client?.model ?? '')) out.push({ name: 'apply_patch', description: prompt('agent/tool-apply_patch'), parameters: obj({ input: str }, ['input']) })
412
- 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)
487
+ if (!s.isSub && s.subagent) {
488
+ const team = s.team?.() ?? []
489
+ const model = { type: 'string', description: prompt('agent/tool-agent-model') }
490
+ out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model }) }, ['description', 'prompt']) })
491
+ }
492
+ return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
414
493
  }
415
494
 
416
495
  // todo: the model's checklist for this message, shown to the user each time it changes.
@@ -533,8 +612,17 @@ async function mcpTool(s, args, signal) {
533
612
  const t = tools.find((x) => x.name === a.name)
534
613
  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
614
  }
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 : ''}`
615
+ return callMcp(s, server, tool, a, signal)
616
+ }
617
+
618
+ // One MCP tool call. Asked about like a command, unless the server marks the tool read-only.
619
+ async function callMcp(s, server, tool, a, signal) {
620
+ if (!allowed(s, mcpName(server.name, tool))) return `error: the tool ${mcpName(server.name, tool)} is not allowed here`
621
+ const def = (server.tools ?? s.mcpCatalog?.get(server.name)?.tools)?.find((t) => t.name === tool)
622
+ if (!def?.annotations?.readOnlyHint) {
623
+ const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
624
+ if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
625
+ }
538
626
  s.ui.spinner(`${server.name} · ${tool}`)
539
627
  const r = await server.call(tool, a, signal)
540
628
  const parts = r.content ?? []
@@ -714,8 +802,28 @@ function normalize(args) {
714
802
  return out
715
803
  }
716
804
 
805
+ // The MCP tool a name means: mcp__server__tool (offered, or a server behind the mcp tool), or a
806
+ // bare tool name only one server has ("click").
807
+ function mcpTarget(s, name) {
808
+ const d = s.mcpDirect?.get(name)
809
+ if (d) return { server: s.mcp.get(d.server), tool: d.tool }
810
+ const m = /^mcp__(.+?)__(.+)$/.exec(name)
811
+ if (m && s.mcp?.get(m[1])) return { server: s.mcp.get(m[1]), tool: m[2] }
812
+ const same = [...(s.mcpDirect?.values() ?? [])].filter((x) => x.tool === name)
813
+ return same.length === 1 ? { server: s.mcp.get(same[0].server), tool: name } : null
814
+ }
815
+
816
+ // Tools the caller allowed (--allowedTools) or took away (--disallowedTools); all by default. An
817
+ // MCP tool goes by its own name, its server's (mcp__server, as in Claude Code) or "mcp" (all of them).
818
+ export function allowed(s, name) {
819
+ const names = name.startsWith('mcp__') ? [name, name.split('__').slice(0, 2).join('__'), 'mcp'] : [name]
820
+ if (names.some((n) => s.denyTools?.has(n))) return false
821
+ if (!s.allowTools) return true
822
+ return names.some((n) => s.allowTools.has(n)) || (name === 'mcp' && [...s.allowTools].some((n) => n.startsWith('mcp__')))
823
+ }
824
+
717
825
  async function tool(name, args, s, signal) {
718
- args = normalize(args)
826
+ if (!name.startsWith('mcp__') && !mcpTarget(s, name)) args = normalize(args) // an MCP tool's arguments are its own
719
827
  try {
720
828
  const { root } = s
721
829
  if (s.ui.permission === 'plan') {
@@ -723,7 +831,7 @@ async function tool(name, args, s, signal) {
723
831
  }
724
832
  switch (name) {
725
833
  case 'read_file':
726
- return readFileTool(root, args.path, args.start_line, args.end_line)
834
+ return IMAGE.test(String(args.path ?? '')) ? readImage(s, args.path) : readFileTool(root, args.path, args.start_line, args.end_line)
727
835
  case 'list_files': {
728
836
  const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
729
837
  return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
@@ -764,8 +872,11 @@ async function tool(name, args, s, signal) {
764
872
  const k = s.skills?.get(String(args.name ?? ''))
765
873
  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
874
  }
767
- default:
875
+ default: {
876
+ const m = mcpTarget(s, name)
877
+ if (m) return await callMcp(s, m.server, m.tool, args, signal)
768
878
  return (await s.director?.tool(name, args, s, signal)) ?? `error: there is no tool named ${name}`
879
+ }
769
880
  }
770
881
  } catch (e) {
771
882
  if (e.name === 'AbortError') throw e