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/headless.js
ADDED
|
@@ -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
|
+
}
|
package/src/importer.js
ADDED
|
@@ -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
|
+
}
|