heartcode 0.1.12 → 0.1.13
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/CHANGELOG.md +11 -0
- package/SKILL.md +5 -4
- package/bin/heart.js +30 -8
- package/bin/heartcode.js +23 -28
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/config.js +48 -2
- package/src/settings.html +42 -53
- package/src/settings.js +36 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in each version of HeartCode, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.13 (2026-10-30)
|
|
6
|
+
|
|
7
|
+
- Settings no longer go back by themselves: a save writes only what changed, so another HeartCode
|
|
8
|
+
or settings page with an older copy doesn't undo your change (and keys HeartCode doesn't know stay).
|
|
9
|
+
- A settings change reaches every HeartCode running on the same settings at once, mid-task too: the
|
|
10
|
+
model order, prompts and HEARTCODE.md, hooks, skills, MCP servers. Open settings pages update by themselves.
|
|
11
|
+
- `--temp` runs going on at once share one throwaway folder: a change in one reaches the others,
|
|
12
|
+
never your real settings. Ctrl+C now cleans it up too.
|
|
13
|
+
- Settings page: nothing is kept in the browser (the provider ranking, sorts and folds are in
|
|
14
|
+
~/.heart); Save order is always there; Invert selection on Your models.
|
|
15
|
+
|
|
5
16
|
## 0.1.12 (2026-10-30)
|
|
6
17
|
|
|
7
18
|
- `auto` is the name for your model list (#1, then the next when one fails); `default` still works.
|
package/SKILL.md
CHANGED
|
@@ -77,9 +77,10 @@ image, one that can't call tools gets the tool calls as text.
|
|
|
77
77
|
|
|
78
78
|
`--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
|
|
79
79
|
|
|
80
|
-
A run picks up settings the user changes meanwhile (`heart settings`,
|
|
81
|
-
without being restarted
|
|
82
|
-
|
|
80
|
+
A run picks up settings the user changes meanwhile (`heart settings`, another HeartCode, or
|
|
81
|
+
`~/.heart` by hand) without being restarted, from its next request: the model order, prompts,
|
|
82
|
+
hooks, skills, MCP servers. `--temp` runs share one throwaway folder, so a change in one reaches the
|
|
83
|
+
other `--temp` runs going on, never the real ones.
|
|
83
84
|
|
|
84
85
|
`heart` is installed by `npm start` in the HeartCode folder. `heartcode` is the same command.
|
|
85
86
|
Needs Node.js 20 or newer. No providers? See "Setting up a model" below.
|
|
@@ -133,7 +134,7 @@ can't switch the model you chose.
|
|
|
133
134
|
| Everything (edits and commands) | `--permission-mode all` (the default unless the user set another) |
|
|
134
135
|
| Changes kept apart from the user's files | `--worktree=<name>` (in a git repo): it works in `.heartcode/worktrees/<name>` on the branch `heartcode/<name>`; kept if anything changed (merge it from there), removed if nothing did |
|
|
135
136
|
| A cap on how long it works | `--max-turns 30` (model steps), `--max-retries 3` (a failing provider) |
|
|
136
|
-
| Nothing written to the user's `~/.heart` (sessions, logs, settings) | `--temp` |
|
|
137
|
+
| Nothing written to the user's `~/.heart` (sessions, logs, settings) | `--temp` (a throwaway copy, shared by the `--temp` runs going on at once) |
|
|
137
138
|
|
|
138
139
|
Tool names: HeartCode's own (`read_file`, `edit_file`, `write_file`, `run_command`, `search`,
|
|
139
140
|
`list_files`, `web_fetch`, `web_search`…) or Claude Code's (`Read`, `Edit`, `Write`, `Bash`,
|
package/bin/heart.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// heart . this folder
|
|
5
5
|
// heart ~/code/my-app that folder (a folder: a file is an error)
|
|
6
6
|
// heart ~/code/my-app "fix the failing test" -m … then anything heartcode takes (heart --help)
|
|
7
|
-
// heart --temp … a throwaway ~/.heart
|
|
8
|
-
// models' test results and
|
|
9
|
-
// quits; your own is
|
|
7
|
+
// heart --temp … a throwaway ~/.heart, shared by the --temp runs going on at once:
|
|
8
|
+
// your config.json (providers, keys), the models' test results and
|
|
9
|
+
// prices copied in; deleted when the last of them quits; your own is
|
|
10
|
+
// never written to
|
|
10
11
|
// Logs, when they're switched on (the settings page: Logging): each run's log in
|
|
11
12
|
// ~/.heart/logs/runs/<day-month-year-hour-min-sec>/, a file per tab ("log": true in config.json);
|
|
12
13
|
// each request to a model in ~/.heart/logs/requests/<same>/, a file per request ("logRequests").
|
|
@@ -30,15 +31,36 @@ const stamp = (() => {
|
|
|
30
31
|
return `${d.getDate()}-${d.getMonth() + 1}-${d.getFullYear()}-${d.getHours()}-${d.getMinutes()}-${d.getSeconds()}`.replace(/\b(\d)\b/g, '0$1')
|
|
31
32
|
})()
|
|
32
33
|
|
|
33
|
-
// --temp: HeartCode's folder is
|
|
34
|
+
// --temp: HeartCode's folder is one in the system's temp folder, shared by every --temp run going
|
|
35
|
+
// on at the same time (a setting changed in one reaches the others, as ~/.heart's do for the real
|
|
36
|
+
// ones). The first one makes it, the last one to quit deletes it. Each run's pid is a file in
|
|
37
|
+
// .runs/ (a run that crashed leaves one: its process is gone, so it doesn't count).
|
|
34
38
|
const temp = args.includes('--temp')
|
|
35
39
|
if (temp) {
|
|
36
40
|
args.splice(args.indexOf('--temp'), 1)
|
|
37
|
-
const dir =
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
const dir = path.join(os.tmpdir(), `heart-temp-${os.userInfo().username}`)
|
|
42
|
+
const runs = path.join(dir, '.runs')
|
|
43
|
+
const alive = (pid) => {
|
|
44
|
+
try {
|
|
45
|
+
return process.kill(Number(pid), 0), true
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return e.code === 'EPERM'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const others = () => (fs.existsSync(runs) ? fs.readdirSync(runs).filter((pid) => Number(pid) !== process.pid && (alive(pid) || (fs.rmSync(path.join(runs, pid), { force: true }), false))) : [])
|
|
51
|
+
if (!others().length) {
|
|
52
|
+
fs.rmSync(dir, { recursive: true, force: true }) // left by a crashed run: from your settings again
|
|
53
|
+
fs.mkdirSync(runs, { recursive: true })
|
|
54
|
+
// your providers and keys; what HeartCode learned about each model and prices (or it tests them again)
|
|
55
|
+
for (const f of ['config.json', 'models', 'prices.json']) if (fs.existsSync(path.join(realHome, f))) fs.cpSync(path.join(realHome, f), path.join(dir, f), { recursive: true })
|
|
56
|
+
}
|
|
57
|
+
fs.writeFileSync(path.join(runs, String(process.pid)), '')
|
|
40
58
|
process.env.HEART_HOME = dir
|
|
41
|
-
process.on('exit', () =>
|
|
59
|
+
process.on('exit', () => {
|
|
60
|
+
fs.rmSync(path.join(runs, String(process.pid)), { force: true })
|
|
61
|
+
if (!others().length) fs.rmSync(dir, { recursive: true, force: true })
|
|
62
|
+
})
|
|
63
|
+
process.once('SIGINT', () => process.exit(130)) // Ctrl+C (heart settings, -p): through 'exit', so the folder goes too
|
|
42
64
|
}
|
|
43
65
|
|
|
44
66
|
// a folder first: HeartCode opens it (subcommands and options go straight through)
|
package/bin/heartcode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
import { Client } from '../src/client.js'
|
|
5
|
-
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, AUTO_MODEL, isAuto, parseModel, VERSION } from '../src/config.js'
|
|
5
|
+
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, AUTO_MODEL, isAuto, parseModel, takeIn, whatOf, VERSION } from '../src/config.js'
|
|
6
6
|
import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
|
|
7
7
|
import { learnFromUsage, LIMIT_MS } from '../src/context.js'
|
|
8
8
|
import { Session, transcript } from '../src/agent.js'
|
|
@@ -26,6 +26,7 @@ import { createWorktree, worktreeState, removeWorktree } from '../src/worktree.j
|
|
|
26
26
|
import { runAcp } from '../src/acp.js'
|
|
27
27
|
import { Tabs } from '../src/tabs.js'
|
|
28
28
|
import { startSettings } from '../src/settings.js'
|
|
29
|
+
import { loadHooks } from '../src/hooks.js'
|
|
29
30
|
import { RESETS, reset, resetPaths, resetCode } from '../src/reset.js'
|
|
30
31
|
|
|
31
32
|
const LOCAL_SERVERS = ['http://localhost:11434/v1', 'http://localhost:1234/v1', 'http://localhost:8080/v1']
|
|
@@ -55,8 +56,9 @@ ${c.bold('options')}
|
|
|
55
56
|
--ask ask before every edit and command, this run
|
|
56
57
|
--hide-thinking collapse the model's thinking to one line
|
|
57
58
|
-C, --cwd <dir> project folder (default: current folder)
|
|
58
|
-
--temp a throwaway settings folder
|
|
59
|
-
|
|
59
|
+
--temp a throwaway settings folder (your providers, model tests and prices
|
|
60
|
+
copied in), shared by the --temp runs going on at once and deleted
|
|
61
|
+
after the last; ~/.heart isn't touched
|
|
60
62
|
--context <n> context window size in tokens
|
|
61
63
|
--force re-run the probe
|
|
62
64
|
--save keep the given -u/-m/--api-key/... in ${CONFIG_FILE}
|
|
@@ -179,12 +181,15 @@ class App {
|
|
|
179
181
|
return mcp
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
// After a skill or MCP server was saved or deleted (
|
|
183
|
-
|
|
184
|
+
// After a skill or MCP server was saved or deleted (the settings page, the director, /import):
|
|
185
|
+
// from the next tool call. The old servers are stopped once the message in progress is done
|
|
186
|
+
// (busy), so a call to one of them finishes.
|
|
187
|
+
reload(kind, busy = false) {
|
|
184
188
|
if (kind === 'skill') this.skills = loadSkills(this.root)
|
|
185
189
|
if (kind === 'mcp') {
|
|
186
|
-
this.mcp
|
|
190
|
+
const old = this.mcp
|
|
187
191
|
this.mcp = this.newMcp()
|
|
192
|
+
busy ? this.later.push(() => old.dispose()) : old.dispose()
|
|
188
193
|
}
|
|
189
194
|
if (this.s) Object.assign(this.s, { skills: this.skills, mcp: this.mcp })
|
|
190
195
|
}
|
|
@@ -852,13 +857,13 @@ function openBrowser(url) {
|
|
|
852
857
|
spawnSync(cmd, args, { stdio: 'ignore' })
|
|
853
858
|
}
|
|
854
859
|
|
|
855
|
-
// A change
|
|
856
|
-
// ACP)
|
|
857
|
-
//
|
|
860
|
+
// A change of the settings (the settings page, another HeartCode on the same folder, a hand), brought
|
|
861
|
+
// into every open tab and run (also -p and ACP) right away: the running task's next request has it
|
|
862
|
+
// (the model list's #1, the prompts, hooks, skills, MCP servers…).
|
|
858
863
|
function applySettings(apps, cfg, what) {
|
|
859
864
|
const before = themeCodes()
|
|
860
865
|
if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
|
|
861
|
-
if (what === 'prompts') reloadPrompts()
|
|
866
|
+
if (what === 'prompts') reloadPrompts()
|
|
862
867
|
if (['setting:log', 'setting:logRequests', 'setting:logDetail'].includes(what)) useRequestLog(cfg) // the run's log and the requests', on or off now
|
|
863
868
|
if (what === 'setting:logDays') pruneLogs(logDays(cfg))
|
|
864
869
|
settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
|
|
@@ -888,7 +893,9 @@ function applySettings(apps, cfg, what) {
|
|
|
888
893
|
// the window size, or a model set up or measured on the page: the model in use takes it again
|
|
889
894
|
// from its profile now (it saves its profile as it learns, so it mustn't keep the old one)
|
|
890
895
|
if ((what === 'setting:context' || what === 'profile') && app.s) app.s.client.onUse?.({ provider: app.s.client.provider, model: app.s.client.model })
|
|
891
|
-
if (what === 'mcp' || what === 'skill')
|
|
896
|
+
if (what === 'mcp' || what === 'skill') app.reload(what, busy)
|
|
897
|
+
if (what === 'prompts' && app.s) app.s.refreshSystem = true // the system prompt, again before the next request
|
|
898
|
+
if (what === 'hooks' && app.s && app.s.hooks !== false) app.s.hooks = loadHooks(app.root)
|
|
892
899
|
if (what === 'keys') ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
|
|
893
900
|
if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
|
|
894
901
|
else ui.render()
|
|
@@ -897,10 +904,10 @@ function applySettings(apps, cfg, what) {
|
|
|
897
904
|
}
|
|
898
905
|
|
|
899
906
|
// Changes saved by another HeartCode, or \`heart settings\` in another terminal, while this one runs:
|
|
900
|
-
// taken in as the page's own are (applySettings). config.json
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
const WATCHED = { 'mcp.json': 'mcp', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', skills: 'skill' }
|
|
907
|
+
// taken in as the page's own are (applySettings). config.json: the keys others changed (takeIn; a
|
|
908
|
+
// model given for this run with -m stays unless the list's #1 changes); the other files by their
|
|
909
|
+
// size and time, and only when this run didn't just apply them itself.
|
|
910
|
+
const WATCHED = { 'mcp.json': 'mcp', 'hooks.json': 'hooks', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', 'HEARTCODE.md': 'prompts', skills: 'skill' }
|
|
904
911
|
const seenStamp = {}
|
|
905
912
|
const stampOf = (p) => {
|
|
906
913
|
try {
|
|
@@ -915,21 +922,9 @@ function settingsSeen(what) {
|
|
|
915
922
|
for (const [name, w] of Object.entries(WATCHED)) if (w === what || (what === 'setting:theme' && w === 'theme')) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
916
923
|
}
|
|
917
924
|
function watchSettings(apps, cfg) {
|
|
918
|
-
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
919
|
-
let last = readJson(CONFIG_FILE, {})
|
|
920
925
|
for (const name of Object.keys(WATCHED)) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
921
926
|
const configChanged = () => {
|
|
922
|
-
const
|
|
923
|
-
if (!now) return
|
|
924
|
-
const keys = [...new Set([...Object.keys(now), ...Object.keys(last)])].filter((k) => !same(now[k], last[k]))
|
|
925
|
-
last = now
|
|
926
|
-
const whats = new Set()
|
|
927
|
-
for (const k of keys) {
|
|
928
|
-
const value = k === 'providers' ? { ...now.providers, ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : now[k]
|
|
929
|
-
if (same(value, cfg[k])) continue
|
|
930
|
-
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
|
931
|
-
whats.add(['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
|
|
932
|
-
}
|
|
927
|
+
const whats = new Set(takeIn(cfg).map(whatOf))
|
|
933
928
|
for (const what of whats) applySettings(apps(), cfg, what)
|
|
934
929
|
if (whats.size) log('settings', `taken in from config.json: ${[...whats].join(', ')}`)
|
|
935
930
|
}
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -669,6 +669,9 @@ async function runNative(s, task, images, signal) {
|
|
|
669
669
|
const stream = strat.toolsStream
|
|
670
670
|
// A long tool call (a whole file) streams in silently: say what's being written.
|
|
671
671
|
const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
|
|
672
|
+
// the prompts or HEARTCODE.md changed meanwhile (applySettings): the system prompt again, now
|
|
673
|
+
if (s.refreshSystem && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: SYSTEM(s) }
|
|
674
|
+
s.refreshSystem = false
|
|
672
675
|
const res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
|
|
673
676
|
ui.stopSpin()
|
|
674
677
|
if (!stream) {
|
package/src/config.js
CHANGED
|
@@ -112,14 +112,60 @@ export function loadConfig(flags = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
const model = flags.model || process.env.HEART_MODEL
|
|
114
114
|
if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
|
|
115
|
+
synced(cfg, readJson(CONFIG_FILE, {}))
|
|
115
116
|
return cfg
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
// What a cfg stands for in config.json (the run-only provider and model left out).
|
|
120
|
+
function saved(cfg) {
|
|
119
121
|
const providers = byName(cfg.providers)
|
|
120
122
|
if (cfg.adhoc) delete providers[cfg.adhoc]
|
|
121
123
|
const keep = cfg.adhoc && cfg.provider === cfg.adhoc ? fromFile() : null // the run-only provider isn't saved as current
|
|
122
124
|
const out = { providers, provider: keep ? keep.provider : cfg.provider, model: keep ? keep.model : cfg.model }
|
|
123
125
|
for (const k of SETTINGS) if (cfg[k] !== undefined) out[k] = cfg[k]
|
|
124
|
-
|
|
126
|
+
return JSON.parse(JSON.stringify(out))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Several HeartCodes (and the settings page in each) keep a copy of the settings. A save writes only
|
|
130
|
+
// what this one changed since it last read or wrote the file, onto the file as it is now, and
|
|
131
|
+
// takeIn brings in what the others changed: no copy undoes another's change with an old value.
|
|
132
|
+
// file: config.json as last read or written here; mine: what this run's cfg stood for then (a -m
|
|
133
|
+
// model for this run is no change of its own, so a save doesn't write it as #1).
|
|
134
|
+
let file = null
|
|
135
|
+
let mine = null
|
|
136
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
137
|
+
const keysOf = (...objs) => [...new Set(objs.flatMap((o) => Object.keys(o ?? {})))]
|
|
138
|
+
const synced = (cfg, disk) => ((file = disk), (mine = saved(cfg)))
|
|
139
|
+
|
|
140
|
+
export function saveConfig(cfg) {
|
|
141
|
+
const out = saved(cfg)
|
|
142
|
+
const was = mine ?? {}
|
|
143
|
+
const disk = readJson(CONFIG_FILE, {})
|
|
144
|
+
for (const k of keysOf(out, was)) {
|
|
145
|
+
if (k === 'providers') {
|
|
146
|
+
// provider by provider: a key changed here and a provider added elsewhere both stay
|
|
147
|
+
const ps = { ...disk.providers }
|
|
148
|
+
for (const n of keysOf(out.providers, was.providers)) if (!same(out.providers?.[n], was.providers?.[n])) out.providers?.[n] === undefined ? delete ps[n] : (ps[n] = out.providers[n])
|
|
149
|
+
disk.providers = byName(ps)
|
|
150
|
+
} else if (!same(out[k], was[k])) out[k] === undefined ? delete disk[k] : (disk[k] = out[k])
|
|
151
|
+
}
|
|
152
|
+
writeJson(CONFIG_FILE, disk)
|
|
153
|
+
synced(cfg, disk)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// What others changed in config.json since this run last read or wrote it, into cfg (the run-only
|
|
157
|
+
// provider stays). The keys that changed: a running HeartCode applies them (the model list…).
|
|
158
|
+
// what a changed key of config.json means to a running HeartCode (applySettings)
|
|
159
|
+
export const whatOf = (k) => (['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
|
|
160
|
+
export function takeIn(cfg) {
|
|
161
|
+
const disk = readJson(CONFIG_FILE, null)
|
|
162
|
+
if (!disk || same(disk, file)) return []
|
|
163
|
+
const keys = keysOf(disk, file).filter((k) => (k === 'providers' || k === 'provider' || k === 'model' || SETTINGS.includes(k)) && !same(disk[k], file?.[k]))
|
|
164
|
+
for (const k of keys) {
|
|
165
|
+
const value = k === 'providers' ? { ...byName(disk.providers), ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : disk[k]
|
|
166
|
+
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
|
167
|
+
}
|
|
168
|
+
file = disk
|
|
169
|
+
mine = saved(cfg)
|
|
170
|
+
return keys
|
|
125
171
|
}
|
package/src/settings.html
CHANGED
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
/* Your models: a table, one row a model */
|
|
263
263
|
.mt-card { padding: 0; }
|
|
264
264
|
.mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
|
|
265
|
-
.mt-top .sorts { flex: 1; min-width: 0; color: var(--muted); font-size: 13px; }
|
|
265
|
+
.mt-top .sorts { flex: 1 1 320px; min-width: 0; color: var(--muted); font-size: 13px; } /* too narrow beside the buttons: a line of its own */
|
|
266
266
|
.mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--brand); background: var(--brand-soft); border-radius: 999px; padding: 1px 4px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
|
|
267
267
|
.mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
|
|
268
268
|
.mt-scroll { overflow-x: auto; }
|
|
@@ -930,36 +930,21 @@ for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown',
|
|
|
930
930
|
const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare // one collator: thousands of names sort fast
|
|
931
931
|
let modelQuery = ''
|
|
932
932
|
// the models under each provider: by name, or by what its list says they cost (cheapest first; no price: last)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
return 'name'
|
|
938
|
-
}
|
|
939
|
-
})()
|
|
940
|
-
$('m-sort').value = catSort
|
|
933
|
+
// The page's own choices (sorts, folds, the provider ranking), kept in ~/.heart (the browser's
|
|
934
|
+
// storage isn't: the page's address has a new port each time)
|
|
935
|
+
const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
|
|
936
|
+
let catSort = 'name'
|
|
941
937
|
$('m-sort').onchange = (e) => {
|
|
942
938
|
catSort = e.target.value
|
|
943
|
-
|
|
944
|
-
localStorage.setItem('heart.catSort', catSort)
|
|
945
|
-
} catch {}
|
|
939
|
+
savePref('catSort', catSort)
|
|
946
940
|
if (S) drawProviders(S)
|
|
947
941
|
}
|
|
948
942
|
const costOf = (d) => (d?.price ? (d.price.input ?? 0) + (d.price.output ?? 0) : Infinity)
|
|
949
|
-
// Providers folded on the page (
|
|
950
|
-
//
|
|
943
|
+
// Providers folded on the page (kept: savePref). Typing a search opens them all, until you fold or
|
|
944
|
+
// unfold one (or all) again.
|
|
951
945
|
let searchOpen = false
|
|
952
|
-
const FOLDED = new Set(
|
|
953
|
-
try {
|
|
954
|
-
return JSON.parse(localStorage.getItem('heart.folded') ?? '[]')
|
|
955
|
-
} catch {
|
|
956
|
-
return []
|
|
957
|
-
}
|
|
958
|
-
})())
|
|
946
|
+
const FOLDED = new Set()
|
|
959
947
|
function applyFolds() {
|
|
960
|
-
try {
|
|
961
|
-
localStorage.setItem('heart.folded', JSON.stringify([...FOLDED]))
|
|
962
|
-
} catch {}
|
|
963
948
|
for (const sec of $('p-list').querySelectorAll('section[data-provider]')) {
|
|
964
949
|
const folded = FOLDED.has(sec.dataset.provider) && !(searchOpen && modelQuery.trim())
|
|
965
950
|
sec.classList.toggle('collapsed', folded)
|
|
@@ -972,9 +957,10 @@ function settle() {
|
|
|
972
957
|
if (searchOpen && modelQuery.trim()) for (const sec of $('p-list').querySelectorAll('section[data-provider]')) FOLDED.delete(sec.dataset.provider)
|
|
973
958
|
searchOpen = false
|
|
974
959
|
}
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
$('
|
|
960
|
+
const folds = () => (applyFolds(), savePref('folded', [...FOLDED])) // a fold changed: shown and kept
|
|
961
|
+
const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), folds())
|
|
962
|
+
$('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), folds())
|
|
963
|
+
$('unfold-all').onclick = () => (settle(), FOLDED.clear(), folds())
|
|
978
964
|
// every model shown on the left (the search's matches, when searching) added to the end of your list
|
|
979
965
|
$('add-all').onclick = () => {
|
|
980
966
|
if (!S) return
|
|
@@ -1491,21 +1477,9 @@ const COLS = {
|
|
|
1491
1477
|
output: { label: 'Out $/M', how: 'cheap', get: (t) => t.price?.output ?? null },
|
|
1492
1478
|
works: { label: 'Works?', get: (t) => { const r = TRIES[keyOf(t)]; return !r || r.going ? 1 : r.ok ? 2 : 0 } }, // tested and working first; not tested; not working
|
|
1493
1479
|
}
|
|
1494
|
-
let SORT =
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
} catch {
|
|
1498
|
-
return []
|
|
1499
|
-
}
|
|
1500
|
-
})()
|
|
1501
|
-
// your ranking of the providers, best first, kept in this browser; a provider not ranked yet comes after, A to Z
|
|
1502
|
-
let RANK = (() => {
|
|
1503
|
-
try {
|
|
1504
|
-
return JSON.parse(localStorage.getItem('heart.rank') ?? '[]').filter((x) => typeof x === 'string')
|
|
1505
|
-
} catch {
|
|
1506
|
-
return []
|
|
1507
|
-
}
|
|
1508
|
-
})()
|
|
1480
|
+
let SORT = []
|
|
1481
|
+
// your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
|
|
1482
|
+
let RANK = []
|
|
1509
1483
|
const ranked = () => {
|
|
1510
1484
|
const names = [...new Set(S.order.map((t) => t.provider))]
|
|
1511
1485
|
return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
|
|
@@ -1530,18 +1504,14 @@ $('pr-cancel').onclick = () => $('pr').close()
|
|
|
1530
1504
|
$('pr-save').onclick = () => {
|
|
1531
1505
|
const list = [...$('pr-list').children].map((li) => li.textContent)
|
|
1532
1506
|
RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
|
|
1533
|
-
|
|
1534
|
-
localStorage.setItem('heart.rank', JSON.stringify(RANK))
|
|
1535
|
-
} catch {}
|
|
1507
|
+
savePref('rank', RANK)
|
|
1536
1508
|
$('pr').close()
|
|
1537
1509
|
setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
|
|
1538
1510
|
}
|
|
1539
1511
|
const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
|
|
1540
1512
|
function setSort(next) {
|
|
1541
1513
|
SORT = next
|
|
1542
|
-
|
|
1543
|
-
localStorage.setItem('heart.sort', JSON.stringify(SORT))
|
|
1544
|
-
} catch {}
|
|
1514
|
+
savePref('sort', SORT)
|
|
1545
1515
|
drawMine(S)
|
|
1546
1516
|
}
|
|
1547
1517
|
// a column's header: first click adds it (after those already sorted by), the second flips it, the third drops it
|
|
@@ -1594,13 +1564,10 @@ function drawMine(s) {
|
|
|
1594
1564
|
what === 'works' ? null : h('button', { type: 'button', className: 'again', textContent: '↻', title: `Test these ${n} again`, 'aria-label': `test the ${what} ones again`, disabled: testingAll, onclick: () => tryAll(models) }))
|
|
1595
1565
|
})
|
|
1596
1566
|
|
|
1597
|
-
const same = view.every((t, i) => t === order[i])
|
|
1598
1567
|
// the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
|
|
1599
1568
|
const keep = (list, moved) => {
|
|
1600
1569
|
SORT = []
|
|
1601
|
-
|
|
1602
|
-
localStorage.setItem('heart.sort', '[]')
|
|
1603
|
-
} catch {}
|
|
1570
|
+
savePref('sort', SORT)
|
|
1604
1571
|
return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1605
1572
|
}
|
|
1606
1573
|
// a row dragged (in the order shown, sorted or not)
|
|
@@ -1636,9 +1603,10 @@ function drawMine(s) {
|
|
|
1636
1603
|
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1637
1604
|
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1638
1605
|
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
1606
|
+
PICKED.size ? h('button', { className: 'small', textContent: 'Invert selection', title: 'tick the ones not ticked, untick the ones ticked', onclick: () => (view.forEach((t) => (PICKED.has(keyOf(t)) ? PICKED.delete(keyOf(t)) : PICKED.add(keyOf(t)))), (lastPicked = null), drawMine(S)) }) : null,
|
|
1639
1607
|
order.some((t) => t.provider !== order[0].provider) ? h('button', { className: 'small', textContent: 'Provider ranking', title: 'drag your providers into order; the Provider column sorts by it', onclick: openRanking }) : null,
|
|
1640
1608
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1641
|
-
|
|
1609
|
+
order.length > 1 ? h('button', { className: 'small primary', textContent: 'Save order', title: 'save the order shown as your model order (#1 first)', onclick: () => keep(view) }) : null,
|
|
1642
1610
|
order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
|
|
1643
1611
|
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
|
|
1644
1612
|
order.length
|
|
@@ -1992,9 +1960,20 @@ function drawFiles(s) {
|
|
|
1992
1960
|
}
|
|
1993
1961
|
|
|
1994
1962
|
function render(s) {
|
|
1963
|
+
if (s.prefs) {
|
|
1964
|
+
// the page's choices, as kept (also changed on another page: shown here too)
|
|
1965
|
+
const p = s.prefs
|
|
1966
|
+
catSort = p.catSort === 'price' ? 'price' : 'name'
|
|
1967
|
+
$('m-sort').value = catSort
|
|
1968
|
+
FOLDED.clear()
|
|
1969
|
+
for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
|
|
1970
|
+
SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
|
|
1971
|
+
RANK = Array.isArray(p.rank) ? p.rank.filter((x) => typeof x === 'string') : []
|
|
1972
|
+
}
|
|
1995
1973
|
S = s
|
|
1996
1974
|
for (const [key, r] of Object.entries(TRIES)) if (!r.going && placeOf(...key.split(/\/(.*)/s)) === undefined) delete TRIES[key] // off your list: its result too
|
|
1997
1975
|
drawProviders(s)
|
|
1976
|
+
applyFolds()
|
|
1998
1977
|
drawMine(s)
|
|
1999
1978
|
drawUsing(s)
|
|
2000
1979
|
drawGeneral(s)
|
|
@@ -2012,6 +1991,7 @@ if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link Heart
|
|
|
2012
1991
|
else {
|
|
2013
1992
|
job('state', { where: ['page'], what: 'Loading your settings', detail: 'providers, models, logging, prompts, MCP servers, skills' })
|
|
2014
1993
|
api('/api/state').then(render, (e) => toast(e.message, true)).finally(() => job('state'))
|
|
1994
|
+
|
|
2015
1995
|
.then(() => api('/api/tests', {}))
|
|
2016
1996
|
.then(({ results, run }) => (Object.assign(TRIES, results), showRun(run)), (e) => toast(`the tests so far: ${e.message}`, true)) // and one going
|
|
2017
1997
|
job('requests', { where: ['requests'], what: 'Loading the request log', detail: 'every request HeartCode logged' })
|
|
@@ -2021,6 +2001,15 @@ else {
|
|
|
2021
2001
|
if (token) {
|
|
2022
2002
|
const events = new EventSource(`/api/events?t=${token}`)
|
|
2023
2003
|
// a request logged: at the top of the list, nothing else redrawn
|
|
2004
|
+
// the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
|
|
2005
|
+
// when it closes (what's typed in it stays)
|
|
2006
|
+
let later = null
|
|
2007
|
+
events.addEventListener('state', (e) => {
|
|
2008
|
+
const s = JSON.parse(e.data)
|
|
2009
|
+
if (!document.querySelector('dialog[open]')) return render(s)
|
|
2010
|
+
later = s
|
|
2011
|
+
})
|
|
2012
|
+
for (const d of document.querySelectorAll('dialog')) d.addEventListener('close', () => later && !document.querySelector('dialog[open]') && (render(later), (later = null)))
|
|
2024
2013
|
events.addEventListener('request', (e) => {
|
|
2025
2014
|
const r = JSON.parse(e.data)
|
|
2026
2015
|
if (page() === 'logging') drawDisk() // the logs grew
|
package/src/settings.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import crypto from 'node:crypto'
|
|
10
10
|
import os from 'node:os'
|
|
11
|
-
import { HOME, saveConfig, readJson, writeJson, byName } from './config.js'
|
|
11
|
+
import { HOME, saveConfig, readJson, writeJson, byName, takeIn, whatOf } from './config.js'
|
|
12
12
|
import { DEFAULT_SERVERS } from './mcp.js'
|
|
13
13
|
import { sections, BUILTIN_PROMPTS } from './prompts.js'
|
|
14
14
|
import { Client } from './client.js'
|
|
@@ -102,6 +102,10 @@ const skillsDir = () => path.join(HOME, 'skills')
|
|
|
102
102
|
// what the provider said (names, sizes, prices; not what's known from models.dev or tests, which
|
|
103
103
|
// changes). Asked for again when its list is switched on or off, or the provider is changed.
|
|
104
104
|
const LISTS = path.join(HOME, 'model-lists.json')
|
|
105
|
+
// The page's own choices (sorts, folded providers, the provider ranking): kept here, not in the
|
|
106
|
+
// browser, whose storage is per address (a new port each time)
|
|
107
|
+
const PREFS = path.join(HOME, 'settings-page.json')
|
|
108
|
+
const PREF_KEYS = ['catSort', 'folded', 'sort', 'rank']
|
|
105
109
|
function forgetList(...names) {
|
|
106
110
|
const all = readJson(LISTS, {})
|
|
107
111
|
if (!names.some((n) => n in all)) return
|
|
@@ -342,11 +346,17 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
342
346
|
mcp: mcpServers(),
|
|
343
347
|
prompts: prompts(),
|
|
344
348
|
home: tilde(HOME),
|
|
349
|
+
prefs: readJson(PREFS, {}),
|
|
345
350
|
})
|
|
346
351
|
|
|
347
352
|
const routes = {
|
|
348
353
|
'GET /api/state': () => state(),
|
|
349
354
|
'GET /api/log-size': () => logSize(),
|
|
355
|
+
'POST /api/prefs': ({ key, value }) => {
|
|
356
|
+
if (!PREF_KEYS.includes(key)) throw new Error(`no such choice: ${key}`)
|
|
357
|
+
writeJson(PREFS, { ...readJson(PREFS, {}), [key]: value })
|
|
358
|
+
return {}
|
|
359
|
+
},
|
|
350
360
|
'POST /api/clear-logs': () => ({ gone: clearLogs(), size: logSize() }),
|
|
351
361
|
'POST /api/provider': async ({ name, baseUrl, apiKey, keepKey, was }) => {
|
|
352
362
|
name = safeName(name)
|
|
@@ -680,6 +690,29 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
680
690
|
},
|
|
681
691
|
}
|
|
682
692
|
|
|
693
|
+
// Settings changed by anyone else (another HeartCode or page on this folder, a hand): taken in, and
|
|
694
|
+
// every open page shown them at once (an event: the whole state, when it's different)
|
|
695
|
+
const SETTINGS_FILES = ['config.json', 'settings-page.json', 'mcp.json', 'hooks.json', 'keybindings.json', 'theme.json', 'format.json', 'HEARTCODE.md', 'prompts', 'skills']
|
|
696
|
+
let shown = ''
|
|
697
|
+
const pushState = () => {
|
|
698
|
+
for (const what of new Set(takeIn(cfg).map(whatOf))) changed(what)
|
|
699
|
+
const now = state()
|
|
700
|
+
const text = JSON.stringify(now)
|
|
701
|
+
if (text !== shown && listeners.size) emit('state', now)
|
|
702
|
+
shown = text
|
|
703
|
+
}
|
|
704
|
+
let pending = null
|
|
705
|
+
try {
|
|
706
|
+
const w = fs.watch(HOME, { recursive: true }, (_, f) => {
|
|
707
|
+
if (!SETTINGS_FILES.includes(String(f ?? '').split(path.sep)[0])) return
|
|
708
|
+
clearTimeout(pending)
|
|
709
|
+
pending = setTimeout(pushState, 150) // a file written in parts, several at once: one look
|
|
710
|
+
})
|
|
711
|
+
w.on('error', () => {})
|
|
712
|
+
w.unref()
|
|
713
|
+
} catch (e) {
|
|
714
|
+
log('web', `can't watch ${HOME}: ${e.message}`)
|
|
715
|
+
}
|
|
683
716
|
const server = http.createServer(async (req, res) => {
|
|
684
717
|
const send = (code, body, type = 'application/json') => {
|
|
685
718
|
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY', 'referrer-policy': 'no-referrer' })
|
|
@@ -705,6 +738,8 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
705
738
|
}
|
|
706
739
|
const route = routes[`${req.method} ${url.pathname}`]
|
|
707
740
|
if (!route) return send(404, { error: 'not found' })
|
|
741
|
+
// what another HeartCode (or a hand) changed meanwhile, first: shown, and changed from, not undone
|
|
742
|
+
for (const what of new Set(takeIn(cfg).map(whatOf))) changed(what)
|
|
708
743
|
let raw = ''
|
|
709
744
|
for await (const ch of req) {
|
|
710
745
|
raw += ch
|