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.
@@ -0,0 +1,218 @@
1
+ // Headless mode (heartcode -p): for other programs to run HeartCode, the way they run Claude Code.
2
+ // Same flags and the same output as `claude -p`, so a program that drives Claude Code (like
3
+ // Nibedita) drives HeartCode by changing the command name:
4
+ // heartcode -p "fix the test" the answer on stdout
5
+ // heartcode -p --output-format json "…" one JSON result
6
+ // heartcode -p --input-format stream-json --output-format stream-json
7
+ // stdin: {"type":"user","message":{"role":"user","content":"…"}} one per line, any number
8
+ // {"type":"control_request","request_id":"1","request":{"subtype":"interrupt"}}
9
+ // stdout: {"type":"system","subtype":"init",…}, {"type":"assistant",…}, {"type":"user",…}
10
+ // (tool results), {"type":"result",…} after each message
11
+ // Nothing asks anything: what the permission mode would ask about is declined, with the reason
12
+ // told to the model. Logs and warnings go to stderr (and HEART_LOG), never stdout.
13
+ import readline from 'node:readline'
14
+ import { log } from './log.js'
15
+ import { clip, TOOL_DEFS, extraTools, loadMcpTools } from './tools.js'
16
+ import { allowed } from './agent.js'
17
+
18
+ // Claude Code's tool names → HeartCode's (for --allowedTools / --disallowedTools).
19
+ const CLAUDE_TOOLS = { Read: 'read_file', Grep: 'search', Glob: 'list_files', LS: 'list_files', Edit: 'edit_file', MultiEdit: 'edit_file', Write: 'write_file', NotebookEdit: 'edit_file', Bash: 'run_command' }
20
+ export function toolNames(list) {
21
+ const names = list.flatMap((x) => String(x).split(/[,\s]+/)).filter(Boolean).map((t) => t.replace(/\(.*$/, '')) // ponytail: "Bash(git:*)" allows every command, not just git
22
+ return new Set(names.map((t) => CLAUDE_TOOLS[t] ?? t)) // mcp__server__tool and mcp__server as they are (tools.js allowed)
23
+ }
24
+
25
+ // --permission-mode, in HeartCode's terms or Claude Code's.
26
+ export const PERMISSION_MODES = { all: 'all', commands: 'commands', ask: 'ask', bypassPermissions: 'all', acceptEdits: 'commands', default: 'ask', plan: 'plan', dontAsk: 'ask' }
27
+
28
+ // A UI that shows nothing and asks nobody.
29
+ export class HeadlessUI {
30
+ constructor({ permission = 'all', verbose = false } = {}) {
31
+ Object.assign(this, { permission, verbose, log: [], showThinking: true, full: false, noUser: true })
32
+ }
33
+ #err(text) {
34
+ if (this.verbose) process.stderr.write(`${text}\n`)
35
+ }
36
+ warn(text) {
37
+ log('warn', text)
38
+ this.#err(`heartcode: ${text}`)
39
+ }
40
+ retrying(err, ms, attempt) {
41
+ if (err) this.#err(`heartcode: retrying in ${Math.ceil(ms / 1000)}s (attempt ${attempt}): ${err.message}`)
42
+ }
43
+ async confirm(question, kind = 'edit') {
44
+ if (this.permission === 'all' || (this.permission === 'commands' && kind === 'edit') || (this.permission === 'plan' && kind === 'read')) return true // plan: only reading
45
+ log('headless', `declined: ${question}`)
46
+ return `Not allowed: HeartCode runs headless with permission mode "${this.permission}", so this can't be approved. Don't retry it; say in your reply what you would have done.`
47
+ }
48
+ setPermission(mode) {
49
+ this.permission = mode
50
+ }
51
+ response() {
52
+ return { text() {}, think() {}, end() {} }
53
+ }
54
+ async ask() {
55
+ return null
56
+ }
57
+ async pick() {
58
+ return null
59
+ }
60
+ spinner() {}
61
+ stopSpin() {}
62
+ print() {}
63
+ write() {}
64
+ gap() {}
65
+ step() {}
66
+ result() {}
67
+ busy() {}
68
+ idle() {}
69
+ render() {}
70
+ notice() {}
71
+ refreshMenu() {}
72
+ cancelAsk() {}
73
+ clear() {}
74
+ setLog() {}
75
+ close() {}
76
+ leaveFullscreen() {}
77
+ }
78
+
79
+ // Runs the messages (prompt, or stdin) and writes the output. Returns the exit code.
80
+ // app: the App (bin/heartcode.js); opts: { prompt, input, output, appendSystem }
81
+ export async function runHeadless(app, opts) {
82
+ const out = opts.output ?? 'text'
83
+ const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n')
84
+ const s = await app.session()
85
+ if (!s) {
86
+ const error = 'no model set up: add a provider and choose a model (heart settings), or pass --base-url and --model'
87
+ if (out === 'text') process.stderr.write(`heartcode: ${error}\n`)
88
+ else emit({ type: 'result', subtype: 'error_during_execution', is_error: true, result: error, num_turns: 0, session_id: app.chat.id })
89
+ return 1
90
+ }
91
+ s.appendSystem = opts.appendSystem || undefined
92
+ const session_id = app.chat.id
93
+ const model = `${app.cfg.provider}/${s.client.model}`
94
+
95
+ // usage and request count, over everything this message sends (tool loop, compaction, memory)
96
+ let usage = { input_tokens: 0, output_tokens: 0 }
97
+ let requests = 0
98
+ const chat = s.client.chat.bind(s.client)
99
+ s.client.chat = async (...a) => {
100
+ const r = await chat(...a)
101
+ requests++
102
+ usage.input_tokens += r.usage?.prompt_tokens ?? 0
103
+ usage.output_tokens += r.usage?.completion_tokens ?? 0
104
+ return r
105
+ }
106
+
107
+ let said = false // the model's text was already sent as an assistant message
108
+ if (out === 'stream-json') {
109
+ s.onEvent = (e) => {
110
+ if (e.type === 'assistant') {
111
+ const content = [
112
+ ...(e.reasoning ? [{ type: 'thinking', thinking: e.reasoning }] : []),
113
+ ...(e.content ? [{ type: 'text', text: e.content }] : []),
114
+ ...e.toolCalls.map((t) => ({ type: 'tool_use', id: t.id, name: t.function.name, input: parseArgs(t.function.arguments) })),
115
+ ]
116
+ if (e.content) said = true
117
+ if (content.length) emit({ type: 'assistant', message: { role: 'assistant', model, content }, session_id })
118
+ } else if (e.type === 'tool') {
119
+ emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, session_id })
120
+ }
121
+ }
122
+ if (s.strat.actions === 'tools' && s.mcp?.names.length) await loadMcpTools(s) // their tools are in the list, as in Claude Code's
123
+ const tools = s.strat.actions === 'tools' ? [...TOOL_DEFS, ...extraTools(s)].map((f) => f.name).filter((n) => allowed(s, n)) : []
124
+ emit({ type: 'system', subtype: 'init', session_id, model, cwd: app.root, tools, mcp_servers: app.mcp.names.map((name) => ({ name, status: s.mcpCatalog?.has(name) ? 'connected' : 'pending' })), permissionMode: app.ui.permission, apiKeySource: 'none' })
125
+ }
126
+
127
+ let current = null // the message running now: { ctl }
128
+ const turn = async (text, images = []) => {
129
+ const t0 = Date.now()
130
+ const spent = app.chat.usage?.cost ?? 0
131
+ usage = { input_tokens: 0, output_tokens: 0 }
132
+ requests = 0
133
+ said = false
134
+ current = { ctl: new AbortController() }
135
+ app.signal = current.ctl.signal
136
+ let answer = ''
137
+ let error = null
138
+ try {
139
+ const k = /^\/(\S+)\s*([\s\S]*)$/.exec(text)
140
+ const skill = k && app.skills.get(k[1])
141
+ answer = await s.run(skill && skill.use !== 'model' ? await app.expandSkill(skill, k[2]) : text, current.ctl.signal, images)
142
+ } catch (e) {
143
+ error = current.ctl.signal.aborted ? 'interrupted' : e.message
144
+ if (e.maxTurns) current.maxTurns = true
145
+ log('error', error, e.stack)
146
+ } finally {
147
+ app.save()
148
+ }
149
+ const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
150
+ current = null
151
+ const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens } }, session_id }
152
+ if (out === 'stream-json') {
153
+ if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, session_id }) // guided mode
154
+ emit(result)
155
+ } else if (out === 'json') emit(result)
156
+ else if (error) process.stderr.write(`heartcode: ${error}\n`)
157
+ else process.stdout.write(answer.trim() + '\n')
158
+ return !error
159
+ }
160
+
161
+ if (opts.input !== 'stream-json') {
162
+ const text = opts.prompt || (process.stdin.isTTY ? '' : await readAll(process.stdin))
163
+ if (!text.trim()) {
164
+ process.stderr.write('heartcode: -p needs a prompt (as an argument or on stdin)\n')
165
+ return 1
166
+ }
167
+ return (await turn(text.trim())) ? 0 : 1
168
+ }
169
+
170
+ // stream-json: messages one per line, in order; an interrupt stops the one running
171
+ let ok = true
172
+ let queue = Promise.resolve()
173
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
174
+ for await (const line of rl) {
175
+ if (!line.trim()) continue
176
+ let msg
177
+ try {
178
+ msg = JSON.parse(line)
179
+ } catch {
180
+ log('headless', `not JSON: ${clip(line, 200)}`)
181
+ continue
182
+ }
183
+ if (msg.type === 'control_request') {
184
+ if (msg.request?.subtype === 'interrupt') current?.ctl.abort()
185
+ emit({ type: 'control_response', response: { subtype: 'success', request_id: msg.request_id } })
186
+ continue
187
+ }
188
+ if (msg.type !== 'user') continue
189
+ const { text, images } = contentOf(msg.message?.content)
190
+ queue = queue.then(() => turn(text, images)).then((r) => (ok = r))
191
+ }
192
+ await queue
193
+ return ok ? 0 : 1
194
+ }
195
+
196
+ // A message's content: a string, or blocks of text and images (Claude's or OpenAI's shape).
197
+ export function contentOf(content) {
198
+ if (typeof content === 'string') return { text: content, images: [] }
199
+ const blocks = Array.isArray(content) ? content : []
200
+ return {
201
+ text: blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\n'),
202
+ images: blocks.flatMap((b) => (b.type === 'image' && b.source?.data ? [`data:${b.source.media_type ?? 'image/png'};base64,${b.source.data}`] : b.type === 'image_url' ? [b.image_url?.url ?? b.image_url] : [])),
203
+ }
204
+ }
205
+
206
+ function parseArgs(text) {
207
+ try {
208
+ return JSON.parse(text || '{}')
209
+ } catch {
210
+ return { raw: text }
211
+ }
212
+ }
213
+
214
+ async function readAll(stream) {
215
+ let text = ''
216
+ for await (const ch of stream) text += ch
217
+ return text
218
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,80 @@
1
+ // Hooks: your own commands, run at points in HeartCode's work, in Claude Code's format (so hooks
2
+ // from it work here too). From ~/.heart/hooks.json and .heartcode/hooks.json (both, global first):
3
+ // { "hooks": { "PreToolUse": [ { "matcher": "edit_file|write_file", "hooks": [ { "type": "command", "command": "./check.sh", "timeout": 60 } ] } ] } }
4
+ // Events:
5
+ // UserPromptSubmit before a message goes to the model; exit 2 blocks it, stdout is added to it
6
+ // PreToolUse before a tool runs (matcher: the tool's name); exit 2 blocks it, stderr is the reason
7
+ // PostToolUse after a tool ran; exit 2 tells the model stderr (e.g. lint errors)
8
+ // Stop when the model is done; exit 2 makes it carry on, with stderr as the reason
9
+ // The command gets the event as JSON on stdin (session_id, cwd, hook_event_name, tool_name,
10
+ // tool_input, tool_response, prompt, stop_hook_active), runs in the project folder, and can also
11
+ // answer with JSON on stdout: {"decision": "block", "reason": "…"} (or "continue": false).
12
+ // Matchers are regexes, tested against HeartCode's tool names and Claude Code's (Edit, Bash, …).
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { HOME, readConfig } from './config.js'
16
+ import { runCommand, clip } from './tools.js'
17
+ import { log } from './log.js'
18
+
19
+ export const EVENTS = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']
20
+
21
+ // HeartCode's tool names → Claude Code's, for matchers and tool_input written for Claude Code.
22
+ export const CLAUDE_NAMES = { read_file: 'Read', edit_file: 'Edit', write_file: 'Write', run_command: 'Bash', search: 'Grep', list_files: 'Glob', web_fetch: 'WebFetch', web_search: 'WebSearch', todo: 'TodoWrite', agent: 'Task' }
23
+
24
+ export const hookFiles = (root) => [path.join(HOME, 'hooks.json'), path.join(root, '.heartcode', 'hooks.json')]
25
+
26
+ // { event: [{ matcher, command, timeout, file }] }
27
+ export function loadHooks(root) {
28
+ const out = {}
29
+ const sources = hookFiles(root).filter((f) => fs.existsSync(f)).map((file) => ({ hooks: readConfig(file, {}), file }))
30
+ for (const { hooks: cfg, file } of sources) {
31
+ for (const [event, groups] of Object.entries(cfg.hooks ?? cfg)) {
32
+ if (!EVENTS.includes(event)) {
33
+ log('hooks', `${file}: unknown event ${event} (known: ${EVENTS.join(', ')})`)
34
+ continue
35
+ }
36
+ for (const g of [groups].flat()) {
37
+ for (const h of g?.hooks ?? []) if (h?.command) (out[event] ??= []).push({ matcher: g.matcher ?? '', command: h.command, timeout: Number(h.timeout) || 60, file })
38
+ }
39
+ }
40
+ }
41
+ return out
42
+ }
43
+
44
+ const matches = (matcher, tool) => {
45
+ if (!matcher || matcher === '*' || !tool) return true
46
+ try {
47
+ const re = new RegExp(`^(?:${matcher})$`)
48
+ return re.test(tool) || re.test(CLAUDE_NAMES[tool] ?? '') || (tool === 'mcp' && /^mcp__/.test(matcher))
49
+ } catch {
50
+ return matcher === tool
51
+ }
52
+ }
53
+
54
+ // Runs the event's hooks (those matching `tool`). Returns { block: reason | null, context: stdout of
55
+ // hooks that passed }. A hook that fails to run is logged and ignored: it never stops the work.
56
+ export async function runHooks(s, event, data = {}, signal) {
57
+ const hooks = (s.hooks?.[event] ?? []).filter((h) => matches(h.matcher, data.tool_name))
58
+ if (!hooks.length) return { block: null, context: '' }
59
+ const input = { session_id: s.chat?.id, cwd: s.root, hook_event_name: event, ...data }
60
+ if (input.tool_input?.path && !input.tool_input.file_path) input.tool_input = { ...input.tool_input, file_path: input.tool_input.path } // as Claude Code names it
61
+ const context = []
62
+ for (const h of hooks) {
63
+ const t0 = Date.now()
64
+ const { code, output, stderr } = await runCommand(s.root, h.command, { signal, timeout: h.timeout * 1000, stdin: JSON.stringify(input), env: { HEARTCODE_PROJECT_DIR: s.root, CLAUDE_PROJECT_DIR: s.root }, split: true })
65
+ log('hook', `${event} ${h.command} → exit ${code} in ${Date.now() - t0}ms`, clip(output + stderr, 2000))
66
+ let json = null
67
+ try {
68
+ json = /^\s*\{/.test(output) ? JSON.parse(output) : null
69
+ } catch {}
70
+ const reason = (json?.reason ?? json?.stopReason ?? stderr.trim()) || output.trim() || `blocked by the hook ${h.command}`
71
+ if (code === 2 || json?.decision === 'block' || json?.continue === false) {
72
+ s.ui.step('hook', `${event}: ${clip(h.command, 50)}`)
73
+ s.ui.result(`blocked: ${clip(reason.split('\n')[0], 100)}`)
74
+ return { block: clip(reason, 4000), context: context.join('\n') }
75
+ }
76
+ if (code !== 0) s.ui.warn(`hook ${h.command} (${event}) failed: exit ${code}${stderr.trim() ? `: ${clip(stderr.trim().split('\n')[0], 120)}` : ''}`)
77
+ else if (output.trim() && !json && event === 'UserPromptSubmit') context.push(output.trim())
78
+ }
79
+ return { block: null, context: context.join('\n') }
80
+ }
@@ -0,0 +1,202 @@
1
+ // /import: finds what other coding CLIs have set up (commands, skills, MCP servers, instructions,
2
+ // memory) and copies what the user picks into HeartCode's places, converted. Nothing is used from
3
+ // the other tools until it's imported, so their setups never cost tokens by surprise; nothing that
4
+ // already exists in HeartCode is overwritten.
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { HOME, readJson, writeJson } from './config.js'
9
+ import { frontMatter } from './skills.js'
10
+ import { Memory } from './memory.js'
11
+
12
+ const exists = (p) => {
13
+ try {
14
+ fs.accessSync(p)
15
+ return true
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ // Every file under dir with one of the extensions: [relative name without extension, full path]
22
+ function filesIn(dir, exts) {
23
+ const out = []
24
+ const walk = (d, prefix) => {
25
+ let entries = []
26
+ try {
27
+ entries = fs.readdirSync(d, { withFileTypes: true })
28
+ } catch {}
29
+ for (const e of entries) {
30
+ if (e.isDirectory()) walk(path.join(d, e.name), `${prefix}${e.name}-`)
31
+ else if (exts.some((x) => e.name.endsWith(x))) out.push([`${prefix}${e.name.replace(/\.[^.]+$/, '')}`, path.join(d, e.name)])
32
+ }
33
+ }
34
+ walk(dir, '')
35
+ return out
36
+ }
37
+
38
+ const safe = (n) => String(n).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
39
+ const skillsDir = (root, scope) => (scope === 'global' ? path.join(HOME, 'skills') : path.join(root, '.heartcode', 'skills'))
40
+ const taken = (root, scope, name) => exists(path.join(skillsDir(root, scope), `${name}.md`)) || exists(path.join(skillsDir(root, scope), name))
41
+
42
+ // A markdown command (Claude Code, Codex, OpenCode) → a HeartCode skill you type ("use: me").
43
+ function importCommand(root, scope, name, file) {
44
+ if (taken(root, scope, name)) return `/${name} already exists, kept it`
45
+ const { meta, body } = frontMatter(fs.readFileSync(file, 'utf8'))
46
+ const head = [`description: ${(meta.description || body.split('\n')[0]).replace(/\n/g, ' ').slice(0, 200)}`, `use: ${meta['user-invocable'] === 'false' ? 'model' : 'me'}`]
47
+ if (meta['argument-hint']) head.push(`argument-hint: ${meta['argument-hint']}`)
48
+ fs.mkdirSync(skillsDir(root, scope), { recursive: true })
49
+ fs.writeFileSync(path.join(skillsDir(root, scope), `${name}.md`), `---\n${head.join('\n')}\n---\n\n${body}\n`)
50
+ return `/${name}`
51
+ }
52
+
53
+ // A Gemini CLI command (.toml with prompt and description) → a HeartCode skill. {{args}} stays as
54
+ // is (it's HeartCode's too); !{cmd} becomes !`cmd`.
55
+ function importToml(root, scope, name, file) {
56
+ if (taken(root, scope, name)) return `/${name} already exists, kept it`
57
+ const text = fs.readFileSync(file, 'utf8')
58
+ const str = (key) => {
59
+ const m = new RegExp(`^${key}\\s*=\\s*(?:"""([\\s\\S]*?)"""|'''([\\s\\S]*?)'''|"((?:[^"\\\\]|\\\\.)*)"|'([^']*)')`, 'm').exec(text)
60
+ return m ? (m[1] ?? m[2] ?? m[3]?.replace(/\\n/g, '\n').replace(/\\"/g, '"') ?? m[4]) : ''
61
+ }
62
+ const prompt = str('prompt').trim().replace(/!\{([^}]*)\}/g, '!`$1`')
63
+ if (!prompt) return `${name}: no prompt in it, skipped`
64
+ fs.mkdirSync(skillsDir(root, scope), { recursive: true })
65
+ fs.writeFileSync(path.join(skillsDir(root, scope), `${name}.md`), `---\ndescription: ${(str('description') || prompt.split('\n')[0]).slice(0, 200)}\nuse: me\n---\n\n${prompt}\n`)
66
+ return `/${name}`
67
+ }
68
+
69
+ // A skill folder (Claude Code, Codex): copied as is, supporting files included.
70
+ function importSkillFolder(root, scope, name, dir) {
71
+ if (taken(root, scope, name)) return `${name} already exists, kept it`
72
+ fs.cpSync(dir, path.join(skillsDir(root, scope), name), { recursive: true })
73
+ return name
74
+ }
75
+
76
+ function importMcp(root, scope, name, cfg) {
77
+ const file = scope === 'global' ? path.join(HOME, 'mcp.json') : path.join(root, '.heartcode', 'mcp.json')
78
+ const data = readJson(file, {})
79
+ data.mcpServers ??= {}
80
+ if (data.mcpServers[name]) return `MCP server ${name} already exists, kept it`
81
+ const { command, args, env, url, httpUrl, headers, type } = cfg
82
+ data.mcpServers[name] = command
83
+ ? { command, ...(args?.length && { args }), ...(env && Object.keys(env).length && { env }) }
84
+ : { url: url ?? httpUrl, ...(headers && Object.keys(headers).length && { headers }), ...(type === 'sse' && { type }) } // Gemini CLI names it httpUrl
85
+ writeJson(file, data)
86
+ return `MCP server ${name}`
87
+ }
88
+
89
+ function importInstructions(file, from) {
90
+ const target = path.join(HOME, 'HEARTCODE.md')
91
+ const text = fs.readFileSync(file, 'utf8').trim()
92
+ const now = exists(target) ? fs.readFileSync(target, 'utf8') : ''
93
+ if (now.includes(text)) return `${from} instructions were already there`
94
+ fs.mkdirSync(HOME, { recursive: true })
95
+ fs.writeFileSync(target, `${now.trim() ? `${now.trim()}\n\n` : ''}<!-- from ${from}: ${file} -->\n${text}\n`)
96
+ return `${from} instructions → ~/.heart/HEARTCODE.md`
97
+ }
98
+
99
+ // Claude Code's memory for this project: its notes become HeartCode notes (tags from the name;
100
+ // /tidy_memory can refine them and move general ones to the global memory).
101
+ function importClaudeMemory(root, dir) {
102
+ const memory = new Memory(root)
103
+ let n = 0
104
+ for (const [name, file] of filesIn(dir, ['.md'])) {
105
+ if (name === 'MEMORY') continue
106
+ const { meta, body } = frontMatter(fs.readFileSync(file, 'utf8'))
107
+ if (memory.read(meta.name || name)) continue
108
+ const type = /^\s*type:\s*(\w+)/m.exec(fs.readFileSync(file, 'utf8'))?.[1]
109
+ const tags = String(meta.name || name).split(/[-_\s]+/).filter((w) => w.length > 2).slice(0, 2)
110
+ memory.write({ name: meta.name || name, description: meta.description, type: type === 'user' || type === 'feedback' || type === 'reference' ? type : 'project', tags, content: body })
111
+ n++
112
+ }
113
+ return `${n} memory note${n === 1 ? '' : 's'} from Claude Code`
114
+ }
115
+
116
+ // Codex keeps MCP servers in TOML: [mcp_servers.name] with command, args and env.
117
+ function codexServers(file) {
118
+ let text = ''
119
+ try {
120
+ text = fs.readFileSync(file, 'utf8')
121
+ } catch {
122
+ return {}
123
+ }
124
+ const out = {}
125
+ for (const m of text.matchAll(/^\[mcp_servers\.("?)([^\]"]+)\1\]\s*\n([\s\S]*?)(?=^\[|$(?![\s\S]))/gm)) {
126
+ const block = m[3]
127
+ const command = /^command\s*=\s*"([^"]*)"/m.exec(block)?.[1]
128
+ const args = [...(/^args\s*=\s*\[([^\]]*)\]/m.exec(block)?.[1] ?? '').matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((a) => a[1])
129
+ const env = Object.fromEntries([...(/^env\s*=\s*\{([^}]*)\}/m.exec(block)?.[1] ?? '').matchAll(/"?([\w.-]+)"?\s*=\s*"([^"]*)"/g)].map((e) => [e[1], e[2]]))
130
+ if (command) out[m[2]] = { command, args, env }
131
+ }
132
+ return out
133
+ }
134
+
135
+ // Everything importable, as picker items: { label, desc, group, run() → what was done }.
136
+ export function findImports(root, home = os.homedir()) {
137
+ const items = []
138
+ const add = (group, label, desc, run) => items.push({ group, label, desc, run })
139
+ const commandSources = [
140
+ ['Claude Code', 'project', path.join(root, '.claude', 'commands')],
141
+ ['Claude Code', 'global', path.join(home, '.claude', 'commands')],
142
+ ['Codex', 'global', path.join(home, '.codex', 'prompts')],
143
+ ['OpenCode', 'project', path.join(root, '.opencode', 'commands')],
144
+ ['OpenCode', 'project', path.join(root, '.opencode', 'command')],
145
+ ['OpenCode', 'global', path.join(home, '.config', 'opencode', 'commands')],
146
+ ['OpenCode', 'global', path.join(home, '.config', 'opencode', 'command')],
147
+ ]
148
+ for (const [tool, scope, dir] of commandSources) {
149
+ for (const [rel, file] of filesIn(dir, ['.md'])) {
150
+ const name = safe(rel)
151
+ const { meta, body } = frontMatter(fs.readFileSync(file, 'utf8'))
152
+ add(`${tool} · ${scope === 'global' ? 'yours' : 'this project'} · commands`, `/${name}`, (meta.description || body.split('\n')[0]).slice(0, 90), () => importCommand(root, scope, name, file))
153
+ }
154
+ }
155
+ for (const [scope, dir] of [['project', path.join(root, '.gemini', 'commands')], ['global', path.join(home, '.gemini', 'commands')]]) {
156
+ for (const [rel, file] of filesIn(dir, ['.toml'])) {
157
+ const name = safe(rel)
158
+ add(`Gemini CLI · ${scope === 'global' ? 'yours' : 'this project'} · commands`, `/${name}`, /^description\s*=\s*"([^"]*)"/m.exec(fs.readFileSync(file, 'utf8'))?.[1] ?? '', () => importToml(root, scope, name, file))
159
+ }
160
+ }
161
+ const skillSources = [
162
+ ['Claude Code', 'project', path.join(root, '.claude', 'skills')],
163
+ ['Claude Code', 'global', path.join(home, '.claude', 'skills')],
164
+ ['Codex', 'global', path.join(home, '.codex', 'skills')],
165
+ ]
166
+ for (const [tool, scope, dir] of skillSources) {
167
+ let entries = []
168
+ try {
169
+ entries = fs.readdirSync(dir, { withFileTypes: true })
170
+ } catch {}
171
+ for (const e of entries) {
172
+ const file = path.join(dir, e.name, 'SKILL.md')
173
+ if (!e.isDirectory() || !exists(file)) continue
174
+ const { meta } = frontMatter(fs.readFileSync(file, 'utf8'))
175
+ add(`${tool} · ${scope === 'global' ? 'yours' : 'this project'} · skills`, safe(e.name), (meta.description ?? '').slice(0, 90), () => importSkillFolder(root, scope, safe(e.name), path.join(dir, e.name)))
176
+ }
177
+ }
178
+ const claudeJson = readJson(path.join(home, '.claude.json'), {})
179
+ const mcpSources = [
180
+ ['Claude Code', 'project', readJson(path.join(root, '.mcp.json'), {}).mcpServers],
181
+ ['Claude Code', 'project', claudeJson.projects?.[root]?.mcpServers],
182
+ ['Claude Code', 'global', claudeJson.mcpServers],
183
+ ['Cursor', 'project', readJson(path.join(root, '.cursor', 'mcp.json'), {}).mcpServers],
184
+ ['Cursor', 'global', readJson(path.join(home, '.cursor', 'mcp.json'), {}).mcpServers],
185
+ ['Gemini CLI', 'project', readJson(path.join(root, '.gemini', 'settings.json'), {}).mcpServers],
186
+ ['Gemini CLI', 'global', readJson(path.join(home, '.gemini', 'settings.json'), {}).mcpServers],
187
+ ['Codex', 'global', codexServers(path.join(home, '.codex', 'config.toml'))],
188
+ ]
189
+ for (const [tool, scope, servers] of mcpSources) {
190
+ for (const [name, cfg] of Object.entries(servers ?? {})) {
191
+ if (!cfg?.command && !cfg?.url && !cfg?.httpUrl) continue
192
+ add(`${tool} · ${scope === 'global' ? 'yours' : 'this project'} · MCP servers`, name, (cfg.command ? `${cfg.command} ${(cfg.args ?? []).join(' ')}` : (cfg.url ?? cfg.httpUrl)).slice(0, 90), () => importMcp(root, scope, name, cfg))
193
+ }
194
+ }
195
+ for (const [tool, file] of [['Claude Code', path.join(home, '.claude', 'CLAUDE.md')], ['Codex', path.join(home, '.codex', 'AGENTS.md')], ['Gemini CLI', path.join(home, '.gemini', 'GEMINI.md')]]) {
196
+ if (exists(file)) add(`${tool} · yours · instructions`, path.basename(file), `your ${tool} instructions → ~/.heart/HEARTCODE.md`, () => importInstructions(file, tool))
197
+ }
198
+ const claudeMemory = path.join(home, '.claude', 'projects', root.replace(/[^a-zA-Z0-9]/g, '-'), 'memory')
199
+ const notes = filesIn(claudeMemory, ['.md']).filter(([n]) => n !== 'MEMORY')
200
+ if (notes.length) add('Claude Code · this project · memory', `${notes.length} memory notes`, 'become HeartCode memory notes for this project', () => importClaudeMemory(root, claudeMemory))
201
+ return items
202
+ }
package/src/log.js ADDED
@@ -0,0 +1,139 @@
1
+ // Logs, when they're switched on (the settings page: Logging).
2
+ // - The run's log: everything HeartCode does, as readable text. HEART_LOG_DIR (bin/heart.js sets
3
+ // one folder per run): a file per tab (tab-1.log, tab-2.log…; heartcode.log before any tab).
4
+ // HEART_LOG=/path/file.log: all of it in that one file.
5
+ // - Requests: each request to a model, and its reply or error, a JSON file of its own in
6
+ // <logs>/requests/<when the run started>/, and a line for it in that folder's index.jsonl.
7
+ // Written synchronously so the last lines survive a crash.
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { inspect } from 'node:util'
12
+ import { AsyncLocalStorage } from 'node:async_hooks'
13
+
14
+ export const LOG_FILE = process.env.HEART_LOG && process.env.HEART_LOG !== 'off' ? path.resolve(process.env.HEART_LOG) : null
15
+ export const LOG_DIR = !LOG_FILE && process.env.HEART_LOG !== 'off' && process.env.HEART_LOG_DIR ? path.resolve(process.env.HEART_LOG_DIR) : null
16
+ // The run's log on or off. HEART_LOG_RUN=on|off (bin/heart.js): its folder is set aside, and config.json's
17
+ // "log" switches it, also while HeartCode runs (useRequestLog); else it's on when a file or folder is given.
18
+ const AUTO = Boolean(LOG_DIR) && ['on', 'off'].includes(process.env.HEART_LOG_RUN)
19
+ export const runLog = { on: Boolean(LOG_FILE || (LOG_DIR && (!AUTO || process.env.HEART_LOG_RUN === 'on'))) }
20
+ // Where the logs go: bin/heart.js says (the system's temp folder with --temp)
21
+ export const LOGS = path.resolve(process.env.HEART_LOGS || path.join(process.env.HEART_HOME || path.join(os.homedir(), '.heart'), 'logs'))
22
+ export const REQUESTS = path.join(LOGS, 'requests')
23
+ export const DAYS = 30 // how long logs are kept, unless config.json's logDays says otherwise
24
+ const STAMP = process.env.HEART_LOG_STAMP || stamp()
25
+
26
+ export function stamp(d = new Date()) {
27
+ return `${d.getDate()}-${d.getMonth() + 1}-${d.getFullYear()}-${d.getHours()}-${d.getMinutes()}-${d.getSeconds()}`.replace(/\b(\d)\b/g, '0$1')
28
+ }
29
+ // Logs older than `days` go: run folders, request folders, and the older one-file-per-run logs.
30
+ // Returns how many went.
31
+ export function pruneLogs(days = DAYS) {
32
+ const before = Date.now() - days * 86_400_000
33
+ let gone = 0
34
+ for (const [dir, isLog] of [[path.join(LOGS, 'runs'), () => true], [REQUESTS, () => true], [LOGS, (f) => f.endsWith('.log')]]) {
35
+ try {
36
+ for (const f of fs.readdirSync(dir)) {
37
+ const at = path.join(dir, f)
38
+ if (!isLog(f) || at === LOG_DIR || at === requestLog.dir || fs.statSync(at).mtimeMs >= before) continue
39
+ fs.rmSync(at, { recursive: true, force: true })
40
+ gone++
41
+ }
42
+ } catch {}
43
+ }
44
+ return gone
45
+ }
46
+ export const logDays = (cfg) => (Number.isInteger(cfg.logDays) && cfg.logDays > 0 ? cfg.logDays : DAYS)
47
+
48
+ // Which tab this is running for (tabs.js runs each tab's work in it): its log file, its requests.
49
+ export const logContext = new AsyncLocalStorage()
50
+ const tabOf = () => logContext.getStore()?.tab ?? null
51
+
52
+ const fds = new Map()
53
+ function fdFor() {
54
+ if (!runLog.on) return null
55
+ const file = LOG_FILE ?? (LOG_DIR && path.join(LOG_DIR, tabOf() ? `tab-${tabOf()}.log` : 'heartcode.log'))
56
+ if (!file) return null
57
+ if (!fds.has(file)) {
58
+ try {
59
+ fs.mkdirSync(path.dirname(file), { recursive: true })
60
+ fds.set(file, fs.openSync(file, 'a'))
61
+ } catch {
62
+ fds.set(file, null)
63
+ }
64
+ }
65
+ return fds.get(file)
66
+ }
67
+
68
+ const show = (x) => (typeof x === 'string' ? x : inspect(x, { depth: 8, breakLength: 140, maxArrayLength: 200, maxStringLength: 50_000 }))
69
+
70
+ // log('http', 'POST', url, body) → "2026-10-25T08:00:00.000Z [http] POST https://… {…}"
71
+ export function log(topic, ...parts) {
72
+ const fd = fdFor()
73
+ if (fd === null) return
74
+ try {
75
+ const text = parts.map(show).join(' ').replace(/\x1b\[[0-9;]*m/g, '')
76
+ fs.writeSync(fd, `${new Date().toISOString()} [${topic}] ${text}\n`)
77
+ } catch {}
78
+ }
79
+
80
+ // The requests log: on, and how much of each (from config.json: logRequests, logDetail).
81
+ export const requestLog = { on: false, detail: 'full', dir: null }
82
+ export function useRequestLog(cfg) {
83
+ if (AUTO) runLog.on = cfg.log === true
84
+ return Object.assign(requestLog, { on: cfg.logRequests === true, detail: cfg.logDetail === 'summary' ? 'summary' : 'full' })
85
+ }
86
+ // At the start: the request log as set, and logs past their days gone.
87
+ export function useLogs(cfg) {
88
+ useRequestLog(cfg)
89
+ pruneLogs(logDays(cfg))
90
+ }
91
+ let count = 0
92
+ const safe = (s) => String(s ?? '').replace(/[^\w.-]+/g, '_').slice(0, 60) || 'none'
93
+
94
+ // One request, after it ended: { ok, stopped, provider, model, place, of, url, status, error, ms,
95
+ // firstTokenMs, usage, request, reply }. "full" keeps what was sent and what came back; "summary"
96
+ // only their sizes. API keys are never in it (they're headers, not the body).
97
+ export function logRequest(rec) {
98
+ if (!requestLog.on) return
99
+ try {
100
+ if (!requestLog.dir) {
101
+ let dir = path.join(REQUESTS, STAMP)
102
+ for (let n = 2; fs.existsSync(dir); n++) dir = path.join(REQUESTS, `${STAMP}-${n}`) // another run started in the same second
103
+ fs.mkdirSync(dir, { recursive: true })
104
+ requestLog.dir = dir
105
+ }
106
+ const n = ++count
107
+ const result = rec.ok ? 'ok' : rec.stopped ? 'stopped' : 'failed'
108
+ const file = `${String(n).padStart(4, '0')}-${result}-${safe(rec.provider)}-${safe(rec.model)}.json`
109
+ const { request, reply, ...summary } = rec
110
+ const head = { n, at: new Date().toISOString(), tab: tabOf(), result, ...summary }
111
+ const sizes = { requestChars: request ? JSON.stringify(request).length : 0, messages: request?.messages?.length ?? 0, replyChars: reply ? (reply.content ?? '').length : 0, toolCalls: reply?.toolCalls?.length ?? 0 }
112
+ const body = requestLog.detail === 'full' ? { ...head, request, reply } : { ...head, ...sizes }
113
+ fs.writeFileSync(path.join(requestLog.dir, file), JSON.stringify(body, null, 2))
114
+ fs.appendFileSync(path.join(requestLog.dir, 'index.jsonl'), JSON.stringify({ file, ...head, ...sizes, errorBody: head.errorBody?.slice(0, 500) }) + '\n') // the list's line: kept short
115
+ } catch {}
116
+ }
117
+
118
+ // The requests logged, newest first (for the settings page): each run's index.jsonl, read backwards.
119
+ export function listRequests(limit = 300) {
120
+ const out = []
121
+ try {
122
+ const runs = fs.readdirSync(REQUESTS, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => [d.name, fs.statSync(path.join(REQUESTS, d.name)).mtimeMs]).sort((a, b) => b[1] - a[1])
123
+ for (const [run] of runs) {
124
+ const lines = fs.existsSync(path.join(REQUESTS, run, 'index.jsonl')) ? fs.readFileSync(path.join(REQUESTS, run, 'index.jsonl'), 'utf8').trim().split('\n').filter(Boolean) : []
125
+ for (const line of lines.reverse()) {
126
+ try {
127
+ out.push({ run, ...JSON.parse(line) })
128
+ } catch {}
129
+ if (out.length >= limit) return out
130
+ }
131
+ }
132
+ } catch {}
133
+ return out
134
+ }
135
+ // One request's file, by its run folder and name (nothing outside the requests folder).
136
+ export function readRequest(run, file) {
137
+ if (![run, file].every((x) => /^[\w.-]+$/.test(String(x ?? '')) && !/^\.+$/.test(x)) || !file.endsWith('.json')) throw new Error('no such request')
138
+ return JSON.parse(fs.readFileSync(path.join(REQUESTS, run, file), 'utf8'))
139
+ }