@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/LICENSE +219 -0
- package/README.md +330 -0
- package/TODO.md +72 -0
- package/bin/hncode +9 -0
- package/bin/hncode.cmd +2 -0
- package/bin/hncode.ps1 +4 -0
- package/bin/install.js +96 -0
- package/bin/preinstall.js +42 -0
- package/package.json +26 -0
- package/src/agent.js +373 -0
- package/src/clipboard.js +114 -0
- package/src/colors.js +149 -0
- package/src/config.js +533 -0
- package/src/index.js +296 -0
- package/src/llm.js +449 -0
- package/src/plugin.js +191 -0
- package/src/session.js +73 -0
- package/src/term.js +292 -0
- package/src/toml.js +281 -0
- package/src/tools/bash.js +240 -0
- package/src/tools/edit.js +138 -0
- package/src/tools/fetch-url.js +108 -0
- package/src/tools/file-lines.js +36 -0
- package/src/tools/glob.js +47 -0
- package/src/tools/grep.js +152 -0
- package/src/tools/ignore.js +99 -0
- package/src/tools/index.js +61 -0
- package/src/tools/matchers.js +71 -0
- package/src/tools/read-media-file.js +54 -0
- package/src/tools/read.js +99 -0
- package/src/tools/tasks.js +126 -0
- package/src/tools/todo.js +33 -0
- package/src/tools/utils.js +71 -0
- package/src/tools/web-search.js +75 -0
- package/src/tools/write.js +29 -0
- package/src/tui.js +5503 -0
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
|
+
}
|