heartcode 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -16
- package/SKILL.md +24 -10
- package/bin/heart.js +2 -1
- package/bin/heartcode.js +25 -3
- package/package.json +1 -1
- package/prompts/agent.md +54 -4
- package/prompts/guided.md +1 -0
- package/src/agent.js +115 -21
- package/src/client.js +96 -12
- package/src/config.js +4 -0
- package/src/guided.js +13 -2
- package/src/headless.js +27 -11
- package/src/log.js +37 -3
- package/src/mcp.js +49 -3
- package/src/prompts.js +2 -1
- package/src/settings.html +144 -32
- package/src/settings.js +31 -1
- package/src/tools.js +126 -15
- package/src/ui.js +4 -3
- package/src/usage.js +16 -1
package/src/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)
|
|
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
|
-
/*
|
|
210
|
-
.olist .test { font-size:
|
|
211
|
-
.olist .test .msg { flex: 1; min-width: 0;
|
|
212
|
-
.olist .test .spin {
|
|
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:
|
|
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
|
-
|
|
231
|
-
.olist li { display:
|
|
232
|
-
.
|
|
233
|
-
.olist
|
|
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:
|
|
247
|
-
.mt-h, .mtable li { display: grid; grid-template-columns: 16px
|
|
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 .
|
|
273
|
-
.olist .
|
|
274
|
-
.olist .p { color: var(--
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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: '
|
|
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
|
-
|
|
929
|
-
h('span', { className: 'n', textContent: `#${i + 1}
|
|
930
|
-
h('
|
|
931
|
-
|
|
932
|
-
|
|
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
|
|
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: '
|
|
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` }) :
|
|
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 }),
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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)
|
|
413
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|