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/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,104 @@
|
|
|
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 { fileURLToPath } from 'node:url'
|
|
13
|
+
import { HOME } from './config.js'
|
|
14
|
+
|
|
15
|
+
const BUILTIN = fileURLToPath(new URL('../prompts/', import.meta.url))
|
|
16
|
+
let project = null
|
|
17
|
+
const cache = new Map()
|
|
18
|
+
|
|
19
|
+
// The project whose .heartcode/prompts and HEARTCODE.md apply.
|
|
20
|
+
export function usePromptsFrom(root) {
|
|
21
|
+
project = root
|
|
22
|
+
cache.clear()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// After the user's sections changed (the settings page): read again, from the next message.
|
|
26
|
+
export const reloadPrompts = () => cache.clear()
|
|
27
|
+
export const BUILTIN_PROMPTS = BUILTIN
|
|
28
|
+
|
|
29
|
+
// "## name" sections of a markdown file (text before the first heading is the file's own notes).
|
|
30
|
+
export function sections(text) {
|
|
31
|
+
const out = new Map()
|
|
32
|
+
let name = null
|
|
33
|
+
let lines = []
|
|
34
|
+
const flush = () => name && out.set(name, lines.join('\n').replace(/^\s*<!--[\s\S]*?-->\s*/, '').replace(/^\s+|\s+$/g, ''))
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
const h = /^## (\S+)\s*$/.exec(line)
|
|
37
|
+
if (h) {
|
|
38
|
+
flush()
|
|
39
|
+
;[name, lines] = [h[1], []]
|
|
40
|
+
} else if (name) lines.push(line)
|
|
41
|
+
}
|
|
42
|
+
flush()
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function load(file) {
|
|
47
|
+
if (!cache.has(file)) {
|
|
48
|
+
const merged = new Map()
|
|
49
|
+
for (const dir of [BUILTIN, path.join(HOME, 'prompts'), project && path.join(project, '.heartcode', 'prompts')]) {
|
|
50
|
+
if (!dir) continue
|
|
51
|
+
try {
|
|
52
|
+
for (const [k, v] of sections(fs.readFileSync(path.join(dir, `${file}.md`), 'utf8'))) merged.set(k, v)
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
cache.set(file, merged)
|
|
56
|
+
}
|
|
57
|
+
return cache.get(file)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function prompt(id, vars = {}) {
|
|
61
|
+
const i = id.indexOf('/')
|
|
62
|
+
const text = load(id.slice(0, i)).get(id.slice(i + 1))
|
|
63
|
+
if (text === undefined) throw new Error(`no prompt "${id}" (a "## ${id.slice(i + 1)}" section in prompts/${id.slice(0, i)}.md)`)
|
|
64
|
+
return text
|
|
65
|
+
.replace(/\{\{#if (\w+)\}\}([\s\S]*?)(?:\{\{else\}\}([\s\S]*?))?\{\{\/if\}\}/g, (m, k, yes, no = '') => (vars[k] ? yes : no))
|
|
66
|
+
.replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => {
|
|
67
|
+
if (!(k in vars)) throw new Error(`prompt "${id}" needs {{${k}}}`)
|
|
68
|
+
return String(vars[k])
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The user's standing instructions: ~/.heart/HEARTCODE.md, then the project's own file. The project's
|
|
73
|
+
// is HEARTCODE.md, or else the first other tool's file found (so a project set up for another CLI
|
|
74
|
+
// works as is). Only one, so the same rules never cost twice. '' when there are none.
|
|
75
|
+
export const INSTRUCTION_FILES = ['HEARTCODE.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules']
|
|
76
|
+
|
|
77
|
+
export function userInstructions(root = project) {
|
|
78
|
+
const read = (f) => {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(f, 'utf8').trim()
|
|
81
|
+
} catch {
|
|
82
|
+
return ''
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let local = ''
|
|
86
|
+
for (const f of root ? INSTRUCTION_FILES : []) if ((local = read(path.join(root, f)))) break
|
|
87
|
+
if (!local && root) local = cursorRules(root)
|
|
88
|
+
return [read(path.join(HOME, 'HEARTCODE.md')), local].filter(Boolean).join('\n\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cursor's .cursor/rules/*.mdc: the ones marked "alwaysApply: true", without their front matter.
|
|
92
|
+
function cursorRules(root) {
|
|
93
|
+
const dir = path.join(root, '.cursor', 'rules')
|
|
94
|
+
let files = []
|
|
95
|
+
try {
|
|
96
|
+
files = fs.readdirSync(dir).filter((f) => /\.mdc?$/.test(f)).sort()
|
|
97
|
+
} catch {}
|
|
98
|
+
return files
|
|
99
|
+
.map((f) => fs.readFileSync(path.join(dir, f), 'utf8'))
|
|
100
|
+
.map((t) => /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(t))
|
|
101
|
+
.filter((m) => m && /^alwaysApply:\s*true/m.test(m[1]))
|
|
102
|
+
.map((m) => m[2].trim())
|
|
103
|
+
.join('\n\n')
|
|
104
|
+
}
|
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('')
|
package/src/sessions.js
ADDED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
}
|