@hncode/hncode 0.2.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/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
 
@@ -62,6 +63,124 @@ export function hncodeConfigFile() {
62
63
  return process.env.HNCODE_CONFIG || path.join(home, '.hncode', 'config.toml');
63
64
  }
64
65
 
66
+ // ---- personal preferences (/personal) ----
67
+ // Free-form markdown the user wants applied on EVERY turn ("reply in Chinese",
68
+ // "I use pnpm", "never add a trailing newline", …). Two scopes are merged, the
69
+ // project one LAST so it can refine the global one:
70
+ // * global — ~/.hncode/PERSONAL.md (every workspace)
71
+ // * project — <workspace>/.hncode/PERSONAL.md (this workspace only)
72
+ // Both are OPTIONAL; a missing/empty file injects nothing. The files are read
73
+ // fresh on each turn, so an edit through /personal takes effect immediately.
74
+ export function personalPromptFile(scope, workspace) {
75
+ if (scope === 'global') return path.join(home, '.hncode', 'PERSONAL.md');
76
+ return path.join(workspace || process.cwd(), '.hncode', 'PERSONAL.md');
77
+ }
78
+
79
+ // Header placed before the injected preferences, so the model knows where the
80
+ // text came from and how much authority it has.
81
+ export const PERSONAL_PROMPT_HEADER =
82
+ 'USER PERSONAL PREFERENCES (persistent notes from the user; follow them unless\n'
83
+ + 'the current request says otherwise):';
84
+
85
+ // Raw file content for one scope (no header) — used by /personal's editor.
86
+ export function readPersonalPromptRaw(scope, workspace) {
87
+ try { return fs.readFileSync(personalPromptFile(scope, workspace), 'utf8'); } catch { return ''; }
88
+ }
89
+
90
+ // Save one scope. Empty text REMOVES the file (so nothing is injected).
91
+ export function writePersonalPrompt(scope, workspace, text) {
92
+ const file = personalPromptFile(scope, workspace);
93
+ const value = String(text == null ? '' : text);
94
+ if (!value.trim()) {
95
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
96
+ return file;
97
+ }
98
+ fs.mkdirSync(path.dirname(file), { recursive: true });
99
+ fs.writeFileSync(file, value.replace(/\s+$/, '') + '\n', 'utf8');
100
+ return file;
101
+ }
102
+
103
+ export function readPersonalPrompt(workspace) {
104
+ const parts = [];
105
+ for (const scope of ['global', 'project']) {
106
+ try {
107
+ const t = fs.readFileSync(personalPromptFile(scope, workspace), 'utf8').trim();
108
+ if (t) parts.push(t);
109
+ } catch { /* no file for this scope: nothing to inject */ }
110
+ }
111
+ if (!parts.length) return '';
112
+ return PERSONAL_PROMPT_HEADER + '\n' + parts.join('\n\n');
113
+ }
114
+
115
+ // ---- AGENTS.md --------------------------------------------------------------
116
+ // Project instruction files, mirroring codex's AGENTS.md spec
117
+ // (codex-rs/core/gpt_5_2_prompt.md, "AGENTS.md spec"):
118
+ // * a file's scope is the whole directory tree rooted where it sits;
119
+ // * for every file you touch you must obey the AGENTS.md whose scope covers it;
120
+ // * more-deeply-nested files take precedence on conflict;
121
+ // * direct system/user instructions outrank all of them.
122
+ // Files are therefore collected from the workspace root DOWN to the working
123
+ // directory and injected outermost-first, so the deepest one is read last and
124
+ // naturally wins — the same precedence, without a merge step.
125
+ //
126
+ // This closes a real gap: /init WRITES an AGENTS.md and the prompt advertises it
127
+ // as "smarter agent context", but nothing ever read it back.
128
+ const AGENTS_MAX_BYTES = 32 * 1024;
129
+ const AGENTS_FILENAMES = ['AGENTS.md', 'agents.md', 'AGENTS.override.md'];
130
+
131
+ export const AGENTS_PROMPT_HEADER =
132
+ 'PROJECT INSTRUCTIONS (AGENTS.md). The scope of each file is the directory it sits in,\n'
133
+ + 'so a deeper file is more specific and takes precedence over a shallower one. They apply\n'
134
+ + 'to every file you touch under that directory. The current request and the user\'s direct\n'
135
+ + 'instructions still outrank anything here.'
136
+
137
+ // Every applicable AGENTS.md, outermost first. `dir` keeps the scope visible.
138
+ export function agentsFilesFor(cwd, workspaceRoot) {
139
+ const start = path.resolve(cwd || process.cwd());
140
+ const root = path.resolve(workspaceRoot || start);
141
+ const dirs = [];
142
+ let dir = start;
143
+ for (;;) {
144
+ dirs.push(dir);
145
+ const parent = path.dirname(dir);
146
+ // Stop at the filesystem root, or once we have walked past the workspace.
147
+ if (parent === dir || dir === root) break;
148
+ dir = parent;
149
+ }
150
+ dirs.reverse(); // outermost first
151
+ const found = [];
152
+ for (const d of dirs) {
153
+ for (const name of AGENTS_FILENAMES) {
154
+ const p = path.join(d, name);
155
+ try {
156
+ if (!fs.statSync(p).isFile()) continue;
157
+ found.push({ path: p, dir: d });
158
+ break; // one instruction file per directory
159
+ } catch { /* not present here */ }
160
+ }
161
+ }
162
+ return found;
163
+ }
164
+
165
+ // The block injected into the system prompt, or '' when the project has none.
166
+ export function readAgentsMd(cwd, workspaceRoot) {
167
+ const files = agentsFilesFor(cwd, workspaceRoot);
168
+ if (!files.length) return '';
169
+ const parts = [];
170
+ for (const f of files) {
171
+ try {
172
+ let text = fs.readFileSync(f.path, 'utf8').trim();
173
+ if (!text) continue;
174
+ if (Buffer.byteLength(text, 'utf8') > AGENTS_MAX_BYTES) {
175
+ text = Buffer.from(text, 'utf8').subarray(0, AGENTS_MAX_BYTES).toString('utf8') + '\n[truncated]';
176
+ }
177
+ parts.push(`--- ${f.path} ---\n${text}`);
178
+ } catch { /* unreadable: skip rather than fail the turn */ }
179
+ }
180
+ if (!parts.length) return '';
181
+ return AGENTS_PROMPT_HEADER + '\n\n' + parts.join('\n\n');
182
+ }
183
+
65
184
  function loadBaseToml() {
66
185
  // Only hncode's own config file participates.
67
186
  const f = hncodeConfigFile();
@@ -76,6 +195,11 @@ function loadBaseToml() {
76
195
 
77
196
  export function resolveConfig() {
78
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
+ }
79
203
 
80
204
  const providers = root.providers || {};
81
205
  const models = root.models || {};
@@ -116,8 +240,11 @@ export function resolveConfig() {
116
240
  // The base URL is used EXACTLY as configured — we only append the API path.
117
241
  // (No `/v1` is injected: if the user's base ends in /v1, the result is
118
242
  // `<base>/chat/completions`; if it doesn't, it's `<base>/chat/completions` too.)
119
- const b = baseUrl.replace(/\/+$/, '');
120
- 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`);
121
248
 
122
249
  // Context window: an explicit env/root override wins, then the model's own
123
250
  // `contextLength` (discovered from the provider's /v1/models), then the
@@ -146,31 +273,66 @@ export function resolveConfig() {
146
273
  // Backwards-compat: read legacy cool_mode key if calm_mode is not set.
147
274
  : (root.calm_mode === true || root.calm_mode === 'true'
148
275
  || root.cool_mode === true || root.cool_mode === 'true'),
276
+ // SUBAGENT MODEL (/swarm-sub-agent): the model each subagent runs on. Empty
277
+ // (the default) means "follow this session's model", which is what the Agent
278
+ // and AgentSwarm tools fall back to. Set from config.toml so it persists.
279
+ subagentModel: process.env.HNCODE_SUBAGENT_MODEL || root.subagent_model || '',
149
280
  // Plugin directory (default: ~/.hncode/plugins). Set to "" or "false" to
150
281
  // disable plugin loading entirely.
151
282
  pluginDir: process.env.HNCODE_PLUGINS || root.plugins_dir || '',
283
+ // AUTO-UPDATE (/auto-update): when true, hncode checks npm for a newer
284
+ // version at startup and then every 30 minutes, installing in the background
285
+ // without interrupting the session.
286
+ autoUpdate: process.env.HNCODE_AUTO_UPDATE
287
+ ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
288
+ : (root.auto_update === true || root.auto_update === 'true'),
152
289
  raw: root,
153
290
  };
154
291
  }
155
292
 
156
293
  export function saveConfig(overlay) {
157
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.
158
298
  let out = '';
159
- 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';
160
300
  fs.mkdirSync(path.dirname(dir), { recursive: true });
161
-
301
+
162
302
  // Check if file exists and has content
163
303
  try {
164
304
  if (fs.statSync(dir).isFile()) {
165
305
  const existing = fs.readFileSync(dir, 'utf8');
166
306
  if (existing.trim().length > 0) {
167
- // Append to existing config instead of overwriting
168
- 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
+ }
169
331
  return dir;
170
332
  }
171
333
  }
172
334
  } catch {}
173
-
335
+
174
336
  // New file or empty file
175
337
  fs.writeFileSync(dir, out, 'utf8');
176
338
  return dir;
@@ -506,6 +668,20 @@ export function effectiveSnapshot(cfg) {
506
668
  // sub-table takes precedence (it's the point of switching); env vars set at boot
507
669
  // are already baked into `cfg` and only remain in effect when the provider table
508
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
+
509
685
  export function resolveProvider(cfg, providerName, innerModel) {
510
686
  const pr = (cfg.raw && cfg.raw.providers) || {};
511
687
  const prov = pr[providerName] || {};
@@ -514,8 +690,11 @@ export function resolveProvider(cfg, providerName, innerModel) {
514
690
  const protocol = (prov.protocol) || cfg.protocol;
515
691
  const inner = prov.model || (innerModel != null ? innerModel : cfg.innerModel);
516
692
  const b = String(baseUrl || '').replace(/\/+$/, '');
517
- const endpoint = protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`;
518
- 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 };
519
698
  }
520
699
 
521
700
  // Switch model, optionally moving to a provider declared via [models.NAME].
@@ -527,7 +706,9 @@ export function resolveModelArg(cfg, modelName) {
527
706
  const entry = models[modelName] || {};
528
707
  const provider = entry.provider || cfg.provider;
529
708
  const inner = bareModelId(provider, modelName, entry);
530
- 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 };
531
712
  }
532
713
 
533
714
  export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile };
package/src/index.js CHANGED
@@ -3,13 +3,16 @@
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
- import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg } from './config.js';
6
+ import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg, readPersonalPrompt } from './config.js';
7
7
  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);
@@ -171,7 +188,12 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
171
188
  }
172
189
  const messages = [];
173
190
  if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
174
- messages.push({ role: 'system', content: SYSTEM_PROMPT });
191
+ // Same layering as the TUI: built-in prompt first, then the user's own
192
+ // system_prompt override if any, then the /personal notes, then calm mode.
193
+ let sysText = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
194
+ const personal = readPersonalPrompt(cfg.workspace);
195
+ if (personal) sysText += '\n\n' + personal;
196
+ messages.push({ role: 'system', content: sysText });
175
197
  const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
176
198
  const agent = new Agent({
177
199
  cfg,
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();
@@ -143,8 +152,18 @@ export async function loadPlugins(dir) {
143
152
  continue;
144
153
  }
145
154
 
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' };
155
+ // Metadata comes from the default export. `name` there is a plain STRING
156
+ // (`export default { name: 'recorder' }`), so reading `meta.name` off it
157
+ // returned undefined and the plugin showed up as its FILENAME with version
158
+ // 0.0.0 — /plugins listed every plugin under the wrong name. Accept both
159
+ // shapes: a string name, or an object carrying { name, version }.
160
+ const def = (mod && mod.default) || {};
161
+ const meta = typeof def === 'string' ? { name: def } : def;
162
+ const plugin = {
163
+ id: f,
164
+ name: meta.name || meta.meta?.name || f,
165
+ version: meta.version || meta.meta?.version || '0.0.0',
166
+ };
148
167
  loadedPlugins.push(plugin);
149
168
  installFn(API);
150
169
  // eslint-disable-next-line no-console
@@ -179,6 +198,10 @@ export async function runHooks(name, ...args) {
179
198
  // ---------------------------------------------------------------------------
180
199
 
181
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 };
182
205
 
183
206
  // Reset state — useful for tests.
184
207
  export function _resetPlugins() {