@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.
- package/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +149 -35
- 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 +478 -242
- package/src/config.js +409 -40
- package/src/context-bar.js +237 -0
- package/src/doctor.js +518 -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/memory-profile.js +247 -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 +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +148 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session-hub.js +1 -0
- package/src/session.js +178 -8
- package/src/settings-schema.js +301 -0
- 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/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tool-result.js +264 -236
- 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/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 +34 -6
- package/src/tui.js +5637 -746
- package/src/web-public/app.js +7 -0
- package/src/web.js +3 -1
package/src/notebook.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Jupyter notebook (.ipynb) support: read and edit CELLS rather than the JSON.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// `tools/edit.js` REFUSED to touch a .ipynb, and the refusal was right: a notebook is JSON,
|
|
6
|
+
// and a snippet replacement inside it either corrupts the structure or lands in the middle
|
|
7
|
+
// of a base64 image. But refusing is only half an answer — it left the model with no way to
|
|
8
|
+
// change a notebook at all, so it would either give up or reach for Bash and do it by hand.
|
|
9
|
+
//
|
|
10
|
+
// What it needs instead is a view of the notebook as cells, and edits expressed against
|
|
11
|
+
// those cells. That is what this module provides:
|
|
12
|
+
//
|
|
13
|
+
// listCells -> the cells, with their index, type and a preview
|
|
14
|
+
// readCell -> one cell's source, in full
|
|
15
|
+
// replaceSource -> replace one cell's source
|
|
16
|
+
// insertCell -> add a cell before/after an index
|
|
17
|
+
// deleteCell -> remove one cell
|
|
18
|
+
//
|
|
19
|
+
// THE ONE INVARIANT THAT MATTERS
|
|
20
|
+
// ------------------------------
|
|
21
|
+
// Everything OUTSIDE `cells[i].source` is preserved byte-for-byte in intent: cell ids,
|
|
22
|
+
// metadata, outputs, execution_count, nbformat, the top-level metadata. A notebook carries
|
|
23
|
+
// state that is not the code — outputs a user may want to keep, a kernel's metadata, cell
|
|
24
|
+
// ids that Jupyter's collaboration features key on — and a tool that rebuilt the document
|
|
25
|
+
// from the parsed object would silently drop the fields it did not know about.
|
|
26
|
+
//
|
|
27
|
+
// So the JSON is PARSED to find what to change, the change is applied to the parsed value,
|
|
28
|
+
// and the parsed value is serialised back. Every field survives because every field is
|
|
29
|
+
// carried by the same object; `JSON.stringify` is given the ORIGINAL indentation so the
|
|
30
|
+
// file's diff stays about the edit rather than about reformatting.
|
|
31
|
+
//
|
|
32
|
+
// A MALFORMED NOTEBOOK IS NOT REPAIRED
|
|
33
|
+
// ------------------------------------
|
|
34
|
+
// If the file does not parse, or has no `cells` array, every function here refuses and says
|
|
35
|
+
// why. Guessing at a broken notebook's structure is how a tool turns "I could not help" into
|
|
36
|
+
// "I destroyed your work".
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
|
|
40
|
+
/** Notebook formats this understands. A newer nbformat is accepted: the `cells` shape has
|
|
41
|
+
* been stable across 4.x, and refusing a 4.6 file because we know 4.5 would be silly. */
|
|
42
|
+
const MIN_NBFORMAT = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a notebook.
|
|
46
|
+
*
|
|
47
|
+
* @returns {{ok: true, doc: object, indent: string} | {ok: false, error: string}}
|
|
48
|
+
*/
|
|
49
|
+
export function parseNotebook(text) {
|
|
50
|
+
let doc;
|
|
51
|
+
try { doc = JSON.parse(text); }
|
|
52
|
+
catch (e) { return { ok: false, error: `not valid JSON: ${e.message}` }; }
|
|
53
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
54
|
+
return { ok: false, error: 'the top level is not an object' };
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(doc.cells)) {
|
|
57
|
+
return { ok: false, error: 'there is no `cells` array (is this really a notebook?)' };
|
|
58
|
+
}
|
|
59
|
+
const nbf = Number(doc.nbformat);
|
|
60
|
+
if (Number.isFinite(nbf) && nbf < MIN_NBFORMAT) {
|
|
61
|
+
return { ok: false, error: `nbformat ${nbf} is older than ${MIN_NBFORMAT}, whose cell shape this understands` };
|
|
62
|
+
}
|
|
63
|
+
// Detect the indentation so a re-serialised file does not reformat the whole document.
|
|
64
|
+
// Jupyter writes one space, but a hand-edited or tool-generated file may differ.
|
|
65
|
+
const m = /^([ \t]+)"/m.exec(text);
|
|
66
|
+
return { ok: true, doc, indent: m ? m[1] : ' ' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read and parse a file. */
|
|
70
|
+
export function loadNotebook(file) {
|
|
71
|
+
let text;
|
|
72
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
73
|
+
catch (e) { return { ok: false, error: `could not read ${file}: ${e.message}` }; }
|
|
74
|
+
const parsed = parseNotebook(text);
|
|
75
|
+
if (!parsed.ok) return { ok: false, error: `${file}: ${parsed.error}` };
|
|
76
|
+
return { ...parsed, text };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The source of a cell as one string.
|
|
81
|
+
*
|
|
82
|
+
* Jupyter stores `source` as an ARRAY of lines (each keeping its own newline) in nbformat 4,
|
|
83
|
+
* and as a plain string in some hand-written files. Both spellings appear in the wild, and a
|
|
84
|
+
* caller that only handled the array read `undefined` from the string form.
|
|
85
|
+
*/
|
|
86
|
+
export function cellSource(cell) {
|
|
87
|
+
if (!cell) return '';
|
|
88
|
+
const s = cell.source;
|
|
89
|
+
if (Array.isArray(s)) return s.join('');
|
|
90
|
+
if (typeof s === 'string') return s;
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Split a source string into the array form Jupyter writes, keeping the newlines. */
|
|
95
|
+
export function toSourceLines(text) {
|
|
96
|
+
const s = String(text == null ? '' : text);
|
|
97
|
+
if (!s) return [];
|
|
98
|
+
// `split` with a capture keeps the separators, so `"a\nb\n"` -> `["a\n", "b\n"]`.
|
|
99
|
+
const parts = s.split(/(?<=\n)/);
|
|
100
|
+
return parts.filter((p) => p !== '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One-line preview of a cell, for `listCells`. */
|
|
104
|
+
function preview(text, max = 60) {
|
|
105
|
+
const flat = String(text).replace(/\s+/g, ' ').trim();
|
|
106
|
+
return flat.length <= max ? flat : flat.slice(0, max - 1) + '\u2026';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* List the cells.
|
|
111
|
+
*
|
|
112
|
+
* @returns {Array<{index:number, id:string, type:string, lines:number, source:string,
|
|
113
|
+
* preview:string, outputs:number, executionCount:number|null}>}
|
|
114
|
+
* `index` is 0-based and is what every other function here takes.
|
|
115
|
+
*/
|
|
116
|
+
export function listCells(doc) {
|
|
117
|
+
return (doc.cells || []).map((c, i) => {
|
|
118
|
+
const src = cellSource(c);
|
|
119
|
+
return {
|
|
120
|
+
index: i,
|
|
121
|
+
id: c.id == null ? '' : String(c.id),
|
|
122
|
+
type: String(c.cell_type || 'unknown'),
|
|
123
|
+
lines: src ? src.split('\n').length : 0,
|
|
124
|
+
source: src,
|
|
125
|
+
preview: preview(src),
|
|
126
|
+
outputs: Array.isArray(c.outputs) ? c.outputs.length : 0,
|
|
127
|
+
executionCount: c.execution_count == null ? null : c.execution_count,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Render the cell list for a tool result or a panel. */
|
|
133
|
+
export function describeCells(doc, file) {
|
|
134
|
+
const cells = listCells(doc);
|
|
135
|
+
const header = file ? `${file} — ${cells.length} cell(s)` : `${cells.length} cell(s)`;
|
|
136
|
+
if (!cells.length) return [header, '', 'The notebook has no cells.'];
|
|
137
|
+
const lines = [header, ''];
|
|
138
|
+
for (const c of cells) {
|
|
139
|
+
const meta = [];
|
|
140
|
+
if (c.type === 'code' && c.executionCount != null) meta.push(`[${c.executionCount}]`);
|
|
141
|
+
if (c.outputs) meta.push(`${c.outputs} output(s)`);
|
|
142
|
+
lines.push(` ${String(c.index).padStart(3)} ${c.type.padEnd(8)} ${c.lines} line(s) ${meta.join(' ')}`);
|
|
143
|
+
if (c.preview) lines.push(` ${c.preview}`);
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Serialise a document back, preserving the file's own indentation. */
|
|
149
|
+
export function serializeNotebook(doc, indent = ' ') {
|
|
150
|
+
// A notebook written by Jupyter ends with a newline; matching that keeps the file's last
|
|
151
|
+
// line unchanged, so a diff does not show a spurious "".
|
|
152
|
+
return JSON.stringify(doc, null, indent) + '\n';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Save a document, atomically, so a crash cannot leave a half-written notebook. */
|
|
156
|
+
export function saveNotebook(file, doc, indent = ' ') {
|
|
157
|
+
const text = serializeNotebook(doc, indent);
|
|
158
|
+
const tmp = `${file}.${process.pid}.nb.tmp`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
161
|
+
fs.renameSync(tmp, file);
|
|
162
|
+
return { ok: true };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
165
|
+
return { ok: false, error: e.message };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** True when an index addresses a real cell. Reported by name so the message is actionable. */
|
|
170
|
+
export function checkIndex(doc, index) {
|
|
171
|
+
const n = (doc.cells || []).length;
|
|
172
|
+
if (!Number.isInteger(index)) return { ok: false, error: `cell index must be an integer (got ${JSON.stringify(index)})` };
|
|
173
|
+
if (index < 0 || index >= n) return { ok: false, error: `cell index ${index} is out of range (the notebook has ${n} cell(s): 0-${n - 1})` };
|
|
174
|
+
return { ok: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Replace one cell's source, leaving everything else about it — outputs, metadata, id —
|
|
179
|
+
* exactly as it was.
|
|
180
|
+
*
|
|
181
|
+
* Clearing `outputs` would be the tempting "correct" thing, since the old output no longer
|
|
182
|
+
* matches the new code. It is the wrong default: the agent is editing SOURCE, and a user who
|
|
183
|
+
* asked for one line to change does not expect their stored outputs to be dropped. The
|
|
184
|
+
* unchanged-output state is what Jupyter itself shows when you edit a cell without running
|
|
185
|
+
* it, so it is also the least surprising.
|
|
186
|
+
*
|
|
187
|
+
* @returns {{ok: true, cell: object} | {ok: false, error: string}}
|
|
188
|
+
*/
|
|
189
|
+
export function replaceSource(doc, index, source) {
|
|
190
|
+
const idx = checkIndex(doc, index);
|
|
191
|
+
if (!idx.ok) return idx;
|
|
192
|
+
const cell = doc.cells[index];
|
|
193
|
+
cell.source = toSourceLines(source);
|
|
194
|
+
return { ok: true, cell };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Insert a cell.
|
|
199
|
+
*
|
|
200
|
+
* @param {number} index insert BEFORE this index; `doc.cells.length` appends.
|
|
201
|
+
*/
|
|
202
|
+
export function insertCell(doc, index, source, type = 'code') {
|
|
203
|
+
const n = (doc.cells || []).length;
|
|
204
|
+
if (!Number.isInteger(index) || index < 0 || index > n) {
|
|
205
|
+
return { ok: false, error: `insert index must be 0-${n} (got ${JSON.stringify(index)})` };
|
|
206
|
+
}
|
|
207
|
+
const kind = type === 'markdown' || type === 'raw' ? type : 'code';
|
|
208
|
+
const cell = { cell_type: kind, metadata: {}, source: toSourceLines(source) };
|
|
209
|
+
if (kind === 'code') { cell.outputs = []; cell.execution_count = null; }
|
|
210
|
+
// nbformat 4.5+ requires every cell to carry an id. Adding one keeps a notebook that had
|
|
211
|
+
// ids from becoming invalid; a notebook that predates them is left alone.
|
|
212
|
+
const anyId = (doc.cells || []).some((c) => c && c.id);
|
|
213
|
+
if (anyId || Number(doc.nbformat_minor) >= 5) cell.id = newCellId();
|
|
214
|
+
doc.cells.splice(index, 0, cell);
|
|
215
|
+
return { ok: true, cell, index };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Remove one cell. */
|
|
219
|
+
export function deleteCell(doc, index) {
|
|
220
|
+
const idx = checkIndex(doc, index);
|
|
221
|
+
if (!idx.ok) return idx;
|
|
222
|
+
const [removed] = doc.cells.splice(index, 1);
|
|
223
|
+
return { ok: true, removed };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A short random id in the shape Jupyter uses (8 lowercase hex/alnum characters). */
|
|
227
|
+
export function newCellId() {
|
|
228
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
229
|
+
let out = '';
|
|
230
|
+
for (let i = 0; i < 8; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Output styles, loaded from FILES — the way Claude Code's `.claude/output-styles/`
|
|
2
|
+
// works (a file's NAME is the style, its BODY is the instructions).
|
|
3
|
+
//
|
|
4
|
+
// Why files rather than another picker entry: an output style is a paragraph of
|
|
5
|
+
// prompting that a user iterates on. Editing a TOML string inside config.toml for
|
|
6
|
+
// that is unpleasant, and it makes the style unreviewable in a diff. A directory of
|
|
7
|
+
// Markdown files means the style is editable with $EDITOR, diffable, and shareable
|
|
8
|
+
// by copying one file.
|
|
9
|
+
//
|
|
10
|
+
// File format (all parts optional except the body):
|
|
11
|
+
//
|
|
12
|
+
// ---
|
|
13
|
+
// name: Terse reviewer
|
|
14
|
+
// description: Reviews code, states findings, no praise
|
|
15
|
+
// ---
|
|
16
|
+
// You answer only in short declarative sentences. Never open with praise…
|
|
17
|
+
//
|
|
18
|
+
// A file with no frontmatter is valid: the name then comes from the filename
|
|
19
|
+
// (`terse-reviewer.md` -> `Terse Reviewer`), which is what makes ad-hoc styles
|
|
20
|
+
// cheap to add.
|
|
21
|
+
//
|
|
22
|
+
// The style is INJECTED as a system-ish reminder on each request rather than baked
|
|
23
|
+
// into the system prompt, so switching styles does not invalidate the prompt cache
|
|
24
|
+
// and reverting is a matter of clearing a flag.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where styles are read from, most specific first.
|
|
32
|
+
*
|
|
33
|
+
* `home` is the `.hncode` DIRECTORY, matching how config.js resolves every other
|
|
34
|
+
* per-user path (`path.join(homeDir(), '.hncode', …)`). Deriving it independently
|
|
35
|
+
* here — `process.env.HNCODE_HOME || ~/.hncode` — got it wrong under HNCODE_HOME,
|
|
36
|
+
* which points at the PARENT of `.hncode`: styles then lived in
|
|
37
|
+
* `<HNCODE_HOME>/output-styles` while the rest of the app looked in
|
|
38
|
+
* `<HNCODE_HOME>/.hncode/…`, so a user with HNCODE_HOME set could never see a style.
|
|
39
|
+
* The caller passes `config.hncodeDir()`, so both agree by construction.
|
|
40
|
+
*/
|
|
41
|
+
export function styleDirs(cwd, home) {
|
|
42
|
+
const base = home || defaultHncodeDir();
|
|
43
|
+
const dirs = [path.join(base, 'output-styles')];
|
|
44
|
+
// Project-level styles override the personal ones of the same name, so a repo can
|
|
45
|
+
// pin its own style for everyone working in it.
|
|
46
|
+
if (cwd) dirs.push(path.join(cwd, '.hncode', 'output-styles'));
|
|
47
|
+
return dirs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The per-user .hncode directory, resolved exactly as config.js does it:
|
|
51
|
+
// path.join(HNCODE_HOME || os.homedir(), '.hncode')
|
|
52
|
+
// HNCODE_HOME stands in for the HOME directory — it is how a test or a user keeping
|
|
53
|
+
// config on another drive relocates the whole thing — so `.hncode` is appended to
|
|
54
|
+
// it. Appending it to a value that ALREADY contained `.hncode` put the styles one
|
|
55
|
+
// directory too deep and made them invisible.
|
|
56
|
+
function defaultHncodeDir() {
|
|
57
|
+
return path.join(process.env.HNCODE_HOME || os.homedir(), '.hncode');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `terse-reviewer` -> `Terse Reviewer`. The fallback name when a file has none. */
|
|
61
|
+
export function nameFromFile(file) {
|
|
62
|
+
const base = path.basename(String(file || ''), '.md');
|
|
63
|
+
return base.split(/[-_]+/).filter(Boolean)
|
|
64
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
65
|
+
.join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split a style file into { meta, body }.
|
|
70
|
+
*
|
|
71
|
+
* Only a leading `---` fence is treated as frontmatter — the same rule as Jekyll's,
|
|
72
|
+
* chosen because a style BODY will often contain `---` (a horizontal rule in
|
|
73
|
+
* Markdown), and treating a later one as a fence would swallow the instructions.
|
|
74
|
+
*/
|
|
75
|
+
export function parseStyle(raw, file = '') {
|
|
76
|
+
const text = String(raw == null ? '' : raw).replace(/\r\n/g, '\n');
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
const meta = {};
|
|
79
|
+
let body = text;
|
|
80
|
+
if (lines[0] !== undefined && lines[0].trim() === '---') {
|
|
81
|
+
// Find the CLOSING fence; everything between is metadata.
|
|
82
|
+
let end = -1;
|
|
83
|
+
for (let i = 1; i < lines.length; i++) {
|
|
84
|
+
if (lines[i].trim() === '---') { end = i; break; }
|
|
85
|
+
}
|
|
86
|
+
if (end > 0) {
|
|
87
|
+
for (let i = 1; i < end; i++) {
|
|
88
|
+
const m = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
|
|
89
|
+
if (!m) continue;
|
|
90
|
+
// Strip one layer of quotes if present; a description with a colon is
|
|
91
|
+
// otherwise fine because only the FIRST colon separates the key.
|
|
92
|
+
meta[m[1].toLowerCase()] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
93
|
+
}
|
|
94
|
+
body = lines.slice(end + 1).join('\n');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const name = meta.name || nameFromFile(file);
|
|
98
|
+
return { name, description: meta.description || '', body: body.trim(), meta };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every style readable right now, sorted by name. Missing directories are not an error. */
|
|
102
|
+
export function listStyles(cwd, home) {
|
|
103
|
+
const found = new Map(); // name (lowercased) -> style
|
|
104
|
+
for (const dir of styleDirs(cwd, home)) {
|
|
105
|
+
let entries = [];
|
|
106
|
+
try { entries = fs.readdirSync(dir); } catch { continue; }
|
|
107
|
+
for (const f of entries.sort()) {
|
|
108
|
+
if (!f.endsWith('.md')) continue;
|
|
109
|
+
let raw = '';
|
|
110
|
+
try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
111
|
+
const style = parseStyle(raw, f);
|
|
112
|
+
if (!style.body) continue; // a style with no body does nothing
|
|
113
|
+
// The LATER directory wins (project over personal): this loop runs home first.
|
|
114
|
+
found.set(style.name.toLowerCase(), { ...style, file: path.join(dir, f), dir });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One style by name (case-insensitive), or null. */
|
|
121
|
+
export function findStyle(name, cwd, home) {
|
|
122
|
+
// A trailing `.md` is dropped, since `ls` shows the extension and typing it is the
|
|
123
|
+
// obvious thing to do after looking at the directory.
|
|
124
|
+
const want = String(name == null ? '' : name).trim().toLowerCase().replace(/\.md$/, '');
|
|
125
|
+
if (!want) return null;
|
|
126
|
+
const all = listStyles(cwd, home);
|
|
127
|
+
// Every spelling a user might type, most exact first:
|
|
128
|
+
// 1. the display name (`Terse Reviewer`)
|
|
129
|
+
// 2. the FILE name without .md (`terse-reviewer`) — this is what `ls` shows and
|
|
130
|
+
// what a user types after seeing the directory, and without it the lookup
|
|
131
|
+
// failed for the very style they just created.
|
|
132
|
+
// 3. a prefix of either, so `/output-style terse` is enough to disambiguate.
|
|
133
|
+
const fileOf = (s) => String(s.file || '').replace(/^.*[\\/]/, '').replace(/\.md$/, '').toLowerCase();
|
|
134
|
+
return all.find((s) => s.name.toLowerCase() === want)
|
|
135
|
+
|| all.find((s) => fileOf(s) === want)
|
|
136
|
+
|| all.find((s) => s.name.toLowerCase().startsWith(want))
|
|
137
|
+
|| all.find((s) => fileOf(s).startsWith(want))
|
|
138
|
+
|| null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The reminder text that carries a style into a request.
|
|
143
|
+
*
|
|
144
|
+
* Deliberately wrapped in the same `<system-reminder>` envelope the harness uses
|
|
145
|
+
* elsewhere, and placed so the model reads it as an instruction about FORM rather
|
|
146
|
+
* than as content to answer. It says the style overrides earlier formatting
|
|
147
|
+
* requests, because a system prompt already contains output rules and the later
|
|
148
|
+
* instruction is the one a model follows.
|
|
149
|
+
*/
|
|
150
|
+
export function styleReminder(style) {
|
|
151
|
+
if (!style || !style.body) return '';
|
|
152
|
+
return [
|
|
153
|
+
'<system-reminder>',
|
|
154
|
+
`The user has selected the "${style.name}" output style. It governs the FORM of`,
|
|
155
|
+
'your replies and takes precedence over any earlier formatting instruction.',
|
|
156
|
+
'',
|
|
157
|
+
style.body,
|
|
158
|
+
'</system-reminder>',
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Create a starter file so /output-style new gives the user something to edit. */
|
|
163
|
+
export function writeStyleTemplate(dir, name) {
|
|
164
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
165
|
+
const slug = String(name || 'my-style').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-style';
|
|
166
|
+
const file = path.join(dir, `${slug}.md`);
|
|
167
|
+
if (fs.existsSync(file)) return { file, created: false };
|
|
168
|
+
const body = [
|
|
169
|
+
'---',
|
|
170
|
+
`name: ${nameFromFile(file)}`,
|
|
171
|
+
'description: what this style is for',
|
|
172
|
+
'---',
|
|
173
|
+
'',
|
|
174
|
+
'Write your style instructions here. They describe the FORM of every reply:',
|
|
175
|
+
'length, tone, structure, what to leave out. They are added to each request as',
|
|
176
|
+
'a system reminder, so they take precedence over the default formatting rules.',
|
|
177
|
+
'',
|
|
178
|
+
].join('\n');
|
|
179
|
+
fs.writeFileSync(file, body, 'utf8');
|
|
180
|
+
return { file, created: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default { styleDirs, listStyles, findStyle, parseStyle, styleReminder, nameFromFile, writeStyleTemplate };
|
package/src/patch.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Symbol-level patching — let a plugin wrap ANY exported function or class method
|
|
2
|
+
// in hncode's own source, without editing that source.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// --------------
|
|
6
|
+
// The original `api.patch(seam, fn)` only exposed nine hand-picked "seams"
|
|
7
|
+
// (systemPrompt, llmRequest, …). Everything else — Agent.send, LLM.chat, any of
|
|
8
|
+
// the ~340 exported functions — was unreachable from a plugin. This module makes
|
|
9
|
+
// every symbol patchable by rewriting the target module's SOURCE at load time.
|
|
10
|
+
//
|
|
11
|
+
// HOW IT WORKS (and why it is safe)
|
|
12
|
+
// ---------------------------------
|
|
13
|
+
// ESM exports are read-only bindings, so a plugin cannot monkey-patch them at
|
|
14
|
+
// runtime (verified: `Cannot assign to read only property`). But a module CAN
|
|
15
|
+
// reassign its OWN bindings, and Node's `module.registerHooks({ load })` lets us
|
|
16
|
+
// rewrite a module's source as it is loaded. So for each requested target we:
|
|
17
|
+
//
|
|
18
|
+
// 1. leave the original declaration untouched (no body parsing at all), and
|
|
19
|
+
// 2. append a small footer that reassigns the symbol through our wrapper:
|
|
20
|
+
// foo = __hncodePatch("file.js#foo", foo);
|
|
21
|
+
// Klass.prototype.m = __hncodePatch("file.js#Klass.m", Klass.prototype.m);
|
|
22
|
+
//
|
|
23
|
+
// For an `export const` target we also rewrite that one declaration line
|
|
24
|
+
// (`const` → `let`) so it can be reassigned — still a single-line change, still
|
|
25
|
+
// no function-body analysis. This was chosen over an AST rewriter (acorn) to keep
|
|
26
|
+
// hncode's zero-runtime-dependency promise; it relies only on the fact that the
|
|
27
|
+
// target's declaration line is well-formed, which holds for every export in src/.
|
|
28
|
+
//
|
|
29
|
+
// ORDERING (important)
|
|
30
|
+
// --------------------
|
|
31
|
+
// The load hook only sees modules that are imported AFTER it is registered;
|
|
32
|
+
// already-cached modules are left alone (verified). hncode's entry therefore
|
|
33
|
+
// registers this loader and loads plugins BEFORE importing the business modules
|
|
34
|
+
// (see src/bootstrap.js). A module that has already been imported cannot be
|
|
35
|
+
// patched — patching it is reported as a skipped target, not a silent no-op.
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import module from 'node:module';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
// Registry: { "file.js#symbol": [handler, …] }. The loader reads it per module.
|
|
43
|
+
const registry = new Map();
|
|
44
|
+
|
|
45
|
+
// The nine legacy abstract seams, kept so `api.patch('systemPrompt', fn)` from an
|
|
46
|
+
// existing plugin keeps working. These are handled by plugin.js's chain runner,
|
|
47
|
+
// NOT by this module; listed here so api.patch can tell the two target kinds apart.
|
|
48
|
+
export const LEGACY_SEAMS = new Set([
|
|
49
|
+
'systemPrompt', 'llmRequest', 'llmHeaders', 'llmBody', 'llmResponse',
|
|
50
|
+
'toolDispatch', 'toolResult', 'beforeRender', 'afterRender',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
let loaderInstalled = false;
|
|
54
|
+
let loaderActive = false;
|
|
55
|
+
const patchedFiles = new Set();
|
|
56
|
+
|
|
57
|
+
// A target is "file.js#symbol" (a plain export) or "file.js#Class.method".
|
|
58
|
+
const TARGET_RE = /^([^#]+)#([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?$/;
|
|
59
|
+
|
|
60
|
+
/** Normalise a user-supplied target into { file, base, method|null } or null. */
|
|
61
|
+
export function parseTarget(target) {
|
|
62
|
+
const m = TARGET_RE.exec(String(target || '').trim());
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return { file: m[1], base: m[2], method: m[3] || null, id: `${m[1]}#${m[2]}${m[3] ? '.' + m[3] : ''}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Register one handler for a symbol target. Returns an unregister function. */
|
|
68
|
+
export function addPatch(target, handler) {
|
|
69
|
+
const info = parseTarget(target);
|
|
70
|
+
if (!info) throw new Error(`patch: bad target "${target}" (want "file.js#symbol" or "file.js#Class.method")`);
|
|
71
|
+
if (!handler || typeof handler !== 'object') throw new Error('patch: handler must be an object like { before, after, around }');
|
|
72
|
+
if (!info.method && patchedFiles.has(info.file) && !registry.get(info.id)) {
|
|
73
|
+
// Allow it anyway; the loader may not have run yet for this file.
|
|
74
|
+
}
|
|
75
|
+
const list = registry.get(info.id) || [];
|
|
76
|
+
list.push(handler);
|
|
77
|
+
registry.set(info.id, list);
|
|
78
|
+
return () => {
|
|
79
|
+
const arr = registry.get(info.id);
|
|
80
|
+
if (!arr) return;
|
|
81
|
+
const i = arr.indexOf(handler);
|
|
82
|
+
if (i >= 0) arr.splice(i, 1);
|
|
83
|
+
if (!arr.length) registry.delete(info.id);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** All registered target ids (for /plugins-style introspection). */
|
|
88
|
+
export function listPatchTargets() { return [...registry.keys()]; }
|
|
89
|
+
|
|
90
|
+
/** Remove every handler (used by _resetPlugins and tests). */
|
|
91
|
+
export function clearPatches() { registry.clear(); }
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// The runtime wrapper applied in the module footer.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wrap `fn` so every handler registered for `id` runs around it.
|
|
99
|
+
* - before(...args): if it returns an array, that replaces the argument list.
|
|
100
|
+
* - after(result, args): if it returns non-undefined, that replaces the result.
|
|
101
|
+
* - around(original, args, thisArg): full control; its return value is the result.
|
|
102
|
+
* Handler `this` is the call's `this` (so a class method can read its instance).
|
|
103
|
+
*/
|
|
104
|
+
export function applyPatches(id, fn) {
|
|
105
|
+
const handlers = registry.get(id);
|
|
106
|
+
if (!handlers || !handlers.length) return fn;
|
|
107
|
+
|
|
108
|
+
function wrapped(...args) {
|
|
109
|
+
let finalArgs = args;
|
|
110
|
+
for (const h of handlers) {
|
|
111
|
+
if (typeof h.before === 'function') {
|
|
112
|
+
const r = h.before.apply(this, finalArgs);
|
|
113
|
+
if (Array.isArray(r)) finalArgs = r;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `around` takes over the call entirely; otherwise call the original.
|
|
118
|
+
const aroundHandler = handlers.filter((h) => typeof h.around === 'function').pop();
|
|
119
|
+
const aroundFn = aroundHandler && aroundHandler.around;
|
|
120
|
+
const invoke = () => (aroundFn ? aroundFn.call(this, fn, finalArgs, this) : fn.apply(this, finalArgs));
|
|
121
|
+
|
|
122
|
+
const finish = (result) => {
|
|
123
|
+
let out = result;
|
|
124
|
+
for (const h of handlers) {
|
|
125
|
+
if (typeof h.after === 'function') {
|
|
126
|
+
const r = h.after.call(this, out, finalArgs);
|
|
127
|
+
if (r !== undefined) out = r;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const r = invoke();
|
|
134
|
+
// Preserve asyncness: only then-ables get the after-chain deferred.
|
|
135
|
+
if (r && typeof r.then === 'function') return r.then(finish);
|
|
136
|
+
return finish(r);
|
|
137
|
+
}
|
|
138
|
+
return wrapped;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Expose to rewritten modules (they run in their own scope and cannot import us
|
|
142
|
+
// without a cycle). One global, set once.
|
|
143
|
+
function installGlobal() {
|
|
144
|
+
if (!globalThis.__hncodePatch) {
|
|
145
|
+
globalThis.__hncodePatch = (id, fn) => applyPatches(id, fn);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// The load hook: rewrite a module's source to append the patch footers.
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** Build the footer source for the targets whose file matches `base`. */
|
|
154
|
+
function footerFor(base, ids) {
|
|
155
|
+
const lines = ['', '/* hncode:patch-footer (auto-generated) */', ';(() => {', ' const P = globalThis.__hncodePatch;', ' if (!P) return;'];
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
const info = parseTarget(id);
|
|
158
|
+
if (!info) continue;
|
|
159
|
+
const ref = info.method ? `${info.base}.prototype.${info.method}` : info.base;
|
|
160
|
+
lines.push(` try { ${ref} = P(${JSON.stringify(id)}, ${ref}); } catch (e) { console.error('[hncode-patch] ${id}:', e.message); }`);
|
|
161
|
+
}
|
|
162
|
+
lines.push('})();', '');
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* In `src`, make target `file#symbol` reassignable:
|
|
168
|
+
* - `export const NAME = …` → `export let NAME = …` (only for a plain target)
|
|
169
|
+
* The declaration line is matched literally; function bodies are never touched.
|
|
170
|
+
*/
|
|
171
|
+
function makeReassignable(src, info) {
|
|
172
|
+
if (info.method) return src; // class methods are patched via the prototype
|
|
173
|
+
const re = new RegExp(`^(\\s*)export const (${info.base})\\b`, 'm');
|
|
174
|
+
return src.replace(re, '$1export let $2');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Rewrite one module's source, or return it unchanged when it has no targets. */
|
|
178
|
+
export function rewriteSource(filename, src) {
|
|
179
|
+
const base = filename.split(/[\\/]/).pop();
|
|
180
|
+
const ids = [...registry.keys()].filter((id) => parseTarget(id)?.file === base);
|
|
181
|
+
if (!ids.length) return null;
|
|
182
|
+
|
|
183
|
+
let out = src;
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const info = parseTarget(id);
|
|
186
|
+
// A plain target declares a top-level function/const; make it mutable if const.
|
|
187
|
+
out = makeReassignable(out, info);
|
|
188
|
+
}
|
|
189
|
+
return out + footerFor(base, ids);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Register the Node load hook. Idempotent and NON-FATAL: if the runtime has no
|
|
194
|
+
* `module.registerHooks` (added in Node 22.15; hncode still declares engines
|
|
195
|
+
* >=20) or registration throws, symbol patching is simply disabled. It must
|
|
196
|
+
* never throw — a throw here would propagate through bootstrap and stop hncode
|
|
197
|
+
* from starting at all, which is far worse than a missing optional feature.
|
|
198
|
+
* Returns true when the loader is active.
|
|
199
|
+
*/
|
|
200
|
+
export function installPatchLoader() {
|
|
201
|
+
installGlobal();
|
|
202
|
+
if (loaderInstalled) return loaderActive;
|
|
203
|
+
loaderInstalled = true;
|
|
204
|
+
|
|
205
|
+
if (typeof module.registerHooks !== 'function') {
|
|
206
|
+
// Older Node: patching is unavailable. Everything else keeps working.
|
|
207
|
+
console.error('[hncode-patch] module.registerHooks is unavailable on this Node — symbol patching disabled.');
|
|
208
|
+
return (loaderActive = false);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let srcDir;
|
|
212
|
+
try { srcDir = path.dirname(fileURLToPath(import.meta.url)); } catch { return (loaderActive = false); }
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
module.registerHooks({
|
|
216
|
+
load(url, context, nextLoad) {
|
|
217
|
+
const r = nextLoad(url, context);
|
|
218
|
+
// Only hncode's own src/*.js files, and only when something targets them.
|
|
219
|
+
if (!url.startsWith('file:') || !url.includes('/src/') || typeof r.source === 'undefined') return r;
|
|
220
|
+
let file;
|
|
221
|
+
try { file = fileURLToPath(url); } catch { return r; }
|
|
222
|
+
if (path.dirname(file) !== srcDir) return r;
|
|
223
|
+
try {
|
|
224
|
+
const s = typeof r.source === 'string' ? r.source : Buffer.from(r.source).toString('utf8');
|
|
225
|
+
const rewritten = rewriteSource(file, s);
|
|
226
|
+
if (rewritten == null) return r;
|
|
227
|
+
patchedFiles.add(path.basename(file));
|
|
228
|
+
return { ...r, source: rewritten, format: r.format || 'module' };
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// A rewrite failure must not break the module load — return it as-is
|
|
231
|
+
// and report, rather than taking hncode down.
|
|
232
|
+
console.error(`[hncode-patch] failed to rewrite ${url}:`, e && e.message);
|
|
233
|
+
return r;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
loaderActive = true;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error('[hncode-patch] could not install the load hook — symbol patching disabled:', e && e.message);
|
|
240
|
+
loaderActive = false;
|
|
241
|
+
}
|
|
242
|
+
return loaderActive;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Whether the load hook is actually active (installed AND usable). index.js uses
|
|
246
|
+
* this to decide whether plugins still need loading — if patching is disabled,
|
|
247
|
+
* bootstrap already loaded them, so it must NOT be treated as "installed". */
|
|
248
|
+
export function isLoaderInstalled() { return loaderActive; }
|