heartcode 0.0.0-stage → 0.1.1
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 +117 -2
- package/SKILL.md +179 -0
- package/bin/heart.js +78 -0
- package/bin/heartcode.js +1572 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +348 -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 +697 -0
- package/src/client.js +629 -0
- package/src/config.js +109 -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 +218 -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 +376 -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 +104 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1618 -0
- package/src/settings.js +554 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +865 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +149 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
export const HOME = process.env.HEART_HOME || path.join(os.homedir(), '.heart')
|
|
7
|
+
export const CONFIG_FILE = path.join(HOME, 'config.json')
|
|
8
|
+
|
|
9
|
+
const SETTINGS = ['context', 'effort', 'permissions', 'autoCompact', 'director', 'theme', 'format', 'lsp', 'voice', 'rotation', 'log', 'logRequests', 'logDetail', 'logDays']
|
|
10
|
+
const LOCAL_NAMES = { 11434: 'ollama', 1234: 'lmstudio', 8080: 'llamacpp' }
|
|
11
|
+
|
|
12
|
+
export function readJson(file, fallback) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
15
|
+
} catch {
|
|
16
|
+
return fallback
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// HeartCode's version, from package.json (npm version changes it there).
|
|
21
|
+
export const VERSION = readJson(fileURLToPath(new URL('../package.json', import.meta.url)), {}).version ?? '0.0.0'
|
|
22
|
+
|
|
23
|
+
// A config file the user writes (hooks.json, mcp.json): one that doesn't parse is skipped, but
|
|
24
|
+
// said (takeConfigErrors, shown before the next message), never silently ignored.
|
|
25
|
+
const configErrors = new Map() // file → { message, stamp, shown }
|
|
26
|
+
export function readConfig(file, fallback) {
|
|
27
|
+
if (!file || !fs.existsSync(file)) return fallback
|
|
28
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text)
|
|
31
|
+
} catch (e) {
|
|
32
|
+
const stamp = fs.statSync(file).mtimeMs
|
|
33
|
+
if (configErrors.get(file)?.stamp !== stamp) configErrors.set(file, { message: `${file} isn't valid JSON (${e.message}); it's skipped until it's fixed`, stamp, shown: false })
|
|
34
|
+
return fallback
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function takeConfigErrors() {
|
|
38
|
+
const out = [...configErrors.values()].filter((e) => !e.shown)
|
|
39
|
+
for (const e of out) e.shown = true
|
|
40
|
+
return out.map((e) => e.message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeJson(file, data) {
|
|
44
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
45
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A short name for a URL: https://api.groq.com/openai/v1 → groq, https://proxy.dev/groq → groq.
|
|
49
|
+
export function providerName(url) {
|
|
50
|
+
const u = new URL(url)
|
|
51
|
+
if (/^(localhost|127\.|0\.0\.0\.0)/.test(u.hostname)) return LOCAL_NAMES[u.port] ?? `local-${u.port || 80}`
|
|
52
|
+
const seg = u.pathname.split('/').filter((s) => s && !/^(v\d+\w*|api|openai)$/i.test(s)).pop()
|
|
53
|
+
if (seg) return seg.toLowerCase()
|
|
54
|
+
const parts = u.hostname.split('.').filter((p) => !['api', 'www', 'integrate', 'generativelanguage'].includes(p))
|
|
55
|
+
return (parts.length > 1 ? parts.at(-2) : parts[0]) || u.hostname
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fromFile() {
|
|
59
|
+
const file = readJson(CONFIG_FILE, {})
|
|
60
|
+
const cfg = { ...file, providers: { ...file.providers }, adhoc: null }
|
|
61
|
+
if (file.baseUrl && !file.providers) {
|
|
62
|
+
// config from before providers existed
|
|
63
|
+
const name = providerName(file.baseUrl)
|
|
64
|
+
cfg.providers[name] = { baseUrl: file.baseUrl, apiKey: file.apiKey }
|
|
65
|
+
cfg.provider = name
|
|
66
|
+
}
|
|
67
|
+
delete cfg.baseUrl
|
|
68
|
+
delete cfg.apiKey
|
|
69
|
+
return cfg
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
|
|
73
|
+
// flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
|
|
74
|
+
// only (cfg.adhoc), unless saved.
|
|
75
|
+
// The model name that means your list: #1, then the fallbacks when one fails (also: no model given).
|
|
76
|
+
// Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
|
|
77
|
+
export const DEFAULT_MODEL = 'default'
|
|
78
|
+
|
|
79
|
+
export function loadConfig(flags = {}) {
|
|
80
|
+
const file = fromFile()
|
|
81
|
+
const cfg = { providers: file.providers, provider: file.provider, model: file.model, adhoc: null }
|
|
82
|
+
for (const k of SETTINGS) cfg[k] = flags[k] ?? file[k]
|
|
83
|
+
if (cfg.context) cfg.context = Number(cfg.context)
|
|
84
|
+
|
|
85
|
+
const baseUrl = flags.baseUrl || process.env.HEART_BASE_URL || process.env.OPENAI_BASE_URL
|
|
86
|
+
const apiKey = flags.apiKey || process.env.HEART_API_KEY || process.env.OPENAI_API_KEY
|
|
87
|
+
if (baseUrl) {
|
|
88
|
+
const name = providerName(baseUrl)
|
|
89
|
+
cfg.providers[name] = { baseUrl, apiKey }
|
|
90
|
+
if (cfg.provider !== name) cfg.model = undefined
|
|
91
|
+
cfg.provider = name
|
|
92
|
+
cfg.adhoc = name
|
|
93
|
+
cfg.fixed = true
|
|
94
|
+
} else if (apiKey && cfg.providers[cfg.provider] && !cfg.providers[cfg.provider].apiKey) {
|
|
95
|
+
cfg.providers[cfg.provider].apiKey = apiKey
|
|
96
|
+
}
|
|
97
|
+
const model = flags.model || process.env.HEART_MODEL
|
|
98
|
+
if (model && model !== DEFAULT_MODEL) Object.assign(cfg, { model, fixed: true })
|
|
99
|
+
return cfg
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function saveConfig(cfg) {
|
|
103
|
+
const providers = { ...cfg.providers }
|
|
104
|
+
if (cfg.adhoc) delete providers[cfg.adhoc]
|
|
105
|
+
const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
|
|
106
|
+
const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
|
|
107
|
+
for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
|
|
108
|
+
writeJson(CONFIG_FILE, out)
|
|
109
|
+
}
|
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
|
+
}
|
package/src/director.js
ADDED
|
@@ -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
|
+
}
|