@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
@@ -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; }