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