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/usage.js ADDED
@@ -0,0 +1,134 @@
1
+ // Tokens used and what they cost, per chat (chat.usage). Prices per million tokens come from
2
+ // models.dev's list of models (https://models.dev/api.json), kept a day in ~/.heart/prices.json,
3
+ // and so does what each model is: its context window, longest reply, and whether it calls tools,
4
+ // thinks and reads images (limitsOf). HeartCode takes a model's abilities from there.
5
+ // A model it doesn't list (a local one, a provider it doesn't know) counts tokens only.
6
+ import path from 'node:path'
7
+ import { HOME, readJson, writeJson } from './config.js'
8
+ import { log } from './log.js'
9
+
10
+ const FILE = path.join(HOME, 'prices.json')
11
+ const DAY = 86_400_000
12
+ const VERSION = 3 // 2: with each model's limits; 3: and its abilities
13
+ let table = null // { v, at, providers: { id: { api, models: { id: { input, output, cache_read, cache_write, limit: { context, output }, can: { tools, thinking, images } } } } } }
14
+
15
+ // Loads the list (from the file if it's from today, else from models.dev). Never throws.
16
+ // force: fetched again however fresh the saved one is (the settings page's retry); its failure throws.
17
+ export async function loadPrices({ url = 'https://models.dev/api.json', timeout = 15_000, force = false } = {}) {
18
+ table ??= readJson(FILE, null)
19
+ const asked = arguments[0]?.url || force // a list from somewhere else, or asked for again: always fetched
20
+ if (!asked && ((table?.v === VERSION && Date.now() - table.at < DAY) || process.env.HEART_NO_PRICES)) return table // tests: no network
21
+ try {
22
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeout) })
23
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
24
+ const data = await res.json()
25
+ const providers = {}
26
+ for (const [id, p] of Object.entries(data)) {
27
+ const models = {}
28
+ for (const [m, info] of Object.entries(p.models ?? {})) {
29
+ const price = info.cost && (({ input, output, cache_read, cache_write }) => ({ input, output, cache_read, cache_write }))(info.cost)
30
+ const limit = info.limit?.context > 0 ? { context: info.limit.context, output: info.limit.output > 0 ? info.limit.output : undefined } : undefined
31
+ const can = { tools: info.tool_call !== false, thinking: Boolean(info.reasoning), images: Array.isArray(info.modalities?.input) ? info.modalities.input.includes('image') : Boolean(info.attachment) }
32
+ if (price || limit) models[m] = { ...price, limit, can }
33
+ }
34
+ providers[id] = { api: p.api, models }
35
+ }
36
+ table = { v: VERSION, at: Date.now(), providers }
37
+ writeJson(FILE, table)
38
+ } catch (e) {
39
+ log('prices', `could not load the price list: ${e.message}`)
40
+ if (force) throw new Error(`couldn't reach models.dev: ${e.message}`)
41
+ }
42
+ return table
43
+ }
44
+
45
+ const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
46
+ const hostOf = (url) => {
47
+ try {
48
+ return new URL(url).host
49
+ } catch {
50
+ return ''
51
+ }
52
+ }
53
+
54
+ // The price of a model at a provider (HeartCode's name for it and its URL), or null. The provider
55
+ // models.dev knows by that name or URL first, else any provider that lists the model.
56
+ export function priceOf(provider, baseUrl, model) {
57
+ if (!table || !model) return null
58
+ const all = Object.entries(table.providers)
59
+ const ids = [model, model.split('/').pop()]
60
+ const find = (list) => {
61
+ for (const [, p] of list) for (const id of ids) if (p.models[id]?.input !== undefined) return (({ limit, can, ...price }) => price)(p.models[id])
62
+ return null
63
+ }
64
+ const own = all.filter(ownBy(provider, baseUrl))
65
+ return find(own) ?? find(all)
66
+ }
67
+ // models.dev's providers that are this one: by its name in HeartCode, or its URL.
68
+ const ownBy = (provider, baseUrl) => {
69
+ const host = hostOf(baseUrl)
70
+ return ([id, p]) => norm(id) === norm(provider) || (p.api && hostOf(p.api) === host) || (host.includes(id) && id.length > 3)
71
+ }
72
+
73
+ // models.dev's models with a size, by their names made plain (norm), the whole name and its last
74
+ // part: built once per list, so a lookup doesn't go through all of them (thousands) again.
75
+ const indexes = new WeakMap()
76
+ function indexOf(t) {
77
+ if (indexes.has(t)) return indexes.get(t)
78
+ const index = new Map()
79
+ let n = 0 // its place in the list: ties go to the first, as they did before the index
80
+ for (const entry of Object.entries(t.providers)) {
81
+ for (const [m, info] of Object.entries(entry[1].models)) {
82
+ if (!info.limit) continue
83
+ const hit = { entry, info, n: n++ }
84
+ for (const k of new Set([norm(m), norm(m.split('/').pop())])) index.has(k) ? index.get(k).push(hit) : index.set(k, [hit])
85
+ }
86
+ }
87
+ indexes.set(t, index)
88
+ return index
89
+ }
90
+
91
+ // What models.dev says of a model: { context, output (tokens), tools, thinking, images }, or null. Found by its name
92
+ // however it's written (google/gemini-2.5-flash, models/gemini-2.5-flash, gpt-oss:120b ≈
93
+ // gpt-oss-120b): at this provider first; else, among all that list it, the size most say (a
94
+ // proxy or a provider models.dev doesn't know serves the model at its usual size).
95
+ export function limitsOf(provider, baseUrl, model) {
96
+ if (!table || !model) return null
97
+ const last = (m) => m.split('/').pop()
98
+ const want = new Set([model, model.replace(/^models\//, ''), last(model), last(model).replace(/:(free|beta|extended|nitro|floor|online|cloud)$/, '').replace(/-cloud$/, '')].map(norm))
99
+ const own = ownBy(provider, baseUrl)
100
+ const found = { own: [], all: [] }
101
+ const hits = [...new Set([...want].flatMap((w) => indexOf(table).get(w) ?? []))].sort((a, b) => a.n - b.n)
102
+ for (const { entry, info } of hits) {
103
+ const it = { ...info.limit, ...(info.can ?? { tools: true, thinking: false, images: false }) }
104
+ found.all.push(it)
105
+ if (own(entry)) found.own.push(it)
106
+ }
107
+ const list = found.own.length ? found.own : found.all
108
+ if (!list.length) return null
109
+ const count = (x) => list.filter((y) => y.context === x.context).length
110
+ const best = list.reduce((a, b) => (count(b) > count(a) || (count(b) === count(a) && b.context > a.context) ? b : a))
111
+ return { context: best.context, output: best.output, tools: best.tools, thinking: best.thinking, images: best.images }
112
+ }
113
+
114
+ // Adds one request's usage (OpenAI's names) to the total; returns what it cost (0 if unknown).
115
+ export function addUsage(total, u, price) {
116
+ const input = u?.prompt_tokens ?? 0
117
+ const output = u?.completion_tokens ?? 0
118
+ const cached = u?.prompt_tokens_details?.cached_tokens ?? 0
119
+ const written = u?.cache_write_tokens ?? 0
120
+ let cost = 0
121
+ if (typeof u?.cost === 'number') cost = u.cost // OpenRouter says what it charged
122
+ else if (price) cost = ((input - cached - written) * (price.input ?? 0) + cached * (price.cache_read ?? price.input ?? 0) + written * (price.cache_write ?? price.input ?? 0) + output * (price.output ?? 0)) / 1e6
123
+ Object.assign(total, {
124
+ requests: (total.requests ?? 0) + 1,
125
+ input: (total.input ?? 0) + input,
126
+ output: (total.output ?? 0) + output,
127
+ cached: (total.cached ?? 0) + cached,
128
+ cost: (total.cost ?? 0) + cost,
129
+ priced: total.priced || Boolean(price) || typeof u?.cost === 'number',
130
+ })
131
+ return cost
132
+ }
133
+
134
+ export const money = (n) => (n >= 1 ? `$${n.toFixed(2)}` : n >= 0.01 ? `$${n.toFixed(3)}` : n > 0 ? `$${n.toFixed(4)}` : '$0')
package/src/voice.js ADDED
@@ -0,0 +1,97 @@
1
+ // Voice input: Ctrl+R (or /voice) records from the microphone; Enter or Ctrl+R again stops, Esc
2
+ // cancels; the words go into the input box, to read over and send.
3
+ // Recording: ffmpeg (macOS: the default microphone; Linux: PulseAudio, else ALSA). Speech to text:
4
+ // - on this machine with whisper.cpp's whisper-cli and a model already on disk (~/.whisper-models,
5
+ // ~/.heart/whisper, or "voice": { "model": "/path/ggml-….bin" } in config.json); nothing is
6
+ // downloaded;
7
+ // - or a provider's OpenAI-compatible /audio/transcriptions: "voice": { "provider": "groq",
8
+ // "model": "whisper-large-v3" }.
9
+ // "voice": { "language": "auto" } for languages other than English (with a multilingual model).
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { spawn } from 'node:child_process'
14
+ import { HOME } from './config.js'
15
+ import { runCommand } from './tools.js'
16
+ import { log } from './log.js'
17
+
18
+ const onPath = (cmd) => (process.env.PATH ?? '').split(path.delimiter).find((d) => d && fs.existsSync(path.join(d, cmd)))
19
+
20
+ // A whisper.cpp model already on disk: the configured one, else the best found (small > base > tiny).
21
+ export function localModel(cfg = {}) {
22
+ if (cfg.model && fs.existsSync(cfg.model)) return cfg.model
23
+ const found = [path.join(os.homedir(), '.whisper-models'), path.join(HOME, 'whisper')].flatMap((d) => {
24
+ try {
25
+ return fs.readdirSync(d).filter((f) => /^ggml-.*\.bin$/.test(f)).map((f) => path.join(d, f))
26
+ } catch {
27
+ return []
28
+ }
29
+ })
30
+ const rank = (f) => ['large', 'medium', 'small', 'base', 'tiny'].findIndex((s) => path.basename(f).includes(s))
31
+ return found.sort((a, b) => (rank(a) === -1 ? 9 : rank(a)) - (rank(b) === -1 ? 9 : rank(b)))[0] ?? null
32
+ }
33
+
34
+ // Why voice can't work here, or null when it can.
35
+ export function voiceProblem(cfg = {}, providers = {}) {
36
+ if (!onPath('ffmpeg')) return 'voice input needs ffmpeg to record (brew install ffmpeg)'
37
+ if (cfg.provider) return providers[cfg.provider] ? null : `voice.provider "${cfg.provider}" isn't one of your providers`
38
+ if (!onPath('whisper-cli')) return 'voice input needs whisper-cli (brew install whisper-cpp) and a model, or "voice": { "provider": …, "model": … } in ~/.heart/config.json'
39
+ return localModel(cfg) ? null : 'voice input needs a whisper model: put a ggml-*.bin in ~/.whisper-models, or set "voice": { "model": "/path/to/ggml-….bin" }'
40
+ }
41
+
42
+ // Starts recording to a 16 kHz mono WAV. Returns { stop() → the file, cancel() }.
43
+ export function record() {
44
+ const file = path.join(os.tmpdir(), `heartcode-voice-${process.pid}-${Date.now()}.wav`)
45
+ const input = process.platform === 'darwin' ? ['-f', 'avfoundation', '-i', ':default'] : ['-f', 'pulse', '-i', 'default']
46
+ const start = (inp) => spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...inp, '-ac', '1', '-ar', '16000', file], { stdio: ['pipe', 'ignore', 'pipe'] })
47
+ let child = start(input)
48
+ let err = ''
49
+ child.stderr.on('data', (d) => (err += d))
50
+ child.stdin.on('error', () => {})
51
+ const ended = () =>
52
+ new Promise((resolve) => {
53
+ if (child.exitCode !== null) return resolve()
54
+ child.once('exit', resolve)
55
+ })
56
+ return {
57
+ async stop() {
58
+ child.stdin.end('q') // ffmpeg finishes the file on "q"
59
+ const t = setTimeout(() => child.kill('SIGINT'), 3000)
60
+ await ended()
61
+ clearTimeout(t)
62
+ if (!fs.existsSync(file) || fs.statSync(file).size < 2000) throw new Error(`nothing was recorded${err.trim() ? `: ${err.trim().split('\n').pop()}` : ' (does the terminal have microphone access? System Settings › Privacy & Security › Microphone)'}`)
63
+ return file
64
+ },
65
+ cancel() {
66
+ child.kill('SIGKILL')
67
+ fs.rmSync(file, { force: true })
68
+ },
69
+ }
70
+ }
71
+
72
+ // The words in the recording (and the file is deleted).
73
+ export async function transcribe(file, cfg = {}, providers = {}, signal) {
74
+ try {
75
+ if (cfg.provider) {
76
+ const p = providers[cfg.provider]
77
+ const form = new FormData()
78
+ form.append('file', new Blob([fs.readFileSync(file)], { type: 'audio/wav' }), 'speech.wav')
79
+ form.append('model', cfg.model ?? 'whisper-1')
80
+ if (cfg.language && cfg.language !== 'auto') form.append('language', cfg.language)
81
+ const res = await fetch(`${p.baseUrl.replace(/\/+$/, '')}/audio/transcriptions`, { method: 'POST', body: form, headers: p.apiKey ? { authorization: `Bearer ${p.apiKey}` } : {}, signal })
82
+ if (!res.ok) throw new Error(`${cfg.provider} said ${res.status}: ${(await res.text()).slice(0, 200)}`)
83
+ return clean((await res.json()).text)
84
+ }
85
+ const model = localModel(cfg)
86
+ const { code, output, stderr } = await runCommand(os.tmpdir(), `whisper-cli -m '${model}' -f '${file}' -nt -np -l ${cfg.language ?? 'en'} -t ${Math.max(2, Math.min(8, os.cpus().length))}`, { signal, timeout: 120_000, split: true }) // its log goes to stderr
87
+ log('voice', `whisper-cli → exit ${code}`, output.slice(0, 2000))
88
+ if (code !== 0) throw new Error(`whisper-cli failed: ${(stderr || output).trim().split('\n').pop()}`)
89
+ return clean(output)
90
+ } finally {
91
+ fs.rmSync(file, { force: true })
92
+ }
93
+ }
94
+
95
+ // One line of text: whisper's line breaks joined, and what it writes for sounds that aren't words
96
+ // dropped: [BLANK_AUDIO], [Music], (wind blowing), (speaking foreign language)…
97
+ export const clean = (t) => String(t ?? '').replace(/\[[^\]]*\]|\([^)]*\)/g, '').replace(/\s+/g, ' ').trim()
package/src/web.js ADDED
@@ -0,0 +1,123 @@
1
+ // web_fetch and web_search: a page as text, and search results, with no API key.
2
+ // Search scrapes DuckDuckGo's HTML page (asked twice when it answers with its bot check). Bing,
3
+ // Startpage and Mojeek were tried too: blocked, or junk results for scripts.
4
+ // ponytail: scraping, no API; add a keyed backend (Brave, Tavily) if DuckDuckGo blocks for good.
5
+
6
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36'
7
+ const HEADERS = { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9' }
8
+
9
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©' }
10
+ export const decode = (s) =>
11
+ s.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (m, e) => {
12
+ if (e[0] === '#') {
13
+ const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
14
+ return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : m
15
+ }
16
+ return ENTITIES[e.toLowerCase()] ?? m
17
+ })
18
+
19
+ // HTML → readable text: scripts, styles and page furniture dropped; headings, list items, links
20
+ // and code blocks kept in a markdown-ish form.
21
+ export function htmlToText(html, base) {
22
+ let h = String(html)
23
+ const title = decode(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(h)?.[1]?.trim() ?? '')
24
+ const main = /<(main|article)\b[\s\S]*?<\/\1>/i.exec(h)?.[0] // the page's content, when it marks it
25
+ if (main && main.length > 500) h = main
26
+ h = h
27
+ .replace(/<!--[\s\S]*?-->/g, '')
28
+ .replace(/<(script|style|noscript|svg|head|title|nav|footer|iframe|template|form)\b[\s\S]*?<\/\1>/gi, '')
29
+ .replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (m, code) => `\n\`\`\`\n${code.replace(/<[^>]+>/g, '')}\n\`\`\`\n`)
30
+ .replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (m, n, t) => `\n\n${'#'.repeat(Number(n))} ${t.replace(/<[^>]+>/g, '').trim()}\n\n`)
31
+ .replace(/<a\b[^>]*href="([^"#][^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (m, href, t) => {
32
+ const text = t.replace(/<[^>]+>/g, '').trim()
33
+ if (!text) return ''
34
+ let url = decode(href)
35
+ try {
36
+ url = new URL(url, base).href
37
+ } catch {}
38
+ return /^https?:/.test(url) && text !== url ? `[${text}](${url})` : text
39
+ })
40
+ .replace(/<li\b[^>]*>/gi, '\n- ')
41
+ .replace(/<(br|hr)\b[^>]*>/gi, '\n')
42
+ .replace(/<\/(p|div|section|tr|table|ul|ol|blockquote|dd|dt)>/gi, '\n')
43
+ .replace(/<\/(td|th)>/gi, ' | ')
44
+ .replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
45
+ .replace(/<[^>]+>/g, '')
46
+ const text = decode(h)
47
+ .split('\n')
48
+ .map((l) => l.replace(/[ \t ]+/g, ' ').trimEnd())
49
+ .join('\n')
50
+ .replace(/\n\s*\n(?=- )/g, '\n') // list items one per line
51
+ .replace(/\n{3,}/g, '\n\n')
52
+ .trim()
53
+ return title && !text.startsWith(`# ${title}`) ? `# ${title}\n\n${text}` : text
54
+ }
55
+
56
+ const MAX = 10_000 // a part and its note fit in a tool result (agent.js keeps 12,000 characters of one)
57
+
58
+ // A page's text, from `start` (characters), for the model.
59
+ export async function webFetch(url, { start = 0, signal } = {}) {
60
+ let u
61
+ try {
62
+ u = new URL(/^\w+:\/\//.test(url) ? url : `https://${url}`)
63
+ } catch {
64
+ return `error: "${url}" is not a URL`
65
+ }
66
+ if (!/^https?:$/.test(u.protocol)) return 'error: only http and https URLs can be fetched'
67
+ const res = await fetch(u, { headers: HEADERS, redirect: 'follow', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) })
68
+ const type = res.headers.get('content-type') ?? ''
69
+ if (!/text|json|xml|javascript|markdown/.test(type) && type) return `error: ${u.href} is ${type.split(';')[0]}, not text (${res.status})`
70
+ const body = await res.text()
71
+ const text = /html/.test(type) || /^\s*<(!doctype|html)/i.test(body) ? htmlToText(body, res.url) : body
72
+ const from = Math.max(0, Number(start) || 0)
73
+ const part = text.slice(from, from + MAX)
74
+ const more = from + MAX < text.length ? `\n\n… (${text.length} characters in all; call web_fetch with start: ${from + MAX} for more)` : ''
75
+ return `${res.ok ? '' : `error: HTTP ${res.status}\n`}${res.url !== u.href ? `(redirected to ${res.url})\n` : ''}${part || '(empty page)'}${more}`
76
+ }
77
+
78
+ const strip = (s) => decode(String(s).replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim()
79
+
80
+ async function duckduckgo(query, signal) {
81
+ let html = ''
82
+ for (let i = 0; i < 2; i++) {
83
+ const res = await fetch(process.env.HEART_SEARCH_URL || 'https://html.duckduckgo.com/html/', { // HEART_SEARCH_URL: a stand-in (tests)
84
+ method: 'POST',
85
+ headers: { ...HEADERS, 'content-type': 'application/x-www-form-urlencoded', referer: 'https://html.duckduckgo.com/' },
86
+ body: new URLSearchParams({ q: query, b: '' }),
87
+ signal,
88
+ })
89
+ html = await res.text()
90
+ if (res.status !== 202) break // 202: its bot check
91
+ if (i === 0) await new Promise((r) => setTimeout(r, 1500))
92
+ else throw new Error('DuckDuckGo is asking for a captcha')
93
+ }
94
+ const out = []
95
+ for (const m of html.matchAll(/<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>([\s\S]*?)(?=<a[^>]*class="result__a"|$)/g)) {
96
+ let url = decode(m[1])
97
+ const wrapped = /[?&]uddg=([^&]+)/.exec(url) // a DuckDuckGo redirect link
98
+ if (wrapped) url = decodeURIComponent(wrapped[1])
99
+ if (/duckduckgo\.com\/y\.js/.test(url)) continue // an ad
100
+ out.push({ title: strip(m[2]), url, snippet: strip(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/.exec(m[3])?.[1] ?? '') })
101
+ }
102
+ return out
103
+ }
104
+
105
+ export const ENGINES = { duckduckgo }
106
+
107
+ // Results as text: title, URL and snippet, numbered.
108
+ export async function webSearch(query, { signal, limit = 8, engines = ENGINES } = {}) {
109
+ const q = String(query ?? '').trim()
110
+ if (!q) return 'error: no query given'
111
+ const failed = []
112
+ for (const [name, engine] of Object.entries(engines)) {
113
+ try {
114
+ const hits = (await engine(q, signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000))).filter((h) => h.url && h.title)
115
+ if (hits.length) return hits.slice(0, limit).map((h, i) => `${i + 1}. ${h.title}\n ${h.url}${h.snippet ? `\n ${h.snippet}` : ''}`).join('\n\n')
116
+ failed.push(`${name}: no results`)
117
+ } catch (e) {
118
+ if (signal?.aborted) throw e
119
+ failed.push(`${name}: ${e.message}`)
120
+ }
121
+ }
122
+ return `no results for "${q}" (${failed.join('; ')}). Try other words, or web_fetch a site you know.`
123
+ }
@@ -0,0 +1,56 @@
1
+ // heartcode -w / --worktree=name: work in a git worktree of its own, on its own branch, so the
2
+ // task's changes stay apart from yours until you merge them. It goes in
3
+ // <repo>/.heartcode/worktrees/<name> (kept out of git status), on the branch heartcode/<name>,
4
+ // from the current commit. The same name again continues in the same worktree.
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { execFileSync } from 'node:child_process'
8
+
9
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
10
+
11
+ export function createWorktree(root, name) {
12
+ let top
13
+ try {
14
+ top = git(root, ['rev-parse', '--show-toplevel'])
15
+ } catch {
16
+ throw new Error(`--worktree needs a git repository; ${root} isn't in one`)
17
+ }
18
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '')
19
+ name = String(name || `task-${stamp}`).replace(/[^\w.-]+/g, '-')
20
+ const dir = path.join(top, '.heartcode', 'worktrees', name)
21
+ const branch = `heartcode/${name}`
22
+ const base = git(top, ['rev-parse', 'HEAD'])
23
+ if (!fs.existsSync(dir)) {
24
+ const exists = (() => {
25
+ try {
26
+ git(top, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
27
+ return true
28
+ } catch {
29
+ return false
30
+ }
31
+ })()
32
+ git(top, ['worktree', 'add', ...(exists ? [dir, branch] : ['-b', branch, dir, 'HEAD'])])
33
+ }
34
+ // keep the worktrees out of the main checkout's git status and file list
35
+ const exclude = path.join(path.resolve(top, git(top, ['rev-parse', '--git-common-dir'])), 'info', 'exclude')
36
+ const text = fs.existsSync(exclude) ? fs.readFileSync(exclude, 'utf8') : ''
37
+ if (!text.split('\n').includes('.heartcode/worktrees/')) {
38
+ fs.mkdirSync(path.dirname(exclude), { recursive: true })
39
+ fs.appendFileSync(exclude, `${text && !text.endsWith('\n') ? '\n' : ''}.heartcode/worktrees/\n`)
40
+ }
41
+ return { name, dir, branch, top, base, cwd: path.join(dir, path.relative(top, root)) }
42
+ }
43
+
44
+ // What the worktree holds now: uncommitted changes and commits on its branch since it was made.
45
+ export function worktreeState(wt) {
46
+ const changes = git(wt.dir, ['status', '--porcelain']).split('\n').filter(Boolean).length
47
+ const commits = Number(git(wt.dir, ['rev-list', '--count', `${wt.base}..HEAD`]))
48
+ return { changes, commits, empty: !changes && !commits }
49
+ }
50
+
51
+ export function removeWorktree(wt) {
52
+ git(wt.top, ['worktree', 'remove', '--force', wt.dir])
53
+ try {
54
+ git(wt.top, ['branch', '-D', wt.branch])
55
+ } catch {}
56
+ }