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