@hncode/hncode 0.4.2 → 0.5.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.
Files changed (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
@@ -175,3 +175,115 @@ export function describeRewind(res) {
175
175
  if (res.failed.length) parts.push(`${res.failed.length} could not be restored`);
176
176
  return parts.join(', ');
177
177
  }
178
+
179
+ /**
180
+ * Every checkpoint on record, newest turn first, as one flat list.
181
+ *
182
+ * WHY THIS EXISTS BESIDE `rewind`
183
+ * -------------------------------
184
+ * `rewind(n)` is all-or-nothing across whole turns: to get one file back you must also roll
185
+ * back every other file those turns touched. That is the right default (a turn is a unit of
186
+ * intent) and the wrong tool for "I want the version of THIS file from before that one
187
+ * edit". The index already records exactly that — one entry per file per turn, with the
188
+ * content it had — so the list is a rearrangement of existing data, not new bookkeeping.
189
+ *
190
+ * @returns {Array<{turn:number, at:number, path:string, existed:boolean, backup:string,
191
+ * size:number|null, ageMs:number}>} turn is 1-based, counting from the oldest.
192
+ */
193
+ export function listCheckpoints(sessionId, opts = {}) {
194
+ const doc = readIndex(sessionId);
195
+ const only = opts.path ? path.resolve(opts.path) : null;
196
+ const out = [];
197
+ const now = Date.now();
198
+ for (let i = doc.turns.length - 1; i >= 0; i--) {
199
+ const turn = doc.turns[i];
200
+ for (const f of turn.files) {
201
+ if (only && f.path !== only) continue;
202
+ let size = null;
203
+ if (f.existed && f.backup) {
204
+ try { size = fs.statSync(f.backup).size; } catch { size = null; }
205
+ }
206
+ out.push({
207
+ // `index` is 1-based over the whole list, newest first — the number the panel
208
+ // prints and `restore <n>` accepts. Assigned here rather than derived from `turn`
209
+ // so a row keeps its identity even after another turn is recorded.
210
+ index: 0,
211
+ turn: i + 1,
212
+ at: turn.at,
213
+ path: f.path,
214
+ existed: f.existed !== false,
215
+ backup: f.backup || '',
216
+ size,
217
+ ageMs: Math.max(0, now - (turn.at || now)),
218
+ });
219
+ }
220
+ }
221
+ out.forEach((c, i) => { c.index = i + 1; });
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Restore ONE file from one checkpoint, leaving every other file and the turn index alone.
227
+ *
228
+ * The index is deliberately NOT truncated: this is a surgical restore, not a rewind, so the
229
+ * remaining checkpoints stay valid. A file that did not exist at that checkpoint is deleted,
230
+ * which is what "restore" means for a file the agent created.
231
+ *
232
+ * @returns {{ok: boolean, action?: 'restored'|'deleted', error?: string}}
233
+ */
234
+ export function restoreCheckpoint(sessionId, entry) {
235
+ const target = entry && entry.path;
236
+ if (!target) return { ok: false, error: 'no path on that checkpoint' };
237
+ try {
238
+ if (entry.existed === false) {
239
+ fs.rmSync(target, { force: true });
240
+ return { ok: true, action: 'deleted' };
241
+ }
242
+ if (!entry.backup) return { ok: false, error: 'that checkpoint has no content copy' };
243
+ // Read BEFORE creating the directory: a missing backup must not leave an empty
244
+ // directory behind where there was none.
245
+ const content = fs.readFileSync(entry.backup);
246
+ fs.mkdirSync(path.dirname(target), { recursive: true });
247
+ fs.writeFileSync(target, content);
248
+ return { ok: true, action: 'restored' };
249
+ } catch (e) {
250
+ return { ok: false, error: e && e.message ? e.message : String(e) };
251
+ }
252
+ }
253
+
254
+ /** A `+N -M` size delta for a checkpoint against the current file, or null when either side
255
+ * is unavailable. Cheap: it reads only the backup (the current file is stat'ed). */
256
+ export function checkpointDelta(entry) {
257
+ if (!entry || !entry.existed || !entry.backup) return null;
258
+ try {
259
+ const before = fs.readFileSync(entry.backup, 'utf8').split(/\r?\n/).length;
260
+ const after = fs.readFileSync(entry.path, 'utf8').split(/\r?\n/).length;
261
+ return { before, after, lines: after - before };
262
+ } catch { return null; }
263
+ }
264
+
265
+ /** Lines for the /checkpoints panel: one row per checkpoint, grouped visually by turn. */
266
+ export function describeCheckpoints(sessionId, workspace) {
267
+ const all = listCheckpoints(sessionId);
268
+ if (!all.length) {
269
+ return ['No checkpoints recorded for this session yet.', '',
270
+ 'A checkpoint is taken the first time the agent changes a file in a turn,',
271
+ 'so this fills in as you work.'];
272
+ }
273
+ const lines = [`Checkpoints (${all.length}) across ${new Set(all.map((c) => c.turn)).size} turn(s)`, ''];
274
+ let lastTurn = null;
275
+ for (const c of all) {
276
+ if (c.turn !== lastTurn) {
277
+ lastTurn = c.turn;
278
+ const when = c.at ? new Date(c.at).toISOString().slice(0, 16).replace('T', ' ') : '?';
279
+ lines.push(`turn ${c.turn} ${when}`);
280
+ }
281
+ const rel = workspace && String(c.path).startsWith(workspace)
282
+ ? path.relative(workspace, c.path).split(path.sep).join('/')
283
+ : c.path;
284
+ const what = c.existed ? `${c.size == null ? '?' : c.size} B` : 'created here';
285
+ lines.push(` ${String(c.index).padStart(3)} ${what.padEnd(12)} ${rel}`);
286
+ }
287
+ lines.push('', 'Restore one with /checkpoints restore <n>');
288
+ return lines;
289
+ }
@@ -0,0 +1,255 @@
1
+ // File tree browser — a navigable view of the workspace, opened with /files.
2
+ //
3
+ // WHY A TREE AND NOT A PICKER
4
+ // ---------------------------
5
+ // The picker is a flat, filtered list: good for "find me this one file" and bad for
6
+ // "what is in here". Reading a project's shape is a question the flat list cannot answer,
7
+ // because it has no directories — every path is a string, and the structure is only
8
+ // visible by reading the prefixes. This module renders the structure itself.
9
+ //
10
+ // WHY IT DOES NOT RE-IMPLEMENT THE WALK
11
+ // -------------------------------------
12
+ // `tools/ignore.js` already walks a tree honouring .gitignore/.hncodeignore, including
13
+ // negation patterns and subtree pruning, and it is what Grep and Glob use. A second walker
14
+ // here would drift: the browser would show files the tools refuse to search, which is
15
+ // worse than no browser. So this module takes a LIST OF PATHS and only arranges it.
16
+ //
17
+ // WHY THE DIRECTORIES ARE DERIVED, NOT WALKED
18
+ // -------------------------------------------
19
+ // `walkFiles` returns files. The directories in between are reconstructed from the paths,
20
+ // which means the tree can never disagree with the list it came from — and it costs one
21
+ // pass over a flat array rather than a second filesystem traversal.
22
+
23
+ import path from 'node:path';
24
+ import { C } from './colors.js';
25
+ import { visualWidth } from './term.js';
26
+
27
+ const ELLIPSIS = '\u2026';
28
+ const ESC = '\x1b';
29
+
30
+ function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
31
+ function col(text, c) { return c + String(text) + C.reset; }
32
+
33
+ /**
34
+ * Build a directory tree from a flat list of absolute paths.
35
+ *
36
+ * @param {string[]} files absolute paths, as `walkFiles` returns them
37
+ * @param {string} root the workspace root they are relative to
38
+ * @returns {{name:string, rel:string, dir:boolean, children:Array}} the root node
39
+ *
40
+ * Children are sorted DIRECTORIES FIRST, then names, which is what makes a tree readable:
41
+ * a mixed sort buries directories among files and the structure stops being visible.
42
+ */
43
+ export function buildTree(files, root) {
44
+ const base = path.resolve(root);
45
+ const rootNode = { name: path.basename(base) || base, rel: '', dir: true, children: [] };
46
+ // One map per directory level, keyed by relative path, so a directory is created once
47
+ // no matter how many files sit under it.
48
+ const dirs = new Map([['', rootNode]]);
49
+
50
+ function ensureDir(rel) {
51
+ if (dirs.has(rel)) return dirs.get(rel);
52
+ const parentRel = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
53
+ const parent = ensureDir(parentRel);
54
+ const node = { name: rel.slice(rel.lastIndexOf('/') + 1), rel, dir: true, children: [] };
55
+ parent.children.push(node);
56
+ dirs.set(rel, node);
57
+ return node;
58
+ }
59
+
60
+ for (const abs of files) {
61
+ let rel = path.relative(base, abs);
62
+ if (!rel || rel.startsWith('..')) continue; // outside the root: not ours to show
63
+ rel = rel.split(path.sep).join('/');
64
+ const parentRel = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
65
+ const parent = ensureDir(parentRel);
66
+ parent.children.push({
67
+ name: rel.slice(rel.lastIndexOf('/') + 1), rel, dir: false, children: [],
68
+ });
69
+ }
70
+ sortTree(rootNode);
71
+ return rootNode;
72
+ }
73
+
74
+ function sortTree(node) {
75
+ if (!node.children.length) return;
76
+ node.children.sort((a, b) => {
77
+ if (a.dir !== b.dir) return a.dir ? -1 : 1;
78
+ return a.name.localeCompare(b.name);
79
+ });
80
+ for (const c of node.children) if (c.dir) sortTree(c);
81
+ }
82
+
83
+ /**
84
+ * Flatten the tree to the rows the panel draws, honouring which directories are open.
85
+ *
86
+ * `expanded` is a Set of relative directory paths. It is the caller's state, not this
87
+ * module's: a browser that owned its own open/closed set could not be reopened where the
88
+ * user left it, and could not be driven by a test.
89
+ *
90
+ * @returns {Array<{node:object, depth:number, last:boolean, prefix:string}>}
91
+ * `prefix` carries the box-drawing gutter (`│ ` / ` `) built from the ancestors.
92
+ */
93
+ export function visibleRows(root, expanded, depth = 0, prefix = '', last = true, out = []) {
94
+ for (let i = 0; i < root.children.length; i++) {
95
+ const node = root.children[i];
96
+ const isLast = i === root.children.length - 1;
97
+ out.push({ node, depth, last: isLast, prefix });
98
+ if (node.dir && expanded.has(node.rel)) {
99
+ // The gutter for a child: a vertical bar while this node still has siblings below
100
+ // it, spaces once it does not — that is what makes the lines reach the right parent.
101
+ const next = prefix + (isLast ? ' ' : '\u2502 ');
102
+ visibleRows(node, expanded, depth + 1, next, isLast, out);
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Every directory path in the tree, so the caller can "expand all" without a walk of its own. */
109
+ export function allDirs(root, out = []) {
110
+ for (const c of root.children) {
111
+ if (c.dir) { out.push(c.rel); allDirs(c, out); }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** Count the files under a node, for the `(N)` after a directory name. */
117
+ export function countFiles(node) {
118
+ if (!node.dir) return 1;
119
+ let n = 0;
120
+ for (const c of node.children) n += countFiles(c);
121
+ return n;
122
+ }
123
+
124
+ /**
125
+ * Render the browser.
126
+ *
127
+ * @param {object} input
128
+ * @param {object} input.root from buildTree
129
+ * @param {Array} input.rows from visibleRows
130
+ * @param {number} input.sel selected index into `rows`
131
+ * @param {number} input.scroll first visible row
132
+ * @param {number} input.width
133
+ * @param {number} input.height rows available to the panel
134
+ * @param {string} [input.filter] substring the file list was narrowed by
135
+ * @param {string} [input.cwd]
136
+ * @returns {{ lines: string[], sel: number, scroll: number, hitRows: Array }}
137
+ */
138
+ export function renderTree(input) {
139
+ const {
140
+ root, rows, width, height, cwd = '',
141
+ } = input;
142
+ const filter = String(input.filter || '');
143
+ // The caller's width is RESPECTED, not raised to a minimum: a row wider than the frame
144
+ // wraps and corrupts every row under it, so a floor here would be a bug, not a courtesy.
145
+ const w = Math.max(1, width | 0);
146
+ // Header (2) + footer (1) come out of the budget; a body of at least one row.
147
+ const bodyH = Math.max(1, (height | 0) - 3);
148
+
149
+ let sel = Math.max(0, Math.min((input.sel | 0), Math.max(0, rows.length - 1)));
150
+ let scroll = Math.max(0, input.scroll | 0);
151
+ if (sel < scroll) scroll = sel;
152
+ if (sel >= scroll + bodyH) scroll = sel - bodyH + 1;
153
+
154
+ const total = root.children.length ? countFiles(root) : 0;
155
+ const title = filter
156
+ ? `Files matching "${filter}" — ${total} file(s)`
157
+ : `Files — ${total} file(s)`;
158
+ // Deliberately terse: the full phrasing ("↑↓ move · →/← open/close · Enter insert path ·
159
+ // Ctrl+O expand all · Esc close") is 71 columns and got clipped mid-word at 70.
160
+ const hint = '↑↓ move · →/← open · Enter insert · Ctrl+O all · Esc close';
161
+
162
+ const lines = [];
163
+ // The header and the hint are clamped to the panel like every other row. Unclamped, a
164
+ // narrow terminal pushed them past the width and the whole frame wrapped.
165
+ lines.push(col(truncate(' ' + title, w), C.white + C.bold));
166
+ lines.push(col(truncate(' ' + hint, w), C.gray));
167
+
168
+ const hitRows = [];
169
+ const shown = rows.slice(scroll, scroll + bodyH);
170
+ for (let i = 0; i < shown.length; i++) {
171
+ const { node, prefix } = shown[i];
172
+ const index = scroll + i;
173
+ const selected = index === sel;
174
+ const marker = node.dir ? (input.expandedSet && input.expandedSet.has(node.rel) ? '\u25be' : '\u25b8') : ' ';
175
+ const label = node.dir ? `${node.name}/` : node.name;
176
+ const lead = ` ${prefix}${marker} `;
177
+ const leadW = visWidth(lead);
178
+ // Budget for the name, AFTER the gutter and the marker. Measured in VISUAL columns,
179
+ // not characters: `slice` counts code units, so a name with a wide character or an
180
+ // escape in it would come out wider than asked and wrap the whole frame.
181
+ const suffixText = node.dir ? ` (${countFiles(node)})` : '';
182
+ const suffixW = visWidth(suffixText);
183
+ let room = Math.max(1, w - leadW - 1);
184
+ // The count is a NICER label than a longer name, so it is dropped only when keeping it
185
+ // would truncate the name to nothing.
186
+ const showSuffix = suffixW > 0 && room - suffixW >= 8;
187
+ if (showSuffix) room -= suffixW;
188
+ const cut = clipToWidth(label, room);
189
+ const text = lead + col(cut, selected ? C.hover : (node.dir ? C.cyan : C.fg))
190
+ + (showSuffix ? col(suffixText, C.gray) : '');
191
+ // A selected row is painted full-width so the highlight is a band, not a word.
192
+ lines.push(selected ? padRight(text, w) : text);
193
+ if (selected) {
194
+ // The caller needs the row index to map a click back to this node.
195
+ hitRows.push(index);
196
+ }
197
+ }
198
+ // Pad the body out so the panel is a solid block rather than a ragged one.
199
+ // Pad the body out so the panel is a solid block rather than a ragged one.
200
+ while (lines.length < bodyH + 2) lines.push(' '.repeat(w));
201
+
202
+ const pos = rows.length ? `${sel + 1}/${rows.length}` : '0/0';
203
+ // A row is `{ node, depth, prefix }`, NOT the node itself — reading `.rel` off the row
204
+ // silently produced an empty path, so the footer showed only the position.
205
+ const cur = rows[sel];
206
+ const rel = cur && cur.node ? cur.node.rel : '';
207
+ const footer = ` ${pos} ${rel}`;
208
+ lines.push(col(truncate(footer, w), C.gray));
209
+
210
+ return { lines, sel, scroll, hitRows };
211
+ }
212
+
213
+ function truncate(s, w) {
214
+ const str = String(s);
215
+ if (visWidth(str) <= w) return str;
216
+ return clipToWidth(str, w);
217
+ }
218
+
219
+ /**
220
+ * Clip a string to at most `w` VISUAL columns, ending with an ellipsis when it was cut.
221
+ *
222
+ * `slice` cannot do this: it counts UTF-16 code units, so a name with a wide character in
223
+ * it comes out wider than the budget and wraps the row it was supposed to fit.
224
+ */
225
+ function clipToWidth(s, w) {
226
+ const str = String(s);
227
+ if (w <= 0) return '';
228
+ if (visWidth(str) <= w) return str;
229
+ let out = '';
230
+ let n = 0;
231
+ for (const ch of str) {
232
+ const cw = visWidth(ch);
233
+ if (n + cw > w - 1) break;
234
+ n += cw;
235
+ out += ch;
236
+ }
237
+ return out + ELLIPSIS;
238
+ }
239
+
240
+ function padRight(s, w) {
241
+ const have = visWidth(s);
242
+ return have >= w ? s : s + ' '.repeat(w - have);
243
+ }
244
+
245
+ /**
246
+ * The next selection when moving by `delta`, skipping nothing.
247
+ *
248
+ * Kept as a function rather than inline arithmetic so the wrap-around rule is stated once:
249
+ * the list WRAPS, because a browser that dead-ends at the bottom makes the arrow keys feel
250
+ * broken on a short list.
251
+ */
252
+ export function moveSel(sel, delta, count) {
253
+ if (!count) return 0;
254
+ return ((sel + delta) % count + count) % count;
255
+ }
@@ -0,0 +1,56 @@
1
+ // Monotonic terminal-flush counter.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // "I changed the state" and "the terminal has that change" are different facts, and
6
+ // this TUI has code that conflates them.
7
+ //
8
+ // State changes reach the screen through `renderSoon()`, which only SCHEDULES a paint
9
+ // (`setImmediate`). Several places then read the screen's geometry back as if the paint
10
+ // had already happened: the mouse handlers map a click's row to a transcript line
11
+ // through `state._bodyScreenTop`, the transcript renderer anchors the scroll position to
12
+ // `state._anchorPin`, and a resize defers its repair to the next frame. Every one of
13
+ // those is computed by `composeFrame` — the same call the pending paint will make, with
14
+ // the same state, so the values agree. The fragile part is not the arithmetic, it is the
15
+ // ORDERING: any of them can run in the window between the state change and the paint,
16
+ // and if the paint is then superseded (a resize, a re-entrant render) the value that was
17
+ // acted upon belongs to a frame that never reached the terminal.
18
+ //
19
+ // A counter makes "a frame has been written" observable, which is what lets a caller
20
+ // wait for it instead of assuming it. Deliberately a plain module-level integer with no
21
+ // allocation and no subscriber list: it is read a handful of times per frame, not
22
+ // subscribed to.
23
+
24
+ let flushTick = 0;
25
+
26
+ /**
27
+ * Record one completed terminal frame write. Called from the single place every screen
28
+ * change passes through, so "the tick advanced" means bytes left this process.
29
+ *
30
+ * Advanced even when a frame produced NO output (`diffFrame` returned an empty string
31
+ * when nothing changed): a frame that was correctly painted by writing nothing is just
32
+ * as painted, and gating on the tick would then wait for a change that will never come.
33
+ */
34
+ export function noteTerminalFlush() {
35
+ flushTick++;
36
+ return flushTick;
37
+ }
38
+
39
+ /** The current tick. Monotonic for the life of the process; never resets. */
40
+ export function getTerminalFlushTick() {
41
+ return flushTick;
42
+ }
43
+
44
+ /**
45
+ * True once a frame has been written SINCE the given tick was taken.
46
+ *
47
+ * The usage that matters is "I am about to change state that is only observable after a
48
+ * paint; if a paint already happened since I looked, the value I would read is current,
49
+ * and if not, it is stale." Callers capture `getTerminalFlushTick()` when they widen a
50
+ * value (open a panel, start a drag) and use this to decide whether to re-read it.
51
+ *
52
+ * @param {number} since a tick from `getTerminalFlushTick`.
53
+ */
54
+ export function frameWrittenSince(since) {
55
+ return flushTick !== since;
56
+ }
package/src/fork.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A message that belongs to ONE conversation.
3
+ *
4
+ * `/fork` used `messages.slice()`, which copies the array but not the messages in it, so
5
+ * the fork and its parent shared every message object. Nothing went wrong until the
6
+ * fork edited one — then the parent changed too, in a session the user had already
7
+ * walked away from. A shallow copy of the message is the minimum; the nested fields are
8
+ * copied as well because a turn writes into them (`toolCalls`, a growing `content`
9
+ * string on a streamed assistant message, the `allLines` of a plugin log).
10
+ *
11
+ * The underscore-prefixed bookkeeping fields are NOT copied: they describe the parent’s
12
+ * render state, and carrying them over would make the fork display rows the new
13
+ * conversation has not produced.
14
+ */
15
+ export function cloneMessageForFork(m) {
16
+ if (!m || typeof m !== 'object') return m;
17
+ const copy = {};
18
+ for (const k of Object.keys(m)) {
19
+ // Render bookkeeping: the fork has its own screen and its own scroll.
20
+ if (k === '_conv' || k === '_mdCache' || k === '_wrapped' || k === '_prevWrap') continue;
21
+ const v = m[k];
22
+ if (Array.isArray(v)) copy[k] = v.map((e) => (e && typeof e === 'object' ? { ...e } : e));
23
+ else if (v && typeof v === 'object') copy[k] = { ...v };
24
+ else copy[k] = v;
25
+ }
26
+ return copy;
27
+ }
28
+ // The command that uses it: `/fork`. Kept beside the clone so the two cannot drift.
29
+ //
30
+ export function forkOf(session, newId, now = Date.now()) {
31
+ const forked = {
32
+ ...session,
33
+ id: newId(),
34
+ title: `${session.title || 'untitled'} (fork)`,
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ messages: (session.messages || []).map(cloneMessageForFork),
38
+ forkedFrom: session.forkedFrom || session.id || null,
39
+ };
40
+ // The turn counters belong to the conversation, not to the copy.
41
+ delete forked.rounds;
42
+ delete forked.steps;
43
+ return forked;
44
+ }
package/src/fx.js ADDED
@@ -0,0 +1,148 @@
1
+ // Currency conversion for the cost readout, so /cost can report in CNY as well
2
+ // as USD. Prices from models.dev are USD per 1M tokens; a Chinese user wants to
3
+ // see what that came to in ¥, and doing that by hand every time is the reason
4
+ // nobody looks at the number.
5
+ //
6
+ // Three ways to get the rate, in order:
7
+ // 1. an explicit override — `cny_per_usd` in config.toml, or HNCODE_CNY_PER_USD
8
+ // in the environment. Checked first and used with NO network call, so a
9
+ // machine without internet still reports a correct ¥ figure.
10
+ // 2. a live fetch from a keyless reference-rate API, memoized for HOURS: the
11
+ // rate moves a fraction of a percent a day, and this only feeds a readout.
12
+ // 3. nothing. The caller then says the rate is unavailable rather than
13
+ // converting with a guessed 7.0, which would be wrong in a way the user
14
+ // cannot see.
15
+ //
16
+ // Endpoints are tried in order because neither is guaranteed: frankfurter
17
+ // republishes ECB reference rates (stable, but ECB-only currencies), and
18
+ // open.er-api.com covers more currencies without a key.
19
+
20
+ const TTL_MS = 6 * 60 * 60 * 1000;
21
+
22
+ // currency -> { rate, source, at }. A plain Map, so a test can inspect or clear it.
23
+ const cache = new Map();
24
+
25
+ /** Currencies /cost accepts. USD is the native one (models.dev prices in it). */
26
+ export const SUPPORTED_CURRENCIES = ['USD', 'CNY'];
27
+
28
+ /** `'cny'`/`'¥'` -> `'CNY'`; returns '' when unrecognised. */
29
+ export function normalizeCurrency(value) {
30
+ const s = String(value == null ? '' : value).trim().toUpperCase();
31
+ if (!s) return '';
32
+ if (s === '¥' || s === '¥' || s === 'RMB' || s === 'YUAN') return 'CNY';
33
+ if (s === '$' || s === 'USD' || s === 'US$') return 'USD';
34
+ return SUPPORTED_CURRENCIES.includes(s) ? s : '';
35
+ }
36
+
37
+ /**
38
+ * Pull the USD->`currency` rate out of either API's response shape:
39
+ * frankfurter { amount: 1, base: 'USD', rates: { CNY: 6.70 } }
40
+ * open-er-api { result: 'success', base_code: 'USD', conversion_rates: { CNY: 7.12 } }
41
+ * Returns a positive number, or null when the payload has no usable rate.
42
+ */
43
+ export function parseRate(json, currency = 'CNY') {
44
+ if (!json || typeof json !== 'object') return null;
45
+ const key = normalizeCurrency(currency) || 'CNY';
46
+ const table = json.rates || json.conversion_rates;
47
+ if (!table || typeof table !== 'object') return null;
48
+ const raw = table[key];
49
+ const n = Number(raw);
50
+ return Number.isFinite(n) && n > 0 ? n : null;
51
+ }
52
+
53
+ /** USD -> `currency` at `rate`. Null when either input is unusable. */
54
+ export function convert(usd, currency, rate) {
55
+ const amount = Number(usd);
56
+ if (!Number.isFinite(amount)) return null;
57
+ const cur = normalizeCurrency(currency) || 'USD';
58
+ if (cur === 'USD') return amount; // no rate needed
59
+ const r = Number(rate);
60
+ if (!Number.isFinite(r) || r <= 0) return null;
61
+ return amount * r;
62
+ }
63
+
64
+ /** The `<CUR>_PER_USD` env override, or null. Both the short and prefixed names. */
65
+ export function envRate(currency) {
66
+ const cur = normalizeCurrency(currency);
67
+ if (!cur || cur === 'USD') return null;
68
+ for (const key of [`HNCODE_${cur}_PER_USD`, `HNCODE_${cur}_RATE`]) {
69
+ const n = Number(process.env[key]);
70
+ if (Number.isFinite(n) && n > 0) return { rate: n, source: key };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const ENDPOINTS = [
76
+ { source: 'frankfurter (ECB)', url: (cur) => `https://api.frankfurter.app/latest?from=USD&to=${cur}` },
77
+ { source: 'open.er-api.com', url: () => 'https://open.er-api.com/v6/latest/USD' },
78
+ ];
79
+
80
+ /**
81
+ * USD -> `currency` rate.
82
+ *
83
+ * `opts.override` is a user-set rate (config.toml); it wins over the network.
84
+ * `opts.timeoutMs` bounds each request. Returns
85
+ * `{ rate, source, at }`, or null when nothing could supply one.
86
+ */
87
+ export async function usdRate(currency, opts = {}) {
88
+ const cur = normalizeCurrency(currency);
89
+ if (!cur || cur === 'USD') return { rate: 1, source: 'native', at: Date.now() };
90
+
91
+ const override = Number(opts.override);
92
+ if (Number.isFinite(override) && override > 0) return { rate: override, source: 'config', at: Date.now() };
93
+ const fromEnv = envRate(cur);
94
+ if (fromEnv) return { ...fromEnv, at: Date.now() };
95
+
96
+ const hit = cache.get(cur);
97
+ if (hit && Date.now() - hit.at < TTL_MS) return hit;
98
+
99
+ for (const ep of ENDPOINTS) {
100
+ try {
101
+ const ctrl = new AbortController();
102
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs || 8000);
103
+ let json;
104
+ try {
105
+ const res = await fetch(ep.url(cur), { signal: ctrl.signal, headers: { accept: 'application/json' } });
106
+ if (!res.ok) continue;
107
+ json = await res.json();
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ const rate = parseRate(json, cur);
112
+ if (rate) {
113
+ const entry = { rate, source: ep.source, at: Date.now() };
114
+ cache.set(cur, entry);
115
+ return entry;
116
+ }
117
+ } catch { /* offline or blocked: try the next endpoint */ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * The rate WITHOUT touching the network: an explicit override, the environment,
124
+ * or a still-fresh memoized value. Returns null when the answer would require a
125
+ * request.
126
+ *
127
+ * This exists because the status row is drawn from a SYNCHRONOUS render function,
128
+ * which cannot await a fetch. The rate therefore has to be warmed ahead of time
129
+ * (see the TUI's startup and /cost) and read here; a missing rate means the row
130
+ * falls back to USD rather than blocking or showing a converted guess.
131
+ */
132
+ export function syncRate(currency, opts = {}) {
133
+ const cur = normalizeCurrency(currency);
134
+ if (!cur) return null;
135
+ if (cur === 'USD') return { rate: 1, source: 'native', at: Date.now() };
136
+ const override = Number(opts.override);
137
+ if (Number.isFinite(override) && override > 0) return { rate: override, source: 'config', at: Date.now() };
138
+ const fromEnv = envRate(cur);
139
+ if (fromEnv) return { ...fromEnv, at: Date.now() };
140
+ const hit = cache.get(cur);
141
+ return (hit && Date.now() - hit.at < TTL_MS) ? hit : null;
142
+ }
143
+
144
+
145
+ /** Drop the memoized rates (tests, and a manual refresh). */
146
+ export function clearRateCache() { cache.clear(); }
147
+
148
+ export default { usdRate, syncRate, convert, parseRate, normalizeCurrency, envRate, clearRateCache, SUPPORTED_CURRENCIES };