heartcode 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/probe.js ADDED
@@ -0,0 +1,343 @@
1
+ // Capability probes: small, automatic tests that decide how HeartCode talks to a model.
2
+ import path from 'node:path'
3
+ import { deflateSync } from 'node:zlib'
4
+ import { HOME, readJson, writeJson } from './config.js'
5
+ import { est } from './client.js'
6
+ import { contextWindow, contextSource, LOCAL_SERVERS, ASSUMED_REMOTE } from './context.js'
7
+ import { loadPrices, limitsOf } from './usage.js'
8
+ import { applyEdit, parseBlocks } from './edit.js'
9
+ import { c } from './ui.js'
10
+ import { log } from './log.js'
11
+ import { prompt } from './prompts.js'
12
+
13
+ // One cached profile per server + model. The URL path counts: proxies serve several providers
14
+ // from one host (https://proxy.dev/groq, https://proxy.dev/openrouter).
15
+ export const profilePath = (client) => {
16
+ const u = new URL(client.baseUrl)
17
+ const where = u.host + u.pathname.replace(/\/v1$/, '')
18
+ return path.join(HOME, 'models', `${where}_${client.model}`.replace(/[^\w.-]+/g, '_') + '.json')
19
+ }
20
+ // Bump when the probe learns something new, so older cached profiles are probed again once.
21
+ const PROBE_VERSION = 2
22
+ export const loadProfile = (client) => {
23
+ const p = readJson(profilePath(client), null)
24
+ return p?.version === PROBE_VERSION ? p : null
25
+ }
26
+
27
+ // --- tiny PNG encoder, for the vision probe ---
28
+ const CRC = new Uint32Array(256).map((_, n) => {
29
+ let v = n
30
+ for (let k = 0; k < 8; k++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1
31
+ return v
32
+ })
33
+ const crc32 = (buf) => {
34
+ let v = 0xffffffff
35
+ for (const b of buf) v = CRC[(v ^ b) & 0xff] ^ (v >>> 8)
36
+ return (v ^ 0xffffffff) >>> 0
37
+ }
38
+ const pngChunk = (type, data) => {
39
+ const head = Buffer.alloc(4)
40
+ head.writeUInt32BE(data.length)
41
+ const body = Buffer.concat([Buffer.from(type), data])
42
+ const crc = Buffer.alloc(4)
43
+ crc.writeUInt32BE(crc32(body))
44
+ return Buffer.concat([head, body, crc])
45
+ }
46
+ export function solidPng(w, h, [r, g, b]) {
47
+ const ihdr = Buffer.alloc(13)
48
+ ihdr.writeUInt32BE(w, 0)
49
+ ihdr.writeUInt32BE(h, 4)
50
+ ihdr[8] = 8 // bit depth
51
+ ihdr[9] = 2 // RGB
52
+ const row = Buffer.alloc(1 + w * 3)
53
+ for (let x = 0; x < w; x++) row.set([r, g, b], 1 + x * 3)
54
+ const raw = Buffer.concat(Array(h).fill(row))
55
+ const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
56
+ return Buffer.concat([sig, pngChunk('IHDR', ihdr), pngChunk('IDAT', deflateSync(raw)), pngChunk('IEND', Buffer.alloc(0))])
57
+ }
58
+
59
+ const COLORS = { red: [220, 30, 30], green: [30, 170, 60], blue: [30, 60, 220], yellow: [245, 220, 30] }
60
+ const firstInt = (t) => Number(/\d+/.exec(t)?.[0])
61
+
62
+ // Rate limits, server errors and dropped connections are waited out (with the countdown, Esc
63
+ // stops it), so a probe that starts finishes and is saved, and the model is never probed again.
64
+ // A request the server rejects (400, 404 "no endpoint supports images", …) just means "no".
65
+ const transient = (e) => !e.status || [408, 409, 429].includes(e.status) || e.status >= 500
66
+ export async function probe(client, status, signal) {
67
+ const [retryIf, purpose] = [client.retryIf, client.purpose]
68
+ client.retryIf = (e, attempt) => transient(e) && attempt < 8 // about 4 minutes of waiting at most
69
+ client.purpose = 'test' // in the requests log
70
+ try {
71
+ return await runProbe(client, status, signal)
72
+ } finally {
73
+ Object.assign(client, { retryIf, purpose })
74
+ }
75
+ }
76
+
77
+ async function runProbe(client, report = () => {}, signal) {
78
+ const status = (label) => {
79
+ log('probe', label)
80
+ report(label)
81
+ }
82
+ const p = { version: PROBE_VERSION, model: client.model, baseUrl: client.baseUrl, probedAt: new Date().toISOString() }
83
+ let tokens = 0
84
+ let ms = 0
85
+ let calls = 0
86
+ let lastError = null
87
+ const chat = async (body, opts) => {
88
+ const t0 = Date.now()
89
+ const r = await client.chat({ temperature: 0, ...body }, { signal, ...opts }) // no max_tokens: the model decides how long
90
+ ms += Date.now() - t0
91
+ calls++
92
+ if (r.usage?.completion_tokens) p.usage = true
93
+ tokens += r.usage?.completion_tokens ?? est(r.content + r.reasoning)
94
+ if (r.thinking) p.thinking = true
95
+ return r
96
+ }
97
+ const say = (user, system) =>
98
+ chat({ messages: system ? [{ role: 'system', content: system }, { role: 'user', content: user }] : [{ role: 'user', content: user }] }).then((r) =>
99
+ r.content.trim(),
100
+ )
101
+ // A rejected request (400, 422, a 404 "no endpoint supports images", …) or an unparsable reply
102
+ // means "not supported". Dead connections, auth errors, rate limits, outages, and a model that
103
+ // was never reachable abort the probe, so a bad profile never gets cached.
104
+ const safe = async (fn) => {
105
+ try {
106
+ return Boolean(await fn())
107
+ } catch (e) {
108
+ if (e.name === 'AbortError' || /^cannot reach/.test(e.message)) throw e
109
+ if ([401, 429].includes(e.status) || e.status >= 500 || ([403, 404].includes(e.status) && !calls))
110
+ throw new Error(`probe stopped, nothing saved: ${e.message}`)
111
+ lastError = e
112
+ return false
113
+ }
114
+ }
115
+ const trials = async (label, fns) => {
116
+ status(label)
117
+ let pass = 0
118
+ for (const fn of fns) if (await safe(fn)) pass++
119
+ return { pass, total: fns.length }
120
+ }
121
+
122
+ status('streaming')
123
+ let streamed = 0
124
+ p.stream = await safe(async () => {
125
+ const r = await chat({ messages: [{ role: 'user', content: prompt('probe/hello') }], stream: true }, { onToken: () => streamed++ })
126
+ return streamed > 0 || r.thinking
127
+ })
128
+
129
+ status('context size')
130
+ // what the server is and says; else models.dev's published size (context.js decides between them)
131
+ const found = await client.contextSize()
132
+ p.server = found.server
133
+ p.context = found.size || (LOCAL_SERVERS.includes(found.server) ? 4096 : ASSUMED_REMOTE)
134
+ p.contextGuessed = !found.size
135
+ await loadPrices()
136
+ const known = limitsOf(client.provider, client.baseUrl, client.model)
137
+ if (known) p.known = known
138
+
139
+ p.system = await trials(
140
+ 'system prompt',
141
+ ['What is 2 + 2?', 'Name a color.'].map((q) => async () => /banana/i.test(await say(q, prompt('probe/system')))),
142
+ )
143
+
144
+ p.format = await trials(
145
+ 'exact format',
146
+ [7, 42].map((n) => async () => (await say(prompt('probe/exact-number', { n }))).replace(/[.\s`*"']/g, '') === String(n)),
147
+ )
148
+
149
+ const menus = [
150
+ ['a fruit', ['a hammer', 'an apple', 'a car'], 2],
151
+ ['an animal', ['a river', 'a chair', 'a dog'], 3],
152
+ ['a color', ['blue', 'a table', 'a spoon'], 1],
153
+ ]
154
+ p.menu = await trials(
155
+ 'menu picking',
156
+ menus.map(([what, opts, ans]) => async () => {
157
+ const list = opts.map((o, i) => `${i + 1}. ${o}`).join('\n')
158
+ return firstInt(await say(prompt('probe/menu', { what, list }))) === ans
159
+ }),
160
+ )
161
+
162
+ status('stop sequences')
163
+ p.stop = await safe(async () => {
164
+ const r = await chat({ messages: [{ role: 'user', content: prompt('probe/count') }], stop: ['5'] })
165
+ return /4/.test(r.content) && !/6/.test(r.content)
166
+ })
167
+
168
+ const tool = {
169
+ type: 'function',
170
+ function: {
171
+ name: 'get_secret_code',
172
+ description: prompt('probe/tool-description'),
173
+ parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
174
+ },
175
+ }
176
+ const toolMsg = (name) => ({ role: 'user', content: prompt('probe/tool-question', { name }) })
177
+ const toolCall = (r, name) => {
178
+ const call = r.toolCalls.find((t) => t.function.name === 'get_secret_code')
179
+ try {
180
+ return call && String(JSON.parse(call.function.arguments).name).toLowerCase().includes(name) ? call : null
181
+ } catch {
182
+ return null
183
+ }
184
+ }
185
+ p.tools = await trials(
186
+ 'tool calls',
187
+ ['alpha', 'falcon', 'ember'].map((n) => async () => toolCall(await chat({ messages: [toolMsg(n)], tools: [tool] }), n)),
188
+ )
189
+ if (p.tools.pass) {
190
+ status('tool results')
191
+ p.toolResult = await safe(async () => {
192
+ const r = await chat({ messages: [toolMsg('alpha')], tools: [tool] })
193
+ const call = toolCall(r, 'alpha')
194
+ if (!call) return false
195
+ const r2 = await chat({
196
+ messages: [
197
+ toolMsg('alpha'),
198
+ { role: 'assistant', content: r.content || '', tool_calls: [call] },
199
+ { role: 'tool', tool_call_id: call.id, content: prompt('probe/tool-result') },
200
+ ],
201
+ tools: [tool],
202
+ })
203
+ return r2.content.includes('4417')
204
+ })
205
+ status('streamed tool calls')
206
+ p.toolsStream = await safe(async () => toolCall(await chat({ messages: [toolMsg('falcon')], tools: [tool], stream: true }), 'falcon'))
207
+
208
+ }
209
+
210
+ status('constrained output')
211
+ const words = ['zebra-purple', 'mango-teal']
212
+ p.constrained = await safe(async () => {
213
+ const schema = { type: 'object', properties: { word: { type: 'string', enum: words } }, required: ['word'], additionalProperties: false }
214
+ const r = await chat({
215
+ messages: [{ role: 'user', content: prompt('probe/capital') }],
216
+ response_format: { type: 'json_schema', json_schema: { name: 'probe', strict: true, schema } },
217
+ })
218
+ return words.includes(JSON.parse(r.content).word)
219
+ })
220
+
221
+ const names = Object.keys(COLORS).sort(() => Math.random() - 0.5).slice(0, 2)
222
+ p.vision = await trials(
223
+ 'vision',
224
+ names.map((name) => async () => {
225
+ const url = 'data:image/png;base64,' + solidPng(256, 256, COLORS[name]).toString('base64')
226
+ const r = await chat({
227
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt('probe/image-color') }, { type: 'image_url', image_url: { url } }] }],
228
+ })
229
+ const t = r.content.toLowerCase()
230
+ return t.includes(name) && Object.keys(COLORS).every((k) => k === name || !t.includes(k))
231
+ }),
232
+ )
233
+
234
+ const cases = [
235
+ {
236
+ code: 'function add(a, b) {\n return a + b\n}\n\nfunction sub(a, b) {\n return a - b\n}\n',
237
+ ask: 'Change the add function so it returns a * b instead of a + b.',
238
+ ok: (t) => t.includes('return a * b') && t.includes('return a - b'),
239
+ },
240
+ {
241
+ code: 'const greeting = "hello"\nconsole.log(greeting)\n',
242
+ ask: 'Change the greeting from "hello" to "good morning".',
243
+ ok: (t) => t.includes('good morning') && t.includes('console.log(greeting)'),
244
+ },
245
+ ]
246
+ p.edit = await trials(
247
+ 'edit skill',
248
+ cases.map((cs) => async () => {
249
+ const reply = await say(prompt('probe/edit', { code: cs.code, ask: cs.ask }))
250
+ let text = cs.code
251
+ const blocks = parseBlocks(reply)
252
+ for (const b of blocks) {
253
+ const r = applyEdit(text, b.search, b.replace)
254
+ if (r.error) return false
255
+ text = r.text
256
+ }
257
+ return blocks.length > 0 && cs.ok(text)
258
+ }),
259
+ )
260
+
261
+ p.speed = ms ? Math.round(tokens / (ms / 1000)) : 0
262
+ p.latency = calls ? Math.round(ms / calls) : 0
263
+ // Every request failed: that's a broken model or endpoint, not a weak model.
264
+ if (!calls) throw new Error(`probe stopped, nothing saved: no request succeeded${lastError ? ` (last error: ${lastError.message})` : ''}`)
265
+ log('probe', 'result', p)
266
+ writeJson(profilePath(client), p)
267
+ return p
268
+ }
269
+
270
+ const all = (s) => Boolean(s && s.total && s.pass === s.total)
271
+ const most = (s) => Boolean(s && s.total && s.pass * 3 >= s.total * 2)
272
+
273
+ // A model's profile without testing it: what models.dev says (info: limitsOf), or, for a model it
274
+ // doesn't know, plain assumptions (source 'assumed': tool calls, streaming, a 128k window) until you
275
+ // say on the settings page what it can do (set: { context, tools, thinking, images, stream },
276
+ // source 'you'). Profiles from a test (/test_model, heart probe) have no source.
277
+ export function profileFrom(client, info) {
278
+ return { version: PROBE_VERSION, model: client.model, baseUrl: client.baseUrl, source: info ? 'models.dev' : 'assumed', at: new Date().toISOString(), usage: true, context: ASSUMED_REMOTE, contextGuessed: true, ...(info && { known: info }) }
279
+ }
280
+
281
+ // Turns a profile into decisions. Your settings for the model (p.set) come first; then, for a model
282
+ // that wasn't tested, what models.dev says; for a tested one, what the test found. Config/flags
283
+ // can override the window (--context).
284
+ export function strategy(p, cfg = {}) {
285
+ const set = p.set ?? {}
286
+ const said = p.source ? (p.known ?? {}) : null // not tested: models.dev's word (or nothing known)
287
+ const tools = set.tools ?? (said ? said.tools !== false : most(p.tools) && p.toolResult)
288
+ const thinking = set.thinking ?? (said ? Boolean(said.thinking) : Boolean(p.thinking))
289
+ const stream = set.stream ?? (said ? true : Boolean(p.stream))
290
+ const win = contextWindow(p)
291
+ return {
292
+ actions: tools ? 'tools' : 'guided',
293
+ edits: said || all(p.edit) ? 'search_replace' : 'rewrite',
294
+ enforce: !said && Boolean(p.constrained),
295
+ stream,
296
+ toolsStream: stream && (said ? true : Boolean(p.toolsStream)),
297
+ stop: said ? !thinking : Boolean(p.stop), // stop sequences cut into a thinking model's thinking
298
+ mergeSystem: said ? false : !most(p.system),
299
+ vision: set.images ?? (said ? Boolean(said.images) : all(p.vision)),
300
+ thinking,
301
+ context: cfg.context || set.context || win.size, // context.js: measured, said, models.dev, learned, or assumed
302
+ contextSure: Boolean(cfg.context || set.context) || win.sure,
303
+ reply: p.known?.output, // the longest reply it gives (models.dev), room kept for it
304
+ ratio: p.tokenRatio ?? 1, // the server's tokens per estimated token
305
+ }
306
+ }
307
+
308
+ export function describe(p, s) {
309
+ const yn = (v) => (v ? c.green('yes') : c.red('no'))
310
+ const score = (x) => {
311
+ if (!x) return c.dim('–')
312
+ const t = `${x.pass}/${x.total}`
313
+ return x.pass === x.total ? c.green(t) : x.pass ? c.yellow(t) : c.red(t)
314
+ }
315
+ const context = ['context', `${s.context}${c.dim(` (${p.set?.context ? 'set by you on /settings' : contextSource(p)}; change it on /settings)`)}`]
316
+ // not tested: where it's from, and what it can do
317
+ if (p.source) {
318
+ const from = { 'models.dev': 'models.dev', you: 'you, on /settings', assumed: "nothing: it isn't in models.dev. Set what it can do on /settings" }[p.source]
319
+ const mine = (k) => (p.set?.[k] !== undefined ? c.dim(' (set by you)') : '')
320
+ return [['from', from], context, ['tool calls', yn(s.actions === 'tools') + mine('tools')], ['thinking', yn(s.thinking) + mine('thinking')], ['images', yn(s.vision) + mine('images')], ['streaming', yn(s.stream) + mine('stream')]]
321
+ .map(([k, v]) => ` ${c.dim(k.padEnd(15))} ${v}`)
322
+ .join('\n')
323
+ }
324
+ const rows = [
325
+ ['streaming', yn(p.stream) + (p.thinking ? c.dim(' (thinking model)') : '')],
326
+ context,
327
+ ['system prompt', score(p.system)],
328
+ ['exact format', score(p.format)],
329
+ ['menu picking', score(p.menu)],
330
+ ['stop sequences', yn(p.stop) + (!p.stop && p.thinking ? c.dim(' (they cut into the thinking; not used)') : '')],
331
+ ['tool calls', score(p.tools) + (p.tools?.pass ? c.dim(` uses results: ${p.toolResult ? 'yes' : 'no'} · streamed: ${p.toolsStream ? 'yes' : 'no'}`) : '')],
332
+ ['constrained', yn(p.constrained)],
333
+ ['vision', score(p.vision)],
334
+ ['edit skill', score(p.edit)],
335
+ ['speed', `${p.speed} tok/s` + (p.latency ? c.dim(` · ${(p.latency / 1000).toFixed(1)}s per reply`) : '')],
336
+ ]
337
+ const lines = rows.map(([k, v]) => ` ${c.dim(k.padEnd(15))} ${v}`)
338
+ lines.push(
339
+ ` ${c.bold('→')} actions: ${c.cyan(s.actions)} · edits: ${c.cyan(s.edits)}` +
340
+ `${s.enforce ? ' · enforced answers' : ''}${s.vision ? ' · images' : ''}${s.mergeSystem ? ' · system→user' : ''}`,
341
+ )
342
+ return lines.join('\n')
343
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,103 @@
1
+ // Every text HeartCode sends to a model is a section of a markdown file in prompts/ (agent.md,
2
+ // guided.md, compact.md, probe.md). prompt('guided/next-action') is the "## next-action" section of
3
+ // guided.md. The user's own sections override the built-in ones, by name:
4
+ // ~/.heart/prompts/<file>.md for every project
5
+ // <project>/.heartcode/prompts/<file>.md for one project
6
+ // In a section:
7
+ // {{name}} filled in from `vars`; a missing one is an error
8
+ // {{#if name}} … {{else}} … {{/if}} optional parts (else is optional)
9
+ // <!-- … --> a note right under the heading; never sent
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { HOME } from './config.js'
13
+
14
+ const BUILTIN = new URL('../prompts/', import.meta.url).pathname
15
+ let project = null
16
+ const cache = new Map()
17
+
18
+ // The project whose .heartcode/prompts and HEARTCODE.md apply.
19
+ export function usePromptsFrom(root) {
20
+ project = root
21
+ cache.clear()
22
+ }
23
+
24
+ // After the user's sections changed (the settings page): read again, from the next message.
25
+ export const reloadPrompts = () => cache.clear()
26
+ export const BUILTIN_PROMPTS = BUILTIN
27
+
28
+ // "## name" sections of a markdown file (text before the first heading is the file's own notes).
29
+ export function sections(text) {
30
+ const out = new Map()
31
+ let name = null
32
+ let lines = []
33
+ const flush = () => name && out.set(name, lines.join('\n').replace(/^\s*<!--[\s\S]*?-->\s*/, '').replace(/^\s+|\s+$/g, ''))
34
+ for (const line of text.split('\n')) {
35
+ const h = /^## (\S+)\s*$/.exec(line)
36
+ if (h) {
37
+ flush()
38
+ ;[name, lines] = [h[1], []]
39
+ } else if (name) lines.push(line)
40
+ }
41
+ flush()
42
+ return out
43
+ }
44
+
45
+ function load(file) {
46
+ if (!cache.has(file)) {
47
+ const merged = new Map()
48
+ for (const dir of [BUILTIN, path.join(HOME, 'prompts'), project && path.join(project, '.heartcode', 'prompts')]) {
49
+ if (!dir) continue
50
+ try {
51
+ for (const [k, v] of sections(fs.readFileSync(path.join(dir, `${file}.md`), 'utf8'))) merged.set(k, v)
52
+ } catch {}
53
+ }
54
+ cache.set(file, merged)
55
+ }
56
+ return cache.get(file)
57
+ }
58
+
59
+ export function prompt(id, vars = {}) {
60
+ const i = id.indexOf('/')
61
+ const text = load(id.slice(0, i)).get(id.slice(i + 1))
62
+ if (text === undefined) throw new Error(`no prompt "${id}" (a "## ${id.slice(i + 1)}" section in prompts/${id.slice(0, i)}.md)`)
63
+ return text
64
+ .replace(/\{\{#if (\w+)\}\}([\s\S]*?)(?:\{\{else\}\}([\s\S]*?))?\{\{\/if\}\}/g, (m, k, yes, no = '') => (vars[k] ? yes : no))
65
+ .replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => {
66
+ if (!(k in vars)) throw new Error(`prompt "${id}" needs {{${k}}}`)
67
+ return String(vars[k])
68
+ })
69
+ }
70
+
71
+ // The user's standing instructions: ~/.heart/HEARTCODE.md, then the project's own file. The project's
72
+ // is HEARTCODE.md, or else the first other tool's file found (so a project set up for another CLI
73
+ // works as is). Only one, so the same rules never cost twice. '' when there are none.
74
+ export const INSTRUCTION_FILES = ['HEARTCODE.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules']
75
+
76
+ export function userInstructions(root = project) {
77
+ const read = (f) => {
78
+ try {
79
+ return fs.readFileSync(f, 'utf8').trim()
80
+ } catch {
81
+ return ''
82
+ }
83
+ }
84
+ let local = ''
85
+ for (const f of root ? INSTRUCTION_FILES : []) if ((local = read(path.join(root, f)))) break
86
+ if (!local && root) local = cursorRules(root)
87
+ return [read(path.join(HOME, 'HEARTCODE.md')), local].filter(Boolean).join('\n\n')
88
+ }
89
+
90
+ // Cursor's .cursor/rules/*.mdc: the ones marked "alwaysApply: true", without their front matter.
91
+ function cursorRules(root) {
92
+ const dir = path.join(root, '.cursor', 'rules')
93
+ let files = []
94
+ try {
95
+ files = fs.readdirSync(dir).filter((f) => /\.mdc?$/.test(f)).sort()
96
+ } catch {}
97
+ return files
98
+ .map((f) => fs.readFileSync(path.join(dir, f), 'utf8'))
99
+ .map((t) => /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(t))
100
+ .filter((m) => m && /^alwaysApply:\s*true/m.test(m[1]))
101
+ .map((m) => m[2].trim())
102
+ .join('\n\n')
103
+ }
package/src/reset.js ADDED
@@ -0,0 +1,49 @@
1
+ // /reset: HeartCode's own files in ~/.heart back to how they were before you changed anything.
2
+ // Three levels, each including the one before. A project's own .heartcode/ is never touched.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import crypto from 'node:crypto'
6
+ import { HOME, CONFIG_FILE, readJson, writeJson } from './config.js'
7
+
8
+ export const RESETS = [
9
+ {
10
+ level: 'settings',
11
+ label: 'Settings',
12
+ desc: 'permissions, theme, effort, the toggles, the model list, voice; your keys, colors, formatters, prompts, MCP servers and hooks. Your providers, keys and model stay.',
13
+ files: ['keybindings.json', 'theme.json', 'format.json', 'prompts', 'mcp.json', 'hooks.json'],
14
+ },
15
+ {
16
+ level: 'providers',
17
+ label: 'Settings and providers',
18
+ desc: 'the above, and your providers with their API keys, MCP sign-ins, what HeartCode learned about each model, prices',
19
+ files: ['config.json', 'mcp-auth.json', 'models', 'prices.json'],
20
+ },
21
+ {
22
+ level: 'everything',
23
+ label: 'Everything',
24
+ desc: `the above, and your sessions, global memory, skills, instructions (HEARTCODE.md), history, logs: all of ${HOME}`,
25
+ files: null, // the whole folder
26
+ },
27
+ ]
28
+
29
+ // What a level removes, for the question (the files that are there).
30
+ export function resetPaths(level) {
31
+ const upto = RESETS.slice(0, RESETS.findIndex((r) => r.level === level) + 1)
32
+ if (upto.at(-1).files === null) return fs.existsSync(HOME) ? fs.readdirSync(HOME).map((f) => path.join(HOME, f)) : []
33
+ return upto.flatMap((r) => r.files).map((f) => path.join(HOME, f)).filter((f) => fs.existsSync(f))
34
+ }
35
+
36
+ export function reset(level) {
37
+ if (!RESETS.some((r) => r.level === level)) throw new Error(`no reset "${level}"`)
38
+ const files = resetPaths(level)
39
+ if (level === 'settings') {
40
+ // config.json: only who answers stays (providers, keys, the model)
41
+ const cfg = readJson(CONFIG_FILE, null)
42
+ if (cfg) writeJson(CONFIG_FILE, Object.fromEntries(Object.entries(cfg).filter(([k]) => ['providers', 'provider', 'model'].includes(k))))
43
+ }
44
+ for (const f of files) fs.rmSync(f, { recursive: true, force: true })
45
+ return files
46
+ }
47
+
48
+ // A code to type before resetting: 4 letters and digits that can't be mistaken for each other.
49
+ export const resetCode = () => Array.from(crypto.randomBytes(4), (b) => 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'[b % 31]).join('')
@@ -0,0 +1,135 @@
1
+ // Saved conversations: one file per chat in ~/.heart/sessions, written after every message, so a
2
+ // chat can be resumed later and rewound to any earlier message (conversation, files, or both).
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { HOME, readJson, writeJson } from './config.js'
6
+
7
+ const DIR = path.join(HOME, 'sessions')
8
+
9
+ // A chat: what the model has seen (messages for tool mode, history for guided mode), what the
10
+ // screen showed (log), and per message ("turn") where each of those stood and the files as they
11
+ // were before HeartCode first changed them in that turn (null = the file didn't exist).
12
+ export function newChat(root) {
13
+ const now = new Date()
14
+ const id = `${now.toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${Math.random().toString(36).slice(2, 6)}`
15
+ return { id, root, created: now.toISOString(), updated: now.toISOString(), title: '', messages: null, history: [], turns: [], log: [] }
16
+ }
17
+
18
+ export function saveChat(chat) {
19
+ const file = path.join(DIR, `${chat.id}.json`)
20
+ const first = chat.turns[0] ?? chat.branches?.[0]?.state.turns[0] // gone back to the start: its branches remain
21
+ if (!first) return fs.rmSync(file, { force: true }) // nothing (left) worth keeping
22
+ chat.updated = new Date().toISOString()
23
+ chat.title = chat.name || first.task.split('\n')[0].slice(0, 80) // its name (/rename), else the first message, even after a rewind
24
+ writeJson(file, chat)
25
+ }
26
+
27
+ // /fork: a copy of the chat to go on with (the original stays as it is, in /resume).
28
+ export function forkChat(chat) {
29
+ const copy = { ...structuredClone(chat), id: newChat(chat.root).id, created: new Date().toISOString() }
30
+ copy.name = `${chat.name || chat.title || 'chat'} (fork)`
31
+ return copy
32
+ }
33
+
34
+ // The conversation as the model saw it, for /export: [{ role, text, tools: [{ name, args, result }] }].
35
+ function entries(chat) {
36
+ const text = (c) => (Array.isArray(c) ? c.map((p) => (p.type === 'text' ? p.text : '[image]')).join('\n') : String(c ?? ''))
37
+ if (!chat.messages?.length) return chat.history.flatMap((h) => [{ role: 'user', text: h.task }, { role: 'assistant', text: h.answer }])
38
+ const out = []
39
+ const calls = new Map()
40
+ for (const m of chat.messages.slice(1)) {
41
+ if (m.role === 'tool') {
42
+ const t = calls.get(m.tool_call_id)
43
+ if (t) t.result = text(m.content)
44
+ continue
45
+ }
46
+ const e = { role: m.role, text: text(m.content).trim(), tools: (m.tool_calls ?? []).map((t) => ({ name: t.function.name, args: t.function.arguments })) }
47
+ m.tool_calls?.forEach((t, i) => calls.set(t.id, e.tools[i]))
48
+ if (e.text || e.tools.length) out.push(e)
49
+ }
50
+ return out
51
+ }
52
+
53
+ const esc = (s) => String(s).replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch])
54
+ const fence = (s) => { const f = '`'.repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${s}\n${f}` }
55
+
56
+ // format: 'md', 'html' or 'json' (the whole session file)
57
+ export function exportChat(chat, format, model = '') {
58
+ if (format === 'json') return JSON.stringify(chat, null, 2) + '\n'
59
+ const title = chat.name || chat.title || chat.turns[0]?.task.split('\n')[0] || 'HeartCode chat'
60
+ const when = `${new Date(chat.created).toLocaleString()}${model ? ` · ${model}` : ''} · ${chat.root}`
61
+ const list = entries(chat)
62
+ if (format === 'md') {
63
+ const body = list.map((e) => {
64
+ const tools = e.tools.map((t) => `<details><summary>${t.name} ${esc(t.args.slice(0, 120))}</summary>\n\n${fence(t.result ?? '')}\n</details>`).join('\n')
65
+ return [`## ${e.role === 'user' ? 'You' : 'HeartCode'}`, e.text, tools].filter(Boolean).join('\n\n')
66
+ })
67
+ return `# ${title}\n\n_${when}_\n\n${body.join('\n\n')}\n`
68
+ }
69
+ const body = list.map((e) => {
70
+ const tools = e.tools.map((t) => `<details><summary><b>${esc(t.name)}</b> <code>${esc(t.args.slice(0, 160))}</code></summary><pre>${esc(t.result ?? '')}</pre></details>`).join('')
71
+ return `<section class="${e.role}"><h2>${e.role === 'user' ? 'You' : 'HeartCode'}</h2>${e.text ? `<div class="text">${esc(e.text)}</div>` : ''}${tools}</section>`
72
+ })
73
+ return `<!doctype html>
74
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title>
75
+ <style>
76
+ :root { --bg: #fff; --fg: #1d1f23; --muted: #6b7080; --user: #f3f0ff; --line: #e3e5ea; --code: #f6f7f9 }
77
+ @media (prefers-color-scheme: dark) { :root { --bg: #16181d; --fg: #e6e8ec; --muted: #9aa0ad; --user: #241f36; --line: #2c3038; --code: #1e2127 } }
78
+ body { background: var(--bg); color: var(--fg); font: 15px/1.55 system-ui, sans-serif; max-width: 860px; margin: 0 auto; padding: 24px 16px }
79
+ header p { color: var(--muted); margin-top: -8px }
80
+ section { border-top: 1px solid var(--line); padding: 12px 0 }
81
+ section.user { background: var(--user); border-radius: 8px; padding: 12px 16px; border: 0; margin: 12px 0 }
82
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 0 0 6px }
83
+ .text { white-space: pre-wrap; overflow-wrap: anywhere }
84
+ details { margin: 6px 0; font-size: 13px }
85
+ summary { cursor: pointer; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
86
+ pre { background: var(--code); padding: 10px; border-radius: 6px; overflow-x: auto; max-height: 400px; white-space: pre-wrap }
87
+ </style></head><body>
88
+ <header><h1>${esc(title)}</h1><p>${esc(when)}</p></header>
89
+ ${body.join('\n')}
90
+ </body></html>
91
+ `
92
+ }
93
+
94
+ // One chat by its id (any folder), or null.
95
+ export function loadChat(id) {
96
+ return /^[\w.-]+$/.test(id) ? readJson(path.join(DIR, `${id}.json`), null) : null
97
+ }
98
+
99
+ // This folder's chats, newest first.
100
+ export function listChats(root) {
101
+ let files = []
102
+ try {
103
+ files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'))
104
+ } catch {}
105
+ return files
106
+ .map((f) => readJson(path.join(DIR, f), null))
107
+ .filter((c) => c && c.root === root && (c.turns?.length || c.branches?.length))
108
+ .sort((a, b) => b.updated.localeCompare(a.updated))
109
+ }
110
+
111
+ // Puts files back as they were before turn `from` (later turns first, so the oldest copy wins).
112
+ // Returns the files touched.
113
+ export function restoreFiles(chat, from) {
114
+ const touched = new Set()
115
+ for (const turn of chat.turns.slice(from).reverse()) {
116
+ for (const [rel, before] of Object.entries(turn.files)) {
117
+ const abs = path.resolve(chat.root, rel)
118
+ if (before === null) fs.rmSync(abs, { force: true })
119
+ else {
120
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
121
+ fs.writeFileSync(abs, before)
122
+ }
123
+ touched.add(rel)
124
+ }
125
+ }
126
+ return [...touched]
127
+ }
128
+
129
+ export const ago = (iso) => {
130
+ const s = (Date.now() - Date.parse(iso)) / 1000
131
+ if (s < 60) return 'just now'
132
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`
133
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`
134
+ return `${Math.floor(s / 86400)}d ago`
135
+ }