heartcode 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// OpenAI-compatible chat client: plain fetch, SSE streaming, <think> stripping.
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
|
+
import { log, logRequest, requestLog } from './log.js'
|
|
4
|
+
|
|
5
|
+
export const est = (text) => Math.ceil(String(text ?? '').length / 3.5)
|
|
6
|
+
// An error that may go away by itself: a rate limit, a server error or overload, a timeout, no
|
|
7
|
+
// connection (no status). Anything else (a wrong model name, key or request) won't.
|
|
8
|
+
export const outage = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500
|
|
9
|
+
// A request's messages, estimated: an image's data isn't text (about 800 tokens, not its base64)
|
|
10
|
+
export function estMessages(messages) {
|
|
11
|
+
let images = 0
|
|
12
|
+
const text = JSON.stringify(messages ?? [], (k, v) => (k === 'url' && typeof v === 'string' && v.startsWith('data:') ? (images++, '') : v))
|
|
13
|
+
return est(text) + images * 800
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// "Request too large" (the context window, or a provider's per-request or per-minute input cap):
|
|
17
|
+
// the same request can never work, so it isn't retried; the caller sends less. Returns the
|
|
18
|
+
// limit in tokens (from the message; else 3/4 of what was sent, but not below what is known to
|
|
19
|
+
// work), or null for other errors.
|
|
20
|
+
export function inputLimit(e, sent, known = 0) {
|
|
21
|
+
const big =
|
|
22
|
+
e.status === 413 ||
|
|
23
|
+
(e.status >= 400 && e.status < 500 && /too large|too long|reduce the length|reduce your message|context length|context window|context size|too many tokens|exceeds? the (maximum|available|context)|maximum (number of )?tokens/i.test(e.message))
|
|
24
|
+
if (!big) return null
|
|
25
|
+
const n = Number(LIMIT_IN_MESSAGE.map((re) => re.exec(e.message)?.[1]).find(Boolean))
|
|
26
|
+
return n > 0 ? n : Math.max(Math.floor(sent * 0.75), known)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The limit when the error states it: { tokens, rate } (rate: a per-minute cap on your account,
|
|
30
|
+
// not the model's window), or null (no number in it: nothing is learned from a guess).
|
|
31
|
+
export function limitSaid(e) {
|
|
32
|
+
if (!inputLimit(e, 0)) return null
|
|
33
|
+
const n = Number(LIMIT_IN_MESSAGE.map((re) => re.exec(e.message)?.[1]).find(Boolean))
|
|
34
|
+
return n > 0 ? { tokens: n, rate: /per minute|tokens per min|\bTPM\b|rate.?limit/i.test(e.message) } : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// How servers say the limit: OpenAI and vLLM, Mistral, llama.cpp, Anthropic-style, Gemini,
|
|
38
|
+
// "context window of N", Groq's per-request cap ("Limit N").
|
|
39
|
+
const LIMIT_IN_MESSAGE = [
|
|
40
|
+
/maximum context length is (\d+)/i,
|
|
41
|
+
/(\d+) maximum context length/i,
|
|
42
|
+
/available context size \((\d+)/i,
|
|
43
|
+
/> ?(\d+) maximum/i,
|
|
44
|
+
/allowed \((\d+)\)/i,
|
|
45
|
+
/context (?:window|length|size) (?:of|is) (\d+)/i,
|
|
46
|
+
/\bLimit (\d+)/,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
// A model's window from a /models entry, whatever the provider calls it. OpenRouter/Together:
|
|
50
|
+
// context_length (and top_provider.context_length) · vLLM: max_model_len · Groq: context_window ·
|
|
51
|
+
// Mistral: max_context_length · LiteLLM: max_input_tokens · Gemini: inputTokenLimit.
|
|
52
|
+
export function windowOf(m) {
|
|
53
|
+
if (!m) return undefined
|
|
54
|
+
return m.context_length ?? m.top_provider?.context_length ?? m.max_model_len ?? m.context_window ?? m.max_context_length ?? m.max_input_tokens ?? m.inputTokenLimit ?? m.context_size ?? m.n_ctx ?? m.meta?.n_ctx_train
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeBaseUrl(url) {
|
|
58
|
+
const u = new URL(url)
|
|
59
|
+
if (u.pathname === '/' || u.pathname === '') u.pathname = '/v1'
|
|
60
|
+
return u.toString().replace(/\/+$/, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isLocal(url) {
|
|
64
|
+
const h = new URL(url).hostname
|
|
65
|
+
return /^(localhost|127\.|0\.0\.0\.0|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Removes reasoning blocks some models put inside the normal content.
|
|
69
|
+
export function stripThink(text) {
|
|
70
|
+
let t = String(text ?? '').replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
71
|
+
const open = t.indexOf('<think>')
|
|
72
|
+
if (open !== -1) t = t.slice(0, open)
|
|
73
|
+
const close = t.indexOf('</think>')
|
|
74
|
+
if (close !== -1) t = t.slice(close + 8)
|
|
75
|
+
return t.trimStart()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// What can be shown so far while streaming: holds back a half-received "<think>".
|
|
79
|
+
function visible(full) {
|
|
80
|
+
let v = stripThink(full)
|
|
81
|
+
for (let k = 7; k > 0; k--) {
|
|
82
|
+
if (v.endsWith('<think>'.slice(0, k))) return v.slice(0, -k)
|
|
83
|
+
}
|
|
84
|
+
return v
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The text inside <think> blocks so far (an unclosed one counts), holding back a half-received "</think>".
|
|
88
|
+
export function thinkOf(full) {
|
|
89
|
+
let out = ''
|
|
90
|
+
let i = 0
|
|
91
|
+
for (;;) {
|
|
92
|
+
const open = full.indexOf('<think>', i)
|
|
93
|
+
if (open === -1) return out
|
|
94
|
+
const close = full.indexOf('</think>', open + 7)
|
|
95
|
+
if (close === -1) {
|
|
96
|
+
let rest = full.slice(open + 7)
|
|
97
|
+
for (let k = 8; k > 0; k--) {
|
|
98
|
+
if (rest.endsWith('</think>'.slice(0, k))) {
|
|
99
|
+
rest = rest.slice(0, -k)
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out + rest
|
|
104
|
+
}
|
|
105
|
+
out += full.slice(open + 7, close)
|
|
106
|
+
i = close + 8
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class Client {
|
|
111
|
+
constructor({ baseUrl, apiKey, model, provider, timeout = 600_000 } = {}) {
|
|
112
|
+
this.provider = provider // its name in config.json, for the requests log
|
|
113
|
+
this.baseUrl = normalizeBaseUrl(baseUrl)
|
|
114
|
+
this.apiKey = apiKey
|
|
115
|
+
this.model = model
|
|
116
|
+
this.timeout = timeout
|
|
117
|
+
this.quirks = {}
|
|
118
|
+
this.retryBase = 1000 // first wait before retrying a failed request (then ×2 each time)
|
|
119
|
+
this.tokenRatio = 1 // the server's token count / est(): calibrated from usage (context.js)
|
|
120
|
+
this.knownGood = 0 // the biggest request known to work, in tokens
|
|
121
|
+
this.onUsage = null // (prompt tokens the server counted, estimate of what was sent): learns the window
|
|
122
|
+
this.speed = { tokens: 0, ms: 0 } // generated tokens and time, for the average tok/s
|
|
123
|
+
this.rotation = null // setRotation
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Models to go on to when one fails (the settings page's numbered list), in order and round
|
|
127
|
+
// again: list [{ provider, baseUrl, apiKey, model }] (#1 is this client's own), on: when to move
|
|
128
|
+
// on ('failure': rate limits, server errors, timeouts, no connection; 'retries': after `retries`
|
|
129
|
+
// failed tries of the same one; 'any': any error), next: what a new message starts with ('first' or
|
|
130
|
+
// 'stay' on the one that worked). onSwitch(target): a different model answered than before.
|
|
131
|
+
setRotation(list, { on = 'failure', retries = 2, next = 'first' } = {}) {
|
|
132
|
+
if (!list || list.length < 2) {
|
|
133
|
+
if (this.rotation) this.#use(this.rotation.list[0])
|
|
134
|
+
this.rotation = null
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const was = this.rotation
|
|
138
|
+
list = list.map((t) => ({ ...t, quirks: was?.list.find((x) => x.baseUrl === t.baseUrl && x.model === t.model)?.quirks ?? {} }))
|
|
139
|
+
if (!was) list[0].quirks = this.quirks
|
|
140
|
+
this.rotation = { list, on, retries, next, at: 0, good: list[0] }
|
|
141
|
+
this.#use(list[0])
|
|
142
|
+
}
|
|
143
|
+
get target() {
|
|
144
|
+
return this.rotation?.list[this.rotation.at] ?? null
|
|
145
|
+
}
|
|
146
|
+
// A new message: back to #1 (unless it stays on the one that worked)
|
|
147
|
+
newMessage() {
|
|
148
|
+
const r = this.rotation
|
|
149
|
+
if (r && r.next === 'first' && r.at) this.#use(r.list[(r.at = 0)])
|
|
150
|
+
}
|
|
151
|
+
#use(t) {
|
|
152
|
+
Object.assign(this, { baseUrl: normalizeBaseUrl(t.baseUrl), apiKey: t.apiKey, model: t.model, provider: t.provider })
|
|
153
|
+
this.quirks = t.quirks ?? (t.quirks = {})
|
|
154
|
+
}
|
|
155
|
+
// After a failure: go on to the next model? (then it's tried at once)
|
|
156
|
+
#rotate(e, tries) {
|
|
157
|
+
const r = this.rotation
|
|
158
|
+
if (!r) return false
|
|
159
|
+
if (!(r.on === 'any' || (r.on === 'failure' && outage(e)) || (r.on === 'retries' && tries >= r.retries))) return false
|
|
160
|
+
r.at = (r.at + 1) % r.list.length
|
|
161
|
+
log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
|
|
162
|
+
this.#use(r.list[r.at])
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The requests log (src/log.js): one request to the model, how it ended, and where it is in your
|
|
167
|
+
// list of models (#2 of 3: a fallback).
|
|
168
|
+
#record(req, sent, { error, reply, firstAt } = {}) {
|
|
169
|
+
if (error) error.recorded = true
|
|
170
|
+
if (!requestLog.on) return
|
|
171
|
+
const r = this.rotation
|
|
172
|
+
logRequest({
|
|
173
|
+
ok: !error,
|
|
174
|
+
stopped: error?.name === 'AbortError',
|
|
175
|
+
purpose: this.purpose ?? 'chat',
|
|
176
|
+
provider: this.provider ?? null,
|
|
177
|
+
model: this.model,
|
|
178
|
+
place: r ? r.at + 1 : 1,
|
|
179
|
+
of: r ? r.list.length : 1,
|
|
180
|
+
url: `${this.baseUrl}/chat/completions`,
|
|
181
|
+
status: error ? (error.status ?? null) : 200,
|
|
182
|
+
error: error?.message,
|
|
183
|
+
errorBody: error?.body,
|
|
184
|
+
ms: Date.now() - sent,
|
|
185
|
+
firstTokenMs: firstAt ? firstAt - sent : undefined,
|
|
186
|
+
usage: reply?.usage,
|
|
187
|
+
finishReason: reply?.finishReason,
|
|
188
|
+
request: req,
|
|
189
|
+
reply: reply && { content: reply.content, reasoning: reply.reasoning, toolCalls: reply.toolCalls },
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get tps() {
|
|
194
|
+
return this.speed.ms ? Math.round(this.speed.tokens / (this.speed.ms / 1000)) : 0
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #fetch(url, init = {}, { signal, timeout = this.timeout } = {}) {
|
|
198
|
+
const limit = AbortSignal.timeout(timeout)
|
|
199
|
+
const headers = { 'content-type': 'application/json', ...(this.apiKey && { authorization: `Bearer ${this.apiKey}` }) }
|
|
200
|
+
const t0 = Date.now()
|
|
201
|
+
log('http', `→ ${init.method ?? 'GET'} ${url}`, init.body ?? '')
|
|
202
|
+
let res
|
|
203
|
+
try {
|
|
204
|
+
res = await fetch(url, { ...init, headers, signal: signal ? AbortSignal.any([signal, limit]) : limit })
|
|
205
|
+
} catch (e) {
|
|
206
|
+
log('http', `✗ ${url} after ${Date.now() - t0}ms: ${e.name} ${e.cause?.code ?? ''} ${e.message}`)
|
|
207
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') throw e
|
|
208
|
+
throw new Error(`cannot reach ${url} (${e.cause?.code || e.cause?.message || e.message})`)
|
|
209
|
+
}
|
|
210
|
+
log('http', `← ${res.status} ${url} in ${Date.now() - t0}ms`)
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
let body = await res.text().catch(() => '')
|
|
213
|
+
log('http', `error body: ${body.slice(0, 5000)}`)
|
|
214
|
+
const raw = body.slice(0, 20_000)
|
|
215
|
+
if (/^\s*</.test(body)) body = /<title>([^<]*)/i.exec(body)?.[1]?.trim() ?? 'an HTML error page' // proxies and gateways
|
|
216
|
+
const err = new Error(`${res.status} from ${url}: ${body.slice(0, 300)}`)
|
|
217
|
+
err.status = res.status
|
|
218
|
+
err.body = raw // all of it, for the requests log
|
|
219
|
+
err.retryAfter = res.headers.get('retry-after') ?? /"retryDelay":\s*"(\d+)s"/.exec(body)?.[1] // Gemini says it in the body
|
|
220
|
+
throw err
|
|
221
|
+
}
|
|
222
|
+
return res
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async #json(url, init, timeout = 5000) {
|
|
226
|
+
return (await this.#fetch(url, init, { timeout })).json()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async listModels(timeout = 10_000) {
|
|
230
|
+
return (await this.modelEntries(timeout)).map((m) => m.id ?? m.name)
|
|
231
|
+
}
|
|
232
|
+
// The server's own entries for its models ({ id, owned_by, context_length, pricing… }, as it sends them).
|
|
233
|
+
async modelEntries(timeout = 10_000) {
|
|
234
|
+
const data = await this.#json(this.baseUrl + '/models', undefined, timeout)
|
|
235
|
+
return (data.data ?? data.models ?? []).filter((m) => m && (m.id ?? m.name))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Best effort: asks the server how big the loaded context window is.
|
|
239
|
+
// What the server is, and the window it says: { size, server }. server: llama.cpp, lmstudio,
|
|
240
|
+
// ollama, ollama-cloud, vllm, or null (a provider or proxy that doesn't say what it is). Only a
|
|
241
|
+
// server that is one of these is treated as a model server on this machine or network; its
|
|
242
|
+
// address (localhost, 192.168…) says nothing (a proxy there may serve Gemini).
|
|
243
|
+
async contextSize() {
|
|
244
|
+
const root = this.baseUrl.replace(/\/v1$/, '')
|
|
245
|
+
const host = new URL(root).hostname
|
|
246
|
+
const n = (x) => (Number(x) > 0 ? Number(x) : undefined)
|
|
247
|
+
const tries = [
|
|
248
|
+
async () => {
|
|
249
|
+
const p = await this.#json(root + '/props')
|
|
250
|
+
if (p.default_generation_settings || 'n_ctx' in p) return { size: n(p.default_generation_settings?.n_ctx ?? p.n_ctx), server: 'llama.cpp' }
|
|
251
|
+
},
|
|
252
|
+
async () => {
|
|
253
|
+
const m = await this.#json(`${root}/api/v0/models/${encodeURIComponent(this.model)}`)
|
|
254
|
+
return { size: n(m.loaded_context_length ?? m.max_context_length), server: 'lmstudio' }
|
|
255
|
+
},
|
|
256
|
+
async () => {
|
|
257
|
+
// Ollama (it answers /api/version), or Ollama's cloud (ollama.com): the window of the model
|
|
258
|
+
// as loaded now, else its num_ctx, else on this machine Ollama's default and in the cloud
|
|
259
|
+
// the model's own length
|
|
260
|
+
const cloud = host === 'ollama.com' || host.endsWith('.ollama.com')
|
|
261
|
+
if (!cloud) await this.#json(root + '/api/version')
|
|
262
|
+
const ps = cloud ? null : await this.#json(root + '/api/ps').catch(() => null)
|
|
263
|
+
const loaded = ps?.models?.find((m) => m.name === this.model || m.model === this.model)?.context_length
|
|
264
|
+
const s = await this.#json(root + '/api/show', { method: 'POST', body: JSON.stringify({ model: this.model }) }).catch(() => null)
|
|
265
|
+
const set = /num_ctx\s+(\d+)/.exec(s?.parameters ?? '')?.[1]
|
|
266
|
+
const trained = Object.entries(s?.model_info ?? {}).find(([k]) => k.endsWith('.context_length'))?.[1]
|
|
267
|
+
return { size: n(loaded) ?? n(set) ?? (cloud ? n(trained) : s ? 4096 : undefined), server: cloud ? 'ollama-cloud' : 'ollama' }
|
|
268
|
+
},
|
|
269
|
+
async () => {
|
|
270
|
+
const d = await this.#json(this.baseUrl + '/models')
|
|
271
|
+
const m = (d.data ?? d.models ?? []).find((x) => x.id === this.model || x.name === this.model)
|
|
272
|
+
return { size: n(windowOf(m)), server: m?.owned_by === 'vllm' || m?.max_model_len ? 'vllm' : null }
|
|
273
|
+
},
|
|
274
|
+
async () => ({ size: n(windowOf(await this.#json(`${this.baseUrl}/models/${encodeURIComponent(this.model)}`))), server: null }),
|
|
275
|
+
]
|
|
276
|
+
let server = null
|
|
277
|
+
for (const t of tries) {
|
|
278
|
+
try {
|
|
279
|
+
const r = await t()
|
|
280
|
+
if (r?.server) server ??= r.server
|
|
281
|
+
if (r?.size) return { size: r.size, server: server ?? r.server ?? null }
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
return { size: undefined, server }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The settings page's Measure: one request far bigger than any window (about 2.2 million
|
|
288
|
+
// tokens), which servers refuse with their limit in the error ("maximum context length is
|
|
289
|
+
// 131072"). Refused requests aren't charged. If it's accepted (a window that big), it's stopped
|
|
290
|
+
// at once. Returns { limit } | { over } (it took that many) | { error }. Not logged: it's 4 MB.
|
|
291
|
+
async measureContext({ signal, tokens = 2_200_000 } = {}) {
|
|
292
|
+
const headers = { 'content-type': 'application/json', ...(this.apiKey && { authorization: `Bearer ${this.apiKey}` }) }
|
|
293
|
+
const body = JSON.stringify({ model: this.model, messages: [{ role: 'user', content: 'a '.repeat(tokens) }], stream: true })
|
|
294
|
+
const stop = new AbortController()
|
|
295
|
+
let res
|
|
296
|
+
try {
|
|
297
|
+
res = await fetch(this.baseUrl + '/chat/completions', { method: 'POST', headers, body, signal: AbortSignal.any([stop.signal, AbortSignal.timeout(180_000), ...(signal ? [signal] : [])]) })
|
|
298
|
+
} catch (e) {
|
|
299
|
+
return { error: `couldn't send it: ${e.cause?.code || e.cause?.message || e.message}` }
|
|
300
|
+
}
|
|
301
|
+
if (res.ok) {
|
|
302
|
+
stop.abort()
|
|
303
|
+
return { over: tokens }
|
|
304
|
+
}
|
|
305
|
+
const text = await res.text().catch(() => '')
|
|
306
|
+
const e = Object.assign(new Error(`${res.status} ${text.slice(0, 4000)}`), { status: res.status })
|
|
307
|
+
const said = limitSaid(e)
|
|
308
|
+
log('measure', `${this.model}: ${res.status}`, text.slice(0, 1000))
|
|
309
|
+
if (said && !said.rate) return { limit: said.tokens }
|
|
310
|
+
const why = /^\s*</.test(text) ? (/<title>([^<]*)/i.exec(text)?.[1]?.trim() ?? 'an HTML error page') : (() => {
|
|
311
|
+
try {
|
|
312
|
+
const j = JSON.parse(text)
|
|
313
|
+
return j.error?.message ?? j.error ?? j.message ?? text
|
|
314
|
+
} catch {
|
|
315
|
+
return text
|
|
316
|
+
}
|
|
317
|
+
})()
|
|
318
|
+
return { error: `${res.status}: ${String(why).slice(0, 300)}${said?.rate ? ' (a per-minute cap on your account, not the window)' : ''}` }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Returns { content, reasoning, toolCalls, usage, finishReason, thinking }.
|
|
322
|
+
// While streaming, onToken gets answer text and onReasoning gets thinking text.
|
|
323
|
+
// With this.onRetry set (during tasks), any failure is retried after 1s, 2s, 4s, … (capped at
|
|
324
|
+
// 60s, or the server's Retry-After) until it works or the signal aborts. onRetry(error, ms,
|
|
325
|
+
// attempt) reports each wait; onRetry(null) reports the recovery.
|
|
326
|
+
async chat(body, opts = {}) {
|
|
327
|
+
// "too large": never retried as is; the limit is learned and the caller sends less
|
|
328
|
+
const tooLarge = (e) => {
|
|
329
|
+
const limit = inputLimit(e, Math.round(estMessages(body.messages) * this.tokenRatio), this.knownGood)
|
|
330
|
+
if (!limit) return false
|
|
331
|
+
e.inputLimit = limit // this request is sent smaller
|
|
332
|
+
// remembered only when the server said the number (a guess isn't); a per-minute cap apart
|
|
333
|
+
const said = limitSaid(e)
|
|
334
|
+
log('limit', `request too large; ${said ? `the server says ${said.tokens}${said.rate ? ' per minute' : ''}` : `no limit given, trying ~${limit}`}`)
|
|
335
|
+
if (said) this.onInputLimit?.(said.tokens, { rate: said.rate })
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
if (!this.onRetry) {
|
|
339
|
+
try {
|
|
340
|
+
return await this.#timed(body, opts)
|
|
341
|
+
} catch (e) {
|
|
342
|
+
tooLarge(e)
|
|
343
|
+
throw e
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
let tries = 0 // of the model being tried now
|
|
347
|
+
let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
|
|
348
|
+
let hard = true // every failure in this round was one that waiting can't fix (see outage)
|
|
349
|
+
for (let attempt = 0; ; attempt++) {
|
|
350
|
+
try {
|
|
351
|
+
const r = await this.#timed(body, { ...opts, once: true })
|
|
352
|
+
if (attempt) this.onRetry(null)
|
|
353
|
+
const rot = this.rotation
|
|
354
|
+
if (rot && rot.good !== rot.list[rot.at]) this.onSwitch?.((rot.good = rot.list[rot.at]))
|
|
355
|
+
return r
|
|
356
|
+
} catch (e) {
|
|
357
|
+
if (e.name === 'AbortError' || opts.signal?.aborted) throw e
|
|
358
|
+
if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
|
|
359
|
+
if (this.retryIf && !this.retryIf(e, attempt)) throw e
|
|
360
|
+
// A wrong model name, key or request (404, 401, 400…): the same request fails the same way
|
|
361
|
+
// every time, so it isn't sent again. It stops here, unless the model list moves on at any
|
|
362
|
+
// error (the settings page); and a whole round of those stops too, rather than going round.
|
|
363
|
+
if (!outage(e) && this.rotation?.on !== 'any') throw e
|
|
364
|
+
hard &&= !outage(e)
|
|
365
|
+
tries++
|
|
366
|
+
if (this.#rotate(e, tries)) {
|
|
367
|
+
tries = 0
|
|
368
|
+
// ponytail: a 1s pause after a whole round fails (no connection at all: not thousands of
|
|
369
|
+
// requests a minute); the user chose "round again at once"
|
|
370
|
+
if (++round >= this.rotation.list.length) {
|
|
371
|
+
if (hard) throw e // every model refused it outright: going round again won't change that
|
|
372
|
+
hard = true
|
|
373
|
+
round = 0
|
|
374
|
+
this.onRetry(e, 1000, attempt + 1, this.target)
|
|
375
|
+
await sleep(1000, null, { signal: opts.signal })
|
|
376
|
+
} else this.onRetry(e, 0, attempt + 1, this.target)
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
const wait = Math.min(Number(e.retryAfter) * 1000 || this.retryBase * 2 ** (tries - 1), 60_000)
|
|
380
|
+
log('retry', `attempt ${attempt + 1} failed, waiting ${wait}ms: ${e.message}`)
|
|
381
|
+
this.onRetry(e, wait, attempt + 1)
|
|
382
|
+
await sleep(wait, null, { signal: opts.signal })
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// inFlight: when the request being waited on now was sent (null between requests), for the spinner.
|
|
388
|
+
async #timed(body, opts) {
|
|
389
|
+
this.inFlight = Date.now()
|
|
390
|
+
try {
|
|
391
|
+
return await this.#chat(body, opts)
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (!e.recorded && this.last) this.#record(this.last.req, this.last.sent, { error: e }) // failed while the reply streamed in
|
|
394
|
+
throw e
|
|
395
|
+
} finally {
|
|
396
|
+
this.inFlight = null
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// One request. Without `once`, rate limits and server errors get three quiet retries.
|
|
401
|
+
async #chat(body, { onToken, onReasoning, onToolDelta, signal, once } = {}) {
|
|
402
|
+
const req = { model: this.model, ...body }
|
|
403
|
+
if (this.quirks.noTemperature) delete req.temperature
|
|
404
|
+
if (this.effort && !this.quirks.noEffort) req.reasoning_effort = this.effort
|
|
405
|
+
if (req.stream && !this.quirks.noStreamUsage) req.stream_options = { include_usage: true } // token counts for the status bar
|
|
406
|
+
let res
|
|
407
|
+
let sent
|
|
408
|
+
let firstAt
|
|
409
|
+
try {
|
|
410
|
+
for (let attempt = 0; ; attempt++) {
|
|
411
|
+
sent = Date.now()
|
|
412
|
+
this.last = { req, sent }
|
|
413
|
+
try {
|
|
414
|
+
res = await this.#fetch(this.baseUrl + '/chat/completions', { method: 'POST', body: JSON.stringify(req) }, { signal })
|
|
415
|
+
break
|
|
416
|
+
} catch (e) {
|
|
417
|
+
this.#record(req, sent, { error: e })
|
|
418
|
+
// Rate limits and server errors, the same set the OpenAI SDKs retry: wait (Retry-After, else 2s, 4s, 8s).
|
|
419
|
+
if (once || !([408, 409, 429].includes(e.status) || e.status >= 500) || attempt === 3 || signal?.aborted) throw e
|
|
420
|
+
const wait = Math.min(Number(e.retryAfter) * 1000 || 2000 * 2 ** attempt, 30_000)
|
|
421
|
+
log('retry', `quiet retry ${attempt + 1} in ${wait}ms: ${e.message}`)
|
|
422
|
+
await sleep(wait, null, { signal })
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch (e) {
|
|
426
|
+
// Some APIs reject parameters older servers need; learn once and retry.
|
|
427
|
+
if (e.status === 400 && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
|
|
428
|
+
this.quirks.noTemperature = true
|
|
429
|
+
log('quirk', 'server rejects temperature')
|
|
430
|
+
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
431
|
+
}
|
|
432
|
+
for (const [quirk, param] of [['noEffort', 'reasoning_effort'], ['noStreamUsage', 'stream_options']]) {
|
|
433
|
+
if (e.status === 400 && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
|
|
434
|
+
this.quirks[quirk] = true
|
|
435
|
+
log('quirk', `server rejects ${param}; dropping it`)
|
|
436
|
+
return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
throw e
|
|
440
|
+
}
|
|
441
|
+
// Tokens the last request used (prompt + reply): what the status bar shows against the context size.
|
|
442
|
+
// Speed counts from the first streamed token (or the request, if not streaming) to the end.
|
|
443
|
+
const count = (r) => {
|
|
444
|
+
if (r.usage?.prompt_tokens) this.onUsage?.(r.usage.prompt_tokens, estMessages(req.messages) + est(JSON.stringify(req.tools ?? '')))
|
|
445
|
+
this.lastTokens = r.usage?.total_tokens || estMessages(req.messages) + est(r.content + r.reasoning)
|
|
446
|
+
const out = r.usage?.completion_tokens || est(r.content + r.reasoning + r.toolCalls.map((t) => t.function.arguments).join(''))
|
|
447
|
+
this.onSpend?.(r.usage?.prompt_tokens || r.usage?.completion_tokens ? r.usage : { prompt_tokens: estMessages(req.messages), completion_tokens: out, estimated: true }) // usage and cost (usage.js)
|
|
448
|
+
const ms = Date.now() - (firstAt ?? sent)
|
|
449
|
+
if (out > 0 && ms > 0) {
|
|
450
|
+
this.speed.tokens += out
|
|
451
|
+
this.speed.ms += ms
|
|
452
|
+
}
|
|
453
|
+
log('reply', `${this.model} in ${Date.now() - sent}ms`, { content: r.content, reasoning: r.reasoning, toolCalls: r.toolCalls, usage: r.usage, finishReason: r.finishReason })
|
|
454
|
+
this.#record(req, sent, { reply: r, firstAt })
|
|
455
|
+
return r
|
|
456
|
+
}
|
|
457
|
+
if (!body.stream) {
|
|
458
|
+
const data = await res.json()
|
|
459
|
+
const choice = data.choices?.[0] ?? {}
|
|
460
|
+
const msg = choice.message ?? {}
|
|
461
|
+
return count(done(msg.content ?? '', msg.reasoning_content ?? msg.reasoning ?? '', msg.tool_calls ?? [], data.usage, choice.finish_reason))
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const reader = res.body.getReader()
|
|
465
|
+
const dec = new TextDecoder()
|
|
466
|
+
let buf = ''
|
|
467
|
+
let content = ''
|
|
468
|
+
let reasoning = ''
|
|
469
|
+
let emitted = ''
|
|
470
|
+
let thought = ''
|
|
471
|
+
let usage
|
|
472
|
+
let finishReason
|
|
473
|
+
const calls = []
|
|
474
|
+
const emit = (v) => {
|
|
475
|
+
if (onToken && v.startsWith(emitted) && v.length > emitted.length) {
|
|
476
|
+
onToken(v.slice(emitted.length))
|
|
477
|
+
emitted = v
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
for (;;) {
|
|
481
|
+
const { value, done: end } = await reader.read()
|
|
482
|
+
firstAt ??= Date.now()
|
|
483
|
+
if (end) break
|
|
484
|
+
buf += dec.decode(value, { stream: true })
|
|
485
|
+
let nl
|
|
486
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
487
|
+
const line = buf.slice(0, nl).trim()
|
|
488
|
+
buf = buf.slice(nl + 1)
|
|
489
|
+
if (!line.startsWith('data:')) continue
|
|
490
|
+
const payload = line.slice(5).trim()
|
|
491
|
+
if (payload === '[DONE]') continue
|
|
492
|
+
let chunk
|
|
493
|
+
try {
|
|
494
|
+
chunk = JSON.parse(payload)
|
|
495
|
+
} catch {
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
if (chunk.error) throw new Error(`server error: ${chunk.error.message ?? JSON.stringify(chunk.error)}`)
|
|
499
|
+
if (chunk.usage) usage = chunk.usage
|
|
500
|
+
const ch = chunk.choices?.[0]
|
|
501
|
+
if (!ch) continue
|
|
502
|
+
if (ch.finish_reason) finishReason = ch.finish_reason
|
|
503
|
+
const d = ch.delta ?? {}
|
|
504
|
+
const r = d.reasoning_content || d.reasoning
|
|
505
|
+
if (r) {
|
|
506
|
+
reasoning += r
|
|
507
|
+
onReasoning?.(r)
|
|
508
|
+
}
|
|
509
|
+
for (const tc of d.tool_calls ?? []) {
|
|
510
|
+
const i = tc.index ?? (tc.id ? calls.length : Math.max(calls.length - 1, 0))
|
|
511
|
+
const call = (calls[i] ??= { id: '', type: 'function', function: { name: '', arguments: '' } })
|
|
512
|
+
if (tc.id) call.id = tc.id
|
|
513
|
+
if (tc.function?.name) call.function.name ||= tc.function.name
|
|
514
|
+
if (tc.function?.arguments) call.function.arguments += tc.function.arguments
|
|
515
|
+
onToolDelta?.(call.function.name, call.function.arguments)
|
|
516
|
+
}
|
|
517
|
+
if (d.content) {
|
|
518
|
+
content += d.content
|
|
519
|
+
const t = thinkOf(content)
|
|
520
|
+
if (onReasoning && t.length > thought.length && t.startsWith(thought)) {
|
|
521
|
+
onReasoning(t.slice(thought.length))
|
|
522
|
+
thought = t
|
|
523
|
+
}
|
|
524
|
+
emit(visible(content))
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason)
|
|
529
|
+
emit(result.content)
|
|
530
|
+
return count(result)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function done(raw, reasoning, toolCalls, usage, finishReason) {
|
|
535
|
+
const calls = toolCalls.filter((c) => c?.function?.name)
|
|
536
|
+
calls.forEach((c, i) => (c.id ||= `call_${Date.now()}_${i}`))
|
|
537
|
+
return {
|
|
538
|
+
content: stripThink(raw),
|
|
539
|
+
reasoning: (reasoning || thinkOf(raw)).trim(),
|
|
540
|
+
toolCalls: calls,
|
|
541
|
+
usage,
|
|
542
|
+
finishReason,
|
|
543
|
+
thinking: Boolean(reasoning) || /<\/?think>/.test(raw),
|
|
544
|
+
}
|
|
545
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const HOME = process.env.HEART_HOME || path.join(os.homedir(), '.heart')
|
|
6
|
+
export const CONFIG_FILE = path.join(HOME, 'config.json')
|
|
7
|
+
|
|
8
|
+
const SETTINGS = ['context', 'effort', 'permissions', 'autoCompact', 'director', 'theme', 'format', 'lsp', 'voice', 'rotation', 'log', 'logRequests', 'logDetail', 'logDays']
|
|
9
|
+
const LOCAL_NAMES = { 11434: 'ollama', 1234: 'lmstudio', 8080: 'llamacpp' }
|
|
10
|
+
|
|
11
|
+
export function readJson(file, fallback) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A config file the user writes (hooks.json, mcp.json): one that doesn't parse is skipped, but
|
|
20
|
+
// said (takeConfigErrors, shown before the next message), never silently ignored.
|
|
21
|
+
const configErrors = new Map() // file → { message, stamp, shown }
|
|
22
|
+
export function readConfig(file, fallback) {
|
|
23
|
+
if (!file || !fs.existsSync(file)) return fallback
|
|
24
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
const stamp = fs.statSync(file).mtimeMs
|
|
29
|
+
if (configErrors.get(file)?.stamp !== stamp) configErrors.set(file, { message: `${file} isn't valid JSON (${e.message}); it's skipped until it's fixed`, stamp, shown: false })
|
|
30
|
+
return fallback
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function takeConfigErrors() {
|
|
34
|
+
const out = [...configErrors.values()].filter((e) => !e.shown)
|
|
35
|
+
for (const e of out) e.shown = true
|
|
36
|
+
return out.map((e) => e.message)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeJson(file, data) {
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
41
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A short name for a URL: https://api.groq.com/openai/v1 → groq, https://proxy.dev/groq → groq.
|
|
45
|
+
export function providerName(url) {
|
|
46
|
+
const u = new URL(url)
|
|
47
|
+
if (/^(localhost|127\.|0\.0\.0\.0)/.test(u.hostname)) return LOCAL_NAMES[u.port] ?? `local-${u.port || 80}`
|
|
48
|
+
const seg = u.pathname.split('/').filter((s) => s && !/^(v\d+\w*|api|openai)$/i.test(s)).pop()
|
|
49
|
+
if (seg) return seg.toLowerCase()
|
|
50
|
+
const parts = u.hostname.split('.').filter((p) => !['api', 'www', 'integrate', 'generativelanguage'].includes(p))
|
|
51
|
+
return (parts.length > 1 ? parts.at(-2) : parts[0]) || u.hostname
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fromFile() {
|
|
55
|
+
const file = readJson(CONFIG_FILE, {})
|
|
56
|
+
const cfg = { ...file, providers: { ...file.providers }, adhoc: null }
|
|
57
|
+
if (file.baseUrl && !file.providers) {
|
|
58
|
+
// config from before providers existed
|
|
59
|
+
const name = providerName(file.baseUrl)
|
|
60
|
+
cfg.providers[name] = { baseUrl: file.baseUrl, apiKey: file.apiKey }
|
|
61
|
+
cfg.provider = name
|
|
62
|
+
}
|
|
63
|
+
delete cfg.baseUrl
|
|
64
|
+
delete cfg.apiKey
|
|
65
|
+
return cfg
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
|
|
69
|
+
// flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
|
|
70
|
+
// only (cfg.adhoc), unless saved.
|
|
71
|
+
// The model name that means your list: #1, then the fallbacks when one fails (also: no model given).
|
|
72
|
+
// Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
|
|
73
|
+
export const DEFAULT_MODEL = 'default'
|
|
74
|
+
|
|
75
|
+
export function loadConfig(flags = {}) {
|
|
76
|
+
const file = fromFile()
|
|
77
|
+
const cfg = { providers: file.providers, provider: file.provider, model: file.model, adhoc: null }
|
|
78
|
+
for (const k of SETTINGS) cfg[k] = flags[k] ?? file[k]
|
|
79
|
+
if (cfg.context) cfg.context = Number(cfg.context)
|
|
80
|
+
|
|
81
|
+
const baseUrl = flags.baseUrl || process.env.HEART_BASE_URL || process.env.OPENAI_BASE_URL
|
|
82
|
+
const apiKey = flags.apiKey || process.env.HEART_API_KEY || process.env.OPENAI_API_KEY
|
|
83
|
+
if (baseUrl) {
|
|
84
|
+
const name = providerName(baseUrl)
|
|
85
|
+
cfg.providers[name] = { baseUrl, apiKey }
|
|
86
|
+
if (cfg.provider !== name) cfg.model = undefined
|
|
87
|
+
cfg.provider = name
|
|
88
|
+
cfg.adhoc = name
|
|
89
|
+
cfg.fixed = true
|
|
90
|
+
} else if (apiKey && cfg.providers[cfg.provider] && !cfg.providers[cfg.provider].apiKey) {
|
|
91
|
+
cfg.providers[cfg.provider].apiKey = apiKey
|
|
92
|
+
}
|
|
93
|
+
const model = flags.model || process.env.HEART_MODEL
|
|
94
|
+
if (model && model !== DEFAULT_MODEL) Object.assign(cfg, { model, fixed: true })
|
|
95
|
+
return cfg
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function saveConfig(cfg) {
|
|
99
|
+
const providers = { ...cfg.providers }
|
|
100
|
+
if (cfg.adhoc) delete providers[cfg.adhoc]
|
|
101
|
+
const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
|
|
102
|
+
const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
|
|
103
|
+
for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
|
|
104
|
+
writeJson(CONFIG_FILE, out)
|
|
105
|
+
}
|