heartcode 0.1.6 → 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 +24 -10
- package/bin/heartcode.js +70 -30
- package/package.json +1 -1
- package/prompts/agent.md +15 -30
- package/prompts/guided.md +6 -6
- package/src/acp.js +3 -3
- package/src/agent.js +168 -68
- package/src/client.js +162 -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 +18 -11
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +6 -3
- package/src/settings.js +19 -2
- package/src/tools.js +138 -41
- 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,12 +180,15 @@ 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() {
|
|
@@ -228,9 +262,11 @@ export class Client {
|
|
|
228
262
|
let body = await res.text().catch(() => '')
|
|
229
263
|
log('http', `error body: ${body.slice(0, 5000)}`)
|
|
230
264
|
const raw = body.slice(0, 20_000)
|
|
231
|
-
|
|
232
|
-
|
|
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)' : ''}`)
|
|
233
268
|
err.status = res.status
|
|
269
|
+
err.page = page
|
|
234
270
|
err.body = raw // all of it, for the requests log
|
|
235
271
|
err.retryAfter = res.headers.get('retry-after') ?? /"retryDelay":\s*"(\d+)s"/.exec(body)?.[1] // Gemini says it in the body
|
|
236
272
|
throw err
|
|
@@ -362,6 +398,7 @@ export class Client {
|
|
|
362
398
|
let tries = 0 // of the model being tried now
|
|
363
399
|
let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
|
|
364
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
|
|
365
402
|
for (let attempt = 0; ; attempt++) {
|
|
366
403
|
try {
|
|
367
404
|
const r = await this.#timed(body, { ...opts, once: true })
|
|
@@ -372,7 +409,16 @@ export class Client {
|
|
|
372
409
|
} catch (e) {
|
|
373
410
|
if (e.name === 'AbortError' || opts.signal?.aborted) throw e
|
|
374
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
|
|
375
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
|
+
}
|
|
376
422
|
// A bad request (400…): the same request fails the same way every time, so it isn't sent
|
|
377
423
|
// again. It stops here, unless the model list moves on at any error (the settings page). A
|
|
378
424
|
// model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
|
|
@@ -393,6 +439,8 @@ export class Client {
|
|
|
393
439
|
} else this.onRetry(e, 0, attempt + 1, this.target)
|
|
394
440
|
continue
|
|
395
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
|
|
396
444
|
const wait = Math.min(Number(e.retryAfter) * 1000 || this.retryBase * 2 ** (tries - 1), 60_000)
|
|
397
445
|
log('retry', `attempt ${attempt + 1} failed, waiting ${wait}ms: ${e.message}`)
|
|
398
446
|
this.onRetry(e, wait, attempt + 1)
|
|
@@ -417,6 +465,10 @@ export class Client {
|
|
|
417
465
|
// One request. Without `once`, rate limits and server errors get three quiet retries.
|
|
418
466
|
async #chat(body, { onToken, onReasoning, onToolDelta, signal, once } = {}) {
|
|
419
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)
|
|
420
472
|
// The model's thinking on its earlier steps (agent.js keeps it as reasoning_content): sent by the
|
|
421
473
|
// name this provider sends it by (reasoning_content: DeepSeek, vLLM…; reasoning: OpenRouter,
|
|
422
474
|
// Ollama), or not at all if it refused it once.
|
|
@@ -431,55 +483,65 @@ export class Client {
|
|
|
431
483
|
let res
|
|
432
484
|
let sent
|
|
433
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
|
|
434
496
|
try {
|
|
435
497
|
for (let attempt = 0; ; attempt++) {
|
|
436
498
|
sent = Date.now()
|
|
437
499
|
this.last = { req, sent }
|
|
438
500
|
try {
|
|
439
|
-
|
|
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 })
|
|
440
503
|
break
|
|
441
504
|
} catch (e) {
|
|
442
505
|
this.#record(req, sent, { error: e })
|
|
443
506
|
// Rate limits and server errors, the same set the OpenAI SDKs retry: wait (Retry-After, else 2s, 4s, 8s).
|
|
444
|
-
if (once || !(
|
|
507
|
+
if (once || !outage(e) || !e.status || attempt === 3 || signal?.aborted) throw e
|
|
445
508
|
const wait = Math.min(Number(e.retryAfter) * 1000 || 2000 * 2 ** attempt, 30_000)
|
|
446
509
|
log('retry', `quiet retry ${attempt + 1} in ${wait}ms: ${e.message}`)
|
|
447
510
|
await sleep(wait, null, { signal })
|
|
448
511
|
}
|
|
449
512
|
}
|
|
450
513
|
} catch (e) {
|
|
451
|
-
|
|
452
|
-
//
|
|
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.
|
|
453
520
|
const refused = e.status === 400 || e.status === 422
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
465
533
|
}
|
|
466
534
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (
|
|
477
|
-
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)) {
|
|
478
|
-
this.quirks.simpleSchemas = true
|
|
479
|
-
log('quirk', 'server rejects the tools\' schemas; sending simpler ones')
|
|
480
|
-
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
481
|
-
}
|
|
482
|
-
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')
|
|
483
545
|
throw e
|
|
484
546
|
}
|
|
485
547
|
// Tokens the last request used (prompt + reply): what the status bar shows against the context size.
|
|
@@ -503,9 +565,10 @@ export class Client {
|
|
|
503
565
|
const choice = data.choices?.[0] ?? {}
|
|
504
566
|
const msg = choice.message ?? {}
|
|
505
567
|
this.#reasoningField(msg)
|
|
506
|
-
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))
|
|
507
569
|
}
|
|
508
570
|
|
|
571
|
+
poke()
|
|
509
572
|
const reader = res.body.getReader()
|
|
510
573
|
const dec = new TextDecoder()
|
|
511
574
|
let buf = ''
|
|
@@ -524,7 +587,14 @@ export class Client {
|
|
|
524
587
|
}
|
|
525
588
|
}
|
|
526
589
|
for (;;) {
|
|
527
|
-
|
|
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()
|
|
528
598
|
firstAt ??= Date.now()
|
|
529
599
|
if (end) break
|
|
530
600
|
buf += dec.decode(value, { stream: true })
|
|
@@ -542,7 +612,17 @@ export class Client {
|
|
|
542
612
|
} catch {
|
|
543
613
|
continue
|
|
544
614
|
}
|
|
545
|
-
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
|
+
}
|
|
546
626
|
if (chunk.usage) usage = chunk.usage
|
|
547
627
|
const ch = chunk.choices?.[0]
|
|
548
628
|
if (!ch) continue
|
|
@@ -570,11 +650,12 @@ export class Client {
|
|
|
570
650
|
onReasoning(t.slice(thought.length))
|
|
571
651
|
thought = t
|
|
572
652
|
}
|
|
573
|
-
emit(visible(content))
|
|
653
|
+
emit(visible(content, thinks))
|
|
574
654
|
}
|
|
575
655
|
}
|
|
576
656
|
}
|
|
577
|
-
|
|
657
|
+
clearTimeout(timer)
|
|
658
|
+
const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason, thinks)
|
|
578
659
|
emit(result.content)
|
|
579
660
|
if (!result.content.trim() && !result.toolCalls.length && !result.reasoning) {
|
|
580
661
|
result.raw = raw // nothing read from it: what came, for the requests log
|
|
@@ -618,11 +699,11 @@ export function simpleSchema(x) {
|
|
|
618
699
|
// Message content as text: a string, or a list of parts ({ type: 'text', text }).
|
|
619
700
|
const textOf = (c) => (Array.isArray(c) ? c.map((p) => (typeof p === 'string' ? p : (p?.text ?? ''))).join('') : (c ?? ''))
|
|
620
701
|
|
|
621
|
-
function done(raw, reasoning, toolCalls, usage, finishReason) {
|
|
702
|
+
function done(raw, reasoning, toolCalls, usage, finishReason, thinks) {
|
|
622
703
|
const calls = toolCalls.filter((c) => c?.function?.name)
|
|
623
|
-
calls.forEach((c, i) => (c.id ||=
|
|
704
|
+
calls.forEach((c, i) => (c.id ||= callId(`${Date.now()}_${i}_${Math.random()}`)))
|
|
624
705
|
return {
|
|
625
|
-
content: stripThink(raw),
|
|
706
|
+
content: stripThink(raw, thinks),
|
|
626
707
|
reasoning: (reasoning || thinkOf(raw)).trim(),
|
|
627
708
|
toolCalls: calls,
|
|
628
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)}`
|