@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/LICENSE +674 -219
- package/README.md +1 -0
- package/README_CN.md +1 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +101 -8
- package/src/colors.js +14 -0
- package/src/config.js +191 -10
- package/src/index.js +31 -9
- package/src/llm.js +60 -11
- package/src/plugin.js +25 -2
- package/src/prompt-presets.js +429 -0
- package/src/session.js +257 -24
- package/src/subagent-types.js +147 -0
- package/src/swarm-progress.js +293 -0
- package/src/task-output-viewer.js +135 -0
- package/src/tasks-browser.js +279 -0
- package/src/tools/agent-swarm.js +208 -0
- package/src/tools/agent.js +342 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +53 -29
- package/src/tools/edit.js +122 -11
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +17 -5
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +19 -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 +141 -126
- package/src/tools/todo.js +29 -2
- package/src/tools/utils.js +11 -1
- package/src/tui.js +2264 -337
- package/src/update.js +95 -0
- package/src/version.js +20 -0
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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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) {
|
|
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();
|
|
@@ -143,8 +152,18 @@ export async function loadPlugins(dir) {
|
|
|
143
152
|
continue;
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
|
|
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() {
|