@hncode/hncode 0.1.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/src/llm.js ADDED
@@ -0,0 +1,449 @@
1
+ // Dual-protocol (OpenAI + Anthropic) chat client with streaming + non-streaming.
2
+ // Normalizes both providers into a common event stream consumed by the agent:
3
+ // {type:'data', text} assistant text delta
4
+ // {type:'tool_start', id, name} a tool call began
5
+ // {type:'tool_args', id, chunk} partial JSON argument chunk
6
+ // {type:'tool_end', id} tool call arguments complete
7
+ // {type:'end'} assistant turn finished
8
+ // {type:'error', error} fatal error
9
+
10
+ import { llmTools } from './tools/index.js';
11
+ import { effortWire } from './config.js';
12
+
13
+ export function openAiToolDefs(tools) {
14
+ return tools.map((t) => ({
15
+ type: 'function',
16
+ function: {
17
+ name: t.name,
18
+ description: t.description,
19
+ parameters: t.parameters,
20
+ },
21
+ }));
22
+ }
23
+
24
+ export function anthropicToolDefs(tools) {
25
+ return tools.map((t) => ({
26
+ name: t.name,
27
+ description: t.description,
28
+ input_schema: t.parameters,
29
+ }));
30
+ }
31
+
32
+ // Internal canonical messages -> OpenAI request shape.
33
+ export function toOpenAi(messages) {
34
+ const out = [];
35
+ for (const m of messages) {
36
+ if (m.role === 'system') out.push({ role: 'system', content: m.content });
37
+ else if (m.role === 'user') out.push({ role: 'user', content: m.content });
38
+ else if (m.role === 'assistant') {
39
+ const hasText = typeof m.content === 'string' && m.content.trim();
40
+ const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
41
+ if (!hasText && !hasCalls) continue; // 上游不允许空 assistant,直接丢弃
42
+ const msg = { role: 'assistant', content: m.content || null };
43
+ if (hasCalls) {
44
+ msg.tool_calls = m.toolCalls.map((tc) => ({
45
+ id: tc.id,
46
+ type: 'function',
47
+ function: { name: tc.name, arguments: JSON.stringify(tc.args) },
48
+ }));
49
+ }
50
+ out.push(msg);
51
+ } else if (m.role === 'tool') {
52
+ out.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // Internal canonical messages -> Anthropic request shape. Returns {system, messages}.
59
+ export function toAnthropic(messages) {
60
+ const sys = [];
61
+ const out = [];
62
+ for (const m of messages) {
63
+ if (m.role === 'system') { sys.push(m.content); continue; }
64
+ if (m.role === 'user') { out.push({ role: 'user', content: [{ type: 'text', text: m.content }] }); continue; }
65
+ if (m.role === 'tool') {
66
+ out.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: m.toolCallId, content: m.content }] });
67
+ continue;
68
+ }
69
+ if (m.role === 'assistant') {
70
+ const blocks = [];
71
+ if (m.content) blocks.push({ type: 'text', text: m.content });
72
+ for (const tc of (m.toolCalls || [])) {
73
+ blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
74
+ }
75
+ out.push({ role: 'assistant', content: blocks });
76
+ }
77
+ }
78
+ return { system: sys.join('\n\n').trim(), messages: out };
79
+ }
80
+
81
+ function authHeaders(cfg) {
82
+ const h = { 'content-type': 'application/json' };
83
+ if (cfg.apiKey) {
84
+ if (cfg.protocol === 'anthropic') {
85
+ h['x-api-key'] = cfg.apiKey;
86
+ h['anthropic-version'] = '2023-06-01';
87
+ } else {
88
+ h['authorization'] = `Bearer ${cfg.apiKey}`;
89
+ }
90
+ }
91
+ return h;
92
+ }
93
+
94
+ function buildBody(cfg, messages, streaming = true) {
95
+ const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
96
+ if (cfg.temperature != null) common.temperature = cfg.temperature;
97
+ // Thinking / reasoning. The wire form depends on the protocol and the chosen
98
+ // effort (see config.effortWire): OpenAI-compatible sends `reasoning_effort`,
99
+ // Anthropic sends a `thinking` budget. Models that think by default need no
100
+ // flag, so nothing is sent when the effort is off/unset.
101
+ const wire = effortWire(cfg, cfg.effort) || {};
102
+ if (cfg.protocol === 'anthropic') {
103
+ const { system, messages: am } = toAnthropic(messages);
104
+ return JSON.stringify({ model: cfg.innerModel, system, messages: am, tools: anthropicToolDefs(llmToolsList), ...common, ...wire });
105
+ }
106
+ return JSON.stringify({ model: cfg.innerModel, messages: toOpenAi(messages), tools: openAiToolDefs(llmToolsList), ...common, ...wire });
107
+ }
108
+
109
+ // tool list used for defs (injected via setTools)
110
+ let llmToolsList = [];
111
+ export function setToolsList(list) { llmToolsList = list; }
112
+
113
+ export class LLM {
114
+ constructor(cfg) { this.cfg = cfg; this.controller = null; }
115
+
116
+ // Abort an in-flight request (Esc / Ctrl-C while the agent is running).
117
+ abort() {
118
+ if (this.controller) { try { this.controller.abort(); } catch {} }
119
+ }
120
+
121
+ // Simple non-streaming text request — used for internal tasks (e.g. context
122
+ // summarization during compaction) that should not surface a tool plan to the
123
+ // user. Returns the assistant's text content or null on failure.
124
+ async requestText(messages) {
125
+ const cfg = this.cfg;
126
+ this.controller = new AbortController();
127
+ try {
128
+ const res = await fetch(cfg.endpoint, {
129
+ method: 'POST',
130
+ headers: authHeaders(cfg),
131
+ body: buildBody(cfg, messages, false),
132
+ signal: this.controller.signal,
133
+ });
134
+ if (!res || !res.ok) {
135
+ let t = '';
136
+ try { if (res) t = await res.text(); } catch {}
137
+ const status = res ? res.status : 'network';
138
+ return null;
139
+ }
140
+ const json = await res.json();
141
+ let text = '';
142
+ if (cfg.protocol === 'anthropic') {
143
+ for (const cb of (json.content || [])) {
144
+ if (cb.type === 'text' && cb.text) text += cb.text;
145
+ }
146
+ } else {
147
+ const msg = json.choices && json.choices[0] && json.choices[0].message;
148
+ if (msg && msg.content) text = msg.content;
149
+ }
150
+ return text || null;
151
+ } catch (e) {
152
+ if (e && e.name === 'AbortError') return null;
153
+ return null;
154
+ }
155
+ }
156
+
157
+ async request(messages, onEvent) {
158
+ const cfg = this.cfg;
159
+ let res = null;
160
+ let lastError = null;
161
+ let lastStatus = 0;
162
+ let attempts = 0;
163
+
164
+ // Retry the HTTP round-trip up to 3 times. Any non-200 response is treated
165
+ // as a problem and retried (5xx, 429, and unexpected 4xx alike), as is a
166
+ // network/connection error. The only exceptions are aborts (Esc/Ctrl-C) —
167
+ // those end the turn — and a response that is not ok on the final attempt.
168
+ const RETRIES = 3;
169
+
170
+ for (let attempt = 1; attempt <= RETRIES; attempt++) {
171
+ attempts = attempt;
172
+ // Fresh controller per attempt so an abort aborts this specific request
173
+ // and the retry loop stops.
174
+ this.controller = new AbortController();
175
+ const signal = this.controller.signal;
176
+ try {
177
+ res = await fetch(cfg.endpoint, {
178
+ method: 'POST',
179
+ headers: authHeaders(cfg),
180
+ body: buildBody(cfg, messages),
181
+ signal,
182
+ });
183
+ } catch (e) {
184
+ // An abort is a normal way to end a turn, not an error to report.
185
+ if (e && e.name === 'AbortError') { onEvent({ type: 'aborted' }); return; }
186
+ lastError = e;
187
+ lastStatus = 0; // network error
188
+ }
189
+ if (res && res.ok) break;
190
+ if (res) lastStatus = res.status;
191
+ if (attempt < RETRIES) {
192
+ // Small exponential backoff (200ms, 400ms). If the user hit Esc during
193
+ // the wait, stop retrying.
194
+ const tryingSignal = this.controller.signal;
195
+ await new Promise((r) => {
196
+ const timer = setTimeout(r, 200 * attempt);
197
+ tryingSignal.addEventListener('abort', () => { clearTimeout(timer); r(); }, { once: true });
198
+ });
199
+ if (tryingSignal.aborted) { onEvent({ type: 'aborted' }); return; }
200
+ }
201
+ }
202
+
203
+ if (lastError && !res) {
204
+ onEvent({ type: 'error', error: lastError });
205
+ return;
206
+ }
207
+ if (!res || !res.ok) {
208
+ let t = '';
209
+ try { if (res) t = await res.text(); } catch {}
210
+ const tries = lastStatus ? `${res.status}` : 'network';
211
+ onEvent({ type: 'error', error: new Error(`LLM request failed after ${attempts} attempt(s) (${tries}): ${truncate(t)}`) });
212
+ return;
213
+ }
214
+ if (cfg.stream === false) {
215
+ const json = await res.json();
216
+ return consumeNonStreaming(cfg.protocol, json, onEvent);
217
+ }
218
+ if (!res.body) { onEvent({ type: 'end' }); return; }
219
+ const reader = res.body.getReader();
220
+ const decoder = new TextDecoder();
221
+ let buf = '';
222
+ const state = { tools: new Map(), indexToId: {}, doneEmitted: false };
223
+ try {
224
+ while (true) {
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+ buf += decoder.decode(value, { stream: true });
228
+ let idx;
229
+ while ((idx = buf.indexOf('\n')) >= 0) {
230
+ const line = buf.slice(0, idx).trim();
231
+ buf = buf.slice(idx + 1);
232
+ if (!line || line.startsWith(':')) continue;
233
+ const s = line.startsWith('data: ') ? line.slice(6) : (line.startsWith('data:') ? line.slice(5) : line);
234
+ if (s === '[DONE]') { finish(state, onEvent); return; }
235
+ if (processEvent(cfg.protocol, s, onEvent, state) === 'end') { finish(state, onEvent); return; }
236
+ }
237
+ }
238
+ } catch (e) {
239
+ // Aborting mid-stream (Esc) also lands here.
240
+ if (e && e.name === 'AbortError') { onEvent({ type: 'aborted' }); return; }
241
+ onEvent({ type: 'error', error: e });
242
+ return;
243
+ } finally {
244
+ reader.releaseLock?.();
245
+ }
246
+ finish(state, onEvent);
247
+ }
248
+ }
249
+
250
+ // ---- inline reasoning tags -------------------------------------------------
251
+ // Some OpenAI-compatible servers do not send a separate `reasoning_content`
252
+ // field: they put the chain of thought INLINE in `content`, wrapped in a tag
253
+ // such as ` thinking…<|end▁of▁thinking|>` or `<thinking>…</thinking>`. Without splitting
254
+ // those out, the raw tags and the whole reasoning trace are rendered as the
255
+ // assistant's answer (and counted as output tokens). This is a streaming state
256
+ // machine: a tag can be split across chunks, so a trailing partial tag is held
257
+ // back until the next chunk decides what it is.
258
+ const THINK_OPEN_TAGS = [' thinking', '<thinking>'];
259
+ // Closing tags. If none of these appear, the block stays in think mode to EOI.
260
+ const THINK_CLOSE_TAGS = ['<|end▁of▁thinking|>', '</thinking>'];
261
+
262
+ // Longest suffix of `buf` that is a proper prefix of one of `tags`.
263
+ // A single trailing space is deliberately NOT a candidate: `' '` is a prefix of
264
+ // `' thinking'`, but holding it back would stall every sentence-ending space
265
+ // (and its neighbour letter could be anything). Only hold at least 2 chars,
266
+ // which makes the tag unambiguous even split across chunks.
267
+ function partialTagSuffixLen(buf, tags) {
268
+ let best = 0;
269
+ for (const tag of tags) {
270
+ const max = Math.min(tag.length - 1, buf.length);
271
+ for (let len = Math.max(2, 1); len <= max; len++) { // len>=2 only
272
+ if (buf.endsWith(tag.slice(0, len))) { if (len > best) best = len; break; }
273
+ }
274
+ }
275
+ return best;
276
+ }
277
+
278
+ function feedContent(text, onEvent, state) {
279
+ let buf = (state.tagPending || '') + String(text || '');
280
+ state.tagPending = '';
281
+ let inThink = !!state.inThink;
282
+ while (buf.length > 0) {
283
+ const tags = inThink ? THINK_CLOSE_TAGS : THINK_OPEN_TAGS;
284
+ let best = -1, bestTag = null;
285
+ for (const tag of tags) {
286
+ const i = buf.indexOf(tag);
287
+ if (i >= 0 && (best === -1 || i < best)) { best = i; bestTag = tag; }
288
+ }
289
+ if (best === -1) {
290
+ const keep = partialTagSuffixLen(buf, tags);
291
+ const emit = buf.slice(0, buf.length - keep);
292
+ if (emit) onEvent({ type: inThink ? 'think' : 'data', text: emit });
293
+ state.tagPending = keep ? buf.slice(buf.length - keep) : '';
294
+ state.inThink = inThink;
295
+ return;
296
+ }
297
+ const before = buf.slice(0, best);
298
+ if (before) onEvent({ type: inThink ? 'think' : 'data', text: before });
299
+ inThink = !inThink;
300
+ buf = buf.slice(best + bestTag.length);
301
+ }
302
+ state.inThink = inThink;
303
+ }
304
+
305
+ // Emit whatever is still held back (a dangling partial tag is plain text).
306
+ function flushContent(onEvent, state) {
307
+ if (state.tagPending) {
308
+ onEvent({ type: state.inThink ? 'think' : 'data', text: state.tagPending });
309
+ state.tagPending = '';
310
+ }
311
+ }
312
+
313
+ function finish(state, onEvent) {
314
+ // close any tool calls that started but never got an explicit end
315
+ if (state.doneEmitted) return;
316
+ flushContent(onEvent, state);
317
+ for (const [, t] of state.tools) {
318
+ if (!t.done) onEvent({ type: 'tool_end', id: t.id });
319
+ }
320
+ state.doneEmitted = true;
321
+ onEvent({ type: 'end' });
322
+ }
323
+
324
+ function processEvent(protocol, raw, onEvent, state) {
325
+ let d;
326
+ try { d = JSON.parse(raw); } catch { return 'continue'; }
327
+ if (protocol === 'anthropic') return processAnthropic(d, onEvent, state);
328
+ return processOpenAI(d, onEvent, state);
329
+ }
330
+
331
+ function processOpenAI(d, onEvent, state) {
332
+ const delta = d.choices && d.choices[0] && d.choices[0].delta;
333
+ if (!delta) {
334
+ if (d.choices && d.choices[0] && d.choices[0].finish_reason) {
335
+ onEvent({ type: 'end' });
336
+ return 'end';
337
+ }
338
+ return 'continue';
339
+ }
340
+ // Reasoning tokens, matching kimi's `extractReasoning` / `extractReasoningDetails`.
341
+ // Different servers name the field differently — `reasoning_content`
342
+ // (vLLM/DeepSeek/hy3), `reasoning` (newer vLLM), or `reasoning_details`. The
343
+ // first observed key is remembered so the output stays consistent.
344
+ if (state.reasoningKey === undefined) {
345
+ state.reasoningKey = ['reasoning_content', 'reasoning_details', 'reasoning']
346
+ .find((k) => typeof delta[k] === 'string' || Array.isArray(delta[k]));
347
+ }
348
+ const key = state.reasoningKey;
349
+ if (key === 'reasoning_details' && Array.isArray(delta.reasoning_details)) {
350
+ // e.g. [{type:'summary'|'encrypted', summary?, encrypted?}]. Only the
351
+ // plain-URL summary is meaningful to hncode — encrypted tokens are opaque.
352
+ for (const el of delta.reasoning_details) {
353
+ if (el && typeof el.summary === 'string') onEvent({ type: 'think', text: el.summary });
354
+ }
355
+ } else if (key && typeof delta[key] === 'string' && delta[key]) {
356
+ onEvent({ type: 'think', text: delta[key] });
357
+ }
358
+ // Content may carry INLINE thinking…<|end▁of▁thinking|> reasoning (hy3/DeepSeek
359
+ // style); feedContent splits it out as think events. Otherwise it would be
360
+ // rendered as part of the answer.
361
+ if (delta.content) feedContent(delta.content, onEvent, state);
362
+ if (delta.tool_calls) {
363
+ for (const tc of delta.tool_calls) {
364
+ // First chunk of a tool call carries `id`; later chunks only repeat `index`.
365
+ if (tc.id) state.indexToId[tc.index] = tc.id;
366
+ const id = tc.id || state.indexToId[tc.index];
367
+ if (!id) continue;
368
+ if (!state.tools.has(id)) {
369
+ state.tools.set(id, { id, name: tc.function && tc.function.name, argsJson: '', done: false });
370
+ onEvent({ type: 'tool_start', id, name: tc.function && tc.function.name });
371
+ }
372
+ if (tc.function && tc.function.arguments) {
373
+ const t = state.tools.get(id); t.argsJson += tc.function.arguments;
374
+ onEvent({ type: 'tool_args', id, chunk: tc.function.arguments });
375
+ }
376
+ }
377
+ }
378
+ return 'continue';
379
+ }
380
+
381
+ function processAnthropic(d, onEvent, state) {
382
+ const t = d.type;
383
+ if (t === 'message_start' || t === 'message_delta' || t === 'message_stop') {
384
+ if (t === 'message_stop') { onEvent({ type: 'end' }); return 'end'; }
385
+ return 'continue';
386
+ }
387
+ if (t === 'content_block_start') {
388
+ const cb = d.content_block;
389
+ if (cb && cb.type === 'tool_use') {
390
+ const id = cb.id; const name = cb.name;
391
+ state.tools.set(d.index, { id, name, argsJson: '', done: false });
392
+ onEvent({ type: 'tool_start', id, name });
393
+ }
394
+ return 'continue';
395
+ }
396
+ if (t === 'content_block_delta') {
397
+ const delta = d.delta || {};
398
+ if (delta.type === 'text_delta') feedContent(delta.text || '', onEvent, state);
399
+ else if (delta.type === 'thinking_delta') onEvent({ type: 'think', text: delta.thinking || '' });
400
+ else if (delta.type === 'input_json') {
401
+ const blk = state.tools.get(d.index);
402
+ if (blk) { blk.argsJson += delta.partial_json || ''; onEvent({ type: 'tool_args', id: blk.id, chunk: delta.partial_json || '' }); }
403
+ }
404
+ return 'continue';
405
+ }
406
+ if (t === 'content_block_stop') {
407
+ const blk = state.tools.get(d.index);
408
+ if (blk && !blk.done) { blk.done = true; onEvent({ type: 'tool_end', id: blk.id }); }
409
+ return 'continue';
410
+ }
411
+ return 'continue';
412
+ }
413
+
414
+ // --- Non-streaming path ---
415
+ export function consumeNonStreaming(protocol, json, onEvent) {
416
+ const state = { tagPending: '', inThink: false };
417
+ if (protocol === 'anthropic') {
418
+ const content = json.content || [];
419
+ for (const cb of content) {
420
+ if (cb.type === 'text' && cb.text) feedContent(cb.text, onEvent, state);
421
+ else if (cb.type === 'tool_use') {
422
+ onEvent({ type: 'tool_start', id: cb.id, name: cb.name });
423
+ onEvent({ type: 'tool_args', id: cb.id, chunk: JSON.stringify(cb.input || {}) });
424
+ onEvent({ type: 'tool_end', id: cb.id });
425
+ }
426
+ }
427
+ } else {
428
+ const msg = json.choices && json.choices[0] && json.choices[0].message;
429
+ if (!msg) { onEvent({ type: 'error', error: new Error('empty LLM response') }); return; }
430
+ if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) onEvent({ type: 'think', text: msg.reasoning_content });
431
+ else if (typeof msg.reasoning === 'string' && msg.reasoning) onEvent({ type: 'think', text: msg.reasoning });
432
+ else if (Array.isArray(msg.reasoning_details)) {
433
+ for (const el of msg.reasoning_details) if (el && typeof el.summary === 'string') onEvent({ type: 'think', text: el.summary });
434
+ }
435
+ if (msg.content) feedContent(msg.content, onEvent, state);
436
+ if (msg.tool_calls) {
437
+ for (const tc of msg.tool_calls) {
438
+ const id = tc.id; const name = tc.function && tc.function.name; const args = tc.function && tc.function.arguments;
439
+ onEvent({ type: 'tool_start', id, name });
440
+ if (args) onEvent({ type: 'tool_args', id, chunk: args });
441
+ onEvent({ type: 'tool_end', id });
442
+ }
443
+ }
444
+ }
445
+ flushContent(onEvent, state);
446
+ onEvent({ type: 'end' });
447
+ }
448
+
449
+ function truncate(s, n = 300) { return s.length > n ? s.slice(0, n) + '...' : s; }
package/src/plugin.js ADDED
@@ -0,0 +1,191 @@
1
+ // Plugin system for hncode.
2
+ //
3
+ // Plugins are plain ESM modules loaded from a configurable directory (default
4
+ // ~/.hncode/plugins/*). Each plugin exports an `install(api)` function that
5
+ // receives a frozen API surface and can register:
6
+ //
7
+ // - tools (agent tools, same shape as built-in specs)
8
+ // - commands (slash-commands in the TUI)
9
+ // - hooks (lifecycle events: onTurnStart, onTurnEnd, onBeforeTool, etc.)
10
+ // - config (default config values that merge into resolveConfig)
11
+ //
12
+ // Example plugin:
13
+ // import { Tool } from 'hncode';
14
+ // export function install(api) {
15
+ // api.registerTool({
16
+ // name: 'Echo',
17
+ // description: 'Echo back the given text',
18
+ // parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
19
+ // async execute(args) { return args.text; }
20
+ // });
21
+ // api.registerCommand({ name: 'echo', run: (arg, ctx) => console.log(arg) });
22
+ // }
23
+ // export default { name: 'echo-plugin' };
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import os from 'node:os';
28
+ import { pathToFileURL } from 'node:url';
29
+
30
+ // Default plugin directory.
31
+ const PLUGIN_DIR = path.join(os.homedir(), '.hncode', 'plugins');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Internal registries — these are the "live" state that plugins mutate.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const registeredTools = [];
38
+ const registeredCommands = [];
39
+ const registeredHooks = {};
40
+ const registeredConfig = {};
41
+ const loadedPlugins = [];
42
+
43
+ const API = {
44
+ // Register a new tool (same shape as built-in tool specs).
45
+ // Returns an unregister function.
46
+ registerTool(spec) {
47
+ if (!spec || typeof spec !== 'object' || !spec.name) {
48
+ throw new Error('registerTool: spec must have a `name`');
49
+ }
50
+ if (registeredTools.some((t) => t.name === spec.name)) {
51
+ throw new Error(`registerTool: tool "${spec.name}" is already registered`);
52
+ }
53
+ registeredTools.push(spec);
54
+ return () => {
55
+ const i = registeredTools.findIndex((t) => t.name === spec.name);
56
+ if (i >= 0) registeredTools.splice(i, 1);
57
+ };
58
+ },
59
+
60
+ // Register a slash-command.
61
+ // { name: 'echo', description: '...', argumentHint: '[text]', run: (arg, ctx) => {} }
62
+ // `run` receives the command argument string and a context object.
63
+ registerCommand(cmd) {
64
+ if (!cmd || typeof cmd !== 'object' || !cmd.name) {
65
+ throw new Error('registerCommand: cmd must have a `name`');
66
+ }
67
+ if (registeredCommands.some((c) => c.name === cmd.name)) {
68
+ throw new Error(`registerCommand: command "/${cmd.name}" is already registered`);
69
+ }
70
+ registeredCommands.push(cmd);
71
+ return () => {
72
+ const i = registeredCommands.findIndex((c) => c.name === cmd.name);
73
+ if (i >= 0) registeredCommands.splice(i, 1);
74
+ };
75
+ },
76
+
77
+ // Register a lifecycle hook. Supported hook names:
78
+ // onTurnStart, onTurnEnd, onBeforeRequest, onAfterRequest,
79
+ // onToolExecute, onToolResult, onNewMessage
80
+ registerHook(name, fn) {
81
+ if (!name || typeof fn !== 'function') {
82
+ throw new Error('registerHook: name and fn are required');
83
+ }
84
+ (registeredHooks[name] = registeredHooks[name] || []).push(fn);
85
+ return () => {
86
+ const arr = registeredHooks[name];
87
+ if (arr) {
88
+ const i = arr.indexOf(fn);
89
+ if (i >= 0) arr.splice(i, 1);
90
+ }
91
+ };
92
+ },
93
+
94
+ // Merge default config values. These are applied in resolveConfig after
95
+ // all built-in defaults and before user config overrides.
96
+ registerConfig(defaults) {
97
+ Object.assign(registeredConfig, defaults || {});
98
+ },
99
+
100
+ // Runtime helpers.
101
+ get ctx() { return _ctx; },
102
+
103
+ // Introspection.
104
+ get tools() { return [...registeredTools]; },
105
+ get commands() { return [...registeredCommands]; },
106
+ get hooks() { return { ...registeredHooks }; },
107
+ get config() { return { ...registeredConfig }; },
108
+ get plugins() { return [...loadedPlugins]; },
109
+ };
110
+
111
+ // The agent context is injected lazily; plugins that need it read api.ctx
112
+ // during their hook callbacks.
113
+ let _ctx = null;
114
+ export function setPluginContext(ctx) { _ctx = ctx; }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Plugin loading
118
+ // ---------------------------------------------------------------------------
119
+
120
+ // Discover and load all plugins from the plugin directory.
121
+ // Each plugin is a .js/.mjs file that exports either:
122
+ // - `install(api)` (named export)
123
+ // - or a default export with an `install` method
124
+ export async function loadPlugins(dir) {
125
+ const pluginDir = dir || PLUGIN_DIR;
126
+ if (!fs.existsSync(pluginDir)) return [];
127
+
128
+ const files = fs.readdirSync(pluginDir)
129
+ .filter((f) => f.endsWith('.js') || f.endsWith('.mjs'))
130
+ .sort();
131
+
132
+ for (const f of files) {
133
+ const full = path.join(pluginDir, f);
134
+ try {
135
+ const mod = await import(pathToFileURL(full).href);
136
+ let installFn;
137
+ if (mod && typeof mod.install === 'function') installFn = mod.install;
138
+ else if (mod && mod.default && typeof mod.default.install === 'function') installFn = mod.default.install;
139
+
140
+ if (typeof installFn !== 'function') {
141
+ // eslint-disable-next-line no-console
142
+ console.error(`[hncode-plugin] ${f}: no install(api) function found, skipped.`);
143
+ continue;
144
+ }
145
+
146
+ const meta = (mod.default && (mod.default.name || mod.default.meta)) || { name: f };
147
+ const plugin = { id: f, name: meta.name || f, version: meta.version || '0.0.0' };
148
+ loadedPlugins.push(plugin);
149
+ installFn(API);
150
+ // eslint-disable-next-line no-console
151
+ console.log(`[hncode-plugin] loaded: ${plugin.name}`);
152
+ } catch (e) {
153
+ // eslint-disable-next-line no-console
154
+ console.error(`[hncode-plugin] ${f}: ${e.message}`);
155
+ }
156
+ }
157
+
158
+ return [...loadedPlugins];
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Hook runner — called by the core from the TUI / agent loop.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export async function runHooks(name, ...args) {
166
+ const arr = registeredHooks[name];
167
+ if (!arr || !arr.length) return;
168
+ for (const fn of arr) {
169
+ try { await fn(...args); } catch (e) {
170
+ // Hooks must never crash the agent.
171
+ // eslint-disable-next-line no-console
172
+ console.error(`[hncode-plugin] hook "${name}" error: ${e.message}`);
173
+ }
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Exports for the core to consume
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export { API, registeredTools as pluginTools, registeredCommands as pluginCommands };
182
+
183
+ // Reset state — useful for tests.
184
+ export function _resetPlugins() {
185
+ registeredTools.length = 0;
186
+ registeredCommands.length = 0;
187
+ for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
188
+ for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
189
+ loadedPlugins.length = 0;
190
+ _ctx = null;
191
+ }