heartcode 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +24 -10
- package/bin/heartcode.js +70 -30
- package/package.json +1 -1
- package/prompts/agent.md +15 -30
- package/prompts/guided.md +6 -6
- package/src/acp.js +3 -3
- package/src/agent.js +168 -68
- package/src/client.js +162 -81
- package/src/context.js +2 -2
- package/src/director.js +8 -5
- package/src/edit.js +5 -6
- package/src/format.js +18 -9
- package/src/guided.js +27 -55
- package/src/headless.js +18 -11
- package/src/hooks.js +1 -1
- package/src/mcp.js +24 -8
- package/src/memory.js +6 -4
- package/src/probe.js +6 -3
- package/src/settings.js +19 -2
- package/src/tools.js +138 -41
- package/src/usage.js +9 -4
package/src/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 =
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
530
|
-
|
|
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 (
|
|
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
|
-
|
|
592
|
-
const
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 }
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
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] ?? '')
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|