heartcode 0.0.0-stage → 0.1.0

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/context.js ADDED
@@ -0,0 +1,91 @@
1
+ // How big a model's context window is, from the most to the least trusted:
2
+ // measured the server's own limit, from one oversized request sent on purpose (the
3
+ // settings page's Measure)
4
+ // context what the running server says (llama.cpp, LM Studio, Ollama, vLLM, a /models
5
+ // list), unless contextGuessed (it didn't say)
6
+ // known models.dev's published limits for the model: { context, output }
7
+ // inputLimit a limit a "too large" error stated, or a silent cut seen twice (inputLimitAt:
8
+ // when); dropped when a bigger request works, and after a week (it's learned
9
+ // again if it's still so: a refused request costs nothing)
10
+ // rateCap a per-minute token cap the provider stated ({ tokens, until }): your account's,
11
+ // not the model's, so it lasts a day
12
+ // None of them: a local model server (server: llama.cpp, lmstudio, ollama) keeps the small size
13
+ // it's assumed to run with; anything else (a provider, a proxy on your network) is assumed to take
14
+ // 128k, or twice its largest request so far (up to 1M), growing as conversations do.
15
+ // Also in the profile: server (what answered the probe), largestAccepted (the biggest request,
16
+ // in tokens the server counted, that worked), tokenRatio (the server's count / HeartCode's
17
+ // estimate), cutAt (a silent cut seen once).
18
+
19
+ export const ASSUMED_REMOTE = 128_000
20
+ export const ASSUMED_MAX = 1_000_000
21
+ export const LOCAL_SERVERS = ['llama.cpp', 'lmstudio', 'ollama']
22
+ export const RATE_CAP_MS = 86_400_000
23
+ export const LIMIT_MS = 7 * 86_400_000
24
+
25
+ // { size, sure, from }: from is measured, server, models.dev, learned, rate or assumed.
26
+ export function contextWindow(p, now = Date.now()) {
27
+ let size
28
+ let from
29
+ if (p.measured > 0) [size, from] = [p.measured, 'measured']
30
+ else if (!p.contextGuessed && p.context > 0) [size, from] = [p.context, 'server']
31
+ else if (p.known?.context > 0) [size, from] = [p.known.context, 'models.dev']
32
+ if (p.inputLimit > 0 && !(now - p.inputLimitAt > LIMIT_MS) && !(size <= p.inputLimit)) [size, from] = [p.inputLimit, 'learned']
33
+ if (p.rateCap?.until > now && !(size <= p.rateCap.tokens)) [size, from] = [p.rateCap.tokens, 'rate']
34
+ if (size) return { size, sure: true, from }
35
+ const base = LOCAL_SERVERS.includes(p.server) ? p.context : ASSUMED_REMOTE
36
+ return { size: Math.min(ASSUMED_MAX, Math.max(base, 2 * (p.largestAccepted ?? 0))), sure: false, from: 'assumed' }
37
+ }
38
+
39
+ // After a reply: `used` is what the server says the request had (prompt tokens), `estimate` what
40
+ // HeartCode thought it sent (images not counted as text). Updates the profile; returns what
41
+ // changed: 'cut' (a limit learned from a silent cut), 'grew' (a bigger request worked), or null.
42
+ export function learnFromUsage(p, used, estimate) {
43
+ if (!(used > 0) || !(estimate > 0)) return null
44
+ const expected = estimate * (p.tokenRatio ?? 1)
45
+ // Far fewer tokens than sent: the server dropped the start (Ollama beyond its num_ctx does).
46
+ // Seen twice at about the same size before it counts, so a provider that leaves out cached
47
+ // tokens, or one odd count, doesn't set a wrong limit.
48
+ if (estimate > 3000 && used >= 1024 && used < expected * 0.5) {
49
+ if (p.cutAt && Math.abs(used - p.cutAt) / p.cutAt < 0.15) {
50
+ p.inputLimit = Math.min(p.inputLimit ?? Infinity, Math.max(used, p.cutAt))
51
+ p.inputLimitAt = Date.now()
52
+ delete p.cutAt
53
+ return 'cut'
54
+ }
55
+ p.cutAt = used
56
+ return null
57
+ }
58
+ if (estimate > 500) {
59
+ const r = Math.min(3, Math.max(0.3, used / estimate))
60
+ p.tokenRatio = Math.round(((p.tokenRatio ?? r) * 0.7 + r * 0.3) * 1000) / 1000
61
+ }
62
+ let change = null
63
+ // a bigger request than a learned limit worked: that limit was wrong (or is gone)
64
+ if (p.inputLimit && used > p.inputLimit * 1.05) {
65
+ delete p.inputLimit
66
+ delete p.inputLimitAt
67
+ change = 'grew'
68
+ }
69
+ if (p.rateCap && used > p.rateCap.tokens) {
70
+ delete p.rateCap
71
+ change = 'grew'
72
+ }
73
+ if (used > (p.largestAccepted ?? 0) * 1.1) {
74
+ p.largestAccepted = used
75
+ change = 'grew'
76
+ }
77
+ return change
78
+ }
79
+
80
+ // What the window size is based on, for /model_info and the settings page.
81
+ export function contextSource(p, cfg = {}) {
82
+ if (cfg.context) return 'set with --context'
83
+ const { from } = contextWindow(p)
84
+ if (from === 'measured') return 'measured: the server said its limit when sent an oversized request'
85
+ if (from === 'server') return `reported by the server${p.server ? ` (${p.server})` : ''}`
86
+ if (from === 'models.dev') return "models.dev's published limit for this model"
87
+ if (from === 'learned') return 'learned from the server refusing or cutting a bigger request'
88
+ if (from === 'rate') return `your per-minute token cap at this provider, until ${new Date(p.rateCap.until).toLocaleString()}`
89
+ if (LOCAL_SERVERS.includes(p.server)) return `the ${p.server} server doesn't say; assumed`
90
+ return `the server doesn't say; assumed${p.largestAccepted ? `, and seen working up to ${p.largestAccepted} tokens` : ''} (grows as bigger requests work)`
91
+ }
@@ -0,0 +1,143 @@
1
+ // Director mode: the main chat can set HeartCode itself up (skills, MCP servers, settings) when
2
+ // asked in plain words, with one tool, manage. The settings page (General → Features) turns it off.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { HOME, readJson, writeJson } from './config.js'
6
+ import { log } from './log.js'
7
+ import { DEFAULT_SERVERS } from './mcp.js'
8
+ import { prompt } from './prompts.js'
9
+ import { frontMatter, loadSkills, skillDirs } from './skills.js'
10
+ import { c, diffLines, renderDiff } from './ui.js'
11
+
12
+ const KINDS = ['skill', 'mcp', 'setting']
13
+ const safeName = (n) => String(n ?? '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
14
+
15
+ export class Director {
16
+ // app: { root, ui, cfg, reload(kind), applySetting(name, value), settings() }
17
+ constructor(app) {
18
+ this.app = app
19
+ }
20
+
21
+ toolDefs(s) {
22
+ if (this.app.cfg.director === false) return []
23
+ const str = { type: 'string' }
24
+ const obj = (properties, required = []) => ({ type: 'object', properties, required })
25
+ return [
26
+ {
27
+ name: 'manage',
28
+ description: prompt('agent/tool-manage'),
29
+ parameters: obj({ action: { type: 'string', enum: ['help', 'list', 'show', 'save', 'delete'] }, kind: { type: 'string', enum: KINDS }, name: str, scope: { type: 'string', enum: ['project', 'global'] }, content: str, config: { type: 'object' }, value: str }, ['action']),
30
+ },
31
+ ]
32
+ }
33
+
34
+ // A tool call for manage: its result, or undefined when the tool isn't manage.
35
+ async tool(name, args, s) {
36
+ return name === 'manage' ? this.manage(args, s) : undefined
37
+ }
38
+
39
+ // --- manage: skills, MCP servers and settings ---
40
+
41
+ async manage(args, s) {
42
+ const { app } = this
43
+ const kind = args.kind
44
+ const scope = args.scope === 'global' ? 'global' : 'project'
45
+ const name = safeName(args.name)
46
+ if (args.action === 'help') return prompt('agent/director-help', Object.fromEntries(['args', '1', '2'].map((k) => [k, `{{${k}}}`]))) // the formats show these holes as they are
47
+ if (!KINDS.includes(kind)) return `error: kind must be one of ${KINDS.join(', ')}`
48
+ if (kind === 'setting') {
49
+ if (args.action === 'list' || args.action === 'show') return app.settings()
50
+ if (args.action === 'save') return app.applySetting(String(args.name ?? ''), String(args.value ?? args.content ?? ''))
51
+ return 'error: settings can be listed or saved'
52
+ }
53
+ if (args.action === 'list') return this.#list(kind)
54
+ if (!name) return `error: give the ${kind}'s name`
55
+ if (kind === 'mcp') return this.#mcp(args.action, name, scope, args.config ?? args.content, s)
56
+ const dirs = skillDirs(app.root)
57
+ const existing = loadSkills(app.root).get(name)
58
+ const dir = dirs[scope === 'global' ? 0 : 1]
59
+ // saving changes the existing file, unless a scope is given that it isn't in (then a new one there)
60
+ const file = existing && (!args.scope || existing.file.startsWith(dir + path.sep)) ? existing.file : path.join(dir, `${name}.md`)
61
+ if (args.action === 'show') return existing ? `${existing.file}\n\n${fs.readFileSync(existing.file, 'utf8')}` : `error: no ${kind} "${name}"`
62
+ if (args.action === 'delete') {
63
+ if (!existing) return `error: no ${kind} "${name}"`
64
+ const ok = await s.ui.confirm(`Delete the ${kind} ${name} (${existing.file})?`, 'edit')
65
+ if (ok !== true) return declined(ok)
66
+ const isFolder = /SKILL\.md$/.test(existing.file)
67
+ fs.rmSync(isFolder ? path.dirname(existing.file) : existing.file, { recursive: true, force: true })
68
+ app.reload(kind)
69
+ return `Deleted the ${kind} ${name}.`
70
+ }
71
+ if (args.action !== 'save') return 'error: action must be help, list, show, save or delete'
72
+ const content = String(args.content ?? '').trim()
73
+ if (!content) return `error: save needs content: the whole ${kind} file (front matter and text); manage help shows the format`
74
+ if (!frontMatter(content).meta.description) return `error: the ${kind} needs "description:" in its front matter (between --- lines at the top); manage help shows the format`
75
+ const old = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
76
+ s.ui.print(renderDiff(diffLines(old, content + '\n'), tilde(file)))
77
+ const ok = await s.ui.confirm(`${old ? 'Update' : 'Save'} the ${kind} ${name}?`, 'edit')
78
+ if (ok !== true) return declined(ok)
79
+ fs.mkdirSync(path.dirname(file), { recursive: true })
80
+ fs.writeFileSync(file, content + '\n')
81
+ app.reload(kind)
82
+ log('director', `saved ${kind} ${name}: ${file}`)
83
+ const how = frontMatter(content).meta.use === 'model' ? '' : `The user can type it as /${name}.`
84
+ return `Saved the ${kind} ${name} (${tilde(file)}). ${how}`.trim()
85
+ }
86
+
87
+ #list(kind) {
88
+ const { app } = this
89
+ if (kind === 'skill') {
90
+ const all = [...loadSkills(app.root).values()]
91
+ return all.length ? all.map((k) => `${k.name} (use: ${k.use}; ${k.file.startsWith(HOME) ? 'global' : 'project'}): ${k.description}`).join('\n') : 'no skills yet'
92
+ }
93
+ const servers = { ...DEFAULT_SERVERS }
94
+ for (const f of mcpFiles(app.root)) Object.assign(servers, ...Object.entries(readJson(f.file, {}).mcpServers ?? {}).map(([n, cfg]) => ({ [n]: { ...servers[n], ...cfg, scope: f.scope } })))
95
+ return Object.entries(servers).map(([n, cfg]) => `${n}${cfg.disabled ? ' (disabled)' : ''}${DEFAULT_SERVERS[n] ? ' (built in)' : ` (${cfg.scope})`}: ${cfg.description ?? ''} [${cfg.command} ${(cfg.args ?? []).join(' ')}]`).join('\n')
96
+ }
97
+
98
+ async #mcp(action, name, scope, config, s) {
99
+ const files = mcpFiles(this.app.root)
100
+ const where = files.find((f) => readJson(f.file, {}).mcpServers?.[name])
101
+ if (action === 'show') {
102
+ const cfg = where ? readJson(where.file, {}).mcpServers[name] : DEFAULT_SERVERS[name]
103
+ return cfg ? JSON.stringify({ [name]: cfg }, null, 2) : `error: no MCP server "${name}"`
104
+ }
105
+ const file = (action === 'delete' && where ? where : files.find((f) => f.scope === scope)).file
106
+ const data = readJson(file, {})
107
+ data.mcpServers ??= {}
108
+ if (action === 'delete') {
109
+ if (!where && !DEFAULT_SERVERS[name]) return `error: no MCP server "${name}"`
110
+ const ok = await s.ui.confirm(`${DEFAULT_SERVERS[name] ? 'Turn off' : 'Delete'} the MCP server ${name}?`, 'edit')
111
+ if (ok !== true) return declined(ok)
112
+ if (DEFAULT_SERVERS[name]) data.mcpServers[name] = { disabled: true } // built in: turned off, not gone
113
+ else delete data.mcpServers[name]
114
+ writeJson(file, data)
115
+ this.app.reload('mcp')
116
+ return `${DEFAULT_SERVERS[name] ? 'Turned off' : 'Deleted'} the MCP server ${name}.`
117
+ }
118
+ if (action !== 'save') return 'error: action must be help, list, show, save or delete'
119
+ let cfg = config
120
+ if (typeof cfg === 'string') {
121
+ try {
122
+ cfg = JSON.parse(cfg)
123
+ } catch {
124
+ return 'error: config must be a JSON object like {"command": "npx", "args": ["-y", "some-mcp"]}'
125
+ }
126
+ }
127
+ if (cfg?.mcpServers) cfg = cfg.mcpServers[name] ?? Object.values(cfg.mcpServers)[0] // pasted a whole mcp.json
128
+ else if (cfg && !cfg.command && Object.keys(cfg).length === 1 && typeof Object.values(cfg)[0] === 'object') cfg = Object.values(cfg)[0] // { "name": { … } }
129
+ if (!cfg?.command && !(DEFAULT_SERVERS[name] && cfg)) return 'error: config needs "command" (and usually "args"), like {"command": "npx", "args": ["-y", "some-mcp"]}'
130
+ const clean = Object.fromEntries(['command', 'args', 'env', 'description', 'disabled'].filter((k) => cfg[k] !== undefined).map((k) => [k, cfg[k]]))
131
+ s.ui.print(`${c.bold(name)} ${c.faint(`→ ${tilde(file)}`)}\n ${c.muted(JSON.stringify(clean))}`)
132
+ const ok = await s.ui.confirm(`Save the MCP server ${name}? HeartCode will run: ${clean.command ?? ''} ${(clean.args ?? []).join(' ')}`, 'command')
133
+ if (ok !== true) return declined(ok)
134
+ data.mcpServers[name] = { ...data.mcpServers[name], ...clean }
135
+ writeJson(file, data)
136
+ this.app.reload('mcp')
137
+ return `Saved the MCP server ${name} (${tilde(file)}). Its tools are available through the mcp tool now.`
138
+ }
139
+ }
140
+
141
+ const declined = (ok) => `The user declined.${typeof ok === 'string' ? ` User feedback: ${ok}` : ''}`
142
+ const tilde = (f) => (process.env.HOME && f.startsWith(process.env.HOME) ? '~' + f.slice(process.env.HOME.length) : f)
143
+ const mcpFiles = (root) => [{ scope: 'global', file: path.join(HOME, 'mcp.json') }, { scope: 'project', file: path.join(root, '.heartcode', 'mcp.json') }]
package/src/edit.js ADDED
@@ -0,0 +1,127 @@
1
+ // Applying model edits, forgiving about the usual small-model mistakes.
2
+
3
+ const lead = (l) => /^\s*/.exec(l)[0]
4
+ const key = (l) => l.trim().replace(/\s+/g, ' ')
5
+
6
+ function trimBlank(lines) {
7
+ let a = 0
8
+ let b = lines.length
9
+ while (a < b && !lines[a].trim()) a++
10
+ while (b > a && !lines[b - 1].trim()) b--
11
+ return lines.slice(a, b)
12
+ }
13
+
14
+ // Replace `search` with `replace` in `text`. Exact match first, then a
15
+ // whitespace-insensitive line match (re-indenting the replacement).
16
+ export function applyEdit(text, search, replace) {
17
+ if (!search.trim()) return { error: 'the SEARCH text is empty' }
18
+ const exact = text.split(search).length - 1
19
+ if (exact === 1) return { text: text.replace(search, () => replace) }
20
+ if (exact > 1) return { error: `the SEARCH text matches ${exact} places; include more surrounding lines so it matches only one` }
21
+
22
+ const lines = text.split('\n')
23
+ const s = trimBlank(search.split('\n'))
24
+ const sk = s.map(key)
25
+ const hits = []
26
+ for (let i = 0; i + s.length <= lines.length; i++) {
27
+ if (sk.every((k, j) => key(lines[i + j]) === k)) hits.push(i)
28
+ }
29
+ if (hits.length > 1) return { error: `the SEARCH text matches ${hits.length} places; include more surrounding lines so it matches only one` }
30
+ if (!hits.length) return { error: notFound(lines, sk) }
31
+
32
+ const i = hits[0]
33
+ lines.splice(i, s.length, ...reindent(s, trimBlank(replace.split('\n')), lines.slice(i, i + s.length)))
34
+ return { text: lines.join('\n') }
35
+ }
36
+
37
+ // The model's lines `repl` replace `orig` (as it saw them, `seen`). If the
38
+ // model shifted the indentation, shift its replacement back.
39
+ export function reindent(seen, repl, orig = seen) {
40
+ const j = seen.findIndex((l) => l.trim())
41
+ const k = repl.findIndex((l) => l.trim())
42
+ if (j < 0 || k < 0) return repl
43
+ const to = lead(orig[j])
44
+ const from = lead(repl[k])
45
+ if (from === to) return repl
46
+ return repl.map((l) => (l.trim() && l.startsWith(from) ? to + l.slice(from.length) : l))
47
+ }
48
+
49
+ function notFound(lines, sk) {
50
+ const words = new Set(sk.join(' ').split(/\W+/).filter((w) => w.length > 2))
51
+ let best = -1
52
+ let score = 0
53
+ lines.forEach((l, i) => {
54
+ const sc = l.split(/\W+/).filter((w) => words.has(w)).length
55
+ if (sc > score) [best, score] = [i, sc]
56
+ })
57
+ const msg = 'the SEARCH text was not found in the file'
58
+ if (best < 0) return msg
59
+ return `${msg}. The closest lines are around line ${best + 1}:\n${lines.slice(Math.max(0, best - 2), best + 3).join('\n')}`
60
+ }
61
+
62
+ // Parses git-conflict-style blocks. Accepts 5-9 marker chars, missing
63
+ // SEARCH/REPLACE words and a missing final marker.
64
+ export function parseBlocks(reply) {
65
+ const blocks = []
66
+ let cur = null
67
+ let part = null
68
+ for (const line of String(reply).replace(/\r\n/g, '\n').split('\n')) {
69
+ // Markers may carry any label ("<<<<<<< SEARCH", "<<<<<<< num.js", or none).
70
+ if (/^\s*<{5,9}(\s.*)?$/.test(line)) {
71
+ cur = { search: [], replace: [] }
72
+ part = 'search'
73
+ } else if (cur && part === 'search' && /^\s*={5,9}\s*$/.test(line)) {
74
+ part = 'replace'
75
+ } else if (cur && /^\s*>{5,9}(\s.*)?$/.test(line)) {
76
+ if (part === 'replace') blocks.push(join(cur))
77
+ cur = null
78
+ } else if (cur) {
79
+ cur[part].push(line)
80
+ }
81
+ }
82
+ if (cur && part === 'replace') blocks.push(join(cur))
83
+ return blocks
84
+ }
85
+
86
+ const join = (b) => ({ search: b.search.join('\n'), replace: b.replace.join('\n') })
87
+
88
+ const isFence = (l) => /^\s*(`{3,}|~{3,})/.test(l)
89
+ const firstReal = (lines) => (lines.find((l) => l.trim()) ?? '').trim()
90
+ const numbered = (lines) => {
91
+ const real = lines.filter((l) => l.trim())
92
+ return real.length > 0 && real.every((l) => /^\s*\d+\s*\|/.test(l))
93
+ }
94
+
95
+ // The whole reply is meant to be code. Remove what models wrap around it:
96
+ // a fence, a line of prose before it, notes after it, copied line numbers.
97
+ export function cleanCode(reply, original = '') {
98
+ let lines = String(reply).replace(/\r\n/g, '\n').split('\n')
99
+ const orig = original.split('\n')
100
+ if (firstReal(lines) !== firstReal(orig)) {
101
+ const open = lines.findIndex(isFence)
102
+ if (open !== -1 && lines.slice(0, open).filter((l) => l.trim()).length <= 3) {
103
+ let close = -1
104
+ for (let i = lines.length - 1; i > open; i--) {
105
+ if (isFence(lines[i])) {
106
+ close = i
107
+ break
108
+ }
109
+ }
110
+ lines = lines.slice(open + 1, close === -1 ? undefined : close)
111
+ }
112
+ }
113
+ if (numbered(lines) && !numbered(orig)) lines = lines.map((l) => l.replace(/^\s*\d+\s*\| ?/, ''))
114
+ const t = lines.join('\n').replace(/^\s*\n/, '').replace(/\s+$/, '')
115
+ return !original || original.endsWith('\n') ? t + '\n' : t
116
+ }
117
+
118
+ const DEF = /\b(?:function|def|class|fn|func|interface|struct|enum)\s+([A-Za-z_]\w*)|\b(?:const|let|var)\s+([A-Za-z_]\w*)\s*=/g
119
+ const defNames = (t) => new Set([...t.matchAll(DEF)].map((m) => m[1] ?? m[2]))
120
+
121
+ // Definitions an edit deleted although the task never asked for it — the
122
+ // typical small-model rewrite accident.
123
+ export function lostNames(old, next, task) {
124
+ if (/\b(remove|delete|drop|rename|replace|rewrite|refactor|clean ?up|merge)\b/i.test(task)) return []
125
+ const now = defNames(next)
126
+ return [...defNames(old)].filter((n) => !now.has(n) && !task.includes(n))
127
+ }
package/src/format.js ADDED
@@ -0,0 +1,74 @@
1
+ // After HeartCode writes a file, the project's formatter runs on it, so its edits look like the rest
2
+ // of the code (and the user's own format-on-save doesn't turn every edit into a second diff).
3
+ // Only formatters the project already uses: prettier or biome from its node_modules, ruff or black
4
+ // when its pyproject/ruff.toml sets them up, clang-format with a .clang-format; and the languages'
5
+ // own (gofmt, rustfmt, dart format, zig fmt) when they're installed.
6
+ // ~/.heart/format.json or .heartcode/format.json (wins) change it, by file pattern:
7
+ // { "*.py": "black -q $FILE", "*.md": false } (false: never for those files)
8
+ // The settings page (General → Features) turns it all off.
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { HOME, readJson } from './config.js'
12
+ import { runCommand } from './tools.js'
13
+ import { log } from './log.js'
14
+
15
+ const has = (root, f) => fs.existsSync(path.join(root, f))
16
+ const read = (root, f) => {
17
+ try {
18
+ return fs.readFileSync(path.join(root, f), 'utf8')
19
+ } catch {
20
+ return ''
21
+ }
22
+ }
23
+ const onPath = (cmd) => (process.env.PATH ?? '').split(path.delimiter).some((d) => d && fs.existsSync(path.join(d, cmd)))
24
+ const local = (root, cmd) => (has(root, `node_modules/.bin/${cmd}`) ? path.join(root, 'node_modules/.bin', cmd) : null)
25
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
26
+
27
+ // [file pattern, (root) → the command (the file's path is added at the end), or null]
28
+ const BUILTIN = [
29
+ [/\.(m?[jt]sx?|cjs|cts|json|jsonc|css|scss|less|html|vue|svelte|astro|graphql|md|mdx|ya?ml)$/, (root) => {
30
+ const biome = local(root, 'biome')
31
+ if (biome && (has(root, 'biome.json') || has(root, 'biome.jsonc'))) return `${q(biome)} format --write`
32
+ const prettier = local(root, 'prettier')
33
+ return prettier && `${q(prettier)} --write --log-level=warn`
34
+ }],
35
+ [/\.pyi?$/, (root) => {
36
+ const py = read(root, 'pyproject.toml')
37
+ if (onPath('ruff') && (has(root, 'ruff.toml') || has(root, '.ruff.toml') || /^\[tool\.ruff/m.test(py))) return 'ruff format -q'
38
+ return onPath('black') && /^\[tool\.black\]/m.test(py) ? 'black -q' : null
39
+ }],
40
+ [/\.go$/, () => onPath('gofmt') && 'gofmt -w'],
41
+ [/\.rs$/, () => onPath('rustfmt') && 'rustfmt --edition 2021'], // rustfmt.toml in the project still applies
42
+ [/\.dart$/, () => onPath('dart') && 'dart format -o write'],
43
+ [/\.zig$/, () => onPath('zig') && 'zig fmt'],
44
+ [/\.(c|cc|cpp|cxx|h|hh|hpp|m|mm)$/, (root) => onPath('clang-format') && has(root, '.clang-format') && 'clang-format -i'],
45
+ ]
46
+
47
+ const glob = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`)
48
+
49
+ // The command for a file ("…$FILE…" or one the path goes after), or null.
50
+ export function formatterFor(root, rel) {
51
+ const own = { ...readJson(path.join(HOME, 'format.json'), {}), ...readJson(path.join(root, '.heartcode', 'format.json'), {}) }
52
+ const base = path.basename(rel)
53
+ for (const [pattern, cmd] of Object.entries(own)) {
54
+ if (glob(pattern).test(base) || glob(pattern).test(rel)) return cmd || null
55
+ }
56
+ for (const [re, pick] of BUILTIN) if (re.test(rel)) return pick(root) || null
57
+ return null
58
+ }
59
+
60
+ // Formats the file HeartCode just wrote. Returns its new text when the formatter changed it, else
61
+ // null. A formatter that fails (a syntax error it can't parse) is left at that: the file stays as
62
+ // written, and the language server or the model's next run says what's wrong.
63
+ export async function formatFile(root, rel) {
64
+ const cmd = formatterFor(root, rel)
65
+ if (!cmd) return null
66
+ const abs = path.join(root, rel)
67
+ const before = fs.readFileSync(abs, 'utf8')
68
+ const line = cmd.includes('$FILE') ? cmd.replaceAll('$FILE', q(rel)) : `${cmd} ${q(rel)}`
69
+ const { code, output } = await runCommand(root, line, { timeout: 20_000 })
70
+ log('format', `${line} → exit ${code}`, code ? output.slice(0, 1000) : '')
71
+ if (code !== 0) return null
72
+ const after = fs.readFileSync(abs, 'utf8')
73
+ return after === before ? null : { text: after, by: cmd.split(/\s+/)[0].split('/').pop().replace(/'$/, '') }
74
+ }