@hncode/hncode 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README_CN.md +0 -1
- package/package.json +1 -1
- package/src/agent.js +5 -0
- package/src/config.js +63 -10
- package/src/index.js +24 -7
- package/src/llm.js +60 -11
- package/src/plugin.js +13 -0
- package/src/session.js +257 -24
- package/src/tools/agent-swarm.js +6 -1
- package/src/tools/agent.js +8 -1
- package/src/tools/bash.js +7 -1
- package/src/tools/edit.js +12 -12
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +9 -4
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +13 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +2 -1
- package/src/tools/utils.js +11 -1
- package/src/tui.js +209 -47
- package/src/update.js +2 -14
- package/src/version.js +20 -0
package/README_CN.md
CHANGED
|
@@ -65,7 +65,6 @@ npx @hncode/hncode
|
|
|
65
65
|
|
|
66
66
|
hncode 从以下几个优秀项目中汲取了灵感:
|
|
67
67
|
|
|
68
|
-
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
69
68
|
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
70
69
|
- **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
|
|
71
70
|
- **[Codex](https://github.com/openai/codex)** - 通过阅读其开源实现,本项目确定了几处行为:AGENTS.md 的加载规则、未获明确许可不得开启子代理、计划工具的状态流转约束、代码审查的判定标准,以及系统提示词的部分内容
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -445,6 +445,11 @@ export class Agent {
|
|
|
445
445
|
});
|
|
446
446
|
}
|
|
447
447
|
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
|
448
|
+
// The turn is over, so the inline-reasoning block it may have left open is
|
|
449
|
+
// abandoned: the NEXT user turn starts a fresh one. Without this, a model that
|
|
450
|
+
// never wrote its closing tag would leave the following turn's answer classified
|
|
451
|
+
// as reasoning.
|
|
452
|
+
if (typeof this.llm.resetThink === 'function') this.llm.resetThink();
|
|
448
453
|
// Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
|
|
449
454
|
// In a finally-equivalent position — every exit path above reaches here.
|
|
450
455
|
await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
|
package/src/config.js
CHANGED
|
@@ -6,6 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { parse } from './toml.js';
|
|
9
|
+
import { pluginConfigDefaults } from './plugin.js';
|
|
9
10
|
|
|
10
11
|
const home = os.homedir();
|
|
11
12
|
|
|
@@ -194,6 +195,11 @@ function loadBaseToml() {
|
|
|
194
195
|
|
|
195
196
|
export function resolveConfig() {
|
|
196
197
|
const root = loadBaseToml() || {};
|
|
198
|
+
// Merge plugin-provided defaults UNDER the user's config: an explicitly set
|
|
199
|
+
// user value always wins.
|
|
200
|
+
for (const [k, v] of Object.entries(pluginConfigDefaults || {})) {
|
|
201
|
+
if (!(k in root)) root[k] = v;
|
|
202
|
+
}
|
|
197
203
|
|
|
198
204
|
const providers = root.providers || {};
|
|
199
205
|
const models = root.models || {};
|
|
@@ -234,8 +240,11 @@ export function resolveConfig() {
|
|
|
234
240
|
// The base URL is used EXACTLY as configured — we only append the API path.
|
|
235
241
|
// (No `/v1` is injected: if the user's base ends in /v1, the result is
|
|
236
242
|
// `<base>/chat/completions`; if it doesn't, it's `<base>/chat/completions` too.)
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
const b = baseUrl.replace(/\/+$/, '');
|
|
244
|
+
// An empty base URL produces a relative endpoint like "/chat/completions",
|
|
245
|
+
// which fetch() rejects with a URL-scheme error the model can't decode. Leave
|
|
246
|
+
// the endpoint empty instead so the caller can show "not set".
|
|
247
|
+
const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`);
|
|
239
248
|
|
|
240
249
|
// Context window: an explicit env/root override wins, then the model's own
|
|
241
250
|
// `contextLength` (discovered from the provider's /v1/models), then the
|
|
@@ -283,22 +292,47 @@ export function resolveConfig() {
|
|
|
283
292
|
|
|
284
293
|
export function saveConfig(overlay) {
|
|
285
294
|
const dir = hncodeConfigFile();
|
|
295
|
+
const entries = Object.entries(overlay || {});
|
|
296
|
+
if (entries.length === 0) return dir;
|
|
297
|
+
// Render the overlay to TOML lines, preserving key order.
|
|
286
298
|
let out = '';
|
|
287
|
-
for (const [k, v] of
|
|
299
|
+
for (const [k, v] of entries) out += toTomlLine(k, v) + '\n';
|
|
288
300
|
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
289
|
-
|
|
301
|
+
|
|
290
302
|
// Check if file exists and has content
|
|
291
303
|
try {
|
|
292
304
|
if (fs.statSync(dir).isFile()) {
|
|
293
305
|
const existing = fs.readFileSync(dir, 'utf8');
|
|
294
306
|
if (existing.trim().length > 0) {
|
|
295
|
-
// Append to existing config instead of overwriting
|
|
296
|
-
|
|
307
|
+
// Append to existing config instead of overwriting — but only for keys
|
|
308
|
+
// that are NOT already present as top-level lines, so two saveConfig
|
|
309
|
+
// calls for the same key never produce duplicate keys. Reuse the same
|
|
310
|
+
// "top-level before any [table] header" rule as setConfigString.
|
|
311
|
+
const keys = new Set(entries.map(([k]) => k));
|
|
312
|
+
const lines = existing.split(/\r?\n/);
|
|
313
|
+
const seen = new Set(); // top-level keys already in the file
|
|
314
|
+
let inTable = false;
|
|
315
|
+
for (const ln of lines) {
|
|
316
|
+
if (/^\s*\[/.test(ln)) inTable = true;
|
|
317
|
+
if (!inTable) {
|
|
318
|
+
const km = /^\s*([A-Za-z0-9_]+)\s*=\s*/.exec(ln);
|
|
319
|
+
if (km) seen.add(km[1]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Nothing to add: every overlay key is already present top-level.
|
|
323
|
+
let add = '';
|
|
324
|
+
for (const [k, v] of entries) {
|
|
325
|
+
if (!seen.has(k)) add += toTomlLine(k, v) + '\n';
|
|
326
|
+
}
|
|
327
|
+
if (add) {
|
|
328
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
329
|
+
fs.writeFileSync(dir, existing + sep + '\n' + add.trimEnd() + '\n', 'utf8');
|
|
330
|
+
}
|
|
297
331
|
return dir;
|
|
298
332
|
}
|
|
299
333
|
}
|
|
300
334
|
} catch {}
|
|
301
|
-
|
|
335
|
+
|
|
302
336
|
// New file or empty file
|
|
303
337
|
fs.writeFileSync(dir, out, 'utf8');
|
|
304
338
|
return dir;
|
|
@@ -634,6 +668,20 @@ export function effectiveSnapshot(cfg) {
|
|
|
634
668
|
// sub-table takes precedence (it's the point of switching); env vars set at boot
|
|
635
669
|
// are already baked into `cfg` and only remain in effect when the provider table
|
|
636
670
|
// does not override a given field.
|
|
671
|
+
// Recompute the context window for a given model key. `resolveConfig` picks the
|
|
672
|
+
// window from the model's [models.*] entry (context_length), an env/root
|
|
673
|
+
// override, or the default. resolveProvider / resolveModelArg must reuse the
|
|
674
|
+
// SAME rule when the user switches provider/model mid-session, otherwise the
|
|
675
|
+
// status-bar context gauge (state.ctxMax) keeps the stale previous model's value.
|
|
676
|
+
function contextForModel(cfg, modelName) {
|
|
677
|
+
const root = (cfg && cfg.raw) || {};
|
|
678
|
+
const models = (root.models) || {};
|
|
679
|
+
const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
|
|
680
|
+
const entry = models[modelName] || {};
|
|
681
|
+
const modelContext = Number(entry.contextLength || entry.context_length || entry.max_context_size || 0);
|
|
682
|
+
return Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
|
|
683
|
+
}
|
|
684
|
+
|
|
637
685
|
export function resolveProvider(cfg, providerName, innerModel) {
|
|
638
686
|
const pr = (cfg.raw && cfg.raw.providers) || {};
|
|
639
687
|
const prov = pr[providerName] || {};
|
|
@@ -642,8 +690,11 @@ export function resolveProvider(cfg, providerName, innerModel) {
|
|
|
642
690
|
const protocol = (prov.protocol) || cfg.protocol;
|
|
643
691
|
const inner = prov.model || (innerModel != null ? innerModel : cfg.innerModel);
|
|
644
692
|
const b = String(baseUrl || '').replace(/\/+$/, '');
|
|
645
|
-
const endpoint = protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions
|
|
646
|
-
|
|
693
|
+
const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`);
|
|
694
|
+
// Provider switch may move the active model too; refresh the context window
|
|
695
|
+
// so the gauge reflects the newly active model, not the old one.
|
|
696
|
+
const maxContextTokens = contextForModel(cfg, cfg.model);
|
|
697
|
+
return { ...cfg, provider: providerName, innerModel: inner, baseUrl: b, endpoint, apiKey, protocol, maxContextTokens };
|
|
647
698
|
}
|
|
648
699
|
|
|
649
700
|
// Switch model, optionally moving to a provider declared via [models.NAME].
|
|
@@ -655,7 +706,9 @@ export function resolveModelArg(cfg, modelName) {
|
|
|
655
706
|
const entry = models[modelName] || {};
|
|
656
707
|
const provider = entry.provider || cfg.provider;
|
|
657
708
|
const inner = bareModelId(provider, modelName, entry);
|
|
658
|
-
|
|
709
|
+
// Refresh the context window for the newly selected model.
|
|
710
|
+
const maxContextTokens = contextForModel(cfg, modelName);
|
|
711
|
+
return { ...resolveProvider(cfg, provider, inner), model: modelName, maxContextTokens };
|
|
659
712
|
}
|
|
660
713
|
|
|
661
714
|
export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile };
|
package/src/index.js
CHANGED
|
@@ -8,8 +8,11 @@ import * as sess from './session.js';
|
|
|
8
8
|
import { Agent, SYSTEM_PROMPT } from './agent.js';
|
|
9
9
|
import { llmTools } from './tools/index.js';
|
|
10
10
|
import { setToolsList } from './llm.js';
|
|
11
|
+
import { localVersion } from './version.js';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// The version shown by `hncode -V` and the help banner. Read from package.json
|
|
14
|
+
// (via version.js) so it can never drift from the published version again.
|
|
15
|
+
export const VERSION = localVersion();
|
|
13
16
|
|
|
14
17
|
const HELP = `hncode ${VERSION} — a Kimi Code-style coding agent.
|
|
15
18
|
|
|
@@ -53,17 +56,31 @@ export function parseArgs(argv) {
|
|
|
53
56
|
else { key = a.slice(2); val = null; }
|
|
54
57
|
if (key === 'resume') key = 'session'; // `--resume <id>` is an alias for `--session <id>`
|
|
55
58
|
if (['session'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
|
|
56
|
-
|
|
57
|
-
if (val === null) {
|
|
58
|
-
|
|
59
|
+
else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir') {
|
|
60
|
+
if (val === null) {
|
|
61
|
+
const nx = argv[i + 1];
|
|
62
|
+
// Only consume the next token if it is a real value, not another flag
|
|
63
|
+
// or the end of argv. Previously `--model -y` and a trailing `--model`
|
|
64
|
+
// swallowed or mangled the value, breaking the real flag.
|
|
65
|
+
val = (nx !== undefined && !nx.startsWith('-')) ? argv[++i] : undefined;
|
|
66
|
+
}
|
|
67
|
+
out[key] = (key === 'add-dir' && val !== undefined)
|
|
68
|
+
? (out[key] || []).concat([val])
|
|
69
|
+
: val;
|
|
59
70
|
} else if (val !== null) { out[key] = val === 'true' ? true : val === 'false' ? false : val; }
|
|
60
71
|
else out[key] = true;
|
|
61
72
|
} else if (a.startsWith('-') && a.length > 1) {
|
|
62
73
|
const key = a.slice(1);
|
|
63
74
|
const short = { V: 'version', S: 'session', c: 'continue', y: 'yolo', auto: 'auto', m: 'model', p: 'prompt', h: 'help' };
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
if (key === 'S' || key === 'm' || key === 'p') {
|
|
76
|
+
const nx = argv[i + 1];
|
|
77
|
+
// Take the next token as the value only if it exists and is not another
|
|
78
|
+
// flag. Previously `-S -y` swallowed `-y` as the session id and a bare
|
|
79
|
+
// trailing `-m` became undefined; both silently broke the real flag.
|
|
80
|
+
const hasVal = nx !== undefined && !nx.startsWith('-');
|
|
81
|
+
if (hasVal) { out[short[key]] = argv[i + 1]; i++; }
|
|
82
|
+
else if (key === 'S') { out.session = true; } // "-S" with no id => pick interactively
|
|
83
|
+
// '-m' / '-p' with no usable value: leave unset (same as a missing arg).
|
|
67
84
|
} else out[short[key]] = true;
|
|
68
85
|
} else {
|
|
69
86
|
out.positional.push(a);
|
package/src/llm.js
CHANGED
|
@@ -111,7 +111,23 @@ let llmToolsList = [];
|
|
|
111
111
|
export function setToolsList(list) { llmToolsList = list; }
|
|
112
112
|
|
|
113
113
|
export class LLM {
|
|
114
|
-
constructor(cfg) {
|
|
114
|
+
constructor(cfg) {
|
|
115
|
+
this.cfg = cfg;
|
|
116
|
+
this.controller = null;
|
|
117
|
+
// Inline-reasoning state carried ACROSS requests. Our system prompt tells the
|
|
118
|
+
// model to put all its reasoning inside one <think> span, and tool calls happen
|
|
119
|
+
// inside that span — so the span outlives a single request. Reset when the turn
|
|
120
|
+
// ends (see resetThink).
|
|
121
|
+
this._inThink = false;
|
|
122
|
+
this._tagPending = '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Called by the agent at the end of a turn: the next turn starts a fresh
|
|
126
|
+
// reasoning block, even if the previous one never saw its closing tag.
|
|
127
|
+
resetThink() {
|
|
128
|
+
this._inThink = false;
|
|
129
|
+
this._tagPending = '';
|
|
130
|
+
}
|
|
115
131
|
|
|
116
132
|
// Abort an in-flight request (Esc / Ctrl-C while the agent is running).
|
|
117
133
|
abort() {
|
|
@@ -219,7 +235,15 @@ export class LLM {
|
|
|
219
235
|
const reader = res.body.getReader();
|
|
220
236
|
const decoder = new TextDecoder();
|
|
221
237
|
let buf = '';
|
|
222
|
-
|
|
238
|
+
// The inline-reasoning state (`inThink` / `tagPending`) is carried on the LLM
|
|
239
|
+
// instance, NOT recreated per request. A model that opens <think>, runs a tool,
|
|
240
|
+
// and only writes </think> in the NEXT step would otherwise have everything
|
|
241
|
+
// after the tool call reclassified as the ANSWER — the reasoning text and the
|
|
242
|
+
// literal `</think>` tag both showed up in the transcript.
|
|
243
|
+
const state = {
|
|
244
|
+
tools: new Map(), indexToId: {}, doneEmitted: false,
|
|
245
|
+
inThink: !!this._inThink, tagPending: this._tagPending || '',
|
|
246
|
+
};
|
|
223
247
|
try {
|
|
224
248
|
while (true) {
|
|
225
249
|
const { done, value } = await reader.read();
|
|
@@ -242,6 +266,12 @@ export class LLM {
|
|
|
242
266
|
return;
|
|
243
267
|
} finally {
|
|
244
268
|
reader.releaseLock?.();
|
|
269
|
+
// Carry the inline-reasoning state forward on EVERY exit path — a normal end,
|
|
270
|
+
// [DONE], an abort and an error all pass through here. The model may resume an
|
|
271
|
+
// open <think> span after a tool call in the next step, so this belongs to the
|
|
272
|
+
// turn, not to one request.
|
|
273
|
+
this._inThink = !!state.inThink;
|
|
274
|
+
this._tagPending = state.tagPending || '';
|
|
245
275
|
}
|
|
246
276
|
finish(state, onEvent);
|
|
247
277
|
}
|
|
@@ -249,15 +279,28 @@ export class LLM {
|
|
|
249
279
|
|
|
250
280
|
// ---- inline reasoning tags -------------------------------------------------
|
|
251
281
|
// 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
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
282
|
+
// field: they put the chain of thought INLINE in `content`, wrapped in a tag.
|
|
283
|
+
// Three spellings have to be supported, because they come from three places:
|
|
284
|
+
// ` thinking…` — the DeepSeek-R1 token markers, as emitted by
|
|
285
|
+
// vLLM / llama.cpp builds of that model
|
|
286
|
+
// `<thinking>…</thinking>` — what several OpenAI-compatible servers wrap it in
|
|
287
|
+
// `<think>…</think>` — what OUR OWN system prompt asks for (agent.js
|
|
288
|
+
// and prompt-presets.js both instruct this)
|
|
289
|
+
// The last one was missing, so a model that followed our prompt had its whole
|
|
290
|
+
// reasoning trace rendered as the ANSWER, raw tags and all.
|
|
291
|
+
//
|
|
292
|
+
// The three are mutually distinguishable, which is what makes supporting all of
|
|
293
|
+
// them safe: `</think>` does not occur inside `</thinking>`, `<think>` does not
|
|
294
|
+
// occur inside `<thinking>`, and `' thinking'` does not occur inside `<think>`.
|
|
295
|
+
// So an early match can never be caused by the longer tag.
|
|
296
|
+
//
|
|
297
|
+
// Without splitting these out, the raw tags and the whole reasoning trace are
|
|
298
|
+
// rendered as the assistant's answer (and counted as output tokens). This is a
|
|
299
|
+
// streaming state machine: a tag can be split across chunks, so a trailing partial
|
|
300
|
+
// tag is held back until the next chunk decides what it is.
|
|
301
|
+
const THINK_OPEN_TAGS = [' thinking', '<thinking>', '<think>'];
|
|
259
302
|
// Closing tags. If none of these appear, the block stays in think mode to EOI.
|
|
260
|
-
const THINK_CLOSE_TAGS = ['<|end▁of▁thinking|>', '</thinking>'];
|
|
303
|
+
const THINK_CLOSE_TAGS = ['<|end▁of▁thinking|>', '</thinking>', '</think>'];
|
|
261
304
|
|
|
262
305
|
// Longest suffix of `buf` that is a proper prefix of one of `tags`.
|
|
263
306
|
// A single trailing space is deliberately NOT a candidate: `' '` is a prefix of
|
|
@@ -313,7 +356,13 @@ function flushContent(onEvent, state) {
|
|
|
313
356
|
function finish(state, onEvent) {
|
|
314
357
|
// close any tool calls that started but never got an explicit end
|
|
315
358
|
if (state.doneEmitted) return;
|
|
316
|
-
|
|
359
|
+
// A still-OPEN reasoning block is deliberately NOT flushed here. The model can
|
|
360
|
+
// continue it after a tool call in the next step — our own system prompt tells it
|
|
361
|
+
// to put ALL reasoning in one <think> span, and tool calls happen inside that
|
|
362
|
+
// span. Flushing would dump the held-back partial tag and end the block early, so
|
|
363
|
+
// the rest of the reasoning would be reclassified as the ANSWER. The caller keeps
|
|
364
|
+
// `inThink`/`tagPending` on the LLM instance and the block resumes there.
|
|
365
|
+
if (!state.inThink) flushContent(onEvent, state);
|
|
317
366
|
for (const [, t] of state.tools) {
|
|
318
367
|
if (!t.done) onEvent({ type: 'tool_end', id: t.id });
|
|
319
368
|
}
|
package/src/plugin.js
CHANGED
|
@@ -125,6 +125,15 @@ export async function loadPlugins(dir) {
|
|
|
125
125
|
const pluginDir = dir || PLUGIN_DIR;
|
|
126
126
|
if (!fs.existsSync(pluginDir)) return [];
|
|
127
127
|
|
|
128
|
+
// loadPlugins() may be called more than once (tests, hot reload). Reset the
|
|
129
|
+
// registries first so a second load cannot throw "tool already registered"
|
|
130
|
+
// and accumulate stale hooks/config over reloads.
|
|
131
|
+
registeredTools.length = 0;
|
|
132
|
+
registeredCommands.length = 0;
|
|
133
|
+
for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
|
|
134
|
+
for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
|
|
135
|
+
loadedPlugins.length = 0;
|
|
136
|
+
|
|
128
137
|
const files = fs.readdirSync(pluginDir)
|
|
129
138
|
.filter((f) => f.endsWith('.js') || f.endsWith('.mjs'))
|
|
130
139
|
.sort();
|
|
@@ -189,6 +198,10 @@ export async function runHooks(name, ...args) {
|
|
|
189
198
|
// ---------------------------------------------------------------------------
|
|
190
199
|
|
|
191
200
|
export { API, registeredTools as pluginTools, registeredCommands as pluginCommands };
|
|
201
|
+
// Plugin-provided config DEFAULTS. resolveConfig() must merge these BEFORE
|
|
202
|
+
// user config so a plugin can supply a value the user has not set. Previously
|
|
203
|
+
// registerConfig() stored into this object and nothing ever read it.
|
|
204
|
+
export { registeredConfig as pluginConfigDefaults };
|
|
192
205
|
|
|
193
206
|
// Reset state — useful for tests.
|
|
194
207
|
export function _resetPlugins() {
|