heartcode 0.1.14 → 0.1.24
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/CHANGELOG.md +197 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +408 -142
- package/src/settings.js +44 -7
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
package/src/client.js
CHANGED
|
@@ -18,6 +18,13 @@ export function estMessages(messages) {
|
|
|
18
18
|
const text = JSON.stringify(messages ?? [], (k, v) => (k === 'url' && typeof v === 'string' && v.startsWith('data:') ? (images++, '') : v))
|
|
19
19
|
return est(text) + images * 800
|
|
20
20
|
}
|
|
21
|
+
// A request's size in a model's tokens, estimated: its messages and tools, by that model's ratio of
|
|
22
|
+
// counted to estimated tokens (context.js). The same measure decides when to compact (agent.js) and
|
|
23
|
+
// whether a request fits the model the list moves on to.
|
|
24
|
+
export const tokensOf = (messages, tools, ratio = 1) => Math.round((estMessages(messages) + (tools?.length ? est(JSON.stringify(tools)) : 0)) * ratio)
|
|
25
|
+
// The share of a model's window a conversation may take: the rest is room for the reply. Past it,
|
|
26
|
+
// the conversation is compacted first.
|
|
27
|
+
export const COMPACT_SHARE = 0.85
|
|
21
28
|
|
|
22
29
|
// "Request too large" (the context window, or a provider's per-request or per-minute input cap):
|
|
23
30
|
// the same request can never work, so it isn't retried; the caller sends less. Returns the
|
|
@@ -136,7 +143,7 @@ export function fitFor(messages, can, model) {
|
|
|
136
143
|
// Refusals a provider may make of one part of a request, learned when sending it without that
|
|
137
144
|
// part works (then kept in the model's profile for a day): temperature, reasoning_effort, the
|
|
138
145
|
// usage in a stream, the thinking sent back, the tools' full schemas, images.
|
|
139
|
-
export const QUIRKS = ['noTemperature', 'noEffort', 'noStreamUsage', 'noReasoningBack', 'simpleSchemas', 'noImages']
|
|
146
|
+
export const QUIRKS = ['noTemperature', 'noEffort', 'noStreamUsage', 'noReasoningBack', 'simpleSchemas', 'noImages', 'noParallel']
|
|
140
147
|
|
|
141
148
|
export class Client {
|
|
142
149
|
constructor({ baseUrl, apiKey, model, provider, timeout = 600_000 } = {}) {
|
|
@@ -402,6 +409,7 @@ export class Client {
|
|
|
402
409
|
// 60s, or the server's Retry-After) until it works or the signal aborts. onRetry(error, ms,
|
|
403
410
|
// attempt) reports each wait; onRetry(null) reports the recovery.
|
|
404
411
|
async chat(body, opts = {}) {
|
|
412
|
+
this.refresh?.() // the settings and this model's profile as they are now (bin/heartcode.js)
|
|
405
413
|
if (!this.onRetry) {
|
|
406
414
|
return await this.#timed(body, opts)
|
|
407
415
|
}
|
|
@@ -412,9 +420,15 @@ export class Client {
|
|
|
412
420
|
const tried = new Set() // places in the model list tried for this request
|
|
413
421
|
const down = new Set() // providers that answered it with an error (400 or above): their other models are skipped
|
|
414
422
|
for (let attempt = 0; ; attempt++) {
|
|
423
|
+
// The list moved on to a model whose window can't hold this request (a smaller one): not sent
|
|
424
|
+
// to it as it is; the caller compacts the conversation for that model, then sends it (fit: the
|
|
425
|
+
// caller can; agent.js).
|
|
426
|
+
if (attempt && opts.fit && this.can?.context && tokensOf(body.messages, body.tools, this.tokenRatio) > this.can.context * COMPACT_SHARE) {
|
|
427
|
+
throw Object.assign(new Error(`the conversation is too big for ${this.provider} · ${this.model} (~${tokensOf(body.messages, body.tools, this.tokenRatio)} tokens; its window is ${this.can.context})`), { tooBig: true })
|
|
428
|
+
}
|
|
429
|
+
if (attempt) this.onRetry(null) // the wait is over and the retried request is starting: drop the countdown, back to the normal spinner
|
|
415
430
|
try {
|
|
416
431
|
const r = await this.#timed(body, { ...opts, once: true })
|
|
417
|
-
if (attempt) this.onRetry(null)
|
|
418
432
|
const rot = this.rotation
|
|
419
433
|
if (rot && rot.good !== rot.list[rot.at]) this.onSwitch?.((rot.good = rot.list[rot.at]))
|
|
420
434
|
return r
|
|
@@ -482,6 +496,7 @@ export class Client {
|
|
|
482
496
|
if (this.quirks.noTemperature) delete req.temperature
|
|
483
497
|
if (this.effort && !this.quirks.noEffort) req.reasoning_effort = this.effort
|
|
484
498
|
if (req.stream && !this.quirks.noStreamUsage) req.stream_options = { include_usage: true } // token counts for the status bar
|
|
499
|
+
if (req.tools?.length && !this.quirks.noParallel) req.parallel_tool_calls = true // several calls in one reply (llama.cpp's default is one)
|
|
485
500
|
let res
|
|
486
501
|
let sent
|
|
487
502
|
let firstAt
|
|
@@ -539,11 +554,13 @@ export class Client {
|
|
|
539
554
|
if (/temperature/i.test(said) && 'temperature' in req && !this.quirks.noTemperature) return without('noTemperature')
|
|
540
555
|
if (/reasoning_effort/.test(said) && 'reasoning_effort' in req && !this.quirks.noEffort) return without('noEffort')
|
|
541
556
|
if (/stream_options/.test(said) && req.stream_options && !this.quirks.noStreamUsage) return without('noStreamUsage')
|
|
557
|
+
if (/parallel_tool_calls/.test(said) && req.parallel_tool_calls && !this.quirks.noParallel) return without('noParallel')
|
|
542
558
|
if (reasoningSent && /reasoning(?!_effort)/i.test(said)) return without('noReasoningBack')
|
|
543
559
|
if (imagesSent && /image|vision|multimodal|modalit|content.{0,20}(type|part)/i.test(said)) return without('noImages')
|
|
544
560
|
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')
|
|
545
561
|
if (imagesSent) return without('noImages')
|
|
546
562
|
if (reasoningSent) return without('noReasoningBack')
|
|
563
|
+
if (req.parallel_tool_calls && !this.quirks.noParallel) return without('noParallel') // refused without naming it
|
|
547
564
|
throw e
|
|
548
565
|
}
|
|
549
566
|
// Tokens the last request used (prompt + reply): what the status bar shows against the context size.
|
|
@@ -582,6 +599,16 @@ export class Client {
|
|
|
582
599
|
let finishReason
|
|
583
600
|
const calls = []
|
|
584
601
|
let raw = '' // the stream as it came (its start), kept for a reply that turns out empty
|
|
602
|
+
// A reply going round (a small model repeating itself, which could fill the window): checked
|
|
603
|
+
// every 2000 characters of its thinking, its text and each call's arguments; cut where it began.
|
|
604
|
+
let looped = null
|
|
605
|
+
const checked = new Map()
|
|
606
|
+
const watch = (what, text) => {
|
|
607
|
+
if (looped || text.length - (checked.get(what) ?? 0) < 2000) return
|
|
608
|
+
checked.set(what, text.length)
|
|
609
|
+
const at = loopStart(text)
|
|
610
|
+
if (at >= 0) looped = { what, at, sample: text.slice(-200).replace(/\s+/g, ' ').trim().slice(0, 120) }
|
|
611
|
+
}
|
|
585
612
|
const emit = (v) => {
|
|
586
613
|
if (onToken && v.startsWith(emitted) && v.length > emitted.length) {
|
|
587
614
|
onToken(v.slice(emitted.length))
|
|
@@ -636,6 +663,7 @@ export class Client {
|
|
|
636
663
|
if (r) {
|
|
637
664
|
reasoning += r
|
|
638
665
|
onReasoning?.(r)
|
|
666
|
+
watch('reasoning', reasoning)
|
|
639
667
|
}
|
|
640
668
|
for (const tc of d.tool_calls ?? []) {
|
|
641
669
|
const i = tc.index ?? (tc.id ? calls.length : Math.max(calls.length - 1, 0))
|
|
@@ -644,6 +672,7 @@ export class Client {
|
|
|
644
672
|
if (tc.function?.name) call.function.name ||= tc.function.name
|
|
645
673
|
if (tc.function?.arguments) call.function.arguments += tc.function.arguments
|
|
646
674
|
onToolDelta?.(call.function.name, call.function.arguments)
|
|
675
|
+
watch(`call ${i}`, call.function.arguments)
|
|
647
676
|
}
|
|
648
677
|
if (d.content) {
|
|
649
678
|
content += d.content
|
|
@@ -653,11 +682,23 @@ export class Client {
|
|
|
653
682
|
thought = t
|
|
654
683
|
}
|
|
655
684
|
emit(visible(content, thinks))
|
|
685
|
+
watch('content', content)
|
|
656
686
|
}
|
|
687
|
+
if (looped) break
|
|
688
|
+
}
|
|
689
|
+
if (looped) {
|
|
690
|
+
await reader.cancel().catch(() => {}) // the server stops generating
|
|
691
|
+
break
|
|
657
692
|
}
|
|
658
693
|
}
|
|
659
694
|
clearTimeout(timer)
|
|
660
|
-
|
|
695
|
+
if (looped) {
|
|
696
|
+
log('reply', `${this.model}: went into a loop in its ${looped.what} (cut after ${looped.at} characters): "${looped.sample}"`)
|
|
697
|
+
if (looped.what === 'reasoning') reasoning = reasoning.slice(0, looped.at)
|
|
698
|
+
if (looped.what === 'content') content = content.slice(0, looped.at)
|
|
699
|
+
}
|
|
700
|
+
const result = done(content, reasoning, looped ? [] : calls.filter(Boolean), usage, looped ? 'loop' : finishReason, thinks) // a call cut short isn't made
|
|
701
|
+
if (looped) result.looped = looped.sample
|
|
661
702
|
emit(result.content)
|
|
662
703
|
if (!result.content.trim() && !result.toolCalls.length && !result.reasoning) {
|
|
663
704
|
result.raw = raw // nothing read from it: what came, for the requests log
|
|
@@ -674,6 +715,21 @@ export class Client {
|
|
|
674
715
|
}
|
|
675
716
|
}
|
|
676
717
|
|
|
718
|
+
// Where a text started going round: its newest 200 characters are already there 6+ times in its
|
|
719
|
+
// last 12,000 (a phrase or a block of up to 2000 characters, repeated). -1: it isn't.
|
|
720
|
+
export function loopStart(text) {
|
|
721
|
+
if (text.length < 2000) return -1
|
|
722
|
+
const probe = text.slice(-200)
|
|
723
|
+
const win = text.slice(-12_000)
|
|
724
|
+
let n = 0
|
|
725
|
+
let first = -1
|
|
726
|
+
for (let i = win.indexOf(probe); i !== -1; i = win.indexOf(probe, i + 1)) {
|
|
727
|
+
n++
|
|
728
|
+
if (first < 0) first = i
|
|
729
|
+
}
|
|
730
|
+
return n >= 6 ? text.length - win.length + first : -1
|
|
731
|
+
}
|
|
732
|
+
|
|
677
733
|
// A tool's arguments as the most limited providers take them (Gemini's function declarations):
|
|
678
734
|
// only types, properties, required, items, enum and descriptions; a choice of types (anyOf, oneOf,
|
|
679
735
|
// ["string", "null"]) becomes its first real one.
|
package/src/config.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
export const HOME = process.env.HEART_HOME || path.join(os.homedir(), '.heart')
|
|
7
7
|
export const CONFIG_FILE = path.join(HOME, 'config.json')
|
|
8
8
|
|
|
9
|
-
const SETTINGS = ['context', 'effort', 'permissions', 'autoCompact', 'director', 'theme', 'format', 'lsp', 'voice', 'rotation', 'log', 'logRequests', 'logDetail', 'logDays']
|
|
9
|
+
const SETTINGS = ['context', 'effort', 'permissions', 'autoCompact', 'director', 'theme', 'format', 'lsp', 'voice', 'queue', 'rotation', 'log', 'logRequests', 'logDetail', 'logDays']
|
|
10
10
|
const LOCAL_NAMES = { 11434: 'ollama', 1234: 'lmstudio', 8080: 'llamacpp' }
|
|
11
11
|
|
|
12
12
|
export function readJson(file, fallback) {
|
|
@@ -106,12 +106,13 @@ export function loadConfig(flags = {}) {
|
|
|
106
106
|
if (cfg.provider !== name) cfg.model = undefined
|
|
107
107
|
cfg.provider = name
|
|
108
108
|
cfg.adhoc = name
|
|
109
|
-
cfg.fixed = true
|
|
110
109
|
} else if (apiKey && cfg.providers[cfg.provider] && !cfg.providers[cfg.provider].apiKey) {
|
|
111
110
|
cfg.providers[cfg.provider].apiKey = apiKey
|
|
112
111
|
}
|
|
113
112
|
const model = flags.model || process.env.HEART_MODEL
|
|
114
|
-
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider)
|
|
113
|
+
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider))
|
|
114
|
+
// -m / an ad-hoc URL pins the chosen model for this run (a session of one): cfg.session, below
|
|
115
|
+
if ((baseUrl || (model && !isAuto(model))) && cfg.providers[cfg.provider] && cfg.model) cfg.session = [{ provider: cfg.provider, model: cfg.model }]
|
|
115
116
|
synced(cfg, readJson(CONFIG_FILE, {}))
|
|
116
117
|
return cfg
|
|
117
118
|
}
|
|
@@ -120,8 +121,10 @@ export function loadConfig(flags = {}) {
|
|
|
120
121
|
function saved(cfg) {
|
|
121
122
|
const providers = byName(cfg.providers)
|
|
122
123
|
if (cfg.adhoc) delete providers[cfg.adhoc]
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
// a run-only provider (-m URL) or models chosen for this session (cfg.session, /select_model) aren't
|
|
125
|
+
// saved as the current model: the pin is local, it must not become everyone's #1 on disk
|
|
126
|
+
const keep = (cfg.adhoc && cfg.provider === cfg.adhoc) || cfg.session?.length ? fromFile() : null // a run-only provider or a model set chosen for this session isn't saved as the current
|
|
127
|
+
const out = { providers, provider: keep?.provider ?? cfg.provider, model: keep?.model ?? cfg.model }
|
|
125
128
|
for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
|
|
126
129
|
return JSON.parse(JSON.stringify(out))
|
|
127
130
|
}
|
|
@@ -160,7 +163,9 @@ export const whatOf = (k) => (['provider', 'model', 'providers'].includes(k) ? '
|
|
|
160
163
|
export function takeIn(cfg) {
|
|
161
164
|
const disk = readJson(CONFIG_FILE, null)
|
|
162
165
|
if (!disk || same(disk, file)) return []
|
|
163
|
-
|
|
166
|
+
// doesn't move it (its provider/model stay); everything else is still taken in
|
|
167
|
+
const takes = (k) => k === 'providers' || k === 'provider' || k === 'model' || SETTINGS.includes(k)
|
|
168
|
+
const keys = keysOf(disk, file).filter((k) => takes(k) && !same(disk[k], file?.[k]))
|
|
164
169
|
for (const k of keys) {
|
|
165
170
|
const value = k === 'providers' ? { ...byName(disk.providers), ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : disk[k]
|
|
166
171
|
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
package/src/headless.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function runHeadless(app, opts) {
|
|
|
83
83
|
const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n')
|
|
84
84
|
const s = await app.session()
|
|
85
85
|
if (!s) {
|
|
86
|
-
const error = 'no model set up: add a provider and choose a model (heart settings), or pass --base-url and --model'
|
|
86
|
+
const error = app.allOff() ? "the providers of all your models are switched off: switch one on (heart settings), or pass --model" : 'no model set up: add a provider and choose a model (heart settings), or pass --base-url and --model'
|
|
87
87
|
if (out === 'text') process.stderr.write(`heartcode: ${error}\n`)
|
|
88
88
|
else emit({ type: 'result', subtype: 'error_during_execution', is_error: true, result: error, num_turns: 0, session_id: app.chat.id })
|
|
89
89
|
return 1
|
package/src/lsp.js
CHANGED
|
@@ -1,35 +1,45 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Language servers, chosen by the model: it works out which server a language needs (from the
|
|
2
|
+
// project, the web), checks it's installed (find_command), and uses it with the lsp tool, naming
|
|
3
|
+
// the command: definitions, references, hover, errors. HeartCode starts each server once per
|
|
4
|
+
// project and command, and keeps it until it exits. A server the model used for a kind of file is
|
|
5
|
+
// remembered for the project (~/.heart/lsp.json): after each edit of such a file its errors go
|
|
6
|
+
// back to the model with the edit's result. The settings page (General → Features) turns that off.
|
|
7
7
|
import fs from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { spawn } from 'node:child_process'
|
|
10
|
-
import { pathToFileURL } from 'node:url'
|
|
10
|
+
import { pathToFileURL, fileURLToPath } from 'node:url'
|
|
11
11
|
import { log } from './log.js'
|
|
12
|
+
import { HOME, readJson, writeJson } from './config.js'
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
14
|
+
const SERVERS_FILE = path.join(HOME, 'lsp.json') // { [project root]: { [".ts"]: { server, language } } }
|
|
15
|
+
|
|
16
|
+
// Where a command is: the project's node_modules/.bin, else PATH (with Windows' extensions). null: not installed.
|
|
17
|
+
export function findCommand(root, cmd) {
|
|
18
|
+
const exts = process.platform === 'win32' ? ['', ...(process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').map((e) => e.toLowerCase())] : ['']
|
|
19
|
+
const runnable = (p) => {
|
|
20
|
+
try {
|
|
21
|
+
const st = fs.statSync(p)
|
|
22
|
+
if (!st.isFile()) return false
|
|
23
|
+
if (process.platform !== 'win32') fs.accessSync(p, fs.constants.X_OK)
|
|
24
|
+
return true
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (cmd.includes('/') || cmd.includes(path.sep)) {
|
|
30
|
+
const abs = path.resolve(root, cmd)
|
|
31
|
+
return runnable(abs) ? abs : null
|
|
32
|
+
}
|
|
33
|
+
for (const d of [path.join(root, 'node_modules', '.bin'), ...(process.env.PATH ?? '').split(path.delimiter)]) {
|
|
34
|
+
if (!d) continue
|
|
35
|
+
for (const e of exts) if (runnable(path.join(d, cmd + e))) return path.join(d, cmd + e)
|
|
36
|
+
}
|
|
30
37
|
return null
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
// "typescript-language-server --stdio" → its words ("…" and '…' keep spaces)
|
|
41
|
+
const words = (cmd) => [...String(cmd).matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3])
|
|
42
|
+
|
|
33
43
|
class Server {
|
|
34
44
|
constructor(spec, argv, root) {
|
|
35
45
|
Object.assign(this, { spec, root, next: 1, pending: new Map(), docs: new Map(), diags: new Map(), waiters: new Set(), dead: false })
|
|
@@ -66,7 +76,13 @@ class Server {
|
|
|
66
76
|
rootPath: root,
|
|
67
77
|
workspaceFolders: [{ uri, name: path.basename(root) }],
|
|
68
78
|
capabilities: {
|
|
69
|
-
textDocument: {
|
|
79
|
+
textDocument: {
|
|
80
|
+
synchronization: { didSave: true },
|
|
81
|
+
publishDiagnostics: { versionSupport: true },
|
|
82
|
+
definition: { linkSupport: true },
|
|
83
|
+
references: {},
|
|
84
|
+
hover: { contentFormat: ['plaintext', 'markdown'] },
|
|
85
|
+
},
|
|
70
86
|
workspace: { configuration: true, workspaceFolders: true },
|
|
71
87
|
window: { workDoneProgress: true },
|
|
72
88
|
},
|
|
@@ -118,6 +134,18 @@ class Server {
|
|
|
118
134
|
}
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
// The file as it is on disk, opened (or brought up to date) in the server.
|
|
138
|
+
async open(abs) {
|
|
139
|
+
await this.ready
|
|
140
|
+
const uri = pathToFileURL(abs).href
|
|
141
|
+
const text = fs.readFileSync(abs, 'utf8')
|
|
142
|
+
const version = (this.docs.get(uri) ?? 0) + 1
|
|
143
|
+
if (version === 1) this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.spec.language, version, text } })
|
|
144
|
+
else this.notify('textDocument/didChange', { textDocument: { uri, version }, contentChanges: [{ text }] })
|
|
145
|
+
this.docs.set(uri, version)
|
|
146
|
+
return { uri, text }
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
// The file's errors after this change: waits for the server's report on it, then for it to go
|
|
122
150
|
// quiet (servers often send a fast report, then a full one).
|
|
123
151
|
async check(abs, text, wait) {
|
|
@@ -125,7 +153,7 @@ class Server {
|
|
|
125
153
|
const uri = pathToFileURL(abs).href
|
|
126
154
|
const since = Date.now()
|
|
127
155
|
const version = (this.docs.get(uri) ?? 0) + 1
|
|
128
|
-
if (version === 1) this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.spec.
|
|
156
|
+
if (version === 1) this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.spec.language, version, text } })
|
|
129
157
|
else this.notify('textDocument/didChange', { textDocument: { uri, version }, contentChanges: [{ text }] })
|
|
130
158
|
this.notify('textDocument/didSave', { textDocument: { uri }, text })
|
|
131
159
|
this.docs.set(uri, version)
|
|
@@ -153,37 +181,128 @@ class Server {
|
|
|
153
181
|
}
|
|
154
182
|
}
|
|
155
183
|
|
|
156
|
-
const running = new Map() // root +
|
|
184
|
+
const running = new Map() // root + command → Server
|
|
157
185
|
process.on('exit', () => {
|
|
158
|
-
for (const
|
|
186
|
+
for (const sv of running.values()) sv.stop()
|
|
159
187
|
})
|
|
160
188
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
// The server for this command in this project: running already, or started now. A string: why not.
|
|
190
|
+
function serverFor(root, server, language) {
|
|
191
|
+
const argv = words(server)
|
|
192
|
+
if (!argv.length) return 'no command given'
|
|
193
|
+
const key = `${root}\0${argv.join(' ')}`
|
|
194
|
+
const had = running.get(key)
|
|
195
|
+
if (had && !had.dead) return had
|
|
196
|
+
const bin = findCommand(root, argv[0])
|
|
197
|
+
if (!bin) return `${argv[0]} is not installed`
|
|
198
|
+
const sv = new Server({ name: argv[0], language }, [bin, ...argv.slice(1)], root)
|
|
199
|
+
log('lsp', `started ${bin} ${argv.slice(1).join(' ')} (${language})`)
|
|
200
|
+
running.set(key, sv)
|
|
201
|
+
return sv
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The servers this project's kinds of file use: { ".ts": { server, language } }
|
|
205
|
+
export const knownServers = (root) => readJson(SERVERS_FILE, {})[root] ?? {}
|
|
206
|
+
function remember(root, ext, server, language) {
|
|
207
|
+
const all = readJson(SERVERS_FILE, {})
|
|
208
|
+
const mine = (all[root] ??= {})
|
|
209
|
+
if (mine[ext]?.server === server && mine[ext]?.language === language) return
|
|
210
|
+
mine[ext] = { server, language }
|
|
211
|
+
writeJson(SERVERS_FILE, all)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// For the system prompt: "typescript-language-server --stdio (typescript) for .ts, .tsx"
|
|
215
|
+
export function serversNote(root) {
|
|
216
|
+
const by = new Map()
|
|
217
|
+
for (const [ext, { server, language }] of Object.entries(knownServers(root))) {
|
|
218
|
+
const k = `${server} (${language})`
|
|
219
|
+
by.set(k, [...(by.get(k) ?? []), ext])
|
|
175
220
|
}
|
|
176
|
-
|
|
177
|
-
return s && !s.dead ? s : null
|
|
221
|
+
return [...by].map(([k, exts]) => `${k} for ${exts.join(', ')}`).join('; ')
|
|
178
222
|
}
|
|
179
223
|
|
|
180
|
-
|
|
224
|
+
// Waits for a promise, at most ms; the server is unref'd, so this keeps HeartCode up meanwhile.
|
|
225
|
+
async function within(p, ms, what) {
|
|
226
|
+
let t
|
|
227
|
+
const keep = setInterval(() => {}, 1000)
|
|
228
|
+
try {
|
|
229
|
+
return await Promise.race([p, new Promise((_, reject) => (t = setTimeout(() => reject(new Error(`no answer to ${what} in ${ms / 1000} seconds`)), ms)))])
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(t)
|
|
232
|
+
clearInterval(keep)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const SEVERITY = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' }
|
|
237
|
+
const at = (root, uri, range) => {
|
|
238
|
+
const abs = uri.startsWith('file:') ? fileURLToPath(uri) : uri
|
|
239
|
+
const rel = path.relative(root, abs)
|
|
240
|
+
let text = ''
|
|
241
|
+
try {
|
|
242
|
+
text = fs.readFileSync(abs, 'utf8').split('\n')[range.start.line]?.trim().slice(0, 160) ?? ''
|
|
243
|
+
} catch {}
|
|
244
|
+
return `${rel.startsWith('..') ? abs : rel}:${range.start.line + 1}:${range.start.character + 1}${text ? `: ${text}` : ''}`
|
|
245
|
+
}
|
|
246
|
+
const hoverText = (c) => (Array.isArray(c) ? c.map(hoverText).join('\n') : typeof c === 'string' ? c : (c?.value ?? ''))
|
|
247
|
+
|
|
248
|
+
// The lsp tool: { server: the command, language: its language id, action, file, line, column | name }
|
|
249
|
+
export async function lspTool(s, args, signal) {
|
|
250
|
+
const { root } = s
|
|
251
|
+
const server = String(args.server ?? '').trim()
|
|
252
|
+
const language = String(args.language ?? '').trim()
|
|
253
|
+
const action = String(args.action ?? '')
|
|
254
|
+
if (!server || !language) return 'error: lsp needs "server" (the command that starts it, like "typescript-language-server --stdio") and "language" (its language id, like "typescript")'
|
|
255
|
+
if (!['definition', 'references', 'hover', 'errors'].includes(action)) return 'error: "action" is one of definition, references, hover, errors'
|
|
256
|
+
if (!args.file) return 'error: lsp needs "file"'
|
|
257
|
+
const abs = path.resolve(root, String(args.file))
|
|
258
|
+
if (!fs.existsSync(abs)) return `error: no file ${args.file}`
|
|
259
|
+
const sv = serverFor(root, server, language)
|
|
260
|
+
if (typeof sv === 'string') return `error: ${sv}. Check with find_command; install it, or use search and read_file instead.`
|
|
261
|
+
const first = !sv.used
|
|
262
|
+
const ms = first ? 60_000 : 20_000 // the first request waits for the server to start and index
|
|
263
|
+
try {
|
|
264
|
+
if (action === 'errors') {
|
|
265
|
+
const list = await within(sv.check(abs, fs.readFileSync(abs, 'utf8'), first ? 30_000 : 6000), ms, 'the errors check')
|
|
266
|
+
sv.used = true
|
|
267
|
+
remember(root, path.extname(abs), server, language)
|
|
268
|
+
if (!list) return `${server} sent no report on ${args.file} in time.`
|
|
269
|
+
if (!list.length) return `${server}: no problems in ${args.file}.`
|
|
270
|
+
return list.slice(0, 30).map((d) => `${at(root, pathToFileURL(abs).href, d.range)} — ${SEVERITY[d.severity ?? 1]}: ${String(d.message).split('\n')[0]}`).join('\n') + (list.length > 30 ? `\n… ${list.length - 30} more` : '')
|
|
271
|
+
}
|
|
272
|
+
const { uri, text } = await within(sv.open(abs), ms, 'opening the file')
|
|
273
|
+
const lines = text.split('\n')
|
|
274
|
+
const line = Math.max(1, Math.min(Number(args.line) || 1, lines.length)) - 1
|
|
275
|
+
let col = Number(args.column) > 0 ? Number(args.column) - 1 : -1
|
|
276
|
+
if (col < 0 && args.name) {
|
|
277
|
+
const m = new RegExp(`\\b${String(args.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).exec(lines[line])
|
|
278
|
+
if (!m) return `error: "${args.name}" isn't on line ${line + 1} of ${args.file}: ${lines[line].trim().slice(0, 120)}`
|
|
279
|
+
col = m.index
|
|
280
|
+
}
|
|
281
|
+
if (col < 0) col = Math.max(0, lines[line].search(/\S/))
|
|
282
|
+
const pos = { textDocument: { uri }, position: { line, character: col } }
|
|
283
|
+
const method = { definition: 'textDocument/definition', references: 'textDocument/references', hover: 'textDocument/hover' }[action]
|
|
284
|
+
const res = await within(sv.request(method, action === 'references' ? { ...pos, context: { includeDeclaration: true } } : pos), ms, action)
|
|
285
|
+
sv.used = true
|
|
286
|
+
remember(root, path.extname(abs), server, language)
|
|
287
|
+
if (action === 'hover') return hoverText(res?.contents).trim().slice(0, 3000) || 'nothing to show there'
|
|
288
|
+
const locs = (Array.isArray(res) ? res : res ? [res] : []).map((l) => ({ uri: l.uri ?? l.targetUri, range: l.range ?? l.targetSelectionRange ?? l.targetRange }))
|
|
289
|
+
if (!locs.length) return `no ${action === 'definition' ? 'definition' : 'references'} found for line ${line + 1}, column ${col + 1} (${lines[line].trim().slice(0, 80)})`
|
|
290
|
+
return locs.slice(0, 50).map((l) => at(root, l.uri, l.range)).join('\n') + (locs.length > 50 ? `\n… ${locs.length - 50} more` : '')
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (signal?.aborted) throw Object.assign(new Error('stopped'), { name: 'AbortError' })
|
|
293
|
+
log('lsp', `${server}: ${e.message}`)
|
|
294
|
+
return `error: ${server}: ${e.message}. Use search and read_file instead, or another server.`
|
|
295
|
+
}
|
|
296
|
+
}
|
|
181
297
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
298
|
+
// After an edit: what's wrong in the file now, as a note for the model, or '' (no server used for
|
|
299
|
+
// this kind of file in the project, nothing wrong, or no report in time). Errors only, unless there
|
|
300
|
+
// are none: the last check's errors are fixed is said too.
|
|
184
301
|
export async function diagnose(s, rel, text) {
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
302
|
+
const known = knownServers(s.root)[path.extname(rel)]
|
|
303
|
+
if (!known) return ''
|
|
304
|
+
const server = serverFor(s.root, known.server, known.language)
|
|
305
|
+
if (typeof server === 'string') return ''
|
|
187
306
|
const abs = path.join(s.root, rel)
|
|
188
307
|
let list
|
|
189
308
|
const keep = setInterval(() => {}, 1000) // the server is unref'd: this keeps HeartCode up while it answers
|
package/src/mcp.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// HeartCode exits.
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
13
|
import crypto from 'node:crypto'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import os from 'node:os'
|
|
14
16
|
import path from 'node:path'
|
|
15
17
|
import { pathToFileURL } from 'node:url'
|
|
16
18
|
import { HOME, readJson, readConfig, writeJson, VERSION } from './config.js'
|
|
@@ -21,7 +23,12 @@ import { savedToken, signIn } from './oauth.js'
|
|
|
21
23
|
export const DEFAULT_SERVERS = {
|
|
22
24
|
'chrome-devtools': {
|
|
23
25
|
command: 'npx',
|
|
24
|
-
|
|
26
|
+
// headless: no window pops up (works over SSH too); isolated: its own throwaway profile, so
|
|
27
|
+
// runs don't fight over one. Override args in ~/.heart/mcp.json for a visible browser.
|
|
28
|
+
args: ['-y', 'chrome-devtools-mcp@latest', '--headless', '--isolated'],
|
|
29
|
+
// a screenshot/snapshot saved by a relative path lands in the server's cwd: a scratch dir, not
|
|
30
|
+
// the project, so the browser never litters it (pages are opened by absolute file:// URLs).
|
|
31
|
+
scratchCwd: true,
|
|
25
32
|
description: 'a Chrome browser: open pages, click, fill forms, read the console and network requests, take screenshots',
|
|
26
33
|
},
|
|
27
34
|
}
|
|
@@ -118,7 +125,8 @@ class Server {
|
|
|
118
125
|
async #spawn(init) {
|
|
119
126
|
const { command, args = [], env = {} } = this.cfg
|
|
120
127
|
log('mcp', `starting ${this.name}: ${command} ${args.join(' ')}`)
|
|
121
|
-
const
|
|
128
|
+
const cwd = this.cfg.scratchCwd ? scratchDir(this.root, this.name) : (this.root ?? undefined) // in the project, so a relative path in its config works; a browser gets a scratch dir instead
|
|
129
|
+
const child = (this.child = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' }))
|
|
122
130
|
let buf = ''
|
|
123
131
|
child.stdout.setEncoding('utf8') // a character split between two chunks stays whole
|
|
124
132
|
child.stdout.on('data', (d) => {
|
|
@@ -327,6 +335,16 @@ class Server {
|
|
|
327
335
|
const TOOLS_FILE = path.join(HOME, 'mcp-tools.json')
|
|
328
336
|
const keyOf = (cfg) => crypto.createHash('sha256').update(JSON.stringify(cfg)).digest('hex').slice(0, 16)
|
|
329
337
|
|
|
338
|
+
// A throwaway working directory for a server (the browser), so files it writes by a relative path
|
|
339
|
+
// don't land in the project. One per project, under the OS temp dir.
|
|
340
|
+
function scratchDir(root, name) {
|
|
341
|
+
const dir = path.join(os.tmpdir(), 'heartcode', name, crypto.createHash('sha256').update(root ?? '').digest('hex').slice(0, 12))
|
|
342
|
+
try {
|
|
343
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
344
|
+
} catch {}
|
|
345
|
+
return dir
|
|
346
|
+
}
|
|
347
|
+
|
|
330
348
|
// All configured servers for a project; nothing runs until a server is first used.
|
|
331
349
|
// onSignIn(name, url): set by the screen, to show a remote server's sign-in page (none headless).
|
|
332
350
|
export class Mcp {
|
package/src/sessions.js
CHANGED
|
@@ -129,7 +129,6 @@ export function restoreFiles(chat, from) {
|
|
|
129
129
|
export const ago = (iso) => {
|
|
130
130
|
const s = (Date.now() - Date.parse(iso)) / 1000
|
|
131
131
|
if (s < 60) return 'just now'
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return `${Math.floor(s / 86400)}d ago`
|
|
132
|
+
const n = Math.floor(s < 3600 ? s / 60 : s < 86400 ? s / 3600 : s / 86400)
|
|
133
|
+
return `${n} ${s < 3600 ? 'minute' : s < 86400 ? 'hour' : 'day'}${n === 1 ? '' : 's'} ago`
|
|
135
134
|
}
|