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/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
- export const outage = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500
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 && /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))
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|\bTPM\b|rate.?limit/i.test(e.message) } : null
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
- export function isLocal(url) {
67
- const h = new URL(url).hostname
68
- return /^(localhost|127\.|0\.0\.0\.0|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)
69
- }
70
-
71
- // Removes reasoning blocks some models put inside the normal content.
72
- export function stripThink(text) {
73
- let t = String(text ?? '').replace(/<think>[\s\S]*?<\/think>/g, '')
74
- const open = t.indexOf('<think>')
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> blocks so far (an unclosed one counts), holding back a half-received "</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
- let out = ''
93
- let i = 0
94
- for (;;) {
95
- const open = full.indexOf('<think>', i)
96
- if (open === -1) return out
97
- const close = full.indexOf('</think>', open + 7)
98
- if (close === -1) {
99
- let rest = full.slice(open + 7)
100
- for (let k = 8; k > 0; k--) {
101
- if (rest.endsWith('</think>'.slice(0, k))) {
102
- rest = rest.slice(0, -k)
103
- break
104
- }
105
- }
106
- return out + rest
107
- }
108
- out += full.slice(open + 7, close)
109
- i = close + 8
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
- if (/^\s*</.test(body)) body = /<title>([^<]*)/i.exec(body)?.[1]?.trim() ?? 'an HTML error page' // proxies and gateways
232
- const err = new Error(`${res.status} from ${url}: ${body.slice(0, 300)}`)
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
- res = await this.#fetch(this.baseUrl + '/chat/completions', { method: 'POST', body: JSON.stringify(req) }, { signal })
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 || !([408, 409, 429].includes(e.status) || e.status >= 500) || attempt === 3 || signal?.aborted) throw e
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
- // Some APIs reject parameters older servers need; learn once and retry. (Refused: 400, or 422
452
- // from the ones that check requests against a schema, like Mistral.)
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
- const said = e.body ?? e.message
455
- if (refused && /temperature/.test(e.message) && 'temperature' in body && !this.quirks.noTemperature) {
456
- this.quirks.noTemperature = true
457
- log('quirk', 'server rejects temperature')
458
- return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
459
- }
460
- for (const [quirk, param] of [['noEffort', 'reasoning_effort'], ['noStreamUsage', 'stream_options']]) {
461
- if (refused && param in req && new RegExp(param).test(e.message) && !this.quirks[quirk]) {
462
- this.quirks[quirk] = true
463
- log('quirk', `server rejects ${param}; dropping it`)
464
- return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
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
- // The thinking sent back, or the tools' arguments (JSON schemas with keys a provider doesn't
468
- // take: Gemini's "Unknown name additionalProperties"), refused: sent without it, or with
469
- // simpler schemas, from now on with this model.
470
- const reasoningSent = !this.quirks.noReasoningBack && body.messages?.some((m) => m.reasoning_content)
471
- const noReasoning = () => {
472
- this.quirks.noReasoningBack = true
473
- log('quirk', 'server rejects the thinking sent back; not sending it')
474
- return this.#chat(body, { onToken, onReasoning, onToolDelta, signal, once })
475
- }
476
- if (refused && reasoningSent && /reasoning(?!_effort)/i.test(said)) return noReasoning()
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
- const { value, done: end } = await reader.read()
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) throw new Error(`server error: ${chunk.error.message ?? JSON.stringify(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
- const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason)
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 ||= `call_${Date.now()}_${i}`))
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 week (it's learned
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 = 7 * 86_400_000
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
- const isFolder = /SKILL\.md$/.test(existing.file)
67
- fs.rmSync(isFolder ? path.dirname(existing.file) : existing.file, { recursive: true, force: true })
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
- if (!cfg?.command && !(DEFAULT_SERVERS[name] && cfg)) return 'error: config needs "command" (and usually "args"), like {"command": "npx", "args": ["-y", "some-mcp"]}'
130
- const clean = Object.fromEntries(['command', 'args', 'env', 'description', 'disabled'].filter((k) => cfg[k] !== undefined).map((k) => [k, cfg[k]]))
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, missing
63
- // SEARCH/REPLACE words and a missing final marker.
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
- if (cur && part === 'replace') blocks.push(join(cur))
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 although the task never asked for it — the
122
- // typical small-model rewrite accident.
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 own = { ...readJson(path.join(HOME, 'format.json'), {}), ...readJson(path.join(root, '.heartcode', 'format.json'), {}) }
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(own)) {
54
- if (glob(pattern).test(base) || glob(pattern).test(rel)) return cmd || null
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
- export async function formatFile(root, rel) {
64
- const cmd = formatterFor(root, rel)
65
- if (!cmd) return null
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)}`