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/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
- const result = done(content, reasoning, calls.filter(Boolean), usage, finishReason, thinks)
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), { fixed: true })
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
- const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
124
- const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
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
- const keys = keysOf(disk, file).filter((k) => (k === 'providers' || k === 'provider' || k === 'model' || SETTINGS.includes(k)) && !same(disk[k], file?.[k]))
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
- // After HeartCode writes a file, a language server for it (when one is installed) says what is now
2
- // wrong, and the errors go back to the model with the edit's result: it fixes a type error right
3
- // away instead of finding it at the end, or never.
4
- // Servers, each found on PATH or in the project's node_modules/.bin: typescript-language-server,
5
- // gopls, pyright (or basedpyright), rust-analyzer, clangd, dart. One per language and project,
6
- // started at the first edit and kept until HeartCode exits. The settings page (General → Features) turns it off.
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 SERVERS = [
14
- { name: 'typescript', ext: /\.(m?[jt]sx?|cjs|cts)$/, cmds: [['typescript-language-server', '--stdio']], id: (f) => (/\.[cm]?tsx?$/.test(f) ? (f.endsWith('x') ? 'typescriptreact' : 'typescript') : f.endsWith('x') ? 'javascriptreact' : 'javascript') },
15
- { name: 'go', ext: /\.go$/, cmds: [['gopls']], id: () => 'go' },
16
- { name: 'python', ext: /\.pyi?$/, cmds: [['pyright-langserver', '--stdio'], ['basedpyright-langserver', '--stdio']], id: () => 'python' },
17
- { name: 'rust', ext: /\.rs$/, cmds: [['rust-analyzer']], id: () => 'rust' },
18
- { name: 'c', ext: /\.(c|cc|cpp|cxx|h|hh|hpp)$/, cmds: [['clangd']], id: (f) => (/\.(c|h)$/.test(f) ? 'c' : 'cpp') },
19
- { name: 'dart', ext: /\.dart$/, cmds: [['dart', 'language-server', '--client-id', 'heartcode']], id: () => 'dart' },
20
- ]
21
-
22
- // Extra servers for tests or the user: { name, ext, cmds, id }; tried before the built-in ones.
23
- export const extraServers = []
24
-
25
- function find(root, cmd) {
26
- const local = path.join(root, 'node_modules', '.bin', cmd)
27
- if (fs.existsSync(local)) return local
28
- if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null
29
- for (const d of (process.env.PATH ?? '').split(path.delimiter)) if (d && fs.existsSync(path.join(d, cmd))) return path.join(d, cmd)
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: { synchronization: { didSave: true }, publishDiagnostics: { versionSupport: true } },
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.id(abs), version, text } })
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 + server name → Server, or null (none installed / it died)
184
+ const running = new Map() // root + command → Server
157
185
  process.on('exit', () => {
158
- for (const s of running.values()) s?.stop()
186
+ for (const sv of running.values()) sv.stop()
159
187
  })
160
188
 
161
- function serverFor(root, rel) {
162
- const spec = [...extraServers, ...SERVERS].find((x) => x.ext.test(rel))
163
- if (!spec) return null
164
- const key = `${root}\0${spec.name}`
165
- if (!running.has(key)) {
166
- let server = null
167
- for (const [cmd, ...args] of spec.cmds) {
168
- const bin = find(root, cmd)
169
- if (!bin) continue
170
- server = new Server(spec, [bin, ...args], root)
171
- log('lsp', `started ${spec.name}: ${bin} ${args.join(' ')}`)
172
- break
173
- }
174
- running.set(key, server)
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
- const s = running.get(key)
177
- return s && !s.dead ? s : null
221
+ return [...by].map(([k, exts]) => `${k} for ${exts.join(', ')}`).join('; ')
178
222
  }
179
223
 
180
- const SEVERITY = { 1: 'error', 2: 'warning' }
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
- // What's wrong in the file now, as a note for the model, or '' (no server, nothing wrong, or no
183
- // report in time). Errors only, unless there are none: the last check's errors are fixed is said too.
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 server = serverFor(s.root, rel)
186
- if (!server) return ''
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
- args: ['-y', 'chrome-devtools-mcp@latest'],
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 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
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
- if (s < 3600) return `${Math.floor(s / 60)}m ago`
133
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`
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
  }