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/guided.js CHANGED
@@ -17,41 +17,15 @@ const ACTIONS = prompt('guided/actions')
17
17
  .map((l) => l.split(/:\s*/, 2))
18
18
  .filter(([n, d]) => n && d)
19
19
  const DESC = Object.fromEntries(ACTIONS)
20
- const CHANGE_WORDS = /\b(fix|add|change|implement|create|write|make|rename|refactor|update|remove|delete|replace|edit|convert|move|improve)\b/i
21
- const RUN_WORDS = /\b(tests?|run|build|lint|compile)\b/i
22
- const FAIL_WORDS = /\b(fail\w*|broken|breaks?|errors?|crash\w*)\b/i
23
20
  const TEST_FILE = /(^|[/._-])(tests?|spec|__tests__)([/._-]|$)/i
24
- const TESTS_ARE_TARGET = /\b(add|write|update|change|fix|edit)\s+(the\s+|a\s+|some\s+)?(unit\s+)?tests?\b/i
25
- const QUESTION = /^\s*(what|how|why|where|which|who|when|explain|describe|summari[sz]e|tell|show|list|is|are|does|do|can|could|should)\b|\?\s*$/i
26
-
27
- // Wording first; the model only breaks a tie (small models answer yes/no unreliably).
28
- export function intent(task) {
29
- const change = CHANGE_WORDS.test(task)
30
- const question = QUESTION.test(task)
31
- return change && !question ? true : question && !change ? false : null
32
- }
33
-
34
- // A path in the task that doesn't exist yet, e.g. "create src/slug.js".
35
- export function newFileIn(task, files) {
36
- if (!/\b(create|new|add|write|make|generate)\b/i.test(task)) return null
37
- for (const m of task.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/)*[\w-]+\.[a-z0-9]{1,6})(?=$|[\s`'"),:;]|\.(?:\s|$))/gi)) {
38
- const f = m[1].replace(/^\.\//, '')
39
- if (!files.includes(f) && !/^(node|next|vue|react|nuxt|express|three|d3)\.js$/i.test(f)) return f
40
- }
41
- return null
42
- }
43
21
 
44
22
  // Obvious next steps are taken without asking: a weak model's effort goes into
45
23
  // writing code, not into navigating. Returns an action, or null to ask.
46
24
  export function autoAction(st) {
47
25
  if (st.delegated) return 'finish' // another model did the task: say what it did
48
- if (st.newFile && st.wantsChange) return 'create'
49
- if (st.failing && st.canRun && !st.runs && !st.edits) return 'run' // see the failure first
50
26
  if (st.lastRun !== null && st.lastRun !== 0 && !st.editsSinceRun && st.wantsChange && st.fixes < 3) return 'edit'
51
27
  if (st.wantsChange && st.reads && !st.edits) return 'edit'
52
- if (st.editsSinceRun && st.canRun && st.wantsRun) return 'run'
53
28
  if (st.lastRun === 0 && st.edits && !st.editsSinceRun) return 'finish'
54
- if (st.wantsRun && !st.runs && !st.wantsChange) return 'run'
55
29
  return null
56
30
  }
57
31
 
@@ -294,13 +268,11 @@ class Guided {
294
268
  }
295
269
 
296
270
  async start(images) {
271
+ this.images = images.length > 0
297
272
  if (images.length) await this.describeImages(images)
298
273
  this.autoRead()
299
274
  this.ui.spinner('reading the task')
300
- this.st.wantsChange = intent(this.task) ?? (await this.yesNo(prompt('guided/intent'), false))
301
- this.st.newFile = newFileIn(this.task, this.files)
302
- this.st.wantsRun = RUN_WORDS.test(this.task)
303
- this.st.failing = FAIL_WORDS.test(this.task)
275
+ this.st.wantsChange = await this.yesNo(prompt('guided/intent'), false) // the model says, not words in the task
304
276
  let last = ''
305
277
  let repeats = 0
306
278
  // No step limit (Esc stops it); a model doing the same thing three times in a row is stopped.
@@ -410,11 +382,9 @@ class Guided {
410
382
  const seen = this.notes.filter((n) => n.key?.startsWith('read:')).map((n) => n.key.slice(5)).reverse()
411
383
  // Weak models pick the first option: offer the code under test before the tests themselves.
412
384
  let files = [...new Set([...seen, ...this.files])]
413
- const testsAreTarget = TESTS_ARE_TARGET.test(this.task)
414
- if (!testsAreTarget) files = [...files.filter((x) => !TEST_FILE.test(x)), ...files.filter((x) => TEST_FILE.test(x))]
385
+ files = [...files.filter((x) => !TEST_FILE.test(x)), ...files.filter((x) => TEST_FILE.test(x))]
415
386
  let f = await this.pickFile(prompt('guided/which-file-to-edit'), files)
416
387
  if (!f) return 'edit:'
417
- if (this.st.lastRun && TEST_FILE.test(f) && !testsAreTarget) f = this.codeUnderTest(f)
418
388
  const old = fs.readFileSync(resolveIn(this.s.root, f), 'utf8')
419
389
  this.ui.step('edit', f)
420
390
  let next = null
@@ -425,24 +395,20 @@ class Guided {
425
395
  if (lost.length) {
426
396
  this.ui.result(c.yellow(`that edit deleted ${lost.join(', ')}; retrying`))
427
397
  const again = await this.editRewrite(f, old, prompt('guided/keep-names', { names: lost.join(', '), them: lost.length > 1 ? 'them' : 'it' }))
428
- if (again !== null && !lostNames(old, again, this.task).length) next = again
429
- else this.ui.warn(`this edit removes ${lost.join(', ')}; check the diff`)
398
+ const still = again === null ? lost : lostNames(old, again, this.task)
399
+ if (again !== null && !still.length) next = again
400
+ // still gone: written only if the model says the task asks for that (not from words in it)
401
+ else if (await this.yesNo(prompt('guided/meant-to-remove', { names: still.join(', ') }), false)) next = again ?? next
402
+ else return (this.failed(f, `the edit removed ${still.join(', ')}, which the task doesn't ask for`), `edit:${f}`)
430
403
  }
431
404
  await this.apply(f, old, next)
432
405
  return `edit:${f}`
433
406
  }
434
407
 
435
- // Weak models "fix" failing tests by editing the tests. Unless the task asks for test changes,
436
- // the edit goes to the code the test imports.
437
- codeUnderTest(test) {
438
- const code = localImports(test, fs.readFileSync(resolveIn(this.s.root, test), 'utf8'), this.files).find((x) => !TEST_FILE.test(x))
439
- if (code) this.note(null, 'note', prompt('guided/note-tests-are-right', { test, code }))
440
- return code ?? test
441
- }
442
-
443
408
  async editBlocks(f, old) {
444
409
  const q = prompt('guided/edit-blocks', { file: f, content: fence(old) })
445
410
  const res = await this.query(q, { room: Math.min(est(old) + 800, 4096), onToken: this.progress(`editing ${f}`), skipKey: `read:${f}` })
411
+ if (this.unfinished(f, res)) return null
446
412
  const blocks = parseBlocks(res.content)
447
413
  let text = old
448
414
  for (const b of blocks) {
@@ -461,7 +427,7 @@ class Guided {
461
427
  if (est(old) * 2.3 + 1000 < this.s.strat.context) {
462
428
  const q = prompt('guided/rewrite', { file: f, content: fence(old), extra })
463
429
  const res = await this.query(q, { room: Math.ceil(est(old) * 1.5) + 600, onToken: this.progress(`rewriting ${f}`), skipKey })
464
- if (res.finishReason === 'length') return this.failed(f, 'the reply was cut off before the end of the file')
430
+ if (this.unfinished(f, res)) return null
465
431
  return cleanCode(res.content, old)
466
432
  }
467
433
 
@@ -500,11 +466,20 @@ class Guided {
500
466
  extra,
501
467
  })
502
468
  const res = await this.query(q, { room: est(chunk) * 2 + 500, onToken: this.progress(`rewriting ${f}:${a}-${b}`), skipKey })
503
- if (res.finishReason === 'length') return this.failed(f, 'the reply was cut off')
469
+ if (this.unfinished(f, res)) return null
504
470
  const repl = reindent(lines.slice(a - 1, b), cleanCode(res.content, chunk).split('\n'))
505
471
  return [...lines.slice(0, a - 1), ...repl, ...lines.slice(b)].join('\n')
506
472
  }
507
473
 
474
+ // A reply that can't be a whole file or edit: cut off (a length limit), stopped by a filter, or
475
+ // empty (all of it in the thinking). Nothing is written from it.
476
+ unfinished(f, res) {
477
+ if (res.finishReason === 'length') return this.failed(f, 'the reply was cut off') ?? true
478
+ if (res.finishReason === 'content_filter') return this.failed(f, 'the reply was stopped by the provider\'s filter') ?? true
479
+ if (!String(res.content ?? '').trim()) return this.failed(f, 'the reply was empty') ?? true
480
+ return false
481
+ }
482
+
508
483
  failed(f, why) {
509
484
  this.ui.result(c.red(`edit failed: ${why}`))
510
485
  this.note(null, `editing ${f} failed`, why)
@@ -526,12 +501,8 @@ class Guided {
526
501
  }
527
502
 
528
503
  async create() {
529
- let rel = this.st.newFile
530
- this.st.newFile = null
531
- if (!rel) {
532
- const res = await this.query(prompt('guided/new-file-path'), { room: 40, oneLine: true })
533
- rel = unquote(answerText(res.content).split('\n')[0].trim())
534
- }
504
+ const res = await this.query(prompt('guided/new-file-path'), { room: 40, oneLine: true })
505
+ const rel = unquote(answerText(res.content).split('\n')[0].trim())
535
506
  if (!rel) return 'create:'
536
507
  const abs = resolveIn(this.s.root, rel)
537
508
  const f = path.relative(this.s.root, abs)
@@ -546,8 +517,7 @@ class Guided {
546
517
  room: Math.min(4096, Math.floor(this.s.strat.context / 2)),
547
518
  onToken: this.progress(`writing ${f}`),
548
519
  })
549
- if (r.finishReason === 'length') this.failed(f, 'the reply was cut off')
550
- else await this.apply(f, '', cleanCode(r.content))
520
+ if (!this.unfinished(f, r)) await this.apply(f, '', cleanCode(r.content))
551
521
  return `create:${f}`
552
522
  }
553
523
 
@@ -588,8 +558,10 @@ class Guided {
588
558
 
589
559
  // Another of your models, one with tool calls, does the whole task; what it did goes in the notes.
590
560
  async delegate() {
591
- const team = this.s.team().filter((t) => t.tools)
592
- const t = (/\.(png|jpe?g|gif|webp)\b/i.test(this.task) && team.find((x) => x.images)) || team[0]
561
+ // the model with tool calls that failed least this session (and sees images, if the task came with some)
562
+ const fails = (t) => this.s.failures?.[`${t.provider} ${t.model}`] ?? 0
563
+ const team = this.s.team().filter((t) => t.tools).sort((a, b) => (this.images ? b.images - a.images : 0) || fails(a) - fails(b))
564
+ const t = team[0]
593
565
  const done = await this.s.subagent({ description: 'the task', prompt: this.task, model: `${t.provider} ${t.model}` }, this.signal)
594
566
  this.note('delegate', `what ${t.model} did for the task`, done)
595
567
  this.st.delegated = true
package/src/headless.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // told to the model. Logs and warnings go to stderr (and HEART_LOG), never stdout.
13
13
  import readline from 'node:readline'
14
14
  import { log } from './log.js'
15
- import { clip, TOOL_DEFS, extraTools, loadMcpTools } from './tools.js'
15
+ import { clip, TOOL_DEFS, extraTools, loadMcpTools, failed as toolFailed } from './tools.js'
16
16
  import { allowed } from './agent.js'
17
17
 
18
18
  // Claude Code's tool names → HeartCode's (for --allowedTools / --disallowedTools).
@@ -97,11 +97,12 @@ export async function runHeadless(app, opts) {
97
97
  let usage = { input_tokens: 0, output_tokens: 0 }
98
98
  let byModel = {}
99
99
  let requests = 0
100
- const count = (client, key) => {
100
+ // each request under the model that answered it (the model list may have moved on)
101
+ const count = (client) => {
101
102
  const chat = client.chat.bind(client)
102
103
  client.chat = async (...a) => {
103
104
  const r = await chat(...a)
104
- const m = (byModel[key] ??= { inputTokens: 0, outputTokens: 0 })
105
+ const m = (byModel[`${client.provider}/${client.model}`] ??= { inputTokens: 0, outputTokens: 0 })
105
106
  if (client === s.client) requests++
106
107
  usage.input_tokens += r.usage?.prompt_tokens ?? 0
107
108
  usage.output_tokens += r.usage?.completion_tokens ?? 0
@@ -110,11 +111,11 @@ export async function runHeadless(app, opts) {
110
111
  return r
111
112
  }
112
113
  }
113
- count(s.client, model)
114
+ count(s.client)
114
115
  const engineFor = s.engineFor
115
116
  if (engineFor) s.engineFor = async (t) => {
116
117
  const e = await engineFor(t)
117
- count(e.client, `${t.provider}/${t.model}`)
118
+ count(e.client)
118
119
  return e
119
120
  }
120
121
 
@@ -129,9 +130,9 @@ export async function runHeadless(app, opts) {
129
130
  ]
130
131
  if (e.content && !e.parent) said = true
131
132
  // a helper's (agent) steps too, as Claude Code sends a subagent's: parent_tool_use_id is its agent call
132
- if (content.length) emit({ type: 'assistant', message: { role: 'assistant', model: e.parent ? e.model : model, content }, parent_tool_use_id: e.parent ?? null, session_id })
133
+ if (content.length) emit({ type: 'assistant', message: { role: 'assistant', model: e.model ?? model, content }, parent_tool_use_id: e.parent ?? null, session_id })
133
134
  } else if (e.type === 'tool') {
134
- emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, parent_tool_use_id: e.parent ?? null, session_id })
135
+ emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: toolFailed(e.output) }] }, parent_tool_use_id: e.parent ?? null, session_id })
135
136
  }
136
137
  }
137
138
  if (s.strat.actions === 'tools' && s.mcp?.names.length) await loadMcpTools(s) // their tools are in the list, as in Claude Code's
@@ -164,9 +165,9 @@ export async function runHeadless(app, opts) {
164
165
  }
165
166
  const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
166
167
  current = null
167
- const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: 0, outputTokens: 0 }, ...byModel }, session_id }
168
+ const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: Object.keys(byModel).length ? byModel : { [model]: { inputTokens: 0, outputTokens: 0 } }, session_id }
168
169
  if (out === 'stream-json') {
169
- if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, parent_tool_use_id: null, session_id }) // guided mode
170
+ if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model: `${s.client.provider}/${s.client.model}`, content: [{ type: 'text', text: answer }] }, parent_tool_use_id: null, session_id }) // guided mode
170
171
  emit(result)
171
172
  } else if (out === 'json') emit(result)
172
173
  else if (error) process.stderr.write(`heartcode: ${error}\n`)
@@ -197,8 +198,14 @@ export async function runHeadless(app, opts) {
197
198
  continue
198
199
  }
199
200
  if (msg.type === 'control_request') {
200
- if (msg.request?.subtype === 'interrupt') current?.ctl.abort()
201
- emit({ type: 'control_response', response: { subtype: 'success', request_id: msg.request_id } })
201
+ // as Claude Code answers them: initialize (an SDK's first line), interrupt, a permission mode;
202
+ // any other is said to be unsupported, not taken as done
203
+ const kind = msg.request?.subtype
204
+ const ok = () => emit({ type: 'control_response', response: { subtype: 'success', request_id: msg.request_id } })
205
+ if (kind === 'interrupt') (current?.ctl.abort(), ok())
206
+ else if (kind === 'initialize') ok()
207
+ else if (kind === 'set_permission_mode' && PERMISSION_MODES[msg.request.mode]) (app.ui.setPermission(PERMISSION_MODES[msg.request.mode]), ok())
208
+ else emit({ type: 'control_response', response: { subtype: 'error', request_id: msg.request_id, error: `HeartCode doesn't support the control request "${kind ?? ''}"` } })
202
209
  continue
203
210
  }
204
211
  if (msg.type !== 'user') continue
package/src/hooks.js CHANGED
@@ -45,7 +45,7 @@ const matches = (matcher, tool) => {
45
45
  if (!matcher || matcher === '*' || !tool) return true
46
46
  try {
47
47
  const re = new RegExp(`^(?:${matcher})$`)
48
- return re.test(tool) || re.test(CLAUDE_NAMES[tool] ?? '') || (tool === 'mcp' && /^mcp__/.test(matcher))
48
+ return re.test(tool) || re.test(CLAUDE_NAMES[tool] ?? '')
49
49
  } catch {
50
50
  return matcher === tool
51
51
  }
package/src/mcp.js CHANGED
@@ -120,6 +120,7 @@ class Server {
120
120
  log('mcp', `starting ${this.name}: ${command} ${args.join(' ')}`)
121
121
  const child = (this.child = spawn(command, args, { cwd: this.root ?? undefined, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' })) // in the project, so a relative path in its config works
122
122
  let buf = ''
123
+ child.stdout.setEncoding('utf8') // a character split between two chunks stays whole
123
124
  child.stdout.on('data', (d) => {
124
125
  buf += d
125
126
  let nl
@@ -133,8 +134,11 @@ class Server {
133
134
  const gone = new Promise((_, reject) => {
134
135
  child.on('error', (e) => reject(new Error(`${this.name} could not start: ${e.message}`)))
135
136
  child.on('exit', (code) => {
136
- this.ready = null
137
- this.#failAll(new Error(`${this.name} stopped (exit ${code})`))
137
+ // a server started again since: this old one's exit is nothing to the new one
138
+ if (this.child === child) {
139
+ this.ready = null
140
+ this.#failAll(new Error(`${this.name} stopped (exit ${code})`))
141
+ }
138
142
  reject(new Error(`${this.name} stopped (exit ${code})`))
139
143
  })
140
144
  })
@@ -235,6 +239,8 @@ class Server {
235
239
  return log('mcp', `${this.name} sent a non-JSON line: ${line.slice(0, 200)}`)
236
240
  }
237
241
  }
242
+ // a long call saying it's still at work: its time to answer starts again
243
+ if (msg.method === 'notifications/progress') return this.pending.get(msg.params?.progressToken)?.alive?.()
238
244
  if (msg.id !== undefined && (msg.result !== undefined || msg.error) && this.pending.has(msg.id)) {
239
245
  const { resolve, reject } = this.pending.get(msg.id)
240
246
  this.pending.delete(msg.id)
@@ -249,13 +255,21 @@ class Server {
249
255
  }
250
256
  }
251
257
 
258
+ // timeout: how long it may go without answering, or saying it's still at work (a tool call asks
259
+ // for progress notifications; each one gives it the time again)
252
260
  request(method, params, timeout = 120_000, signal) {
253
261
  return new Promise((resolve, reject) => {
254
262
  const id = this.next++
255
- const timer = setTimeout(() => {
256
- this.pending.delete(id)
257
- reject(new Error(`${this.name} did not answer ${method} within ${timeout / 1000}s`))
258
- }, timeout)
263
+ let timer
264
+ const alive = () => {
265
+ clearTimeout(timer)
266
+ timer = setTimeout(() => {
267
+ this.pending.delete(id)
268
+ reject(new Error(`${this.name} did not answer ${method} within ${timeout / 1000}s`))
269
+ }, timeout)
270
+ }
271
+ alive()
272
+ if (method === 'tools/call') params = { ...params, _meta: { ...params?._meta, progressToken: id } }
259
273
  const done = (fn) => (v) => {
260
274
  clearTimeout(timer)
261
275
  signal?.removeEventListener('abort', onAbort)
@@ -266,7 +280,7 @@ class Server {
266
280
  done(reject)(Object.assign(new Error('stopped'), { name: 'AbortError' }))
267
281
  }
268
282
  signal?.addEventListener('abort', onAbort, { once: true })
269
- this.pending.set(id, { resolve: done(resolve), reject: done(reject) })
283
+ this.pending.set(id, { resolve: done(resolve), reject: done(reject), alive })
270
284
  this.#send({ jsonrpc: '2.0', id, method, params })
271
285
  })
272
286
  }
@@ -277,7 +291,7 @@ class Server {
277
291
  this.tools = []
278
292
  let cursor
279
293
  do {
280
- const r = await this.request('tools/list', cursor ? { cursor } : {})
294
+ const r = await this.request('tools/list', cursor ? { cursor } : {}, 120_000, signal)
281
295
  this.tools.push(...(r.tools ?? []))
282
296
  cursor = r.nextCursor
283
297
  } while (cursor)
@@ -296,6 +310,7 @@ class Server {
296
310
  if (this.url) {
297
311
  this.abort?.abort()
298
312
  Object.assign(this, { ready: null, session: null, endpoint: null })
313
+ this.#failAll(new Error(`${this.name} was stopped`))
299
314
  return
300
315
  }
301
316
  if (!this.child) return
@@ -305,6 +320,7 @@ class Server {
305
320
  } catch {}
306
321
  this.child = null
307
322
  this.ready = null
323
+ this.#failAll(new Error(`${this.name} was stopped`)) // calls waiting on it fail now (its exit comes later, ignored)
308
324
  }
309
325
  }
310
326
 
package/src/memory.js CHANGED
@@ -18,13 +18,15 @@ import { HOME } from './config.js'
18
18
 
19
19
  export const TYPES = ['user', 'project', 'feedback', 'reference']
20
20
  const LINK = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g
21
- export const slug = (s) =>
22
- String(s)
21
+ // A note's file name (60 characters at most: a longer name is refused when written, tools.js, so
22
+ // two long names that start alike can't become one note)
23
+ export const fullSlug = (s) =>
24
+ String(s ?? '')
23
25
  .toLowerCase()
24
26
  .replace(/\.md$/, '')
25
27
  .replace(/[^a-z0-9]+/g, '-')
26
28
  .replace(/^-+|-+$/g, '')
27
- .slice(0, 60) || 'note'
29
+ export const slug = (s) => fullSlug(s).slice(0, 60) || 'note'
28
30
 
29
31
  // One folder of notes.
30
32
  class Notes {
@@ -71,7 +73,7 @@ class Notes {
71
73
 
72
74
  // Creates or replaces a note; returns its name.
73
75
  write({ name, description, type, tags, content }) {
74
- const n = slug(name || description)
76
+ const n = slug(String(name ?? '').trim() || description)
75
77
  const old = this.read(n)
76
78
  const t = TYPES.includes(type) ? type : (old?.type ?? 'project')
77
79
  const d = String(description || old?.description || String(content).split('\n')[0]).replace(/\s+/g, ' ').trim().slice(0, 200)
package/src/probe.js CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { deflateSync } from 'node:zlib'
4
4
  import { HOME, readJson, writeJson } from './config.js'
5
5
  import { est } from './client.js'
6
- import { contextWindow, contextSource, LOCAL_SERVERS, ASSUMED_REMOTE } from './context.js'
6
+ import { contextWindow, contextSource, LOCAL_SERVERS, ASSUMED_REMOTE, LIMIT_MS } from './context.js'
7
7
  import { loadPrices, limitsOf } from './usage.js'
8
8
  import { applyEdit, parseBlocks } from './edit.js'
9
9
  import { c } from './ui.js'
@@ -107,6 +107,9 @@ async function runProbe(client, report = () => {}, signal) {
107
107
  return Boolean(await fn())
108
108
  } catch (e) {
109
109
  if (e.name === 'AbortError' || /^cannot reach/.test(e.message)) throw e
110
+ // no answer (a timeout, a stalled reply): nothing learned, nothing saved; an answer it can't
111
+ // read (not JSON) counts as "not supported" below
112
+ if (e.name === 'TimeoutError') throw new Error(`probe stopped, nothing saved: ${e.message}`)
110
113
  if ([401, 429].includes(e.status) || e.status >= 500 || ([403, 404].includes(e.status) && !calls))
111
114
  throw new Error(`probe stopped, nothing saved: ${e.message}`)
112
115
  lastError = e
@@ -297,7 +300,7 @@ export function strategy(p, cfg = {}) {
297
300
  toolsStream: stream && (said ? true : Boolean(p.toolsStream)),
298
301
  stop: said ? !thinking : Boolean(p.stop), // stop sequences cut into a thinking model's thinking
299
302
  mergeSystem: said ? false : !most(p.system),
300
- vision: set.images ?? (said ? Boolean(said.images) : all(p.vision)),
303
+ vision: !(Date.now() - (p.quirks?.noImages ?? -Infinity) < LIMIT_MS) && (set.images ?? (said ? Boolean(said.images) : all(p.vision))), // its provider refused images this week: none
301
304
  thinking,
302
305
  context: cfg.context || set.context || win.size, // context.js: measured, said, models.dev, learned, or assumed
303
306
  contextSure: Boolean(cfg.context || set.context) || win.sure,
@@ -318,7 +321,7 @@ export function describe(p, s) {
318
321
  if (p.source) {
319
322
  const from = { 'models.dev': 'models.dev', you: 'you, on /settings', assumed: "nothing: it isn't in models.dev. Set what it can do on /settings" }[p.source]
320
323
  const mine = (k) => (p.set?.[k] !== undefined ? c.dim(' (set by you)') : '')
321
- return [['from', from], context, ['tool calls', yn(s.actions === 'tools') + mine('tools')], ['thinking', yn(s.thinking) + mine('thinking')], ['images', yn(s.vision) + mine('images')], ['streaming', yn(s.stream) + mine('stream')]]
324
+ return [['from', from], context, ['tool calls', yn(s.actions === 'tools') + mine('tools')], ['thinking', yn(s.thinking) + mine('thinking')], ['images', yn(s.vision) + (Date.now() - (p.quirks?.noImages ?? -Infinity) < LIMIT_MS ? c.dim(' (its provider refused an image today)') : mine('images'))], ['streaming', yn(s.stream) + mine('stream')]]
322
325
  .map(([k, v]) => ` ${c.dim(k.padEnd(15))} ${v}`)
323
326
  .join('\n')
324
327
  }
package/src/settings.js CHANGED
@@ -78,6 +78,21 @@ const ROTATION = { on: ['failure', 'retries', 'any'], next: ['first', 'stay'] }
78
78
  const rotationOf = (cfg) => ({ on: 'failure', retries: 2, next: 'first', ...cfg.rotation, then: (cfg.rotation?.then ?? []).filter((t) => cfg.providers[t.provider]) })
79
79
 
80
80
  const mask = (k) => (k ? (k.length > 12 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '…') : '')
81
+ async function schemeFor(address) {
82
+ try {
83
+ await fetch(`https://${address.replace(/\/+$/, '')}/models`, { redirect: 'manual', signal: AbortSignal.timeout(8000) })
84
+ } catch (e) {
85
+ // https can't connect at all (refused, or a server that only speaks http): http, if that answers
86
+ if (e.name !== 'TimeoutError') {
87
+ try {
88
+ await fetch(`http://${address.replace(/\/+$/, '')}/models`, { redirect: 'manual', signal: AbortSignal.timeout(8000) })
89
+ return `http://${address}`
90
+ } catch {}
91
+ }
92
+ }
93
+ return `https://${address}` // it answered on https, or is slow: never the key in the clear on a guess
94
+ }
95
+
81
96
  const safeName = (n) => String(n ?? '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
82
97
  const skillsDir = () => path.join(HOME, 'skills')
83
98
 
@@ -191,11 +206,13 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
191
206
  'GET /api/state': () => state(),
192
207
  'GET /api/log-size': () => logSize(),
193
208
  'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
194
- 'POST /api/provider': ({ name, baseUrl, apiKey, keepKey, was }) => {
209
+ 'POST /api/provider': async ({ name, baseUrl, apiKey, keepKey, was }) => {
195
210
  name = safeName(name)
196
211
  if (!name) throw new Error('a name is needed')
197
212
  baseUrl = String(baseUrl ?? '').trim()
198
- if (!/^https?:\/\//i.test(baseUrl)) baseUrl = (/^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.)/.test(baseUrl) ? 'http://' : 'https://') + baseUrl
213
+ // an address without http(s)://: whichever of the two the server answers on (any answer, even
214
+ // a 401, means it's there), https first
215
+ if (!/^https?:\/\//i.test(baseUrl)) baseUrl = await schemeFor(baseUrl)
199
216
  try {
200
217
  if (!new URL(baseUrl).hostname) throw new Error()
201
218
  } catch {