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/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
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
|
+
}
|
package/src/worktree.js
ADDED
|
@@ -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
|
+
}
|