@hncode/hncode 0.4.1 → 0.5.0
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/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The segmented context bar: used tokens by content type, then the remainder as one free
|
|
2
|
+
// segment whose right edge carries the usage readout.
|
|
3
|
+
//
|
|
4
|
+
// WHY A BAR AND NOT A NUMBER
|
|
5
|
+
// -------------------------
|
|
6
|
+
// `context: 12% (1.2k/97.7k)` says how full the window is and nothing about what filled
|
|
7
|
+
// it. The bar answers the question people actually have — "why is this getting full" — by
|
|
8
|
+
// making the composition readable: a session that grew from tool output looks different
|
|
9
|
+
// from one that grew from long answers, without reading a single figure.
|
|
10
|
+
//
|
|
11
|
+
// THE SHAPE OF THE ALLOCATION
|
|
12
|
+
// --------------------------
|
|
13
|
+
// Two passes, because plain rounding is wrong in both directions:
|
|
14
|
+
//
|
|
15
|
+
// 1. Every non-zero segment is guaranteed ONE column. Without this, a segment holding
|
|
16
|
+
// 0.3% of the window rounds to zero columns and disappears — and "thinking is a small
|
|
17
|
+
// share" is exactly the fact a user is looking for.
|
|
18
|
+
// 2. The remaining columns are shared proportionally by LARGEST REMAINDER. Rounding each
|
|
19
|
+
// share independently gives a total that is off by one or two, so the bar either falls
|
|
20
|
+
// short of the row or overruns it.
|
|
21
|
+
//
|
|
22
|
+
// THE THREE-STEP LABEL
|
|
23
|
+
// -------------------
|
|
24
|
+
// Each segment says as much about itself as fits, because the segments are often only a
|
|
25
|
+
// few columns wide and there are three different things it could say:
|
|
26
|
+
//
|
|
27
|
+
// 1. `name + value` when the segment is wide enough for both — `ast 4.4k`
|
|
28
|
+
// 2. the name alone when only that fits — `ast`
|
|
29
|
+
// 3. nothing at all when not even the abbreviation does
|
|
30
|
+
//
|
|
31
|
+
// Steps 2 and 3 are not dead ends: every segment's columns are returned as a hit span, so
|
|
32
|
+
// pointing at one prints its full name and value in the row below the bar. That is why step
|
|
33
|
+
// 3 is acceptable — the information stays one pointer away.
|
|
34
|
+
|
|
35
|
+
import { C } from './colors.js';
|
|
36
|
+
|
|
37
|
+
/** The segments, in the order they are drawn. Order is part of the reading: the session
|
|
38
|
+
* grows from left to right in the same sequence the context does. */
|
|
39
|
+
export const SEGMENTS = [
|
|
40
|
+
{ key: 'system', name: 'system prompt', fill: C.ctxSystem },
|
|
41
|
+
{ key: 'prompt', name: 'user messages', fill: C.ctxPrompt },
|
|
42
|
+
{ key: 'assistant', name: 'assistant', fill: C.ctxAssistant },
|
|
43
|
+
{ key: 'thinking', name: 'thinking', fill: C.ctxThinking },
|
|
44
|
+
{ key: 'tools', name: 'tool results', fill: C.ctxTools },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// `labelFor` closes over `compactTokens`, which is declared below — hoisted function
|
|
48
|
+
// declarations make that safe, and keeping the forms next to the metadata is what stops the
|
|
49
|
+
// on-bar label and the hover caption from drifting apart.
|
|
50
|
+
//
|
|
51
|
+
// There is no abbreviation: `ast` says nothing to somebody who does not already know what
|
|
52
|
+
// the bar is, and a full word that does not fit is handled by DROPPING it, not by
|
|
53
|
+
// shortening it into a private code. The three-step degradation in `paintBar` is what makes
|
|
54
|
+
// that affordable — a wide segment gets `assistant 4.4k`, a narrow one gets `assistant`, and
|
|
55
|
+
// a very narrow one gets nothing and relies on the hover caption instead.
|
|
56
|
+
for (const seg of SEGMENTS) {
|
|
57
|
+
seg.labelFor = (tokens) => {
|
|
58
|
+
const v = compactTokens(tokens);
|
|
59
|
+
return { name: seg.name, full: `${seg.name} ${v}`, value: v, title: seg.name };
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** The caption a hovered segment produces: `assistant 4.4k`. */
|
|
63
|
+
/** The caption a hovered segment produces: `assistant 4.4k`. */
|
|
64
|
+
export function segmentCaption(key, tokens) {
|
|
65
|
+
const seg = SEGMENTS.find((s) => s.key === key);
|
|
66
|
+
if (!seg) return '';
|
|
67
|
+
return `${seg.name} ${compactTokens(tokens)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Give every non-zero segment at least one column, then share the rest proportionally.
|
|
72
|
+
*
|
|
73
|
+
* @param {readonly number[]} values one per segment, plus the free segment last.
|
|
74
|
+
* @param {number} width columns available.
|
|
75
|
+
* @returns {number[]} columns per entry, summing to at most `width`.
|
|
76
|
+
*/
|
|
77
|
+
export function allocateBarColumns(values, width) {
|
|
78
|
+
const used = SEGMENTS
|
|
79
|
+
.map((_, i) => i)
|
|
80
|
+
.filter((i) => (values[i] || 0) > 0);
|
|
81
|
+
if (used.length === 0 || used.length >= width) return shareLargestRemainder(values, width);
|
|
82
|
+
|
|
83
|
+
const minimum = values.map(() => 0);
|
|
84
|
+
for (const i of used) minimum[i] = 1;
|
|
85
|
+
const rest = shareLargestRemainder(values, width - used.length);
|
|
86
|
+
return minimum.map((m, i) => m + (rest[i] || 0));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Proportional split by LARGEST REMAINDER: floor each share, then hand the columns left
|
|
91
|
+
* over to the biggest fractional parts, largest first. This is what makes the parts sum to
|
|
92
|
+
* exactly `width`; rounding each independently does not.
|
|
93
|
+
*/
|
|
94
|
+
function shareLargestRemainder(values, width) {
|
|
95
|
+
const total = values.reduce((a, b) => a + (b || 0), 0);
|
|
96
|
+
if (!(total > 0) || !(width > 0)) return values.map(() => 0);
|
|
97
|
+
|
|
98
|
+
const exact = values.map((v) => ((v || 0) / total) * width);
|
|
99
|
+
const floors = exact.map((x) => Math.floor(x));
|
|
100
|
+
let left = width - floors.reduce((a, b) => a + b, 0);
|
|
101
|
+
|
|
102
|
+
const order = exact
|
|
103
|
+
.map((x, i) => ({ i, frac: x - Math.floor(x) }))
|
|
104
|
+
.sort((a, b) => b.frac - a.frac || a.i - b.i); // stable on index for equal fractions
|
|
105
|
+
for (let k = 0; left > 0; k++, left--) floors[order[k % order.length].i]++;
|
|
106
|
+
return floors;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The bar and its readout, filling exactly `width` columns together.
|
|
111
|
+
*
|
|
112
|
+
* THE READOUT GETS ITS SPACE FIRST, AND THE BAR IS WHAT GIVES UP. Laying it out the other
|
|
113
|
+
* way round — bar across the full row, readout painted on top of the free segment — means
|
|
114
|
+
* the readout disappears the moment the window fills, because a full window has no free
|
|
115
|
+
* segment at all. That is the one moment the figure matters most, and it was the one moment
|
|
116
|
+
* it could not be seen.
|
|
117
|
+
*
|
|
118
|
+
* So the row is split: the readout claims the columns it needs (one gap before it, so the
|
|
119
|
+
* figures do not read as part of a segment), and the bar takes whatever is left. The bar can
|
|
120
|
+
* therefore never reach the readout, at any fill level, and the readout is never truncated
|
|
121
|
+
* into nonsense like `11.7k/97`.
|
|
122
|
+
*
|
|
123
|
+
* @param {Record<string, number>} segments tokens per content type.
|
|
124
|
+
* @param {number} usedTokens total used, driving the readout.
|
|
125
|
+
* @param {number} window the context window, or 0 when unknown.
|
|
126
|
+
* @param {number} width columns to fill.
|
|
127
|
+
* @param {string} [readout] the caller's own wording, e.g. `context: 12% (11.7k/97.7k)`.
|
|
128
|
+
* Preferred over the derived one because it is what the status line already said; when the
|
|
129
|
+
* row is too narrow for it, the derived form takes over, and failing that just the
|
|
130
|
+
* percentage — a readout that is always SOMETHING beats one that is sometimes absent.
|
|
131
|
+
* @returns {{ text: string, hits: Array<{col0: number, col1: number, data: string}> }}
|
|
132
|
+
* `text` occupies exactly `width` columns; `hits` cover the bar's segments only.
|
|
133
|
+
*/
|
|
134
|
+
export function renderContextBar(segments, usedTokens, window, width, readout) {
|
|
135
|
+
if (!(width > 0) || !(window > 0)) return { text: '', hits: [] };
|
|
136
|
+
// Widest form first, then the derived ones, so the row always carries SOMETHING. A
|
|
137
|
+
// half-printed `11.7k/97` is worse than a bare percentage, so each step is a complete
|
|
138
|
+
// alternative rather than a truncation.
|
|
139
|
+
const pct = `${((usedTokens / window) * 100).toFixed(1)}%`;
|
|
140
|
+
const figures = `${compactTokens(usedTokens)}/${compactTokens(window)}`;
|
|
141
|
+
const own = typeof readout === 'string' ? readout : '';
|
|
142
|
+
const forms = [...new Set([own, `${figures} ${pct}`, pct].filter(Boolean))];
|
|
143
|
+
const text = forms.find((f) => f.length + 1 <= width) || '';
|
|
144
|
+
const barWidth = Math.max(0, width - text.length - 1);
|
|
145
|
+
|
|
146
|
+
if (barWidth <= 0) {
|
|
147
|
+
// Too narrow for a bar AND a gap: the readout has the row to itself. Padded to exactly
|
|
148
|
+
// `width` because every row of the frame is, and a short one shifts everything below it.
|
|
149
|
+
return { text: text ? C.white + text.padEnd(width) : ' '.repeat(width), hits: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const free = Math.max(0, window - usedTokens);
|
|
153
|
+
const values = [...SEGMENTS.map((s) => segments[s.key] || 0), free];
|
|
154
|
+
const cols = allocateBarColumns(values, barWidth);
|
|
155
|
+
return paintBar(cols, cols[cols.length - 1] || 0, '', values, text, width);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Paint the allocated columns, then the readout.
|
|
160
|
+
*
|
|
161
|
+
* Each segment carries as much of its own label as fits, measured against the segment's OWN
|
|
162
|
+
* width, so a label can never spill into its neighbour and shift every segment to the right
|
|
163
|
+
* of it.
|
|
164
|
+
*
|
|
165
|
+
* @param {number[]} cols columns per entry, the last being the free segment.
|
|
166
|
+
* @param {number} freeCols the free segment's width, already included in `cols`.
|
|
167
|
+
* @param {string} _unused kept for call-shape stability; the readout no longer lives inside
|
|
168
|
+
* the free segment, so nothing is painted into it here.
|
|
169
|
+
* @param {number[]} values tokens per segment, driving each label.
|
|
170
|
+
* @param {string} readout the text set after the bar, on the page background rather than on
|
|
171
|
+
* any fill — so it is legible at every fill level, including a completely full bar.
|
|
172
|
+
* @param {number} width the whole row, bar plus readout.
|
|
173
|
+
*/
|
|
174
|
+
function paintBar(cols, freeCols, _unused, values, readout, width) {
|
|
175
|
+
let out = '';
|
|
176
|
+
let at = 0; // visible column the next segment starts at
|
|
177
|
+
const hits = [];
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < SEGMENTS.length; i++) {
|
|
180
|
+
const n = cols[i] || 0;
|
|
181
|
+
if (!n) continue;
|
|
182
|
+
const seg = SEGMENTS[i];
|
|
183
|
+
const label = seg.labelFor(values[i]);
|
|
184
|
+
// One blank column on EACH side of the label. The right one keeps two adjacent short
|
|
185
|
+
// labels from running together (`thinkingtool results`) and making the boundary between
|
|
186
|
+
// them invisible; the left one stops the text from sitting hard against the edge of its
|
|
187
|
+
// own fill, which reads as if the label belonged to the segment before it. The first
|
|
188
|
+
// segment's left column is the row's own margin, so both cost the same.
|
|
189
|
+
const room = n - 2;
|
|
190
|
+
const inner = room >= label.full.length
|
|
191
|
+
? label.full
|
|
192
|
+
: (room >= label.name.length ? label.name : '');
|
|
193
|
+
// `room` columns of label, a blank on the left and one on the right: `n`.
|
|
194
|
+
out += seg.fill + (inner ? ' ' + C.white + inner.padEnd(room, ' ') + ' ' : ' '.repeat(n));
|
|
195
|
+
hits.push({ col0: at, col1: at + n - 1, data: seg.key });
|
|
196
|
+
at += n;
|
|
197
|
+
}
|
|
198
|
+
// The free segment needs SPACES, not just its fill colour: a background colour on its own
|
|
199
|
+
// paints nothing, so the "how much room is left" band vanished the moment the readout moved
|
|
200
|
+
// out of it. It was only ever visible because the readout used to sit on those cells.
|
|
201
|
+
if (freeCols) { out += C.ctxFree + ' '.repeat(freeCols); at += freeCols; }
|
|
202
|
+
|
|
203
|
+
// The readout goes AFTER the bar, on the page background, in the columns the caller
|
|
204
|
+
// reserved for it. `C.reset` first: the last thing the bar emitted was a fill, and a
|
|
205
|
+
// background colour behind the figures would be a band nobody asked for.
|
|
206
|
+
//
|
|
207
|
+
// `at` is the bar's visible width and the row is `width` wide, so the gap is whatever is
|
|
208
|
+
// left minus the figures — at least one column, which is what keeps `11.7k/97` from reading
|
|
209
|
+
// as a continuation of a segment's fill.
|
|
210
|
+
const gap = Math.max(1, width - at - readout.length);
|
|
211
|
+
out += C.reset + ' '.repeat(gap) + C.white + readout;
|
|
212
|
+
|
|
213
|
+
return { text: out + C.reset, hits };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The right-hand readout, stepped down as its segment narrows: the full form, then the
|
|
218
|
+
* percentage alone, then nothing. A half-printed `11.7k/97` is worse than no number.
|
|
219
|
+
*/
|
|
220
|
+
export function formatReadout(used, window, freeCols) {
|
|
221
|
+
const pct = `${((used / window) * 100).toFixed(1)}%`;
|
|
222
|
+
const full = `${compactTokens(used)}/${compactTokens(window)} ${pct}`;
|
|
223
|
+
// The thresholds match the renderer's own condition exactly (`freeCols >= length`). They
|
|
224
|
+
// used to ask for one more, which is how the bar came up a column short: the readout was
|
|
225
|
+
// accepted HERE and then not written THERE.
|
|
226
|
+
if (freeCols >= full.length) return full;
|
|
227
|
+
if (freeCols >= pct.length) return pct;
|
|
228
|
+
return '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 1200 -> `1.2k`, 97000 -> `97.0k`, 3_400_000 -> `3.4M`. */
|
|
232
|
+
export function compactTokens(n) {
|
|
233
|
+
const v = Number(n) || 0;
|
|
234
|
+
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
|
|
235
|
+
if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
|
|
236
|
+
return String(Math.round(v));
|
|
237
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// /doctor — report the environment this session is running in, and flag the things that
|
|
2
|
+
// break it. Modelled on Claude Code's and cline's `doctor` commands.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------
|
|
6
|
+
// Every failure this project has hit in practice was visible in the environment BEFORE
|
|
7
|
+
// it bit, and none of it was visible from the TUI:
|
|
8
|
+
//
|
|
9
|
+
// * a session file grew past the V8 string limit, so saving silently stopped working
|
|
10
|
+
// for an hour and a half (only a line in ~/.hncode/session-errors.log);
|
|
11
|
+
// * three intact conversations became unlistable because their transcript pushed the
|
|
12
|
+
// metadata key out of the head reader's reach;
|
|
13
|
+
// * an MCP server that never connected, so its tools simply were not there;
|
|
14
|
+
// * an API key that was absent or a base URL that was malformed, which surfaces as a
|
|
15
|
+
// provider error on the first real turn and looks like a model problem.
|
|
16
|
+
//
|
|
17
|
+
// A diagnosis is a LIST OF CHECKS, each with a severity, so it can be tested: the checks
|
|
18
|
+
// that read the filesystem take their paths from this module's own resolution functions
|
|
19
|
+
// (the same ones the rest of the program uses), which is what makes a report trustworthy
|
|
20
|
+
// rather than a second, divergent opinion about where things live.
|
|
21
|
+
//
|
|
22
|
+
// READ-ONLY BY CONSTRUCTION. Nothing here writes, repairs or deletes: a diagnostic that
|
|
23
|
+
// "fixes" something is a diagnostic nobody can run on a machine they care about. The
|
|
24
|
+
// only outbound work is an optional reachability probe, which the caller opts into.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
/** Severities, worst first. `ok` is the only one that means "nothing to do". */
|
|
31
|
+
export const SEVERITY_ORDER = ['error', 'warn', 'ok', 'info'];
|
|
32
|
+
|
|
33
|
+
const MARK = { error: '✖', warn: '!', ok: '✔', info: '·' };
|
|
34
|
+
|
|
35
|
+
/** One check's outcome. `fix` is the actionable half — a finding with no remedy is noise. */
|
|
36
|
+
function check(name, severity, detail, fix) {
|
|
37
|
+
return { name, severity, detail: String(detail == null ? '' : detail), fix: fix || '' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- individual checks -----------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** The config file is where every provider and key lives; a malformed one is fatal. */
|
|
43
|
+
function checkConfig(cfg, files) {
|
|
44
|
+
const file = files.config;
|
|
45
|
+
if (!fs.existsSync(file)) {
|
|
46
|
+
return [check('config file', 'warn', `${file} does not exist`,
|
|
47
|
+
'Run hncode once to create it, or /model to pick a provider.')];
|
|
48
|
+
}
|
|
49
|
+
let text;
|
|
50
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
|
|
51
|
+
return [check('config file', 'error', `unreadable: ${e.message}`, `Check permissions on ${file}`)];
|
|
52
|
+
}
|
|
53
|
+
const out = [check('config file', 'ok', file)];
|
|
54
|
+
// A TOML file the parser rejects would have failed before this point, so the useful
|
|
55
|
+
// signal here is the SHAPE: a config with no provider cannot make a request.
|
|
56
|
+
if (!cfg.provider) {
|
|
57
|
+
out.push(check('provider', 'error', 'no provider configured',
|
|
58
|
+
'Run /model to choose one, or /provider add <name> <base-url>.'));
|
|
59
|
+
}
|
|
60
|
+
if (!cfg.model) {
|
|
61
|
+
out.push(check('model', 'error', 'no model selected', 'Run /model.'));
|
|
62
|
+
}
|
|
63
|
+
if (!cfg.apiKey) {
|
|
64
|
+
// A local endpoint (ollama, llama.cpp, lmstudio) legitimately has no key.
|
|
65
|
+
const local = /localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/.test(String(cfg.endpoint || ''));
|
|
66
|
+
out.push(local
|
|
67
|
+
? check('api key', 'info', 'none set, and the endpoint is local', '')
|
|
68
|
+
: check('api key', 'error', 'not set', 'Set it in config.toml, or via /provider.'));
|
|
69
|
+
}
|
|
70
|
+
if (cfg.endpoint && !/^https?:\/\//i.test(String(cfg.endpoint))) {
|
|
71
|
+
out.push(check('endpoint', 'error', `does not look like a URL: ${cfg.endpoint}`,
|
|
72
|
+
'It must start with http:// or https://.'));
|
|
73
|
+
}
|
|
74
|
+
if (!text.includes('[' ) && !text.includes('=')) {
|
|
75
|
+
out.push(check('config shape', 'warn', 'the file has no keys in it', `Open ${file} and check it.`));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The session directory. A store that cannot be listed is a store whose work is invisible. */
|
|
81
|
+
function checkSessions(files, sessionsDir, limit = 4) {
|
|
82
|
+
const out = [];
|
|
83
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
84
|
+
return [check('session store', 'warn', `${sessionsDir} does not exist yet`,
|
|
85
|
+
'It is created on the first save.')];
|
|
86
|
+
}
|
|
87
|
+
let names;
|
|
88
|
+
try {
|
|
89
|
+
names = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return [check('session store', 'error', `cannot list ${sessionsDir}: ${e.message}`,
|
|
92
|
+
'Check that the directory exists and is readable.')];
|
|
93
|
+
}
|
|
94
|
+
out.push(check('session store', 'ok', `${names.length} session file(s) in ${sessionsDir}`));
|
|
95
|
+
|
|
96
|
+
// Writability is the property that matters: a read-only store loses every turn while
|
|
97
|
+
// the TUI looks completely normal. Tested with a create-and-remove, not a mode bit,
|
|
98
|
+
// because the mode bit lies on Windows and on a mounted volume.
|
|
99
|
+
const probe = path.join(sessionsDir, `.doctor-${process.pid}.tmp`);
|
|
100
|
+
try {
|
|
101
|
+
fs.writeFileSync(probe, 'x');
|
|
102
|
+
fs.rmSync(probe, { force: true });
|
|
103
|
+
out.push(check('session store writable', 'ok', 'yes'));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
out.push(check('session store writable', 'error', `no: ${e.message}`,
|
|
106
|
+
'Every turn will fail to save. Fix permissions, or point HNCODE_SESSIONS_DIR somewhere writable.'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A file big enough to approach the V8 string limit. This is the failure this project
|
|
110
|
+
// actually hit: past ~512 MB `JSON.stringify` throws, and every save of that session
|
|
111
|
+
// then fails until the transcript is trimmed.
|
|
112
|
+
const V8_LIMIT = 512 * 1024 * 1024;
|
|
113
|
+
const big = [];
|
|
114
|
+
for (const f of names) {
|
|
115
|
+
try {
|
|
116
|
+
const size = fs.statSync(path.join(sessionsDir, f)).size;
|
|
117
|
+
if (size > 8 * 1024 * 1024) big.push({ f, size });
|
|
118
|
+
} catch { /* raced away */ }
|
|
119
|
+
}
|
|
120
|
+
big.sort((a, b) => b.size - a.size);
|
|
121
|
+
for (const b of big.slice(0, limit)) {
|
|
122
|
+
const mb = (b.size / 1048576).toFixed(1);
|
|
123
|
+
const sev = b.size > V8_LIMIT * 0.75 ? 'error' : 'warn';
|
|
124
|
+
out.push(check('oversized session', sev, `${mb} MB ${b.f}`,
|
|
125
|
+
b.size > V8_LIMIT * 0.75
|
|
126
|
+
? 'Approaching the ~512 MB string limit, past which saving FAILS. Trim the reasoning transcript — see tools/repair-transcripts.mjs.'
|
|
127
|
+
: 'Large; loading and saving this session will be slow.'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Leftover temp files from an interrupted save.
|
|
131
|
+
let tmp = [];
|
|
132
|
+
try { tmp = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.tmp')); } catch { /* listed above */ }
|
|
133
|
+
if (tmp.length) {
|
|
134
|
+
out.push(check('interrupted save', 'warn', `${tmp.length} .tmp file(s) left behind`,
|
|
135
|
+
`The write did not finish; the session itself is fine. Delete ${sessionsDir}\\*.tmp`));
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The error log. Its very existence usually means something failed silently. */
|
|
141
|
+
function checkErrorLog(files) {
|
|
142
|
+
if (!fs.existsSync(files.errorLog)) return [];
|
|
143
|
+
let text = '';
|
|
144
|
+
try { text = fs.readFileSync(files.errorLog, 'utf8'); } catch { return []; }
|
|
145
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
146
|
+
if (!lines.length) return [];
|
|
147
|
+
const tail = lines.slice(-3);
|
|
148
|
+
const sev = lines.length > 20 ? 'warn' : 'info';
|
|
149
|
+
return [check('error log', sev, `${lines.length} entr(ies), latest: ${tail[tail.length - 1].slice(0, 90)}`,
|
|
150
|
+
`Read ${files.errorLog}. A repeated message there is a bug worth reporting.`)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** MCP servers: configured but not connected is the silent case. */
|
|
154
|
+
function checkMcp(servers, connections) {
|
|
155
|
+
const names = Object.keys(servers || {});
|
|
156
|
+
if (!names.length) return [check('MCP servers', 'info', 'none configured', '')];
|
|
157
|
+
const byName = new Map((connections || []).map((c) => [c.name, c]));
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const n of names) {
|
|
160
|
+
const conn = byName.get(n);
|
|
161
|
+
if (!conn) out.push(check(`MCP ${n}`, 'warn', 'configured but not started', 'Restart, or check the server command.'));
|
|
162
|
+
else if (!conn.ok) out.push(check(`MCP ${n}`, 'error', `failed: ${conn.error}`, 'Fix the command/URL in /mcp-config.'));
|
|
163
|
+
else out.push(check(`MCP ${n}`, 'ok', `${conn.toolCount} tool(s)`));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Hooks: a malformed hooks.json silently never fires. */
|
|
169
|
+
function checkHooks(files, workspace) {
|
|
170
|
+
const candidates = [files.hooks, path.join(workspace || process.cwd(), '.hncode', 'hooks.json')];
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const f of candidates) {
|
|
173
|
+
if (!fs.existsSync(f)) continue;
|
|
174
|
+
try {
|
|
175
|
+
const doc = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
176
|
+
const events = Object.keys(doc.hooks || {});
|
|
177
|
+
out.push(check('hooks', 'ok', `${events.length} event(s) configured in ${f}`));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
out.push(check('hooks', 'error', `${f} is not valid JSON: ${e.message}`,
|
|
180
|
+
'A malformed hooks file makes every hook in it silently never run.'));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The workspace: tools resolve paths against it, so it has to exist and be writable. */
|
|
187
|
+
function checkWorkspace(workspace) {
|
|
188
|
+
if (!workspace) return [check('workspace', 'error', 'not set', 'Run /move, or start hncode in a directory.')];
|
|
189
|
+
if (!fs.existsSync(workspace)) {
|
|
190
|
+
return [check('workspace', 'error', `${workspace} does not exist`,
|
|
191
|
+
'Every file tool will fail. /move to a directory that exists.')];
|
|
192
|
+
}
|
|
193
|
+
const probe = path.join(workspace, `.doctor-${process.pid}.tmp`);
|
|
194
|
+
try {
|
|
195
|
+
fs.writeFileSync(probe, 'x');
|
|
196
|
+
fs.rmSync(probe, { force: true });
|
|
197
|
+
return [check('workspace', 'ok', `${workspace} (writable)`)];
|
|
198
|
+
} catch {
|
|
199
|
+
return [check('workspace', 'warn', `${workspace} (NOT writable)`,
|
|
200
|
+
'Reads work; Edit, Write and a background command that writes will fail.')];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Skills and plugins: a directory that exists but holds nothing is worth knowing about. */
|
|
205
|
+
function checkExtensions(files) {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const [label, dir] of [['skills', files.skills], ['plugins', files.plugins]]) {
|
|
208
|
+
if (!fs.existsSync(dir)) continue;
|
|
209
|
+
let n = 0;
|
|
210
|
+
try { n = fs.readdirSync(dir).length; } catch { /* unreadable */ }
|
|
211
|
+
out.push(check(label, 'info', `${n} entr(ies) in ${dir}`, ''));
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The terminal. An unknown size or a dumb TERM explains a broken-looking UI. */
|
|
217
|
+
function checkTerminal(env, dims) {
|
|
218
|
+
const out = [];
|
|
219
|
+
const term = env.TERM || '';
|
|
220
|
+
if (env.NO_COLOR) out.push(check('colour', 'info', 'NO_COLOR is set', ''));
|
|
221
|
+
else if (!term || term === 'dumb') {
|
|
222
|
+
out.push(check('TERM', 'warn', term ? `"${term}"` : 'not set',
|
|
223
|
+
'Colour and cursor control may not work. Set TERM=xterm-256color.'));
|
|
224
|
+
} else {
|
|
225
|
+
out.push(check('TERM', 'ok', `${term}${env.COLORTERM ? ` / ${env.COLORTERM}` : ''}`));
|
|
226
|
+
}
|
|
227
|
+
if (dims && (dims.cols < 60 || dims.rows < 15)) {
|
|
228
|
+
out.push(check('terminal size', 'warn', `${dims.cols}x${dims.rows}`,
|
|
229
|
+
'Below 60x15 the layout cannot fit; widen the window.'));
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Runtime facts, so a bug report carries them. */
|
|
235
|
+
function checkRuntime(node, platform, dirs) {
|
|
236
|
+
return [
|
|
237
|
+
check('node', 'ok', node),
|
|
238
|
+
check('platform', 'ok', platform),
|
|
239
|
+
check('hncode home', 'info', dirs.home, ''),
|
|
240
|
+
check('cwd', 'info', dirs.cwd, ''),
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- the report ------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Run every check and return the results.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} input
|
|
250
|
+
* @param {object} input.cfg resolved config (provider/model/endpoint/apiKey)
|
|
251
|
+
* @param {object} [input.files] path overrides, for tests
|
|
252
|
+
* @param {string} [input.workspace]
|
|
253
|
+
* @param {object} [input.mcpServers] config.servers
|
|
254
|
+
* @param {Array} [input.mcpConnections]
|
|
255
|
+
* @param {object} [input.env]
|
|
256
|
+
* @param {object} [input.dims] { cols, rows }
|
|
257
|
+
* @param {string} [input.node] process.version, for tests
|
|
258
|
+
* @returns {Array<{name:string,severity:string,detail:string,fix:string}>}
|
|
259
|
+
*/
|
|
260
|
+
export function runDoctor(input = {}) {
|
|
261
|
+
const cfg = input.cfg || {};
|
|
262
|
+
const env = input.env || process.env;
|
|
263
|
+
const workspace = input.workspace || cfg.workspace || process.cwd();
|
|
264
|
+
const sessionsDir = input.sessionsDir || path.join(env.HOME || os.homedir(), '.hncode', 'sessions');
|
|
265
|
+
const files = {
|
|
266
|
+
config: path.join(env.HOME || os.homedir(), '.hncode', 'config.toml'),
|
|
267
|
+
errorLog: path.join(env.HOME || os.homedir(), '.hncode', 'session-errors.log'),
|
|
268
|
+
hooks: path.join(env.HOME || os.homedir(), '.hncode', 'hooks.json'),
|
|
269
|
+
skills: path.join(env.HOME || os.homedir(), '.hncode', 'skills'),
|
|
270
|
+
plugins: path.join(env.HOME || os.homedir(), '.hncode', 'plugins'),
|
|
271
|
+
...(input.files || {}),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const results = [
|
|
275
|
+
...checkRuntime(input.node || process.version, input.platform || process.platform, {
|
|
276
|
+
home: path.join(env.HOME || os.homedir(), '.hncode'),
|
|
277
|
+
cwd: process.cwd(),
|
|
278
|
+
}),
|
|
279
|
+
...checkConfig(cfg, files),
|
|
280
|
+
...checkWorkspace(workspace),
|
|
281
|
+
...checkSessions(files, sessionsDir),
|
|
282
|
+
...checkErrorLog(files),
|
|
283
|
+
...checkMcp(input.mcpServers, input.mcpConnections),
|
|
284
|
+
...checkHooks(files, workspace),
|
|
285
|
+
...checkExtensions(files),
|
|
286
|
+
...checkTerminal(env, input.dims),
|
|
287
|
+
];
|
|
288
|
+
return results;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Counts by severity, for a one-line summary. */
|
|
292
|
+
export function summarize(results) {
|
|
293
|
+
const n = { error: 0, warn: 0, ok: 0, info: 0 };
|
|
294
|
+
for (const r of results || []) if (n[r.severity] != null) n[r.severity] += 1;
|
|
295
|
+
return n;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Render the report as a list of lines, worst first. Pure, so a test can assert on it. */
|
|
299
|
+
export function formatDoctor(results, opts = {}) {
|
|
300
|
+
const list = Array.isArray(results) ? results : [];
|
|
301
|
+
const showOk = opts.showOk !== false;
|
|
302
|
+
const rank = (s) => { const i = SEVERITY_ORDER.indexOf(s); return i < 0 ? 99 : i; };
|
|
303
|
+
const sorted = list.slice().sort((a, b) => rank(a.severity) - rank(b.severity));
|
|
304
|
+
const n = summarize(list);
|
|
305
|
+
const head = `Doctor — ${n.error} error(s), ${n.warn} warning(s)`
|
|
306
|
+
+ (opts.version ? ` hncode v${opts.version}` : '');
|
|
307
|
+
const lines = [head, ''];
|
|
308
|
+
for (const r of sorted) {
|
|
309
|
+
if (!showOk && (r.severity === 'ok' || r.severity === 'info')) continue;
|
|
310
|
+
const mark = MARK[r.severity] || '·';
|
|
311
|
+
lines.push(`${mark} ${r.name.padEnd(24)} ${r.detail}`);
|
|
312
|
+
if (r.fix && (r.severity === 'error' || r.severity === 'warn')) lines.push(` ${' '.repeat(24)} → ${r.fix}`);
|
|
313
|
+
}
|
|
314
|
+
if (!n.error && !n.warn) {
|
|
315
|
+
lines.push('', 'No problems found.');
|
|
316
|
+
} else {
|
|
317
|
+
lines.push('', 'Run /doctor --all to include the passing checks.');
|
|
318
|
+
}
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Feature flags for work that is not finished enough to be on for everyone.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists instead of "just leave it off in the code": an unfinished feature
|
|
4
|
+
// needs to be EXERCISED, by the person building it and by anyone willing to help,
|
|
5
|
+
// against real sessions. Commenting it out means it is only ever tested on the
|
|
6
|
+
// branch where it was written. A named flag also makes the risk legible — `/experiments`
|
|
7
|
+
// lists what is on, and `hncode.experiments` records it, so a bug report can say
|
|
8
|
+
// which combination produced it.
|
|
9
|
+
//
|
|
10
|
+
// Scope rules:
|
|
11
|
+
// * OFF unless turned on. A flag whose default is "on" is not an experiment, it
|
|
12
|
+
// is a release.
|
|
13
|
+
// * A flag that no code reads is a bug, not a no-op: `pruneUnknown()` exists so
|
|
14
|
+
// the list cannot silently rot, and the test asserts every declared flag is
|
|
15
|
+
// referenced somewhere in src/.
|
|
16
|
+
// * Turning a flag off must leave no residue. `applyExperiments()` is therefore
|
|
17
|
+
// called on every reload and is idempotent.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The registry. Each entry is a flag with a one-line reason it exists — a flag with
|
|
21
|
+
* no explanation is indistinguishable from a mistake, and this list is what
|
|
22
|
+
* `/experiments` shows.
|
|
23
|
+
*/
|
|
24
|
+
export const EXPERIMENTS = {
|
|
25
|
+
'parallel-tools': {
|
|
26
|
+
default: false,
|
|
27
|
+
desc: 'Run independent tool calls from one model turn concurrently',
|
|
28
|
+
since: '0.5.0',
|
|
29
|
+
},
|
|
30
|
+
'prompt-cache-hints': {
|
|
31
|
+
default: false,
|
|
32
|
+
desc: 'Send cache_control breakpoints on long stable prefixes (Anthropic)',
|
|
33
|
+
since: '0.5.0',
|
|
34
|
+
},
|
|
35
|
+
'shell-parser-strict': {
|
|
36
|
+
default: false,
|
|
37
|
+
desc: 'Refuse to run a shell command the classifier cannot fully parse',
|
|
38
|
+
since: '0.5.0',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Is `name` a declared flag? */
|
|
43
|
+
export function isExperiment(name) {
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(EXPERIMENTS, String(name == null ? '' : name).trim());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The flag state from config plus the environment.
|
|
49
|
+
*
|
|
50
|
+
* `HNCODE_EXPERIMENTS=a,b` enables without touching config.toml — the escape hatch a
|
|
51
|
+
* test or a one-off run needs. Config wins when both are present, because config is
|
|
52
|
+
* the thing the user edited deliberately.
|
|
53
|
+
*/
|
|
54
|
+
export function experimentState(cfg, env = process.env) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [name, spec] of Object.entries(EXPERIMENTS)) out[name] = !!spec.default;
|
|
57
|
+
const fromEnv = String(env.HNCODE_EXPERIMENTS || '')
|
|
58
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
59
|
+
for (const name of fromEnv) if (isExperiment(name)) out[name] = true;
|
|
60
|
+
const raw = (cfg && ((cfg.raw && cfg.raw.experiments) ?? cfg.experiments)) || {};
|
|
61
|
+
if (raw && typeof raw === 'object') {
|
|
62
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
63
|
+
if (isExperiment(name)) out[name] = v === true || v === 'true' || v === 'on';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Just the enabled names, sorted — what a panel or a log line shows. */
|
|
70
|
+
export function enabledExperiments(cfg, env) {
|
|
71
|
+
const st = experimentState(cfg, env);
|
|
72
|
+
return Object.keys(st).filter((k) => st[k]).sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Merge new flag values into a config's raw table, returning the table to persist.
|
|
77
|
+
*
|
|
78
|
+
* Written as a pure function of (current, changes) so the caller does not have to
|
|
79
|
+
* know how the config is shaped, and so the "turn one off" direction is testable:
|
|
80
|
+
* an explicit `false` is KEPT (it records a deliberate choice), while an unknown
|
|
81
|
+
* name is dropped rather than written into the file.
|
|
82
|
+
*/
|
|
83
|
+
export function mergeExperiments(current, changes) {
|
|
84
|
+
const out = { ...(current && typeof current === 'object' ? current : {}) };
|
|
85
|
+
for (const [name, v] of Object.entries(changes || {})) {
|
|
86
|
+
if (!isExperiment(name)) continue;
|
|
87
|
+
if (v === undefined) delete out[name];
|
|
88
|
+
else out[name] = v === true || v === 'true' || v === 'on';
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Drop names that are no longer flags, so a config does not accumulate entries for
|
|
95
|
+
* features that were removed or renamed. Returns { kept, dropped }.
|
|
96
|
+
*/
|
|
97
|
+
export function pruneUnknown(current) {
|
|
98
|
+
const kept = {};
|
|
99
|
+
const dropped = [];
|
|
100
|
+
for (const [name, v] of Object.entries(current && typeof current === 'object' ? current : {})) {
|
|
101
|
+
if (isExperiment(name)) kept[name] = v;
|
|
102
|
+
else dropped.push(name);
|
|
103
|
+
}
|
|
104
|
+
return { kept, dropped };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Panel lines for `/experiments`. */
|
|
108
|
+
export function describeExperiments(state) {
|
|
109
|
+
const rows = [];
|
|
110
|
+
for (const [name, spec] of Object.entries(EXPERIMENTS)) {
|
|
111
|
+
rows.push({
|
|
112
|
+
name,
|
|
113
|
+
on: !!(state && state[name]),
|
|
114
|
+
desc: spec.desc,
|
|
115
|
+
since: spec.since,
|
|
116
|
+
defaultOn: !!spec.default,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return rows;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default { EXPERIMENTS, experimentState, enabledExperiments, mergeExperiments, pruneUnknown, describeExperiments, isExperiment };
|