heartcode 0.1.5 → 0.1.7
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/SKILL.md +33 -11
- package/bin/heartcode.js +83 -30
- package/package.json +27 -1
- package/prompts/agent.md +42 -32
- package/prompts/guided.md +6 -6
- package/src/acp.js +4 -4
- package/src/agent.js +232 -66
- package/src/client.js +165 -81
- package/src/context.js +2 -2
- package/src/director.js +8 -5
- package/src/edit.js +5 -6
- package/src/format.js +18 -9
- package/src/guided.js +27 -55
- package/src/headless.js +20 -12
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +8 -4
- package/src/settings.js +19 -2
- package/src/tools.js +149 -38
- package/src/usage.js +9 -4
package/src/client.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// OpenAI-compatible chat client: plain fetch, SSE streaming, <think> stripping.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
4
|
import { log, logRequest, requestLog } from './log.js'
|
|
4
5
|
|
|
5
6
|
export const est = (text) => Math.ceil(String(text ?? '').length / 3.5)
|
|
6
7
|
// An error that may go away by itself: a rate limit, a server error or overload, a timeout, no
|
|
7
8
|
// connection (no status). Anything else (a wrong model name, key or request) won't.
|
|
8
|
-
|
|
9
|
+
// A 403 that is a web page, not the API's own (JSON) answer, comes from a gateway or proxy in
|
|
10
|
+
// front of it, often for a moment (seen: the same request working a second later): an outage too.
|
|
11
|
+
export const outage = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500 || (e.status === 403 && e.page)
|
|
9
12
|
// The model can't be used as it's set up (a wrong key, no access to it, no such model): waiting
|
|
10
13
|
// won't help, but the next one in the list may work.
|
|
11
|
-
export const unusable = (e) => [401, 403, 404].includes(e.status)
|
|
14
|
+
export const unusable = (e) => [401, 403, 404].includes(e.status) && !outage(e)
|
|
12
15
|
// A request's messages, estimated: an image's data isn't text (about 800 tokens, not its base64)
|
|
13
16
|
export function estMessages(messages) {
|
|
14
17
|
let images = 0
|
|
@@ -23,7 +26,7 @@ export function estMessages(messages) {
|
|
|
23
26
|
export function inputLimit(e, sent, known = 0) {
|
|
24
27
|
const big =
|
|
25
28
|
e.status === 413 ||
|
|
26
|
-
(e.status >= 400 && e.status < 500 &&
|
|
29
|
+
(e.status >= 400 && e.status < 500 && /\b(request|prompt|input|message|messages|context|payload|body)\b[^.]{0,30}\btoo (large|long|big)|reduce the length|reduce your message|context length|context window|context size|too many (input )?tokens|exceeds? the (maximum|available|context)|maximum (number of )?(input |context )?tokens/i.test(e.message))
|
|
27
30
|
if (!big) return null
|
|
28
31
|
const n = Number(LIMIT_IN_MESSAGE.map((re) => re.exec(e.message)?.[1]).find(Boolean))
|
|
29
32
|
return n > 0 ? n : Math.max(Math.floor(sent * 0.75), known)
|
|
@@ -34,7 +37,7 @@ export function inputLimit(e, sent, known = 0) {
|
|
|
34
37
|
export function limitSaid(e) {
|
|
35
38
|
if (!inputLimit(e, 0)) return null
|
|
36
39
|
const n = Number(LIMIT_IN_MESSAGE.map((re) => re.exec(e.message)?.[1]).find(Boolean))
|
|
37
|
-
return n > 0 ? { tokens: n, rate: /per minute|tokens per min|\
|
|
40
|
+
return n > 0 ? { tokens: n, rate: /per (minute|hour|day)|tokens per (min|hour|day)|\bTP[MHD]\b|rate.?limit|quota/i.test(e.message) } : null
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
// How servers say the limit: OpenAI and vLLM, Mistral, llama.cpp, Anthropic-style, Gemini,
|
|
@@ -63,52 +66,77 @@ export function normalizeBaseUrl(url) {
|
|
|
63
66
|
return u.toString().replace(/\/+$/, '')
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (open !== -1) t = t.slice(0, open)
|
|
76
|
-
const close = t.indexOf('</think>')
|
|
77
|
-
if (close !== -1) t = t.slice(close + 8)
|
|
78
|
-
return t.trimStart()
|
|
69
|
+
// Removes the thinking some models put in the normal content: a <think> block the reply starts
|
|
70
|
+
// with (still open: all of it so far). A reply that only mentions the tag later keeps its text. A
|
|
71
|
+
// closing tag with no opening one counts only for a model that thinks (some leave the opening out).
|
|
72
|
+
export function stripThink(text, thinks = false) {
|
|
73
|
+
const t = String(text ?? '')
|
|
74
|
+
const lead = /^\s*<think>[\s\S]*?(<\/think>|$)/.exec(t)
|
|
75
|
+
if (lead) return t.slice(lead[0].length).trimStart()
|
|
76
|
+
const close = thinks && !t.includes('<think>') ? t.indexOf('</think>') : -1
|
|
77
|
+
return close !== -1 ? t.slice(close + 8).trimStart() : t
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
// What can be shown so far while streaming: holds back a half-received "<think>".
|
|
82
|
-
function visible(full) {
|
|
83
|
-
let v = stripThink(full)
|
|
81
|
+
function visible(full, thinks) {
|
|
82
|
+
let v = stripThink(full, thinks)
|
|
84
83
|
for (let k = 7; k > 0; k--) {
|
|
85
84
|
if (v.endsWith('<think>'.slice(0, k))) return v.slice(0, -k)
|
|
86
85
|
}
|
|
87
86
|
return v
|
|
88
87
|
}
|
|
89
88
|
|
|
90
|
-
// The text inside <think>
|
|
89
|
+
// The text inside the <think> block the reply starts with, so far (an unclosed one counts),
|
|
90
|
+
// holding back a half-received "</think>".
|
|
91
91
|
export function thinkOf(full) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
92
|
+
const m = /^\s*<think>/.exec(full)
|
|
93
|
+
if (!m) return ''
|
|
94
|
+
const close = full.indexOf('</think>', m[0].length)
|
|
95
|
+
if (close !== -1) return full.slice(m[0].length, close)
|
|
96
|
+
let rest = full.slice(m[0].length)
|
|
97
|
+
for (let k = 8; k > 0; k--) {
|
|
98
|
+
if (rest.endsWith('</think>'.slice(0, k))) return rest.slice(0, -k)
|
|
99
|
+
}
|
|
100
|
+
return rest
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The conversation as the model asked now can take it. It may have been written with others (the
|
|
104
|
+
// model list moving on, helpers), so: images only for a model that sees them (else a note), tool
|
|
105
|
+
// calls only for one that calls tools (else as text), tool call ids in one form every provider
|
|
106
|
+
// takes (9 letters and digits), tool arguments always JSON, and only its own thinking sent back.
|
|
107
|
+
// can: { vision, actions } (the model's abilities); the saved conversation stays as it is.
|
|
108
|
+
export const writtenBy = new WeakMap() // an assistant message → the model that wrote it
|
|
109
|
+
const IMAGE_NOTE = "(an image is here, but you can't see images: to know what's in it, ask a model that can, with the agent tool's images)"
|
|
110
|
+
const callId = (id) => (/^[A-Za-z0-9]{9}$/.test(id) ? id : createHash('sha256').update(String(id)).digest('hex').slice(0, 9))
|
|
111
|
+
const asJson = (a) => {
|
|
112
|
+
try {
|
|
113
|
+
JSON.parse(a || '{}')
|
|
114
|
+
return a || '{}'
|
|
115
|
+
} catch {
|
|
116
|
+
return '{}'
|
|
110
117
|
}
|
|
111
118
|
}
|
|
119
|
+
export function fitFor(messages, can, model) {
|
|
120
|
+
const tools = can.actions !== 'guided'
|
|
121
|
+
return messages.map((m) => {
|
|
122
|
+
let x = { ...m }
|
|
123
|
+
if (x.reasoning_content && writtenBy.has(m) && writtenBy.get(m) !== model) delete x.reasoning_content
|
|
124
|
+
if (Array.isArray(x.content) && !can.vision) x.content = x.content.map((p) => (p.type === 'image_url' ? IMAGE_NOTE : (p.text ?? ''))).join('\n')
|
|
125
|
+
if (!tools && x.role === 'tool') return { role: 'user', content: `(the result of your earlier tool call)\n${x.content}` }
|
|
126
|
+
if (!tools && x.tool_calls) {
|
|
127
|
+
const { tool_calls, ...rest } = x
|
|
128
|
+
return { ...rest, content: [x.content, ...tool_calls.map((c) => `(called ${c.function.name} ${c.function.arguments || '{}'})`)].filter(Boolean).join('\n') }
|
|
129
|
+
}
|
|
130
|
+
if (x.tool_calls) x.tool_calls = x.tool_calls.map((c) => ({ ...c, id: callId(c.id), function: { ...c.function, arguments: asJson(c.function.arguments) } }))
|
|
131
|
+
if (x.role === 'tool') x.tool_call_id = callId(x.tool_call_id)
|
|
132
|
+
return x
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Refusals a provider may make of one part of a request, learned when sending it without that
|
|
137
|
+
// part works (then kept in the model's profile for a day): temperature, reasoning_effort, the
|
|
138
|
+
// usage in a stream, the thinking sent back, the tools' full schemas, images.
|
|
139
|
+
export const QUIRKS = ['noTemperature', 'noEffort', 'noStreamUsage', 'noReasoningBack', 'simpleSchemas', 'noImages']
|
|
112
140
|
|
|
113
141
|
export class Client {
|
|
114
142
|
constructor({ baseUrl, apiKey, model, provider, timeout = 600_000 } = {}) {
|
|
@@ -119,6 +147,9 @@ export class Client {
|
|
|
119
147
|
this.timeout = timeout
|
|
120
148
|
this.quirks = {}
|
|
121
149
|
this.retryBase = 1000 // first wait before retrying a failed request (then ×2 each time)
|
|
150
|
+
// how long a streamed reply may go without sending anything before it counts as stalled (then
|
|
151
|
+
// tried again, or the next model asked); a reply that keeps coming has no limit, however long
|
|
152
|
+
this.idleMs = 150_000
|
|
122
153
|
this.tokenRatio = 1 // the server's token count / est(): calibrated from usage (context.js)
|
|
123
154
|
this.knownGood = 0 // the biggest request known to work, in tokens
|
|
124
155
|
this.onUsage = null // (prompt tokens the server counted, estimate of what was sent): learns the window
|
|
@@ -149,21 +180,27 @@ export class Client {
|
|
|
149
180
|
// The model in use answers but gives nothing usable (agent.js: empty replies, again and again):
|
|
150
181
|
// the next one in the list takes over. False when there's no list.
|
|
151
182
|
moveOn(why) {
|
|
183
|
+
if (!this.rotation) return false
|
|
184
|
+
this.#next(why)
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
#next(why) {
|
|
152
188
|
const r = this.rotation
|
|
153
|
-
if (!r) return false
|
|
154
189
|
r.at = (r.at + 1) % r.list.length
|
|
155
190
|
log('rotate', `${why} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
156
191
|
this.#use(r.list[r.at])
|
|
157
|
-
return true
|
|
158
192
|
}
|
|
159
193
|
// A new message: back to #1 (unless it stays on the one that worked)
|
|
160
194
|
newMessage() {
|
|
161
195
|
const r = this.rotation
|
|
162
196
|
if (r && r.next === 'first' && r.at) this.#use(r.list[(r.at = 0)])
|
|
163
197
|
}
|
|
198
|
+
// onUse(target): the model the next request goes to changed (its abilities and profile follow)
|
|
164
199
|
#use(t) {
|
|
200
|
+
const changed = this.model !== t.model || this.baseUrl !== normalizeBaseUrl(t.baseUrl)
|
|
165
201
|
Object.assign(this, { baseUrl: normalizeBaseUrl(t.baseUrl), apiKey: t.apiKey, model: t.model, provider: t.provider })
|
|
166
202
|
this.quirks = t.quirks ?? (t.quirks = {})
|
|
203
|
+
if (changed) this.onUse?.(t)
|
|
167
204
|
}
|
|
168
205
|
// After a failure: go on to the next model? (then it's tried at once)
|
|
169
206
|
#rotate(e, tries) {
|
|
@@ -225,9 +262,11 @@ export class Client {
|
|
|
225
262
|
let body = await res.text().catch(() => '')
|
|
226
263
|
log('http', `error body: ${body.slice(0, 5000)}`)
|
|
227
264
|
const raw = body.slice(0, 20_000)
|
|
228
|
-
|
|
229
|
-
|
|
265
|
+
const page = /^\s*</.test(body) // proxies and gateways answer with a web page
|
|
266
|
+
if (page) body = /<title>([^<]*)/i.exec(body)?.[1]?.trim() ?? 'an HTML error page'
|
|
267
|
+
const err = new Error(`${res.status} from ${url}: ${body.slice(0, 300)}${!raw.trim() ? '(the server gave no reason)' : ''}`)
|
|
230
268
|
err.status = res.status
|
|
269
|
+
err.page = page
|
|
231
270
|
err.body = raw // all of it, for the requests log
|
|
232
271
|
err.retryAfter = res.headers.get('retry-after') ?? /"retryDelay":\s*"(\d+)s"/.exec(body)?.[1] // Gemini says it in the body
|
|
233
272
|
throw err
|
|
@@ -359,6 +398,7 @@ export class Client {
|
|
|
359
398
|
let tries = 0 // of the model being tried now
|
|
360
399
|
let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
|
|
361
400
|
let hard = true // every failure in this round was one that waiting can't fix (see outage)
|
|
401
|
+
let refusedBy = 0 // models that refused this request (a bad request), tried in turn
|
|
362
402
|
for (let attempt = 0; ; attempt++) {
|
|
363
403
|
try {
|
|
364
404
|
const r = await this.#timed(body, { ...opts, once: true })
|
|
@@ -369,7 +409,16 @@ export class Client {
|
|
|
369
409
|
} catch (e) {
|
|
370
410
|
if (e.name === 'AbortError' || opts.signal?.aborted) throw e
|
|
371
411
|
if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
|
|
412
|
+
this.onFail?.(e, { provider: this.provider, model: this.model }) // counted for orchestration
|
|
372
413
|
if (this.retryIf && !this.retryIf(e, attempt)) throw e
|
|
414
|
+
// A bad request (400…) with a list of models: the next one may take it (it's often what the
|
|
415
|
+
// conversation holds from another model). Two more at most, then it stops.
|
|
416
|
+
if (!outage(e) && !unusable(e) && this.rotation && this.rotation.on !== 'any' && refusedBy < Math.min(2, this.rotation.list.length - 1)) {
|
|
417
|
+
refusedBy++
|
|
418
|
+
this.#next(`refused (${e.status})`)
|
|
419
|
+
this.onRetry(e, 0, attempt + 1, this.target)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
373
422
|
// A bad request (400…): the same request fails the same way every time, so it isn't sent
|
|
374
423
|
// again. It stops here, unless the model list moves on at any error (the settings page). A
|
|
375
424
|
// model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
|
|
@@ -390,6 +439,8 @@ export class Client {
|
|
|
390
439
|
} else this.onRetry(e, 0, attempt + 1, this.target)
|
|
391
440
|
continue
|
|
392
441
|
}
|
|
442
|
+
// a server error or no connection, again and again (not a rate limit, which is waited out)
|
|
443
|
+
if (e.status !== 429 && tries >= 8) throw e
|
|
393
444
|
const wait = Math.min(Number(e.retryAfter) * 1000 || this.retryBase * 2 ** (tries - 1), 60_000)
|
|
394
445
|
log('retry', `attempt ${attempt + 1} failed, waiting ${wait}ms: ${e.message}`)
|
|
395
446
|
this.onRetry(e, wait, attempt + 1)
|
|
@@ -414,6 +465,10 @@ export class Client {
|
|
|
414
465
|
// One request. Without `once`, rate limits and server errors get three quiet retries.
|
|
415
466
|
async #chat(body, { onToken, onReasoning, onToolDelta, signal, once } = {}) {
|
|
416
467
|
const req = { model: this.model, ...body }
|
|
468
|
+
const can = this.can && { ...this.can, vision: this.can.vision && !this.quirks.noImages }
|
|
469
|
+
if (can && req.messages) req.messages = fitFor(req.messages, can, this.model)
|
|
470
|
+
if (can?.actions === 'guided') delete req.tools
|
|
471
|
+
const thinks = Boolean(this.can?.thinking)
|
|
417
472
|
// The model's thinking on its earlier steps (agent.js keeps it as reasoning_content): sent by the
|
|
418
473
|
// name this provider sends it by (reasoning_content: DeepSeek, vLLM…; reasoning: OpenRouter,
|
|
419
474
|
// Ollama), or not at all if it refused it once.
|
|
@@ -428,55 +483,65 @@ export class Client {
|
|
|
428
483
|
let res
|
|
429
484
|
let sent
|
|
430
485
|
let firstAt
|
|
486
|
+
// a stream that stops sending: stalled (see idleMs)
|
|
487
|
+
const idle = req.stream ? new AbortController() : null
|
|
488
|
+
let timer
|
|
489
|
+
const poke = () => {
|
|
490
|
+
if (!idle) return
|
|
491
|
+
clearTimeout(timer)
|
|
492
|
+
timer = setTimeout(() => idle.abort(Object.assign(new Error(`${this.model} sent nothing for ${this.idleMs / 1000}s`), { name: 'TimeoutError' })), this.idleMs)
|
|
493
|
+
timer.unref?.()
|
|
494
|
+
}
|
|
495
|
+
const stop = idle ? (signal ? AbortSignal.any([signal, idle.signal]) : idle.signal) : signal
|
|
431
496
|
try {
|
|
432
497
|
for (let attempt = 0; ; attempt++) {
|
|
433
498
|
sent = Date.now()
|
|
434
499
|
this.last = { req, sent }
|
|
435
500
|
try {
|
|
436
|
-
|
|
501
|
+
poke()
|
|
502
|
+
res = await this.#fetch(this.baseUrl + '/chat/completions', { method: 'POST', body: JSON.stringify(req) }, { signal: stop, timeout: idle ? 86_400_000 : this.timeout })
|
|
437
503
|
break
|
|
438
504
|
} catch (e) {
|
|
439
505
|
this.#record(req, sent, { error: e })
|
|
440
506
|
// Rate limits and server errors, the same set the OpenAI SDKs retry: wait (Retry-After, else 2s, 4s, 8s).
|
|
441
|
-
if (once || !(
|
|
507
|
+
if (once || !outage(e) || !e.status || attempt === 3 || signal?.aborted) throw e
|
|
442
508
|
const wait = Math.min(Number(e.retryAfter) * 1000 || 2000 * 2 ** attempt, 30_000)
|
|
443
509
|
log('retry', `quiet retry ${attempt + 1} in ${wait}ms: ${e.message}`)
|
|
444
510
|
await sleep(wait, null, { signal })
|
|
445
511
|
}
|
|
446
512
|
}
|
|
447
513
|
} catch (e) {
|
|
448
|
-
|
|
449
|
-
//
|
|
514
|
+
clearTimeout(timer)
|
|
515
|
+
// A provider that refuses one part of the request (400, or 422 from the ones that check
|
|
516
|
+
// requests against a schema): the same request without that part. Learned (onLearn: kept in
|
|
517
|
+
// the model's profile) only when that works; else it's put back and the refusal stands. The
|
|
518
|
+
// part the error names first; with none named, images (a model listed as seeing them that
|
|
519
|
+
// doesn't take them here), then the thinking sent back.
|
|
450
520
|
const refused = e.status === 400 || e.status === 422
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
521
|
+
if (!refused || inputLimit(e, 1)) throw e // "too large": the caller sends less
|
|
522
|
+
const said = String(e.body ?? e.message)
|
|
523
|
+
const without = async (quirk) => {
|
|
524
|
+
this.quirks[quirk] = true
|
|
525
|
+
log('quirk', `${this.model}: sending it without what ${quirk} leaves out`)
|
|
526
|
+
try {
|
|
527
|
+
const r = await this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
528
|
+
this.onLearn?.(quirk)
|
|
529
|
+
return r
|
|
530
|
+
} catch (e2) {
|
|
531
|
+
if (e2.status === 400 || e2.status === 422) delete this.quirks[quirk]
|
|
532
|
+
throw e2.status === 400 || e2.status === 422 ? e : e2
|
|
462
533
|
}
|
|
463
534
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
if (refused && body.tools?.length && !this.quirks.simpleSchemas && /schema|function_declarations|parameters|additionalProperties|exclusiveM|minItems|unknown name|invalid.{0,40}(tool|function)|tools\[/i.test(said)) {
|
|
475
|
-
this.quirks.simpleSchemas = true
|
|
476
|
-
log('quirk', 'server rejects the tools\' schemas; sending simpler ones')
|
|
477
|
-
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
478
|
-
}
|
|
479
|
-
if (refused && reasoningSent && /extra|unknown|unrecognized|not permitted|additional propert/i.test(said)) return noReasoning()
|
|
535
|
+
const reasoningSent = !this.quirks.noReasoningBack && req.messages?.some((m) => m.reasoning_content || m.reasoning)
|
|
536
|
+
const imagesSent = !this.quirks.noImages && req.messages?.some((m) => Array.isArray(m.content) && m.content.some((p) => p.type === 'image_url'))
|
|
537
|
+
if (/temperature/i.test(said) && 'temperature' in req && !this.quirks.noTemperature) return without('noTemperature')
|
|
538
|
+
if (/reasoning_effort/.test(said) && 'reasoning_effort' in req && !this.quirks.noEffort) return without('noEffort')
|
|
539
|
+
if (/stream_options/.test(said) && req.stream_options && !this.quirks.noStreamUsage) return without('noStreamUsage')
|
|
540
|
+
if (reasoningSent && /reasoning(?!_effort)/i.test(said)) return without('noReasoningBack')
|
|
541
|
+
if (imagesSent && /image|vision|multimodal|modalit|content.{0,20}(type|part)/i.test(said)) return without('noImages')
|
|
542
|
+
if (req.tools?.length && !this.quirks.simpleSchemas && /schema|function_declarations|parameters|additionalProperties|exclusiveM|minItems|unknown name|invalid.{0,40}(tool|function)|tools\[/i.test(said)) return without('simpleSchemas')
|
|
543
|
+
if (imagesSent) return without('noImages')
|
|
544
|
+
if (reasoningSent) return without('noReasoningBack')
|
|
480
545
|
throw e
|
|
481
546
|
}
|
|
482
547
|
// Tokens the last request used (prompt + reply): what the status bar shows against the context size.
|
|
@@ -500,9 +565,10 @@ export class Client {
|
|
|
500
565
|
const choice = data.choices?.[0] ?? {}
|
|
501
566
|
const msg = choice.message ?? {}
|
|
502
567
|
this.#reasoningField(msg)
|
|
503
|
-
return count(done(textOf(msg.content), msg.reasoning_content ?? msg.reasoning ?? msg.thinking ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
|
|
568
|
+
return count(done(textOf(msg.content), msg.reasoning_content ?? msg.reasoning ?? msg.thinking ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason, thinks))
|
|
504
569
|
}
|
|
505
570
|
|
|
571
|
+
poke()
|
|
506
572
|
const reader = res.body.getReader()
|
|
507
573
|
const dec = new TextDecoder()
|
|
508
574
|
let buf = ''
|
|
@@ -521,7 +587,14 @@ export class Client {
|
|
|
521
587
|
}
|
|
522
588
|
}
|
|
523
589
|
for (;;) {
|
|
524
|
-
|
|
590
|
+
let value, end
|
|
591
|
+
try {
|
|
592
|
+
;({ value, done: end } = await reader.read())
|
|
593
|
+
} catch (e) {
|
|
594
|
+
clearTimeout(timer)
|
|
595
|
+
throw e
|
|
596
|
+
}
|
|
597
|
+
poke()
|
|
525
598
|
firstAt ??= Date.now()
|
|
526
599
|
if (end) break
|
|
527
600
|
buf += dec.decode(value, { stream: true })
|
|
@@ -539,7 +612,17 @@ export class Client {
|
|
|
539
612
|
} catch {
|
|
540
613
|
continue
|
|
541
614
|
}
|
|
542
|
-
if (chunk.error)
|
|
615
|
+
if (chunk.error) {
|
|
616
|
+
// an error sent in the stream: its status from its code, else a "too large" one as 400
|
|
617
|
+
// (sent smaller, not again as it is), else a server error (tried again, a few times)
|
|
618
|
+
clearTimeout(timer)
|
|
619
|
+
const said = chunk.error.message ?? JSON.stringify(chunk.error)
|
|
620
|
+
const code = Number(chunk.error.code ?? chunk.error.status)
|
|
621
|
+
const err = Object.assign(new Error(`server error: ${said}`), { body: payload })
|
|
622
|
+
err.status = code >= 400 && code < 600 ? code : 502
|
|
623
|
+
if (err.status === 502 && inputLimit({ status: 400, message: said }, 1)) err.status = 400
|
|
624
|
+
throw err
|
|
625
|
+
}
|
|
543
626
|
if (chunk.usage) usage = chunk.usage
|
|
544
627
|
const ch = chunk.choices?.[0]
|
|
545
628
|
if (!ch) continue
|
|
@@ -567,11 +650,12 @@ export class Client {
|
|
|
567
650
|
onReasoning(t.slice(thought.length))
|
|
568
651
|
thought = t
|
|
569
652
|
}
|
|
570
|
-
emit(visible(content))
|
|
653
|
+
emit(visible(content, thinks))
|
|
571
654
|
}
|
|
572
655
|
}
|
|
573
656
|
}
|
|
574
|
-
|
|
657
|
+
clearTimeout(timer)
|
|
658
|
+
const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason, thinks)
|
|
575
659
|
emit(result.content)
|
|
576
660
|
if (!result.content.trim() && !result.toolCalls.length && !result.reasoning) {
|
|
577
661
|
result.raw = raw // nothing read from it: what came, for the requests log
|
|
@@ -615,11 +699,11 @@ export function simpleSchema(x) {
|
|
|
615
699
|
// Message content as text: a string, or a list of parts ({ type: 'text', text }).
|
|
616
700
|
const textOf = (c) => (Array.isArray(c) ? c.map((p) => (typeof p === 'string' ? p : (p?.text ?? ''))).join('') : (c ?? ''))
|
|
617
701
|
|
|
618
|
-
function done(raw, reasoning, toolCalls, usage, finishReason) {
|
|
702
|
+
function done(raw, reasoning, toolCalls, usage, finishReason, thinks) {
|
|
619
703
|
const calls = toolCalls.filter((c) => c?.function?.name)
|
|
620
|
-
calls.forEach((c, i) => (c.id ||=
|
|
704
|
+
calls.forEach((c, i) => (c.id ||= callId(`${Date.now()}_${i}_${Math.random()}`)))
|
|
621
705
|
return {
|
|
622
|
-
content: stripThink(raw),
|
|
706
|
+
content: stripThink(raw, thinks),
|
|
623
707
|
reasoning: (reasoning || thinkOf(raw)).trim(),
|
|
624
708
|
toolCalls: calls,
|
|
625
709
|
usage,
|
package/src/context.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// list), unless contextGuessed (it didn't say)
|
|
6
6
|
// known models.dev's published limits for the model: { context, output }
|
|
7
7
|
// inputLimit a limit a "too large" error stated, or a silent cut seen twice (inputLimitAt:
|
|
8
|
-
// when); dropped when a bigger request works, and after a
|
|
8
|
+
// when); dropped when a bigger request works, and after a day (it's learned
|
|
9
9
|
// again if it's still so: a refused request costs nothing)
|
|
10
10
|
// rateCap a per-minute token cap the provider stated ({ tokens, until }): your account's,
|
|
11
11
|
// not the model's, so it lasts a day
|
|
@@ -20,7 +20,7 @@ export const ASSUMED_REMOTE = 128_000
|
|
|
20
20
|
export const ASSUMED_MAX = 1_000_000
|
|
21
21
|
export const LOCAL_SERVERS = ['llama.cpp', 'lmstudio', 'ollama']
|
|
22
22
|
export const RATE_CAP_MS = 86_400_000
|
|
23
|
-
export const LIMIT_MS =
|
|
23
|
+
export const LIMIT_MS = 86_400_000 // a learned limit, and a refusal learned from a provider: a day (learned again if it still holds)
|
|
24
24
|
|
|
25
25
|
// { size, sure, from }: from is measured, server, models.dev, learned, rate or assumed.
|
|
26
26
|
export function contextWindow(p, now = Date.now()) {
|
package/src/director.js
CHANGED
|
@@ -63,8 +63,10 @@ export class Director {
|
|
|
63
63
|
if (!existing) return `error: no ${kind} "${name}"`
|
|
64
64
|
const ok = await s.ui.confirm(`Delete the ${kind} ${name} (${existing.file})?`, 'edit')
|
|
65
65
|
if (ok !== true) return declined(ok)
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// a skill in its own folder (<name>/SKILL.md): that folder; never a skills folder itself
|
|
67
|
+
const folder = path.dirname(existing.file)
|
|
68
|
+
const isFolder = path.basename(existing.file) === 'SKILL.md' && !skillDirs(app.root).some((d) => path.resolve(d) === path.resolve(folder))
|
|
69
|
+
fs.rmSync(isFolder ? folder : existing.file, { recursive: true, force: true })
|
|
68
70
|
app.reload(kind)
|
|
69
71
|
return `Deleted the ${kind} ${name}.`
|
|
70
72
|
}
|
|
@@ -126,10 +128,11 @@ export class Director {
|
|
|
126
128
|
}
|
|
127
129
|
if (cfg?.mcpServers) cfg = cfg.mcpServers[name] ?? Object.values(cfg.mcpServers)[0] // pasted a whole mcp.json
|
|
128
130
|
else if (cfg && !cfg.command && Object.keys(cfg).length === 1 && typeof Object.values(cfg)[0] === 'object') cfg = Object.values(cfg)[0] // { "name": { … } }
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
// a program to run (command), or a server on the web (url)
|
|
132
|
+
if (!cfg?.command && !cfg?.url && !(DEFAULT_SERVERS[name] && cfg)) return 'error: config needs "command" (and usually "args"), like {"command": "npx", "args": ["-y", "some-mcp"]}, or "url" for a remote server, like {"url": "https://example.com/mcp"}'
|
|
133
|
+
const clean = Object.fromEntries(['command', 'args', 'env', 'url', 'headers', 'description', 'disabled'].filter((k) => cfg[k] !== undefined).map((k) => [k, cfg[k]]))
|
|
131
134
|
s.ui.print(`${c.bold(name)} ${c.faint(`→ ${tilde(file)}`)}\n ${c.muted(JSON.stringify(clean))}`)
|
|
132
|
-
const ok = await s.ui.confirm(`Save the MCP server ${name}? HeartCode will run: ${clean.command ?? ''} ${(clean.args ?? []).join(' ')}`, 'command')
|
|
135
|
+
const ok = await s.ui.confirm(clean.url && !clean.command ? `Save the MCP server ${name}? HeartCode will connect to ${clean.url}` : `Save the MCP server ${name}? HeartCode will run: ${clean.command ?? ''} ${(clean.args ?? []).join(' ')}`, 'command')
|
|
133
136
|
if (ok !== true) return declined(ok)
|
|
134
137
|
data.mcpServers[name] = { ...data.mcpServers[name], ...clean }
|
|
135
138
|
writeJson(file, data)
|
package/src/edit.js
CHANGED
|
@@ -59,8 +59,8 @@ function notFound(lines, sk) {
|
|
|
59
59
|
return `${msg}. The closest lines are around line ${best + 1}:\n${lines.slice(Math.max(0, best - 2), best + 3).join('\n')}`
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// Parses git-conflict-style blocks. Accepts 5-9 marker chars
|
|
63
|
-
//
|
|
62
|
+
// Parses git-conflict-style blocks. Accepts 5-9 marker chars and missing SEARCH/REPLACE words; a
|
|
63
|
+
// block without its final marker is a cut-off reply, left out.
|
|
64
64
|
export function parseBlocks(reply) {
|
|
65
65
|
const blocks = []
|
|
66
66
|
let cur = null
|
|
@@ -79,7 +79,7 @@ export function parseBlocks(reply) {
|
|
|
79
79
|
cur[part].push(line)
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
// a block with no closing >>>>>>> is a reply that was cut off: its replacement is part of one (left out)
|
|
83
83
|
return blocks
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -118,10 +118,9 @@ export function cleanCode(reply, original = '') {
|
|
|
118
118
|
const DEF = /\b(?:function|def|class|fn|func|interface|struct|enum)\s+([A-Za-z_]\w*)|\b(?:const|let|var)\s+([A-Za-z_]\w*)\s*=/g
|
|
119
119
|
const defNames = (t) => new Set([...t.matchAll(DEF)].map((m) => m[1] ?? m[2]))
|
|
120
120
|
|
|
121
|
-
// Definitions an edit deleted
|
|
122
|
-
//
|
|
121
|
+
// Definitions an edit deleted that the task doesn't name — the typical small-model rewrite
|
|
122
|
+
// accident (or what the task asked: the model is asked which, guided.js).
|
|
123
123
|
export function lostNames(old, next, task) {
|
|
124
|
-
if (/\b(remove|delete|drop|rename|replace|rewrite|refactor|clean ?up|merge)\b/i.test(task)) return []
|
|
125
124
|
const now = defNames(next)
|
|
126
125
|
return [...defNames(old)].filter((n) => !now.has(n) && !task.includes(n))
|
|
127
126
|
}
|
package/src/format.js
CHANGED
|
@@ -46,23 +46,32 @@ const BUILTIN = [
|
|
|
46
46
|
|
|
47
47
|
const glob = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`)
|
|
48
48
|
|
|
49
|
-
// The command for a file ("…$FILE…" or one the path goes after), or null.
|
|
49
|
+
// The command for a file ("…$FILE…" or one the path goes after), or null. fromProject: the
|
|
50
|
+
// project gave it (its .heartcode/format.json, or a program in its node_modules): a cloned repo's
|
|
51
|
+
// own, run only once the user says yes (tools.js).
|
|
50
52
|
export function formatterFor(root, rel) {
|
|
51
|
-
const
|
|
53
|
+
const mine = readJson(path.join(HOME, 'format.json'), {})
|
|
54
|
+
const project = readJson(path.join(root, '.heartcode', 'format.json'), {})
|
|
52
55
|
const base = path.basename(rel)
|
|
53
|
-
for (const [pattern, cmd] of Object.entries(
|
|
54
|
-
if (glob(pattern).test(base) || glob(pattern).test(rel)) return cmd
|
|
56
|
+
for (const [pattern, cmd] of Object.entries({ ...mine, ...project })) {
|
|
57
|
+
if (glob(pattern).test(base) || glob(pattern).test(rel)) return cmd ? { cmd, fromProject: pattern in project } : null
|
|
58
|
+
}
|
|
59
|
+
for (const [re, pick] of BUILTIN) {
|
|
60
|
+
if (!re.test(rel)) continue
|
|
61
|
+
const cmd = pick(root)
|
|
62
|
+
return cmd ? { cmd, fromProject: cmd.startsWith(q(path.join(root, 'node_modules')).slice(0, -1)) } : null
|
|
55
63
|
}
|
|
56
|
-
for (const [re, pick] of BUILTIN) if (re.test(rel)) return pick(root) || null
|
|
57
64
|
return null
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
// Formats the file HeartCode just wrote. Returns its new text when the formatter changed it, else
|
|
61
68
|
// null. A formatter that fails (a syntax error it can't parse) is left at that: the file stays as
|
|
62
|
-
// written, and the language server or the model's next run says what's wrong.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
// written, and the language server or the model's next run says what's wrong. allow(cmd): whether
|
|
70
|
+
// a formatter the project gave may run (asked once).
|
|
71
|
+
export async function formatFile(root, rel, allow = async () => true) {
|
|
72
|
+
const found = formatterFor(root, rel)
|
|
73
|
+
if (!found || (found.fromProject && !(await allow(found.cmd)))) return null
|
|
74
|
+
const { cmd } = found
|
|
66
75
|
const abs = path.join(root, rel)
|
|
67
76
|
const before = fs.readFileSync(abs, 'utf8')
|
|
68
77
|
const line = cmd.includes('$FILE') ? cmd.replaceAll('$FILE', q(rel)) : `${cmd} ${q(rel)}`
|