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/settings.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// The settings page: a small web server on this machine (127.0.0.1 only) where everything but
|
|
2
|
+
// the chat can be changed: providers and their keys, the model, settings, MCP servers, hooks,
|
|
3
|
+
// keys, colors, skills. Open with `heartcode settings` or /settings. A secret token in
|
|
4
|
+
// the link guards it (another site, or another user on this machine, can't use it); API keys
|
|
5
|
+
// are never sent to the page, only their first and last characters.
|
|
6
|
+
import http from 'node:http'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import { HOME, saveConfig, readJson, writeJson } from './config.js'
|
|
12
|
+
import { DEFAULT_SERVERS } from './mcp.js'
|
|
13
|
+
import { sections, BUILTIN_PROMPTS } from './prompts.js'
|
|
14
|
+
import { Client } from './client.js'
|
|
15
|
+
import { clip } from './tools.js'
|
|
16
|
+
import { loadProfile, strategy, profilePath, profileFrom } from './probe.js'
|
|
17
|
+
import { loadPrices, priceOf, limitsOf } from './usage.js'
|
|
18
|
+
import { THEMES, PERMISSIONS } from './ui.js'
|
|
19
|
+
import { loadSkills, frontMatter } from './skills.js'
|
|
20
|
+
import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLogs, logDays } from './log.js'
|
|
21
|
+
|
|
22
|
+
const PAGE = new URL('./settings.html', import.meta.url)
|
|
23
|
+
const EFFORTS = ['default', 'low', 'medium', 'high']
|
|
24
|
+
const TOGGLES = { autoCompact: 'compact the conversation when the context is 70% full', director: 'let the model add skills, MCP servers and change settings when asked', format: "run the project's formatter on every file HeartCode writes", lsp: "show the model a language server's errors after each edit" }
|
|
25
|
+
// The Logging page's: both off unless switched on; how much of each request ("full" or "summary")
|
|
26
|
+
const LOG_SWITCHES = ['log', 'logRequests']
|
|
27
|
+
const DETAILS = ['full', 'summary']
|
|
28
|
+
// Files in ~/.heart the page edits as text: [name, what it is, JSON?]
|
|
29
|
+
const FILES = [
|
|
30
|
+
['mcp.json', 'MCP servers: programs that give the model more tools', true, '{\n "mcpServers": {\n "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] },\n "docs": { "url": "https://mcp.example.com/mcp" }\n }\n}'],
|
|
31
|
+
['hooks.json', 'Your commands, run before or after the model uses a tool (one can block it)', true, '{\n "hooks": {\n "PreToolUse": [\n { "matcher": "run_command", "hooks": [{ "type": "command", "command": "./check-command.sh" }] }\n ]\n }\n}'],
|
|
32
|
+
['keybindings.json', 'Your own keys: an action, or a /command to send', true, '{\n "ctrl+o": "cycle_permissions",\n "ctrl+g": "/toggle_thinking"\n}'],
|
|
33
|
+
['theme.json', 'Your own colors, on top of the theme you chose', true, '{\n "brand": "#ff6b9d",\n "background": "#101014"\n}'],
|
|
34
|
+
['format.json', 'The formatter to run on each kind of file HeartCode writes', true, '{\n ".go": "gofmt -w {file}",\n ".py": "ruff format {file}"\n}'],
|
|
35
|
+
['HEARTCODE.md', 'Your instructions for every project (like AGENTS.md, for all of them)', false, '- Answer briefly.\n- Use pnpm, not npm.'],
|
|
36
|
+
]
|
|
37
|
+
// The prompts the page edits: every section of these files (prompts/*.md); the system prompts first.
|
|
38
|
+
const PROMPT_FILES = ['agent', 'guided', 'compact', 'probe']
|
|
39
|
+
const FIRST = { 'agent/system': 'System prompt (models with tool calls)', 'guided/system': 'System prompt (guided mode: models without tool calls)' }
|
|
40
|
+
const ownPrompts = (file) => path.join(HOME, 'prompts', `${file}.md`)
|
|
41
|
+
const tilde = (p) => (p.startsWith(os.homedir() + path.sep) ? '~' + p.slice(os.homedir().length) : p)
|
|
42
|
+
const varsOf = (text) => new Set([...text.matchAll(/\{\{\s*(?:#if\s+)?(\w+)\s*\}\}/g)].map((m) => m[1]).filter((v) => v !== 'else'))
|
|
43
|
+
// built-in sections with their notes (the <!-- … --> under a heading, never sent)
|
|
44
|
+
function builtinPrompts(file) {
|
|
45
|
+
const raw = fs.readFileSync(path.join(BUILTIN_PROMPTS, `${file}.md`), 'utf8')
|
|
46
|
+
const notes = new Map([...raw.matchAll(/^## (\S+)\s*\n+<!--([\s\S]*?)-->/gm)].map((m) => [m[1], m[2].trim()]))
|
|
47
|
+
return [...sections(raw)].map(([name, text]) => ({ name, text, note: notes.get(name) ?? '' }))
|
|
48
|
+
}
|
|
49
|
+
function prompts() {
|
|
50
|
+
const out = PROMPT_FILES.flatMap((file) => {
|
|
51
|
+
const own = sections(fs.existsSync(ownPrompts(file)) ? fs.readFileSync(ownPrompts(file), 'utf8') : '')
|
|
52
|
+
return builtinPrompts(file).map((b) => ({ id: `${file}/${b.name}`, title: FIRST[`${file}/${b.name}`] ?? null, note: b.note, builtin: b.text, text: own.get(b.name) ?? b.text, changed: own.has(b.name) }))
|
|
53
|
+
})
|
|
54
|
+
return [...out.filter((p) => p.title), ...out.filter((p) => !p.title)]
|
|
55
|
+
}
|
|
56
|
+
// ~/.heart/prompts/<file>.md: the user's sections, one changed (text) or put back (null)
|
|
57
|
+
function setPrompt(file, name, text) {
|
|
58
|
+
const f = ownPrompts(file)
|
|
59
|
+
const own = sections(fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '')
|
|
60
|
+
if (text === null) own.delete(name)
|
|
61
|
+
else own.set(name, text.trim())
|
|
62
|
+
if (!own.size) return fs.rmSync(f, { force: true })
|
|
63
|
+
fs.mkdirSync(path.dirname(f), { recursive: true })
|
|
64
|
+
fs.writeFileSync(f, `# ${file}.md: your changes to HeartCode's prompts (the settings page; prompts/README.md in HeartCode)\n\n` + [...own].map(([n, t]) => `## ${n}\n\n${t}\n`).join('\n'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// MCP servers: built in, yours (mcp.json); each on or off.
|
|
68
|
+
function mcpServers() {
|
|
69
|
+
const own = readJson(path.join(HOME, 'mcp.json'), {})?.mcpServers ?? {}
|
|
70
|
+
const from = Object.fromEntries(Object.keys(DEFAULT_SERVERS).map((n) => [n, 'built in']))
|
|
71
|
+
const all = structuredClone(DEFAULT_SERVERS)
|
|
72
|
+
for (const [n, c] of Object.entries(own)) all[n] = { ...all[n], ...c }
|
|
73
|
+
return Object.entries(all).map(([name, c]) => ({ name, from: from[name] ?? 'yours', on: !c.disabled, what: c.url ?? [c.command, ...(c.args ?? [])].filter(Boolean).join(' '), description: c.description ?? '' }))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The model list: #1 is the model (cfg.provider/model), the rest cfg.rotation.then, in order.
|
|
77
|
+
const ROTATION = { on: ['failure', 'retries', 'any'], next: ['first', 'stay'] }
|
|
78
|
+
const rotationOf = (cfg) => ({ on: 'failure', retries: 2, next: 'first', ...cfg.rotation, then: (cfg.rotation?.then ?? []).filter((t) => cfg.providers[t.provider]) })
|
|
79
|
+
|
|
80
|
+
const mask = (k) => (k ? (k.length > 12 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '…') : '')
|
|
81
|
+
const safeName = (n) => String(n ?? '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
82
|
+
const skillsDir = () => path.join(HOME, 'skills')
|
|
83
|
+
|
|
84
|
+
// cfg: the running HeartCode's config (changed in place, and saved). apply(what): after a change,
|
|
85
|
+
// to bring it into the running sessions ('model', 'setting:<name>', 'theme', 'mcp', 'skill', 'keys').
|
|
86
|
+
export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
87
|
+
const token = crypto.randomBytes(24).toString('hex')
|
|
88
|
+
const changed = (what) => {
|
|
89
|
+
try {
|
|
90
|
+
apply(what)
|
|
91
|
+
} catch (e) {
|
|
92
|
+
log('web', `applying ${what}: ${e.stack}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const save = (what) => (saveConfig(cfg), changed(what))
|
|
96
|
+
|
|
97
|
+
// the page listens (GET /api/events): a model's profile as it changes, a request as it's logged
|
|
98
|
+
const listeners = new Set()
|
|
99
|
+
const emit = (event, data) => {
|
|
100
|
+
for (const res of listeners) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
101
|
+
}
|
|
102
|
+
// A request logged (by this HeartCode or another one running): sent to the page as it's written.
|
|
103
|
+
let watcher = null
|
|
104
|
+
const read = new Map() // each run's index.jsonl → how much of it was sent already
|
|
105
|
+
const watchRequests = () => {
|
|
106
|
+
if (watcher) return
|
|
107
|
+
try {
|
|
108
|
+
// the logs folder (not requests/, which may not be there yet, or be deleted with old logs)
|
|
109
|
+
fs.mkdirSync(LOGS, { recursive: true })
|
|
110
|
+
for (const run of fs.existsSync(REQUESTS) ? fs.readdirSync(REQUESTS) : []) read.set(path.join(REQUESTS, run, 'index.jsonl'), fs.statSync(path.join(REQUESTS, run, 'index.jsonl'), { throwIfNoEntry: false })?.size ?? 0)
|
|
111
|
+
watcher = fs.watch(LOGS, { recursive: true }, (_, name) => {
|
|
112
|
+
const file = name && path.join(LOGS, name)
|
|
113
|
+
if (!file?.endsWith('index.jsonl') || path.dirname(path.dirname(file)) !== REQUESTS) return
|
|
114
|
+
try {
|
|
115
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
116
|
+
const from = read.get(file) ?? 0
|
|
117
|
+
read.set(file, Buffer.byteLength(text))
|
|
118
|
+
const fresh = Buffer.from(text).subarray(from).toString('utf8')
|
|
119
|
+
for (const line of fresh.split('\n').filter(Boolean)) emit('request', { run: path.basename(path.dirname(file)), ...JSON.parse(line) })
|
|
120
|
+
} catch {}
|
|
121
|
+
})
|
|
122
|
+
watcher.on('error', () => {})
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
// What a model is, for the page: { source, mode, context, sure, thinking, vision, stream, set }, or
|
|
126
|
+
// null (not looked up yet). source: models.dev, you (set on this page), assumed (models.dev doesn't
|
|
127
|
+
// know it: set it up), or tested (an older profile from a test).
|
|
128
|
+
const clientFor = (t) => cfg.providers[t.provider] && new Client({ baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey, model: t.model, provider: t.provider })
|
|
129
|
+
const testedOf = (t) => {
|
|
130
|
+
const c = clientFor(t)
|
|
131
|
+
const p = c && loadProfile(c)
|
|
132
|
+
if (!p) return null
|
|
133
|
+
const known = limitsOf(t.provider, c.baseUrl, t.model) ?? p.known
|
|
134
|
+
const st = strategy({ ...p, known, source: p.source === 'assumed' && known ? 'models.dev' : p.source })
|
|
135
|
+
return { source: p.source === 'assumed' && known ? 'models.dev' : (p.source ?? 'tested'), mode: st.actions, context: st.context, sure: st.contextSure, thinking: st.thinking, vision: st.vision, stream: st.stream, set: p.set ?? {} }
|
|
136
|
+
}
|
|
137
|
+
const knownOf = (t) => {
|
|
138
|
+
const p = cfg.providers[t.provider]
|
|
139
|
+
return (p && limitsOf(t.provider, p.baseUrl, t.model)) ?? null
|
|
140
|
+
}
|
|
141
|
+
const priceFor = (t) => {
|
|
142
|
+
const p = cfg.providers[t.provider]
|
|
143
|
+
const x = p && priceOf(t.provider, p.baseUrl, t.model)
|
|
144
|
+
return x && (x.input != null || x.output != null) ? { input: x.input ?? null, output: x.output ?? null } : null
|
|
145
|
+
}
|
|
146
|
+
const testNews = (key, t) => emit('test', { key, tested: t ? testedOf(t) : null, known: t ? knownOf(t) : null })
|
|
147
|
+
// A model in your list gets its profile from models.dev at once (nothing is sent to the model); one
|
|
148
|
+
// models.dev doesn't know is marked so, for you to set up.
|
|
149
|
+
async function test(list) {
|
|
150
|
+
await loadPrices()
|
|
151
|
+
for (const t of list) {
|
|
152
|
+
const c = clientFor(t)
|
|
153
|
+
if (!c || loadProfile(c)) continue
|
|
154
|
+
writeJson(profilePath(c), profileFrom(c, limitsOf(t.provider, c.baseUrl, t.model)))
|
|
155
|
+
testNews(`${t.provider}/${t.model}`, t)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
// A failed request in a few words: its status and what the server said (its JSON's message).
|
|
161
|
+
const whyFailed = (e) => {
|
|
162
|
+
let said = String(e.body ?? '').trim()
|
|
163
|
+
try {
|
|
164
|
+
const j = [JSON.parse(said)].flat()[0]
|
|
165
|
+
said = j?.error?.message ?? (typeof j?.error === 'string' ? j.error : null) ?? (typeof j?.detail === 'string' ? j.detail : null) ?? j?.message ?? j?.title ?? said
|
|
166
|
+
} catch {}
|
|
167
|
+
if (!said || /^\s*</.test(said)) said = e.message.replace(/^\d+ from \S+: /, '')
|
|
168
|
+
return clip(`${e.status ? `${e.status}: ` : ''}${said}`, 300)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const state = () => ({
|
|
172
|
+
providers: Object.entries(cfg.providers)
|
|
173
|
+
.filter(([n]) => n !== cfg.adhoc)
|
|
174
|
+
.map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, added: p.models ?? [] })),
|
|
175
|
+
provider: cfg.provider ?? null,
|
|
176
|
+
model: cfg.model ?? null,
|
|
177
|
+
order: [...(cfg.providers[cfg.provider] && cfg.model ? [{ provider: cfg.provider, model: cfg.model }] : []), ...rotationOf(cfg).then].map((t) => ({ ...t, tested: testedOf(t), known: knownOf(t), price: priceFor(t) })),
|
|
178
|
+
rotation: (({ then, ...opts }) => opts)(rotationOf(cfg)),
|
|
179
|
+
settings: { permissions: cfg.permissions ?? 'all', effort: cfg.effort ?? 'default', theme: cfg.theme ?? 'dark', context: cfg.context ?? null, ...Object.fromEntries(Object.keys(TOGGLES).map((k) => [k, cfg[k] !== false])), ...Object.fromEntries(LOG_SWITCHES.map((k) => [k, cfg[k] === true])), logDetail: cfg.logDetail === 'summary' ? 'summary' : 'full', logDays: logDays(cfg) },
|
|
180
|
+
logs: { runs: tilde(path.join(LOGS, 'runs')), requests: tilde(REQUESTS) },
|
|
181
|
+
voice: cfg.voice ? JSON.stringify(cfg.voice, null, 2) : '',
|
|
182
|
+
choices: { permissions: PERMISSIONS, effort: EFFORTS, theme: Object.keys(THEMES), toggles: TOGGLES },
|
|
183
|
+
files: FILES.map(([name, about, json, example]) => ({ name, about, json, example, text: fs.existsSync(path.join(HOME, name)) ? fs.readFileSync(path.join(HOME, name), 'utf8') : '' })),
|
|
184
|
+
skills: [...loadSkills(null).values()].map((s) => ({ name: s.name, description: s.description, use: s.use, mine: s.file.startsWith(skillsDir() + path.sep), text: fs.readFileSync(s.file, 'utf8') })),
|
|
185
|
+
mcp: mcpServers(),
|
|
186
|
+
prompts: prompts(),
|
|
187
|
+
home: tilde(HOME),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const routes = {
|
|
191
|
+
'GET /api/state': () => state(),
|
|
192
|
+
'POST /api/provider': ({ name, baseUrl, apiKey, keepKey, was }) => {
|
|
193
|
+
name = safeName(name)
|
|
194
|
+
if (!name) throw new Error('a name is needed')
|
|
195
|
+
baseUrl = String(baseUrl ?? '').trim()
|
|
196
|
+
if (!/^https?:\/\//i.test(baseUrl)) baseUrl = (/^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.)/.test(baseUrl) ? 'http://' : 'https://') + baseUrl
|
|
197
|
+
try {
|
|
198
|
+
if (!new URL(baseUrl).hostname) throw new Error()
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error(`"${baseUrl}" is not a URL`)
|
|
201
|
+
}
|
|
202
|
+
baseUrl = baseUrl.replace(/\/+$/, '')
|
|
203
|
+
const old = cfg.providers[was ?? name]
|
|
204
|
+
const key = keepKey ? old?.apiKey : String(apiKey ?? '').trim() || undefined
|
|
205
|
+
if (was && was !== name) {
|
|
206
|
+
delete cfg.providers[was]
|
|
207
|
+
if (cfg.provider === was) cfg.provider = name
|
|
208
|
+
for (const t of cfg.rotation?.then ?? []) if (t.provider === was) t.provider = name
|
|
209
|
+
}
|
|
210
|
+
cfg.providers[name] = { ...old, baseUrl, apiKey: key } // its list switch and added models stay
|
|
211
|
+
if (!key) delete cfg.providers[name].apiKey
|
|
212
|
+
save('model')
|
|
213
|
+
return state()
|
|
214
|
+
},
|
|
215
|
+
'POST /api/provider/delete': ({ name }) => {
|
|
216
|
+
if (!cfg.providers[name]) throw new Error(`no provider "${name}"`)
|
|
217
|
+
delete cfg.providers[name]
|
|
218
|
+
if (cfg.rotation?.then) cfg.rotation.then = cfg.rotation.then.filter((t) => t.provider !== name)
|
|
219
|
+
if (cfg.provider === name) {
|
|
220
|
+
const [first, ...rest] = cfg.rotation?.then ?? [] // the next in the list becomes #1
|
|
221
|
+
Object.assign(cfg, { provider: first?.provider, model: first?.model })
|
|
222
|
+
if (cfg.rotation) cfg.rotation.then = rest
|
|
223
|
+
}
|
|
224
|
+
save('model')
|
|
225
|
+
return state()
|
|
226
|
+
},
|
|
227
|
+
// a provider's models, and what's known about each (for the page's tooltip): what the provider
|
|
228
|
+
// says (name, context, owner, date, description, price), HeartCode's price list, its own test
|
|
229
|
+
// Its model list switched off: HeartCode never asks it for its models; only the ones added by name show.
|
|
230
|
+
'POST /api/provider/list': ({ name, on }) => {
|
|
231
|
+
const p = cfg.providers[name]
|
|
232
|
+
if (!p) throw new Error(`no provider "${name}"`)
|
|
233
|
+
if (on) delete p.list
|
|
234
|
+
else p.list = false
|
|
235
|
+
save('provider')
|
|
236
|
+
return state()
|
|
237
|
+
},
|
|
238
|
+
// A model added by its name (one the provider doesn't list, or its list is off): remembered.
|
|
239
|
+
'POST /api/provider/added': ({ name, model, remove }) => {
|
|
240
|
+
const p = cfg.providers[name]
|
|
241
|
+
if (!p) throw new Error(`no provider "${name}"`)
|
|
242
|
+
model = String(model ?? '').trim()
|
|
243
|
+
if (!model) throw new Error('a model is needed')
|
|
244
|
+
const rest = (p.models ?? []).filter((m) => m !== model)
|
|
245
|
+
p.models = remove ? rest : [...rest, model]
|
|
246
|
+
if (!p.models.length) delete p.models
|
|
247
|
+
save('provider')
|
|
248
|
+
return state()
|
|
249
|
+
},
|
|
250
|
+
'POST /api/models': async ({ name }) => {
|
|
251
|
+
const p = cfg.providers[name]
|
|
252
|
+
if (!p) throw new Error(`no provider "${name}"`)
|
|
253
|
+
if (p.list === false) return { models: [], details: {}, off: true }
|
|
254
|
+
const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
|
|
255
|
+
await loadPrices().catch(() => null)
|
|
256
|
+
const num = (...xs) => xs.map(Number).find((x) => x > 0)
|
|
257
|
+
const details = Object.fromEntries(entries.map((m) => {
|
|
258
|
+
const id = String(m.id ?? m.name)
|
|
259
|
+
const perMillion = (x) => Number((Number(x) * 1e6).toPrecision(6)) // OpenRouter: per token
|
|
260
|
+
const listed = m.pricing && { input: perMillion(m.pricing.prompt), output: perMillion(m.pricing.completion) }
|
|
261
|
+
const price = priceOf(name, p.baseUrl, id) ?? (listed && Number.isFinite(listed.input) ? listed : null)
|
|
262
|
+
const profile = loadProfile(new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey, model: id }))
|
|
263
|
+
return [id, {
|
|
264
|
+
name: m.name && m.name !== id ? String(m.name) : undefined,
|
|
265
|
+
context: num(m.context_length, m.context_window, m.max_model_len, m.max_context_length, m.top_provider?.context_length),
|
|
266
|
+
owner: m.owned_by && m.owned_by !== 'system' ? String(m.owned_by) : undefined,
|
|
267
|
+
created: num(m.created) && Number(m.created) > 946684800 ? Number(m.created) * 1000 : undefined, // seconds; 2000 or later
|
|
268
|
+
description: m.description ? String(m.description).slice(0, 400) : undefined,
|
|
269
|
+
price: price ? { input: price.input, output: price.output } : undefined,
|
|
270
|
+
tested: profile ? { mode: strategy(profile).actions, context: profile.context, thinking: Boolean(profile.thinking), vision: strategy(profile).vision } : undefined,
|
|
271
|
+
}]
|
|
272
|
+
}))
|
|
273
|
+
return { models: entries.map((m) => String(m.id ?? m.name)), details }
|
|
274
|
+
},
|
|
275
|
+
'POST /api/model': ({ provider, model }) => {
|
|
276
|
+
if (!cfg.providers[provider]) throw new Error(`no provider "${provider}"`)
|
|
277
|
+
if (!String(model ?? '').trim()) throw new Error('a model is needed')
|
|
278
|
+
Object.assign(cfg, { provider, model: String(model).trim() })
|
|
279
|
+
if (cfg.rotation?.then) cfg.rotation.then = cfg.rotation.then.filter((t) => t.provider !== provider || t.model !== cfg.model) // #1 now, not also further down
|
|
280
|
+
save('model')
|
|
281
|
+
test([{ provider, model: cfg.model }])
|
|
282
|
+
return state()
|
|
283
|
+
},
|
|
284
|
+
// the model list, in order (#1 is the model), and when to go on to the next
|
|
285
|
+
'POST /api/order': ({ list, on, retries, next }) => {
|
|
286
|
+
if (!Array.isArray(list)) throw new Error('a list of models is needed')
|
|
287
|
+
const seen = new Set()
|
|
288
|
+
const clean = list.map((t) => ({ provider: String(t?.provider ?? ''), model: String(t?.model ?? '').trim() }))
|
|
289
|
+
for (const t of clean) {
|
|
290
|
+
if (!cfg.providers[t.provider]) throw new Error(`no provider "${t.provider}"`)
|
|
291
|
+
if (!t.model) throw new Error('a model is needed')
|
|
292
|
+
if (seen.has(`${t.provider}/${t.model}`)) throw new Error(`${t.provider} · ${t.model} is in the list twice`)
|
|
293
|
+
seen.add(`${t.provider}/${t.model}`)
|
|
294
|
+
}
|
|
295
|
+
const opts = { ...rotationOf(cfg), ...(on !== undefined && { on }), ...(retries !== undefined && { retries: Number(retries) }), ...(next !== undefined && { next }) }
|
|
296
|
+
if (!ROTATION.on.includes(opts.on)) throw new Error(`move on: ${ROTATION.on.join(', ')}`)
|
|
297
|
+
if (!Number.isInteger(opts.retries) || opts.retries < 1 || opts.retries > 20) throw new Error('tries before moving on: 1 to 20')
|
|
298
|
+
if (!ROTATION.next.includes(opts.next)) throw new Error(`a new message starts with: ${ROTATION.next.join(', ')}`)
|
|
299
|
+
// an empty list: no model (HeartCode says so until one is chosen)
|
|
300
|
+
const changed1 = cfg.provider !== clean[0]?.provider || cfg.model !== clean[0]?.model
|
|
301
|
+
Object.assign(cfg, { provider: clean[0]?.provider, model: clean[0]?.model })
|
|
302
|
+
cfg.rotation = { on: opts.on, retries: opts.retries, next: opts.next, then: clean.slice(1) }
|
|
303
|
+
saveConfig(cfg)
|
|
304
|
+
changed(changed1 ? 'model' : 'setting:rotation')
|
|
305
|
+
test(clean)
|
|
306
|
+
return state()
|
|
307
|
+
},
|
|
308
|
+
// models.dev again (one model, or all of yours): its list fetched anew, and each model's profile
|
|
309
|
+
// takes what it says now (one it didn't know before becomes "from models.dev"; your own settings
|
|
310
|
+
// for it stay above). A running HeartCode takes it at once.
|
|
311
|
+
// Test: one short message to the model, to see that it answers (key, access, name, the server
|
|
312
|
+
// itself). In the requests log as a model test. Never throws: { ok, ms, reply } or { ok: false, error }.
|
|
313
|
+
'POST /api/try': async ({ provider, model } = {}) => {
|
|
314
|
+
const c = clientFor({ provider, model: String(model ?? '') })
|
|
315
|
+
if (!c || !model) return { ok: false, error: `no provider "${provider}"` }
|
|
316
|
+
c.purpose = 'test'
|
|
317
|
+
const t0 = Date.now()
|
|
318
|
+
try {
|
|
319
|
+
const r = await c.chat({ messages: [{ role: 'user', content: 'Reply with just the word OK.' }], stream: true }, { signal: AbortSignal.timeout(90_000) })
|
|
320
|
+
const reply = (r.content || '').trim()
|
|
321
|
+
if (!reply && !r.toolCalls.length) return { ok: false, ms: Date.now() - t0, error: `it answered with nothing${r.usage?.completion_tokens ? ` (${r.usage.completion_tokens} tokens it said it wrote)` : ''}` }
|
|
322
|
+
return { ok: true, ms: Date.now() - t0, reply: reply.slice(0, 80) }
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return { ok: false, ms: Date.now() - t0, status: e.status ?? null, error: e.name === 'TimeoutError' ? 'no answer within 90 seconds' : whyFailed(e) }
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
'POST /api/models-dev': async ({ provider, model } = {}) => {
|
|
328
|
+
await loadPrices({ force: true })
|
|
329
|
+
const list = provider && model ? [{ provider, model: String(model) }] : state().order.map(({ provider, model }) => ({ provider, model }))
|
|
330
|
+
let found = 0
|
|
331
|
+
for (const t of list) {
|
|
332
|
+
const c = clientFor(t)
|
|
333
|
+
if (!c) continue
|
|
334
|
+
const known = limitsOf(t.provider, c.baseUrl, t.model)
|
|
335
|
+
if (known) found++
|
|
336
|
+
const p = loadProfile(c)
|
|
337
|
+
if (!p) writeJson(profilePath(c), profileFrom(c, known))
|
|
338
|
+
else if (p.source && known) writeJson(profilePath(c), { ...p, known, source: p.source === 'assumed' ? 'models.dev' : p.source })
|
|
339
|
+
testNews(`${t.provider}/${t.model}`, t)
|
|
340
|
+
}
|
|
341
|
+
changed('profile')
|
|
342
|
+
return { ...state(), found, of: list.length }
|
|
343
|
+
},
|
|
344
|
+
// What a model can do, set by you (a model models.dev doesn't know, or to correct it): its
|
|
345
|
+
// context window (null: as found) and tool calls, thinking, images, streaming. Kept in its profile,
|
|
346
|
+
// above what models.dev says; a running HeartCode takes it at once.
|
|
347
|
+
'POST /api/model-setup': async ({ provider, model, context, tools, thinking, images, stream }) => {
|
|
348
|
+
const t = { provider, model: String(model ?? '').trim() }
|
|
349
|
+
const c = clientFor(t)
|
|
350
|
+
if (!c) throw new Error(`no provider "${provider}"`)
|
|
351
|
+
if (!t.model) throw new Error('a model is needed')
|
|
352
|
+
if (context !== null && context !== undefined && !(Number.isInteger(context) && context >= 512 && context <= 100_000_000)) throw new Error('the context window: a whole number of tokens (512 or more), or empty')
|
|
353
|
+
for (const [k, v] of Object.entries({ tools, thinking, images, stream })) if (typeof v !== 'boolean') throw new Error(`${k}: yes or no`)
|
|
354
|
+
await loadPrices()
|
|
355
|
+
const p = loadProfile(c) ?? profileFrom(c, limitsOf(t.provider, c.baseUrl, t.model))
|
|
356
|
+
p.set = { ...(context ? { context } : {}), tools, thinking, images, stream }
|
|
357
|
+
if (p.source === 'assumed') p.source = 'you'
|
|
358
|
+
writeJson(profilePath(c), p)
|
|
359
|
+
changed('profile')
|
|
360
|
+
testNews(`${provider}/${t.model}`, t)
|
|
361
|
+
return state()
|
|
362
|
+
},
|
|
363
|
+
// Measure a tested model's window: one oversized request (client.measureContext); the limit the
|
|
364
|
+
// server says is kept in its profile, above every other source. A running HeartCode uses it at once.
|
|
365
|
+
'POST /api/measure': async ({ provider, model }) => {
|
|
366
|
+
const t = { provider, model: String(model ?? '') }
|
|
367
|
+
const c = clientFor(t)
|
|
368
|
+
if (!c) throw new Error(`no provider "${provider}"`)
|
|
369
|
+
await loadPrices()
|
|
370
|
+
const p = loadProfile(c) ?? profileFrom(c, limitsOf(t.provider, c.baseUrl, t.model))
|
|
371
|
+
const r = await c.measureContext()
|
|
372
|
+
if (r.error) throw new Error(`couldn't measure it: ${r.error}`)
|
|
373
|
+
Object.assign(p, { measured: r.limit ?? r.over, measuredAt: new Date().toISOString() })
|
|
374
|
+
writeJson(profilePath(c), p)
|
|
375
|
+
changed('profile')
|
|
376
|
+
testNews(`${provider}/${t.model}`, t)
|
|
377
|
+
return { ...state(), measured: r }
|
|
378
|
+
},
|
|
379
|
+
'POST /api/setting': ({ name, value }) => {
|
|
380
|
+
if (name === 'permissions' && !PERMISSIONS.some(([p]) => p === value)) throw new Error('permissions: all, commands, ask or plan')
|
|
381
|
+
if (name === 'permissions' && value === 'plan') throw new Error("HeartCode doesn't start in plan mode (Shift+Tab gets there)")
|
|
382
|
+
if (name === 'effort' && !EFFORTS.includes(value)) throw new Error(`effort: ${EFFORTS.join(', ')}`)
|
|
383
|
+
if (name === 'theme' && !THEMES[value]) throw new Error(`theme: ${Object.keys(THEMES).join(', ')}`)
|
|
384
|
+
if (name === 'context' && value !== null && !(Number(value) > 0)) throw new Error('context: a number of tokens, or empty for what the model says')
|
|
385
|
+
if ((name in TOGGLES || LOG_SWITCHES.includes(name)) && typeof value !== 'boolean') throw new Error(`${name}: on or off`)
|
|
386
|
+
if (name === 'logDays' && !(Number.isInteger(value) && value >= 1 && value <= 3650)) throw new Error('logDays: a whole number of days, 1 to 3650')
|
|
387
|
+
if (name === 'logDetail' && !DETAILS.includes(value)) throw new Error(`logDetail: ${DETAILS.join(' or ')}`)
|
|
388
|
+
if (!['permissions', 'effort', 'theme', 'context', 'logDetail', 'logDays', ...Object.keys(TOGGLES), ...LOG_SWITCHES].includes(name)) throw new Error(`no setting "${name}"`)
|
|
389
|
+
cfg[name] = name === 'effort' && value === 'default' ? undefined : name === 'context' ? (value === null ? undefined : Number(value)) : value
|
|
390
|
+
save(`setting:${name}`)
|
|
391
|
+
if (name === 'logRequests' || name === 'logDetail') useRequestLog(cfg) // from the next request (a running HeartCode takes it in too: applySettings)
|
|
392
|
+
if (name === 'logDays') pruneLogs(value) // older ones go now
|
|
393
|
+
return state()
|
|
394
|
+
},
|
|
395
|
+
// The requests log: the newest first; one in full
|
|
396
|
+
'GET /api/requests': () => ({ items: listRequests() }),
|
|
397
|
+
'POST /api/request': ({ run, file }) => readRequest(run, file),
|
|
398
|
+
'POST /api/voice': ({ text }) => {
|
|
399
|
+
cfg.voice = String(text ?? '').trim() ? JSON.parse(text) : undefined
|
|
400
|
+
save('setting:voice')
|
|
401
|
+
return state()
|
|
402
|
+
},
|
|
403
|
+
'POST /api/file': ({ name, text }) => {
|
|
404
|
+
const f = FILES.find(([n]) => n === name)
|
|
405
|
+
if (!f) throw new Error(`not a settings file: ${name}`)
|
|
406
|
+
const file = path.join(HOME, name)
|
|
407
|
+
if (!String(text ?? '').trim()) fs.rmSync(file, { force: true })
|
|
408
|
+
else {
|
|
409
|
+
if (f[2])
|
|
410
|
+
try {
|
|
411
|
+
JSON.parse(text)
|
|
412
|
+
} catch (e) {
|
|
413
|
+
throw new Error(`${name} isn't saved: ${e.message}`) // a broken one isn't saved
|
|
414
|
+
}
|
|
415
|
+
fs.mkdirSync(HOME, { recursive: true })
|
|
416
|
+
fs.writeFileSync(file, text.endsWith('\n') ? text : text + '\n', { mode: 0o600 })
|
|
417
|
+
}
|
|
418
|
+
changed({ 'mcp.json': 'mcp', 'keybindings.json': 'keys', 'theme.json': 'theme' }[name] ?? `file:${name}`)
|
|
419
|
+
return state()
|
|
420
|
+
},
|
|
421
|
+
'POST /api/mcp/toggle': ({ name, on }) => {
|
|
422
|
+
if (!mcpServers().some((m) => m.name === name)) throw new Error(`no MCP server "${name}"`)
|
|
423
|
+
const file = path.join(HOME, 'mcp.json')
|
|
424
|
+
const data = readJson(file, null) ?? {}
|
|
425
|
+
if (fs.existsSync(file) && !readJson(file, null)) throw new Error("mcp.json isn't valid JSON: fix it below first")
|
|
426
|
+
const servers = (data.mcpServers ??= {})
|
|
427
|
+
if (on) {
|
|
428
|
+
if (servers[name]) delete servers[name].disabled
|
|
429
|
+
if (servers[name] && !Object.keys(servers[name]).length) delete servers[name] // only the "off" was ours
|
|
430
|
+
} else servers[name] = { ...servers[name], disabled: true }
|
|
431
|
+
writeJson(file, data)
|
|
432
|
+
changed('mcp')
|
|
433
|
+
return state()
|
|
434
|
+
},
|
|
435
|
+
// a server of yours, from the form: a command on this machine, or a remote URL
|
|
436
|
+
'POST /api/mcp/add': ({ name, command, url }) => {
|
|
437
|
+
name = safeName(name)
|
|
438
|
+
if (!name) throw new Error('a name is needed')
|
|
439
|
+
const words = String(command ?? '').match(/"[^"]*"|'[^']*'|\S+/g)?.map((w) => w.replace(/^(["'])(.*)\1$/, '$2')) ?? []
|
|
440
|
+
url = String(url ?? '').trim()
|
|
441
|
+
if (!words.length === !url) throw new Error('give a command, or a URL')
|
|
442
|
+
if (url && !/^https?:\/\/\S+$/.test(url)) throw new Error(`"${url}" is not a URL`)
|
|
443
|
+
const file = path.join(HOME, 'mcp.json')
|
|
444
|
+
if (fs.existsSync(file) && !readJson(file, null)) throw new Error("mcp.json isn't valid JSON: fix it first")
|
|
445
|
+
const data = readJson(file, null) ?? {}
|
|
446
|
+
const servers = (data.mcpServers ??= {})
|
|
447
|
+
if (servers[name] && !servers[name].disabled && (servers[name].command || servers[name].url)) throw new Error(`there's a server "${name}" already`)
|
|
448
|
+
servers[name] = url ? { url } : { command: words[0], args: words.slice(1) }
|
|
449
|
+
writeJson(file, data)
|
|
450
|
+
changed('mcp')
|
|
451
|
+
return state()
|
|
452
|
+
},
|
|
453
|
+
'POST /api/mcp/remove': ({ name }) => {
|
|
454
|
+
const file = path.join(HOME, 'mcp.json')
|
|
455
|
+
const data = readJson(file, null)
|
|
456
|
+
if (!data?.mcpServers?.[name]) throw new Error(`no server "${name}" of yours`)
|
|
457
|
+
delete data.mcpServers[name]
|
|
458
|
+
writeJson(file, data)
|
|
459
|
+
changed('mcp')
|
|
460
|
+
return state()
|
|
461
|
+
},
|
|
462
|
+
'POST /api/prompt': ({ id, text }) => {
|
|
463
|
+
const [file, name] = String(id ?? '').split('/')
|
|
464
|
+
const b = PROMPT_FILES.includes(file) && builtinPrompts(file).find((x) => x.name === name)
|
|
465
|
+
if (!b) throw new Error(`no prompt "${id}"`)
|
|
466
|
+
if (text === null || String(text).trim() === b.text.trim()) setPrompt(file, name, null) // as built in: nothing of yours to keep
|
|
467
|
+
else {
|
|
468
|
+
if (!String(text ?? '').trim()) throw new Error('a prompt can\'t be empty (Reset puts the built-in one back)')
|
|
469
|
+
const known = varsOf(b.text)
|
|
470
|
+
const unknown = [...varsOf(text)].filter((v) => !known.has(v))
|
|
471
|
+
if (unknown.length) throw new Error(`HeartCode doesn't fill in ${unknown.map((v) => `{{${v}}}`).join(', ')} here${known.size ? `; it fills in ${[...known].map((v) => `{{${v}}}`).join(', ')}` : ''}`)
|
|
472
|
+
setPrompt(file, name, text)
|
|
473
|
+
}
|
|
474
|
+
changed('prompts')
|
|
475
|
+
return state()
|
|
476
|
+
},
|
|
477
|
+
'POST /api/skill': ({ name, text, was }) => {
|
|
478
|
+
name = safeName(name)
|
|
479
|
+
if (!name) throw new Error('a name is needed')
|
|
480
|
+
if (!String(text ?? '').trim()) throw new Error('a skill needs its text')
|
|
481
|
+
const { meta } = frontMatter(text)
|
|
482
|
+
if (meta.name && safeName(meta.name) !== name) throw new Error(`the text says name: ${meta.name}, but the skill is called ${name}`)
|
|
483
|
+
if (was && was !== name) removeSkill(was)
|
|
484
|
+
const dir = path.join(skillsDir(), name)
|
|
485
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
486
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), text.endsWith('\n') ? text : text + '\n')
|
|
487
|
+
changed('skill')
|
|
488
|
+
return state()
|
|
489
|
+
},
|
|
490
|
+
'POST /api/skill/delete': ({ name }) => {
|
|
491
|
+
if (!removeSkill(name)) throw new Error(`no skill "${name}" of yours`)
|
|
492
|
+
changed('skill')
|
|
493
|
+
return state()
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const server = http.createServer(async (req, res) => {
|
|
498
|
+
const send = (code, body, type = 'application/json') => {
|
|
499
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY', 'referrer-policy': 'no-referrer' })
|
|
500
|
+
res.end(type === 'application/json' ? JSON.stringify(body) : body)
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
// only as 127.0.0.1 or localhost (a site renamed to this address can't reach it), and only with the token
|
|
504
|
+
const { port: p } = server.address()
|
|
505
|
+
if (![`127.0.0.1:${p}`, `localhost:${p}`].includes(req.headers.host)) return send(403, { error: 'wrong host' })
|
|
506
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
507
|
+
if (url.pathname === '/favicon.ico') return res.writeHead(204).end() // the browser asks, without the token
|
|
508
|
+
const given = url.pathname === '/' || url.pathname === '/api/events' ? url.searchParams.get('t') : req.headers['x-heart-token']
|
|
509
|
+
if (!given || given.length !== token.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(token))) return send(403, url.pathname === '/' ? 'Open the link HeartCode printed (it has the key to this page).' : { error: 'no token' }, url.pathname === '/' ? 'text/plain' : 'application/json')
|
|
510
|
+
if (url.pathname === '/' && req.method === 'GET') return send(200, fs.readFileSync(PAGE, 'utf8'), 'text/html; charset=utf-8')
|
|
511
|
+
if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
512
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-content-type-options': 'nosniff' })
|
|
513
|
+
res.write(': listening\n\n')
|
|
514
|
+
listeners.add(res)
|
|
515
|
+
watchRequests()
|
|
516
|
+
const beat = setInterval(() => res.write(': still here\n\n'), 25_000) // through proxies and sleep
|
|
517
|
+
req.on('close', () => (clearInterval(beat), listeners.delete(res)))
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
const route = routes[`${req.method} ${url.pathname}`]
|
|
521
|
+
if (!route) return send(404, { error: 'not found' })
|
|
522
|
+
let raw = ''
|
|
523
|
+
for await (const ch of req) {
|
|
524
|
+
raw += ch
|
|
525
|
+
if (raw.length > 1_000_000) return send(413, { error: 'too big' })
|
|
526
|
+
}
|
|
527
|
+
if (req.method === 'POST' && !/^application\/json/.test(req.headers['content-type'] ?? '')) return send(415, { error: 'JSON only' })
|
|
528
|
+
send(200, await route(raw ? JSON.parse(raw) : {}))
|
|
529
|
+
} catch (e) {
|
|
530
|
+
send(400, { error: e.message })
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
return new Promise((resolve, reject) => {
|
|
534
|
+
server.on('error', reject)
|
|
535
|
+
server.listen(port, '127.0.0.1', () => {
|
|
536
|
+
const url = `http://127.0.0.1:${server.address().port}/?t=${token}`
|
|
537
|
+
log('web', `settings page at 127.0.0.1:${server.address().port}`)
|
|
538
|
+
// models.dev's list (its sizes for your models, whether their providers' lists are on or not):
|
|
539
|
+
// loaded now, and your models' lines brought up to date when it's in
|
|
540
|
+
loadPrices().then(() => {
|
|
541
|
+
for (const t of state().order) testNews(`${t.provider}/${t.model}`, t)
|
|
542
|
+
test(state().order) // any without a profile yet
|
|
543
|
+
})
|
|
544
|
+
resolve({ url, server, close: () => (watcher?.close(), server.closeAllConnections?.(), server.close()) })
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function removeSkill(name) {
|
|
550
|
+
const dir = path.join(skillsDir(), safeName(name))
|
|
551
|
+
const file = path.join(skillsDir(), `${safeName(name)}.md`)
|
|
552
|
+
for (const f of [dir, file]) if (safeName(name) && fs.existsSync(f)) return fs.rmSync(f, { recursive: true, force: true }), true
|
|
553
|
+
return false
|
|
554
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Skills: reusable prompts that you can type as /name and/or the model can load when a task
|
|
2
|
+
// matches. One file (<name>.md) or a folder (<name>/SKILL.md, for extra files), in
|
|
3
|
+
// ~/.heart/skills/ for every project
|
|
4
|
+
// .heartcode/skills/ for one project (wins on the same name)
|
|
5
|
+
// ---
|
|
6
|
+
// description: Review my changes before committing (what the model sees; also in the / menu)
|
|
7
|
+
// use: both me (only typed: costs nothing until used) · model (only the model) · both
|
|
8
|
+
// ---
|
|
9
|
+
// Review the uncommitted changes, focusing on {{args}}.
|
|
10
|
+
// !`git diff HEAD`
|
|
11
|
+
// In the text: {{args}} (all arguments), {{1}} {{2}}… (one each; $ARGUMENTS and $1 work too, as in
|
|
12
|
+
// other CLIs), @path/to/file (its contents), !`command` (its output).
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { HOME } from './config.js'
|
|
16
|
+
|
|
17
|
+
export const USES = ['me', 'model', 'both']
|
|
18
|
+
|
|
19
|
+
export function frontMatter(text) {
|
|
20
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
21
|
+
const meta = Object.fromEntries((m?.[1] ?? '').split('\n').map((l) => /^([\w-]+):\s*(.*)$/.exec(l.trim())).filter(Boolean).map(([, k, v]) => [k, v.replace(/^["']|["']$/g, '')]))
|
|
22
|
+
return { meta, body: (m ? m[2] : text).trim() }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Who can use a skill. Other CLIs' fields are understood too (disable-model-invocation, user-invocable).
|
|
26
|
+
function useOf(meta, fallback = 'both') {
|
|
27
|
+
if (USES.includes(meta.use)) return meta.use
|
|
28
|
+
if (meta['disable-model-invocation'] === 'true') return 'me'
|
|
29
|
+
if (meta['user-invocable'] === 'false') return 'model'
|
|
30
|
+
return fallback
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// More folders of skills from the caller (--skills-dir), after these two.
|
|
34
|
+
export const extraSkillDirs = []
|
|
35
|
+
|
|
36
|
+
export function skillDirs(root) {
|
|
37
|
+
return [path.join(HOME, 'skills'), root && path.join(root, '.heartcode', 'skills'), ...extraSkillDirs].filter(Boolean)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadSkills(root) {
|
|
41
|
+
const skills = new Map()
|
|
42
|
+
for (const dir of skillDirs(root)) {
|
|
43
|
+
let entries = []
|
|
44
|
+
try {
|
|
45
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
46
|
+
} catch {}
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
const file = e.isDirectory() ? path.join(dir, e.name, 'SKILL.md') : e.name.endsWith('.md') ? path.join(dir, e.name) : null
|
|
49
|
+
if (!file) continue
|
|
50
|
+
let text
|
|
51
|
+
try {
|
|
52
|
+
text = fs.readFileSync(file, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
const { meta, body } = frontMatter(text)
|
|
57
|
+
const name = (meta.name || e.name.replace(/\.md$/, '')).toLowerCase().replace(/[^a-z0-9_:-]+/g, '-')
|
|
58
|
+
skills.set(name, { name, description: meta.description || body.split('\n')[0].slice(0, 100) || '(no description)', use: useOf(meta), hint: meta['argument-hint'] ?? '', body, file })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return skills
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The skill's text with the arguments filled in ({{args}}, {{1}}…, or $ARGUMENTS, $1…).
|
|
65
|
+
export function fillArgs(body, args) {
|
|
66
|
+
const words = String(args ?? '').trim().split(/\s+/).filter(Boolean)
|
|
67
|
+
const all = String(args ?? '').trim()
|
|
68
|
+
return body
|
|
69
|
+
.replace(/\{\{\s*args\s*\}\}|\$ARGUMENTS\b/g, all)
|
|
70
|
+
.replace(/\{\{\s*(\d)\s*\}\}|\$(\d)\b/g, (m, a, b) => words[Number(a ?? b) - 1] ?? '')
|
|
71
|
+
}
|