heartcode 0.1.15 → 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/settings.js CHANGED
@@ -21,7 +21,7 @@ import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLog
21
21
 
22
22
  const PAGE = new URL('./settings.html', import.meta.url)
23
23
  const EFFORTS = ['default', 'low', 'medium', 'high']
24
- const TOGGLES = { autoCompact: 'compact the conversation when the context is 70% full', director: 'let the model add skills, MCP servers and change settings when asked', format: "run the project's formatter on every file HeartCode writes", lsp: "show the model a language server's errors after each edit" }
24
+ const TOGGLES = { autoCompact: 'compact the conversation when it fills 85% of the model\'s context window', director: 'let the model add skills, MCP servers and change settings when asked', format: "run the project's formatter on every file HeartCode writes", lsp: "show the model a language server's errors after each edit" }
25
25
  // The Logging page's: both off unless switched on; how much of each request ("full" or "summary")
26
26
  const LOG_SWITCHES = ['log', 'logRequests']
27
27
  const DETAILS = ['full', 'summary']
@@ -333,7 +333,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
333
333
  const state = () => ({
334
334
  providers: Object.entries(byName(cfg.providers))
335
335
  .filter(([n]) => n !== cfg.adhoc)
336
- .map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, added: p.models ?? [] })),
336
+ .map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, off: p.off === true, added: p.models ?? [] })),
337
337
  provider: cfg.provider ?? null,
338
338
  model: cfg.model ?? null,
339
339
  order: [...(cfg.providers[cfg.provider] && cfg.model ? [{ provider: cfg.provider, model: cfg.model }] : []), ...rotationOf(cfg).then].map((t) => ({ ...t, tested: testedOf(t), known: knownOf(t), price: priceFor(t) })),
@@ -415,6 +415,15 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
415
415
  save('provider')
416
416
  return state()
417
417
  },
418
+ // A provider switched off: kept (its key, its models in your list), but HeartCode skips its models
419
+ // until it's on again.
420
+ 'POST /api/provider/on': ({ name, on }) => {
421
+ if (!cfg.providers[name]) throw new Error(`no provider "${name}"`)
422
+ if (on) delete cfg.providers[name].off
423
+ else cfg.providers[name].off = true
424
+ save('provider')
425
+ return state()
426
+ },
418
427
  // A model added by its name (one the provider doesn't list, or its list is off): remembered.
419
428
  // all: every model added by name forgotten at once
420
429
  'POST /api/provider/added': ({ name, model, remove, all }) => {
package/src/skills.js CHANGED
@@ -18,7 +18,26 @@ export const USES = ['me', 'model', 'both']
18
18
 
19
19
  export function frontMatter(text) {
20
20
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
21
- const meta = Object.fromEntries((m?.[1] ?? '').split('\n').map((l) => /^([\w-]+):\s*(.*)$/.exec(l.trim())).filter(Boolean).map(([, k, v]) => [k, v.replace(/^["']|["']$/g, '')]))
21
+ const lines = (m?.[1] ?? '').replace(/\r/g, '').split('\n')
22
+ const meta = {}
23
+ const indent = (l) => l.length - l.trimStart().length
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const key = /^([\w-]+):\s*(.*)$/.exec(lines[i])
26
+ if (!key) continue
27
+ const [, k, raw] = key
28
+ const base = indent(lines[i])
29
+ const block = /^([|>])([+-]?)\s*$/.exec(raw) // a YAML block scalar: | literal, > folded (Claude Code, others use "description: >-")
30
+ if (block) {
31
+ const kept = []
32
+ while (i + 1 < lines.length && (lines[i + 1].trim() === '' || indent(lines[i + 1]) > base)) kept.push(lines[++i])
33
+ const minIndent = Math.min(...kept.filter((l) => l.trim()).map(indent), Infinity)
34
+ let v = kept.map((l) => l.slice(Number.isFinite(minIndent) ? minIndent : 0)).join(block[1] === '>' ? ' ' : '\n')
35
+ if (block[2] !== '+') v = v.replace(/\n+$/, '') // "-" strips trailing newlines; default keeps one at most
36
+ meta[k] = v.trim()
37
+ } else {
38
+ meta[k] = raw.replace(/^["']|["']$/g, '')
39
+ }
40
+ }
22
41
  return { meta, body: (m ? m[2] : text).trim() }
23
42
  }
24
43
 
package/src/tabs.js CHANGED
@@ -42,7 +42,7 @@ export class Tabs {
42
42
  tab.output = Object.assign(new EventEmitter(), { isTTY: true, write: (s) => (on() ? real.output.write(s) : true) })
43
43
  for (const k of ['columns', 'rows']) Object.defineProperty(tab.output, k, { get: () => real.output[k] })
44
44
  Object.defineProperty(tab.output, 'hidden', { get: () => !on() })
45
- tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what) }
45
+ tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what), onTabClick: (col) => this.click(col) }
46
46
  this.list.push(tab)
47
47
  this.at = tab
48
48
  tab.id = this.made = (this.made ?? 0) + 1 // for its log file: tab-1.log… (its place in the bar can change)
@@ -96,6 +96,13 @@ export class Tabs {
96
96
  if (n) this.show(this.list[Number(n) - 1])
97
97
  }
98
98
 
99
+ // a click on the tab bar (a 0-based column): that tab
100
+ click(col) {
101
+ if (this.locked) return
102
+ const hit = this.spans?.find((s) => col >= s.from && col < s.to)
103
+ if (hit && hit.tab !== this.at) this.show(hit.tab)
104
+ }
105
+
99
106
  // working, waiting for an answer, or done since you last looked at it
100
107
  state(t) {
101
108
  const ui = t.ui
@@ -108,40 +115,48 @@ export class Tabs {
108
115
  // The tab bar, three rows: tabs drawn as tabs, the one on screen open onto it (its bottom
109
116
  // edge missing), the others closed off. Number, name, and what it's doing.
110
117
  // ╭─────────────╮ ╭──────────────╮
111
- // │ 1 heart_cli │ │ 2 fix tests ●│ + Ctrl+T new tab
118
+ // │ 1 heart_cli │ │ 2 fix tests ●│ Ctrl+T open a new tab
112
119
  // ─╯ ╰─┴──────────────┴──────────────────
113
120
  bar(cols = 100) {
114
121
  const mark = { '?': c.warn('?'), '●': c.brand('●'), '✓': c.ok('✓') }
115
122
  const room = Math.max(6, Math.floor((cols - 24) / this.list.length) - 7) // a name's cells, so they all fit
116
123
  let [top, mid, bottom] = [' ', ' ', c.ghost('─')]
124
+ let col = 1
125
+ this.spans = [] // the columns each tab takes, for a click (click)
117
126
  this.list.forEach((t, i) => {
118
127
  const on = t === this.at
119
128
  const s = this.state(t)
120
- const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), room)
129
+ const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), Math.min(NAME_MAX, room))
121
130
  const text = ` ${i + 1} ${name}${s ? ' ' + s : ''} `
122
131
  const w = width(text)
123
132
  const line = on ? c.brand : c.ghost
124
133
  const label = ` ${on ? c.bright(c.bold(`${i + 1} ${name}`)) : c.muted(`${i + 1} ${name}`)}${s ? ' ' + mark[s] : ''} `
134
+ this.spans.push({ from: col, to: col + w + 2, tab: t })
135
+ col += w + 3
125
136
  top += line(`╭${'─'.repeat(w)}╮`) + ' '
126
137
  mid += line('│') + label + line('│') + ' '
127
138
  bottom += (on ? c.brand(`╯${' '.repeat(w)}╰`) : c.ghost(`┴${'─'.repeat(w)}┴`)) + c.ghost('─')
128
139
  })
129
- const hint = this.list.length > 1 ? 'Ctrl+T new · Ctrl+Q close · Ctrl+N/P switch' : 'Ctrl+T new tab'
140
+ const hint = this.list.length > 1 ? 'Ctrl+T open a tab · Ctrl+Q close this one · Ctrl+N/P switch tabs' : 'Ctrl+T open a new tab'
130
141
  mid += ` ${c.faint(hint)}`
131
142
  bottom += c.ghost('─'.repeat(Math.max(0, cols - width(bottom.replace(/\x1b\[[0-9;]*m/g, '')))))
132
143
  return [top, mid, bottom]
133
144
  }
134
145
  }
135
146
 
136
- // At most n cells, with … when cut.
147
+ // A tab's name: at most 20 cells, "..." included (fewer when many tabs share a narrow screen).
148
+ const NAME_MAX = 20
149
+
150
+ // At most n cells, ending in "..." when cut.
137
151
  function cut(s, n) {
152
+ s = String(s ?? '').replace(/\s+/g, ' ').trim()
138
153
  if (width(s) <= n) return s
139
154
  let out = ''
140
155
  for (const ch of s) {
141
- if (width(out + ch) > n - 1) break
156
+ if (width(out + ch) > n - 3) break
142
157
  out += ch
143
158
  }
144
- return out + '…'
159
+ return out.trimEnd() + '...'
145
160
  }
146
161
 
147
162
  const pathTail = (p = '') => p.split(/[\\/]/).filter(Boolean).pop() ?? p
package/src/tools.js CHANGED
@@ -15,7 +15,7 @@ import { webFetch, webSearch } from './web.js'
15
15
  import { runHooks } from './hooks.js'
16
16
  import { parsePatch, applyChunks } from './patch.js'
17
17
  import { formatFile } from './format.js'
18
- import { diagnose } from './lsp.js'
18
+ import { diagnose, lspTool, findCommand } from './lsp.js'
19
19
 
20
20
  const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.venv', 'venv', '__pycache__', 'target', '.cache', '.DS_Store'])
21
21
 
@@ -42,15 +42,28 @@ export function saveOutput(chatId, name, data, ext = 'txt') {
42
42
  export function resolveIn(root, p) {
43
43
  const clean = String(p ?? '').trim().replace(/^["'`]+|["'`]+$/g, '')
44
44
  if (!clean) throw new Error('no path given')
45
- let abs = path.resolve(root, clean)
45
+ const bare = root.replace(/^[\\/]+/, '') // the project's own path, its leading "/" left out by a sloppy copy
46
+ let abs = clean === bare || clean.startsWith(bare + '/') ? path.join(root, clean.slice(bare.length)) : path.resolve(root, clean)
46
47
  if (abs.startsWith(OUTPUTS + path.sep)) return abs // a saved tool output
47
48
  // "num.js:" or "num.js," from a sloppy model: the file without the stray mark, if that one exists
48
49
  const trimmed = path.resolve(root, clean.replace(/[:;,.]+$/, ''))
49
50
  if (trimmed !== abs && !fs.existsSync(abs) && fs.existsSync(trimmed)) abs = trimmed
50
- if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error(`${clean} is outside the project; use a path relative to the project folder`)
51
+ if (abs !== root && !abs.startsWith(root + path.sep)) abs = intoProject(root, clean) ?? abs
52
+ if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error(`${clean} is outside the project; use a path relative to the project folder (${root}), like "index.html" or "src/app.js"`)
51
53
  return abs
52
54
  }
53
55
 
56
+ // A small model's mangled copy of the project's long path, meant inside it: any path with the
57
+ // project folder's name in it ("/Users-me-…/myapp/index.html"): what comes after that name.
58
+ // null: no such reading.
59
+ function intoProject(root, p) {
60
+ const slash = p.replace(/\\/g, '/')
61
+ const name = path.basename(root)
62
+ const parts = slash.split('/')
63
+ const at = parts.lastIndexOf(name)
64
+ return at >= 0 ? path.resolve(root, parts.slice(at + 1).join('/') || '.') : null
65
+ }
66
+
54
67
  // Project files a message names: by its path, or by a file name only one file in the project has
55
68
  // ("num.js"; in a project with three index.js, "index.js" names none of them).
56
69
  export function namedFiles(text, files) {
@@ -99,6 +112,33 @@ export function listFiles(root, limit = 2000) {
99
112
  return files.sort()
100
113
  }
101
114
 
115
+ // One folder's entries (the project root: ''), all of them: folders end in "/", what .gitignore
116
+ // leaves out says "(ignored)". Names only. At most `limit`.
117
+ export function folderView(root, rel = '', limit = 200) {
118
+ const abs = resolveIn(root, rel || '.')
119
+ let entries
120
+ try {
121
+ entries = fs.readdirSync(abs, { withFileTypes: true })
122
+ } catch (e) {
123
+ return { error: e.code === 'ENOENT' ? `no folder ${rel}` : e.code === 'ENOTDIR' ? `${rel} is a file, not a folder` : e.message }
124
+ }
125
+ let ignored = null // the entries .gitignore leaves out ("node_modules/", "dist/", ".env")
126
+ try {
127
+ const out = execFileSync('git', ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory', '--', abs], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 << 20 })
128
+ ignored = new Set(out.split('\0').filter(Boolean).map((f) => f.replace(/\/$/, '')))
129
+ } catch {}
130
+ const here = path.relative(root, abs)
131
+ const names = entries
132
+ .map((e) => ({ name: e.name, dir: e.isDirectory() || (e.isSymbolicLink() && fs.statSync(path.join(abs, e.name), { throwIfNoEntry: false })?.isDirectory()) }))
133
+ .sort((a, b) => b.dir - a.dir || a.name.localeCompare(b.name))
134
+ const shown = names.slice(0, limit).map(({ name, dir }) => {
135
+ const p = path.join(here, name).split(path.sep).join('/')
136
+ const off = ignored ? ignored.has(p) : IGNORE.has(name)
137
+ return `${name}${dir ? '/' : ''}${off && name !== '.git' ? ' (ignored)' : ''}`
138
+ })
139
+ return { entries: shown, more: Math.max(0, names.length - limit) }
140
+ }
141
+
102
142
  const isBinary = (buf) => buf.subarray(0, 8000).includes(0)
103
143
  export const numberLines = (lines, from = 1) => lines.map((l, i) => `${from + i}| ${l}`).join('\n')
104
144
 
@@ -285,6 +325,7 @@ export async function proposeWrite(s, rel, old, next) {
285
325
  else {
286
326
  fs.mkdirSync(path.dirname(abs), { recursive: true })
287
327
  fs.writeFileSync(abs, next)
328
+ if (/\.(html?|css)$/i.test(rel)) (s.uiTouched ??= new Set()).add(rel) // looked at in a browser when the model says it's done (agent.js)
288
329
  }
289
330
  const add = ops.filter(([t]) => t === '+').length
290
331
  const del = ops.filter(([t]) => t === '-').length
@@ -441,7 +482,9 @@ const obj = (properties, required = []) => ({ type: 'object', properties, requir
441
482
  export const TOOL_DEFS = [
442
483
  { name: 'read_file', description: prompt('agent/tool-read_file'), parameters: obj({ path: str, start_line: int, end_line: int }, ['path']) },
443
484
  { name: 'search', description: prompt('agent/tool-search'), parameters: obj({ query: str, path: str }, ['query']) },
444
- { name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ filter: str }) },
485
+ { name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ path: str, filter: str }) },
486
+ { name: 'find_command', description: prompt('agent/tool-find_command'), parameters: obj({ names: { type: 'array', items: str } }, ['names']) },
487
+ { name: 'lsp', description: prompt('agent/tool-lsp'), parameters: obj({ server: str, language: str, action: { type: 'string', enum: ['definition', 'references', 'hover', 'errors'] }, file: str, line: int, column: int, name: str }, ['server', 'language', 'action', 'file']) },
445
488
  // One edit per call: a list of edits per call measured 2–6 malformed calls per benchmark run vs 0.
446
489
  { name: 'edit_file', description: prompt('agent/tool-edit_file'), parameters: obj({ path: str, search: str, replace: str }, ['path', 'search', 'replace']) },
447
490
  { name: 'write_file', description: prompt('agent/tool-write_file'), parameters: obj({ path: str, content: str, append: { type: 'boolean' } }, ['path', 'content']) },
@@ -455,7 +498,15 @@ export const TOOL_DEFS = [
455
498
  // Plan mode (permission "plan"): looking around is fine, changing things isn't. Edits are refused,
456
499
  // and a command runs only when it only reads (else it's refused, not asked about).
457
500
  export const PLAN_HIDDEN = new Set(['edit_file', 'write_file', 'apply_patch'])
458
- const READ_ONLY = /^(ls|cat|head|tail|wc|grep|egrep|rg|find|fd|tree|pwd|file|stat|du|df|which|type|echo|date|printenv|diff|cmp|sort|uniq|cut|jq|nl|basename|dirname|realpath|readlink|uname|whoami|(node|npm|python3?|go|cargo) --version|npm (ls|list|view|outdated)|git (status|log|diff|show|ls-files|blame|grep|describe|shortlog|rev-parse|config --get)|git (branch|tag|remote)( -[avlr]+| --list| -v)*$)\b/
501
+ const READ_ONLY = /^(cd|ls|cat|head|tail|wc|grep|egrep|rg|find|fd|tree|pwd|file|stat|du|df|which|type|echo|date|printenv|diff|cmp|sort|uniq|cut|jq|nl|basename|dirname|realpath|readlink|uname|whoami|(node|npm|python3?|go|cargo) --version|npm (ls|list|view|outdated)|git (status|log|diff|show|ls-files|blame|grep|describe|shortlog|rev-parse|config --get)|git (branch|tag|remote)( -[avlr]+| --list| -v)*$)\b/
502
+ // A command that went into a folder outside the project (a small model copying the project's long
503
+ // path wrong, then lost in what it finds there): told where the project is.
504
+ function outsideNote(root, cmd) {
505
+ const dirs = [...cmd.matchAll(/(?:^|&&|;|\|\|)\s*cd\s+("[^"]+"|'[^']+'|\S+)/g)].map((m) => m[1].replace(/^["']|["']$/g, '').replace(/^~(?=\/|$)/, os.homedir()))
506
+ const out = dirs.map((d) => path.resolve(root, d)).find((d) => d !== root && !d.startsWith(root + path.sep))
507
+ return out ? `\n\n${prompt('agent/outside-project', { dir: out, root })}` : ''
508
+ }
509
+
459
510
  export function readOnlyCommand(cmd) {
460
511
  const c = String(cmd).trim()
461
512
  if (/[>`]|\$\(|\b(sudo|rm|mv|cp|tee|chmod|chown|dd|truncate|mkdir|touch|ln)\b|-delete\b|-exec|-ok\b|sed\s+-i|\bsort\b.*\s-o/.test(c)) return false // writes, or runs something else
@@ -940,11 +991,22 @@ async function tool(name, args, s, signal, id) {
940
991
  case 'read_file':
941
992
  return IMAGE.test(String(args.path ?? '')) ? readImage(s, args.path) : readFileTool(root, args.path, args.start_line, args.end_line)
942
993
  case 'list_files': {
994
+ if (args.path !== undefined && args.path !== '' && !args.filter) {
995
+ const v = folderView(root, String(args.path))
996
+ return v.error ? `error: ${v.error}` : v.entries.join('\n') + (v.more ? `\n… ${v.more} more` : '') || '(empty folder)'
997
+ }
943
998
  const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
944
999
  return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
945
1000
  }
946
1001
  case 'search':
947
1002
  return searchFiles(root, String(args.query ?? ''), args.path)
1003
+ case 'find_command': {
1004
+ const names = (Array.isArray(args.names) ? args.names : String(args.names ?? args.name ?? '').split(/[\s,]+/)).map(String).filter(Boolean)
1005
+ if (!names.length) return 'error: find_command needs "names": the commands to look for, like ["rg", "pnpm"]'
1006
+ return names.map((n) => `${n}: ${findCommand(root, n) ?? 'not installed'}`).join('\n')
1007
+ }
1008
+ case 'lsp':
1009
+ return await lspTool(s, args, signal)
948
1010
  case 'edit_file':
949
1011
  return await editFiles(s, args)
950
1012
  case 'write_file':
@@ -963,7 +1025,7 @@ async function tool(name, args, s, signal, id) {
963
1025
  }
964
1026
  case 'run_command':
965
1027
  if (/^\s*apply_patch\b[\s\S]*\*\*\* Begin Patch/.test(String(args.command ?? ''))) return await applyPatch(s, args.command) // apply_patch <<'EOF' … as Codex runs it
966
- return await runConfirmed(s, String(args.command ?? ''), signal)
1028
+ return (await runConfirmed(s, String(args.command ?? ''), signal)) + outsideNote(root, String(args.command ?? ''))
967
1029
  case 'apply_patch':
968
1030
  return await applyPatch(s, args.input ?? args.patch ?? args.command ?? '')
969
1031
  case 'mcp':