@hncode/hncode 0.3.0 → 0.3.2

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- const b = baseUrl.replace(/\/+$/, '');
238
- const endpoint = protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`;
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 Object.entries(overlay)) out += toTomlLine(k, v) + '\n';
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
- fs.writeFileSync(dir, existing + '\n' + out, 'utf8');
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
- return { ...cfg, provider: providerName, innerModel: inner, baseUrl: b, endpoint, apiKey, protocol };
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
- return { ...resolveProvider(cfg, provider, inner), model: modelName };
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
- export const VERSION = '0.1.0';
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
- else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir') {
57
- if (val === null) { val = argv[++i]; }
58
- out[key] = key === 'add-dir' ? (out[key] || []).concat([val]) : val;
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
- if (key === 'S' || key === 'm' || key === 'p') {
65
- let val = argv[++i];
66
- out[short[key]] = val;
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) { this.cfg = cfg; this.controller = null; }
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
- const state = { tools: new Map(), indexToId: {}, doneEmitted: false };
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
- // 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>'];
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
- flushContent(onEvent, state);
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() {