@hncode/hncode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +219 -0
- package/README.md +330 -0
- package/TODO.md +72 -0
- package/bin/hncode +9 -0
- package/bin/hncode.cmd +2 -0
- package/bin/hncode.ps1 +4 -0
- package/bin/install.js +96 -0
- package/bin/preinstall.js +42 -0
- package/package.json +26 -0
- package/src/agent.js +373 -0
- package/src/clipboard.js +114 -0
- package/src/colors.js +149 -0
- package/src/config.js +533 -0
- package/src/index.js +296 -0
- package/src/llm.js +449 -0
- package/src/plugin.js +191 -0
- package/src/session.js +73 -0
- package/src/term.js +292 -0
- package/src/toml.js +281 -0
- package/src/tools/bash.js +240 -0
- package/src/tools/edit.js +138 -0
- package/src/tools/fetch-url.js +108 -0
- package/src/tools/file-lines.js +36 -0
- package/src/tools/glob.js +47 -0
- package/src/tools/grep.js +152 -0
- package/src/tools/ignore.js +99 -0
- package/src/tools/index.js +61 -0
- package/src/tools/matchers.js +71 -0
- package/src/tools/read-media-file.js +54 -0
- package/src/tools/read.js +99 -0
- package/src/tools/tasks.js +126 -0
- package/src/tools/todo.js +33 -0
- package/src/tools/utils.js +71 -0
- package/src/tools/web-search.js +75 -0
- package/src/tools/write.js +29 -0
- package/src/tui.js +5503 -0
package/bin/install.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hncode auto-install script
|
|
4
|
+
* Creates default configuration and sets up the environment
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// Get user home directory (cross-platform)
|
|
12
|
+
const homeDir = os.homedir();
|
|
13
|
+
const configDir = path.join(homeDir, '.hncode');
|
|
14
|
+
const configFile = path.join(configDir, 'config.toml');
|
|
15
|
+
const sessionsDir = path.join(configDir, 'sessions');
|
|
16
|
+
const pluginsDir = path.join(configDir, 'plugins');
|
|
17
|
+
|
|
18
|
+
console.log('🚀 Setting up hncode...\n');
|
|
19
|
+
|
|
20
|
+
// Create directories if they don't exist
|
|
21
|
+
const dirs = [configDir, sessionsDir, pluginsDir];
|
|
22
|
+
for (const dir of dirs) {
|
|
23
|
+
if (!fs.existsSync(dir)) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
console.log(`✓ Created directory: ${dir}`);
|
|
26
|
+
} else {
|
|
27
|
+
console.log(`✓ Directory exists: ${dir}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check if config file exists
|
|
32
|
+
if (!fs.existsSync(configFile)) {
|
|
33
|
+
const defaultConfig = `# hncode Configuration
|
|
34
|
+
# Generated by hncode install script
|
|
35
|
+
|
|
36
|
+
# Default model (change this to your preferred model)
|
|
37
|
+
default_model = "gpt-4o-mini"
|
|
38
|
+
|
|
39
|
+
# Optional: Add providers directly here
|
|
40
|
+
# Uncomment and configure as needed:
|
|
41
|
+
#
|
|
42
|
+
# [providers.openai]
|
|
43
|
+
# base_url = "https://api.openai.com/v1"
|
|
44
|
+
# api_key = "your-api-key-here"
|
|
45
|
+
# protocol = "openai"
|
|
46
|
+
|
|
47
|
+
# Or use environment variables:
|
|
48
|
+
# HNCODE_API_KEY="your-key"
|
|
49
|
+
# HNCODE_PROVIDER="openai"
|
|
50
|
+
# HNCODE_MODEL="gpt-4o-mini"
|
|
51
|
+
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
fs.writeFileSync(configFile, defaultConfig);
|
|
55
|
+
console.log(`\n✅ Created default configuration at: ${configFile}`);
|
|
56
|
+
console.log('\n💡 Next steps:');
|
|
57
|
+
console.log(' 1. Edit ~/.hncode/config.toml with your API key');
|
|
58
|
+
console.log(' 2. Or set environment variables:');
|
|
59
|
+
console.log(' export HNCODE_API_KEY="your-key"');
|
|
60
|
+
console.log(' export HNCODE_PROVIDER="openai"');
|
|
61
|
+
console.log(' 3. Run "hncode" to start!');
|
|
62
|
+
} else {
|
|
63
|
+
console.log(`\nℹ️ Configuration already exists at: ${configFile}`);
|
|
64
|
+
console.log(' Run "hncode init" to regenerate or edit manually.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('\n✨ hncode is ready to use!');
|
|
68
|
+
|
|
69
|
+
// Try to refresh environment automatically
|
|
70
|
+
try {
|
|
71
|
+
const platform = os.platform();
|
|
72
|
+
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
// Windows: Use PowerShell to refresh environment
|
|
75
|
+
const { execSync } = require('child_process');
|
|
76
|
+
try {
|
|
77
|
+
execSync('Refresh-Environment', { stdio: 'pipe' });
|
|
78
|
+
console.log('✓ Environment refreshed automatically (PowerShell)');
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.log('ℹ️ To refresh environment in PowerShell:');
|
|
81
|
+
console.log(' . "$env:LOCALAPPDATA\\npm\\npm.cmd"');
|
|
82
|
+
console.log(' Or simply close and reopen your terminal');
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
// Unix-like systems
|
|
86
|
+
console.log('ℹ️ Please run one of these to refresh environment:');
|
|
87
|
+
console.log(' source ~/.zshrc # For Zsh');
|
|
88
|
+
console.log(' source ~/.bashrc # For Bash');
|
|
89
|
+
console.log(' Or simply close and reopen your terminal');
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.log('ℹ️ Note: You may need to restart your terminal');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(' Type "hncode --help" for available commands.\n');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hncode pre-install script
|
|
4
|
+
* Checks environment requirements before installation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
console.log('🔍 Checking environment...\n');
|
|
11
|
+
|
|
12
|
+
// Check Node.js version
|
|
13
|
+
const nodeVersion = process.version.replace('v', '');
|
|
14
|
+
const [major] = nodeVersion.split('.').map(Number);
|
|
15
|
+
|
|
16
|
+
if (major < 20) {
|
|
17
|
+
console.warn(`⚠️ Warning: Node.js v${nodeVersion} detected.`);
|
|
18
|
+
console.warn(' hncode requires Node.js >= 20. Please upgrade.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
} else {
|
|
21
|
+
console.log(`✓ Node.js v${nodeVersion} ✓`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check platform
|
|
25
|
+
const platform = os.platform();
|
|
26
|
+
console.log(`✓ Platform: ${platform}`);
|
|
27
|
+
|
|
28
|
+
// Check if already installed globally
|
|
29
|
+
try {
|
|
30
|
+
const { execSync } = require('child_process');
|
|
31
|
+
const npm = execSync('npm list -g hncode 2>&1').toString();
|
|
32
|
+
if (npm.includes('hncode')) {
|
|
33
|
+
console.log('ℹ️ hncode is already installed globally');
|
|
34
|
+
console.log(' Run "npm uninstall -g hncode" to reinstall.\n');
|
|
35
|
+
} else {
|
|
36
|
+
console.log('✓ Not installed globally yet\n');
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.log('✓ Not installed globally yet\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('✅ Environment check passed!\n');
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hncode/hncode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hncode": "bin/hncode"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"preinstall": "node bin/preinstall.js",
|
|
11
|
+
"install": "node bin/install.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"license": "GPL-3.0-only",
|
|
17
|
+
"author": "NiceHello666",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"agent",
|
|
21
|
+
"coding",
|
|
22
|
+
"ai",
|
|
23
|
+
"tui"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {}
|
|
26
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// The agent loop: sends the message history to the LLM, consumes the normalized
|
|
2
|
+
// event stream, executes any tool calls, appends results, and repeats until the
|
|
3
|
+
// model answers without tool calls (or a step/interrupt condition is hit).
|
|
4
|
+
|
|
5
|
+
import { LLM, setToolsList } from './llm.js';
|
|
6
|
+
import { tools, getTool, llmTools } from './tools/index.js';
|
|
7
|
+
import { estimateMessagesTokens } from './term.js';
|
|
8
|
+
|
|
9
|
+
// ---- completion guard --------------------------------------------------------
|
|
10
|
+
// A turn is only complete when the model ends it with an actual answer. Models
|
|
11
|
+
// do stop mid-task: the stream ends right after a reasoning block, or with an
|
|
12
|
+
// empty assistant message, and the caller would report "done" for unfinished
|
|
13
|
+
// work. When that happens we tell the model what it did and give it more steps.
|
|
14
|
+
// Bounded, so a model that insists on stopping cannot loop forever.
|
|
15
|
+
export const MAX_NUDGES = 5;
|
|
16
|
+
|
|
17
|
+
export function nudgeMessage(why) {
|
|
18
|
+
return `[hncode] Your turn ended without a final answer: ${why}. `
|
|
19
|
+
+ 'This usually means the task is not finished. If the work IS complete, reply with a short '
|
|
20
|
+
+ 'summary of what changed and how you verified it. Otherwise keep going with the tools right '
|
|
21
|
+
+ 'now — do not stop silently.';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
25
|
+
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
26
|
+
|
|
27
|
+
export const SYSTEM_PROMPT = `You are hncode, a coding agent running. You must monitor your context usage. When you detect that you're approaching the context limit (see your last messages about token counts), you should proactively call /compact to reduce the conversation history. in the user's terminal on ${process.platform}.
|
|
28
|
+
You operate inside a workspace directory. You can inspect files, edit code, run commands, and track tasks using the tools available.
|
|
29
|
+
|
|
30
|
+
Tools:
|
|
31
|
+
- Prefer Edit over Write for existing files.
|
|
32
|
+
- Read before you edit; never guess file contents. Never propose changes to code you haven't read.
|
|
33
|
+
- If a tool fails, diagnose why before switching tactics. Read the error; don't guess.
|
|
34
|
+
After 2 failed attempts on the same goal, STOP and report the blocker.
|
|
35
|
+
- Independent tool calls can run in parallel, but never parallelize calls with dependencies.
|
|
36
|
+
|
|
37
|
+
Scope:
|
|
38
|
+
- Do what was asked. Do not refactor, rename, or "improve" unrelated code.
|
|
39
|
+
- If the request is ambiguous, ask one short clarifying question before acting.
|
|
40
|
+
- If you make an assumption, state it explicitly.
|
|
41
|
+
|
|
42
|
+
Finishing:
|
|
43
|
+
- Never stop silently after a tool call.
|
|
44
|
+
- End every turn with a short report in the user's language:
|
|
45
|
+
Done: what changed (files / commands / result), and how you verified it.
|
|
46
|
+
Not done: what failed, what you tried, and what you need from the user.
|
|
47
|
+
- Never claim success without evidence. Cite file_path:line_number for any code you
|
|
48
|
+
reference. If you didn't run a command or read back the file, you don't know it worked.
|
|
49
|
+
- Be brief. No filler, no restating the request.
|
|
50
|
+
- After tools succeed, don't re-explain what the tool did. Report only the outcome.
|
|
51
|
+
|
|
52
|
+
Finishing a turn:
|
|
53
|
+
- End every turn with a written answer. Never stop right after a tool call, and never
|
|
54
|
+
stop with reasoning only: either keep calling tools until the task is done, or write
|
|
55
|
+
the summary. A turn that ends without an answer is flagged back to you with a reminder.
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
export class Agent {
|
|
59
|
+
constructor({ cfg, messages, onEvent, maxSteps = 0, onToolStart, canInterrupt = () => false, onApproval }) {
|
|
60
|
+
this.cfg = cfg;
|
|
61
|
+
this.messages = messages;
|
|
62
|
+
this.onEvent = onEvent;
|
|
63
|
+
this.maxSteps = maxSteps;
|
|
64
|
+
this.onToolStart = onToolStart; // (name, args) => void (for headless/TTY overlays)
|
|
65
|
+
// Ensure the LLM client knows the bare tool specs (the client wraps them per-protocol).
|
|
66
|
+
// A toolFilter (array of tool names) limits which tools are exposed — used
|
|
67
|
+
// by Plan mode (read-only subset) and Focus mode (minimal subset first).
|
|
68
|
+
const filtered = Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
69
|
+
? tools.filter((t) => cfg.toolFilter.includes(t.name))
|
|
70
|
+
: tools;
|
|
71
|
+
setToolsList(filtered);
|
|
72
|
+
// AbortSignal handed to every tool so a long-running one (Bash) is killed
|
|
73
|
+
// when the user presses Esc / Ctrl-C — otherwise the command keeps running
|
|
74
|
+
// after the turn was interrupted.
|
|
75
|
+
this.toolAbort = new AbortController();
|
|
76
|
+
this.ctx = { ...cfg, tasks: {}, todoState: Array.isArray(cfg.todoState) ? cfg.todoState.slice() : [], allowExternal: !!cfg.allowExternal, signal: this.toolAbort.signal };
|
|
77
|
+
// Live output stream for a RUNNING tool (Bash): the tool calls this with raw
|
|
78
|
+
// chunks and the agent tags them with the id of the tool call in flight, so
|
|
79
|
+
// the TUI can append them to the matching "Using …" row while it runs.
|
|
80
|
+
this._currentToolId = null;
|
|
81
|
+
this.ctx.onOutput = (chunk) => {
|
|
82
|
+
if (chunk && this._currentToolId) this.onEvent({ type: 'tool_output', id: this._currentToolId, chunk });
|
|
83
|
+
};
|
|
84
|
+
this.llm = new LLM(cfg);
|
|
85
|
+
this.stopRequested = false;
|
|
86
|
+
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
87
|
+
// Messages the user types while the agent is streaming. They are injected
|
|
88
|
+
// as user messages right AFTER the current round of tool calls, so the
|
|
89
|
+
// model sees "tool callback / user steer" together on the next request —
|
|
90
|
+
// exactly like kimi-code's steer (no separate turn feel).
|
|
91
|
+
this.steerQueue = [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Queue a steering message typed while streaming. It takes effect once the
|
|
95
|
+
// in-flight tool calls finish and the loop asks the model again.
|
|
96
|
+
steer(text) {
|
|
97
|
+
if (text && text.trim()) this.steerQueue.push(String(text).trim());
|
|
98
|
+
}
|
|
99
|
+
// True when a steer is pending injection.
|
|
100
|
+
get hasSteer() { return this.steerQueue.length > 0; }
|
|
101
|
+
|
|
102
|
+
interrupt() {
|
|
103
|
+
this.stopRequested = true;
|
|
104
|
+
try { this.toolAbort.abort(); } catch {}
|
|
105
|
+
if (this.llm) this.llm.abort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async run() {
|
|
109
|
+
let text = '';
|
|
110
|
+
let toolCalls = [];
|
|
111
|
+
let emittedEnd = false;
|
|
112
|
+
// Reasoning text seen in the current step. A turn that ends with reasoning
|
|
113
|
+
// but no answer is a truncated turn (see the completion guard below).
|
|
114
|
+
let thought = '';
|
|
115
|
+
let nudges = 0;
|
|
116
|
+
// True when the most recent iteration still had tool calls pending; used to
|
|
117
|
+
// decide whether running out of steps actually left the turn unfinished.
|
|
118
|
+
let hadToolCallsOnFinalStep = false;
|
|
119
|
+
|
|
120
|
+
const handle = async (e) => {
|
|
121
|
+
switch (e.type) {
|
|
122
|
+
case 'data':
|
|
123
|
+
text += e.text;
|
|
124
|
+
this.onEvent({ type: 'data', text: e.text });
|
|
125
|
+
break;
|
|
126
|
+
case 'think':
|
|
127
|
+
// Reasoning tokens: forward verbatim so the TUI can render a live
|
|
128
|
+
// "thinking…" block. They are NOT part of the assistant message.
|
|
129
|
+
thought += e.text;
|
|
130
|
+
this.onEvent({ type: 'think', text: e.text });
|
|
131
|
+
break;
|
|
132
|
+
case 'tool_start':
|
|
133
|
+
toolCalls.push({ id: e.id, name: e.name, args: {}, argsJson: '' });
|
|
134
|
+
this.onEvent({ type: 'tool_start', id: e.id, name: e.name });
|
|
135
|
+
break;
|
|
136
|
+
case 'tool_args': {
|
|
137
|
+
const tc = toolCalls.find((t) => t.id === e.id);
|
|
138
|
+
if (tc) { tc.argsJson += e.chunk; }
|
|
139
|
+
// Forward the raw argument chunk so the TUI can stream a Write's
|
|
140
|
+
// content (or an Edit's old/new text) while the model is emitting it.
|
|
141
|
+
this.onEvent({ type: 'tool_args', id: e.id, chunk: e.chunk });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case 'tool_end': {
|
|
145
|
+
const tc = toolCalls.find((t) => t.id === e.id);
|
|
146
|
+
if (tc) {
|
|
147
|
+
try { tc.args = tc.argsJson ? JSON.parse(tc.argsJson) : {}; }
|
|
148
|
+
catch { tc.args = { raw: tc.argsJson }; }
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case 'end':
|
|
153
|
+
emittedEnd = true;
|
|
154
|
+
break;
|
|
155
|
+
case 'aborted':
|
|
156
|
+
// The request was aborted (Esc / Ctrl-C): surface it and stop the loop.
|
|
157
|
+
this.stopRequested = true;
|
|
158
|
+
this.onEvent({ type: 'aborted' });
|
|
159
|
+
break;
|
|
160
|
+
case 'error':
|
|
161
|
+
this.onEvent({ type: 'error', error: e.error });
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// No step cap: the loop runs until the model produces a final answer, the
|
|
167
|
+
// user interrupts (Esc / Ctrl-C), or an error ends the turn. maxSteps is
|
|
168
|
+
// kept only for callers that explicitly request a bound (0/undefined = none).
|
|
169
|
+
// Runaway safety comes from the completion guard (MAX_NUDGES) and the user's
|
|
170
|
+
// ability to interrupt, not from a fixed tool-round budget.
|
|
171
|
+
const stepLimit = (this.maxSteps > 0) ? this.maxSteps : Infinity;
|
|
172
|
+
for (let step = 0; step < stepLimit; step++) {
|
|
173
|
+
// Process any steer messages collected during the previous step
|
|
174
|
+
if (this.steerQueue.length > 0) {
|
|
175
|
+
const steers = this.steerQueue.splice(0);
|
|
176
|
+
for (const text of steers) {
|
|
177
|
+
this.messages.push({ role: 'user', content: text });
|
|
178
|
+
this.onEvent({ type: 'steer', text });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (this.stopRequested) { this.onEvent({ type: 'stopped' }); break; }
|
|
182
|
+
text = ''; toolCalls = []; emittedEnd = false; thought = '';
|
|
183
|
+
// Report the start of each model round so the TUI can count steps/rounds.
|
|
184
|
+
this.onEvent({ type: 'step_start', step, round: step + 1 });
|
|
185
|
+
// ---- auto-compaction (kimi's runtime strategy) ----
|
|
186
|
+
// Before each model call, if the estimated context usage is at or above
|
|
187
|
+
// 85% of the model's max context, summarize the older portion of the
|
|
188
|
+
// conversation and keep only the most recent 20% of messages (plus the
|
|
189
|
+
// summary). This mirrors kimi's triggerRatio=0.85 & maxRecentSizeRatio=0.2
|
|
190
|
+
// policy, using the same ASCII/4 + non-ASCII token estimator. The dropped
|
|
191
|
+
// messages are replaced by a single AI-generated summary so context is
|
|
192
|
+
// preserved rather than simply discarded.
|
|
193
|
+
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
194
|
+
let usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
195
|
+
if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
196
|
+
const before = usedNow;
|
|
197
|
+
const keepN = Math.max(1, Math.ceil(this.messages.length * 0.2));
|
|
198
|
+
const keep = this.messages.slice(-keepN);
|
|
199
|
+
const dropped = this.messages.slice(0, this.messages.length - keepN);
|
|
200
|
+
this.messages = keep;
|
|
201
|
+
// Ask the model to summarize what was trimmed so far, then prepend it
|
|
202
|
+
// as a system message so the model retains the conversation arc.
|
|
203
|
+
let summary = '';
|
|
204
|
+
if (dropped.length > 0) {
|
|
205
|
+
const summaryPrompt = [
|
|
206
|
+
{ role: 'system', content: 'Summarize the conversation history that follows. Capture the user\'s goals, the key decisions made, files created/modified, and the current state of any ongoing work. Be concise but thorough.' },
|
|
207
|
+
...dropped,
|
|
208
|
+
];
|
|
209
|
+
const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
|
|
210
|
+
const llm = new LLM(cfgCopy);
|
|
211
|
+
try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
|
|
212
|
+
if (summary) {
|
|
213
|
+
this.messages = [{
|
|
214
|
+
role: 'system',
|
|
215
|
+
content: `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of what was trimmed:\n\n${summary}`,
|
|
216
|
+
}, ...this.messages];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
220
|
+
this.onEvent({ type: 'compacted', before, after: usedNow, kept: keepN });
|
|
221
|
+
}
|
|
222
|
+
// Live context gauge: report the estimated size of the request about to be
|
|
223
|
+
// sent, so the TUI's context readout is refreshed at every step rather than
|
|
224
|
+
// only once the whole turn ends.
|
|
225
|
+
this.onEvent({ type: 'context', tokens: usedNow, max: maxCtx });
|
|
226
|
+
|
|
227
|
+
// Inject any user steering messages typed during the previous step.
|
|
228
|
+
// They are appended right after the last assistant/tool round so the
|
|
229
|
+
// model sees "tool callback / user steer" on this next request.
|
|
230
|
+
if (this.steerQueue.length > 0) {
|
|
231
|
+
for (const steerText of this.steerQueue.splice(0)) {
|
|
232
|
+
this.messages.push({ role: 'user', content: steerText });
|
|
233
|
+
this.onEvent({ type: 'steer', text: steerText });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await this.llm.request(this.messages, handle);
|
|
238
|
+
|
|
239
|
+
// An abort ends the turn: keep whatever text arrived, then stop.
|
|
240
|
+
if (this.stopRequested) {
|
|
241
|
+
if (text) this.messages.push({ role: 'assistant', content: text });
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// append assistant message —— 绝不 push 既无文本、又无 tool_calls 的空消息,
|
|
246
|
+
// 否则上游报 "content or tool_calls must be set"。
|
|
247
|
+
const hasText = !!(text && text.trim());
|
|
248
|
+
const hasCalls = toolCalls.length > 0;
|
|
249
|
+
if (hasText || hasCalls) {
|
|
250
|
+
this.messages.push({
|
|
251
|
+
role: 'assistant',
|
|
252
|
+
content: hasText ? text : null,
|
|
253
|
+
toolCalls: hasCalls ? toolCalls : undefined,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- if tool calls exist, execute them ----
|
|
258
|
+
if (toolCalls.length > 0) {
|
|
259
|
+
// Execute tools below
|
|
260
|
+
} else if (this.steerQueue.length > 0) {
|
|
261
|
+
const steers = this.steerQueue.splice(0);
|
|
262
|
+
for (const msg of steers) this.messages.push({ role: 'user', content: msg });
|
|
263
|
+
continue;
|
|
264
|
+
} else if (!hasText) {
|
|
265
|
+
if (nudges < MAX_NUDGES) {
|
|
266
|
+
nudges++;
|
|
267
|
+
const why = thought.trim()
|
|
268
|
+
? 'your last output was reasoning/thinking only, with no answer text'
|
|
269
|
+
: 'your final message was empty';
|
|
270
|
+
this.onEvent({ type: 'nudge', reason: why, attempt: nudges, max: MAX_NUDGES });
|
|
271
|
+
const last = this.messages[this.messages.length - 1];
|
|
272
|
+
const body = nudgeMessage(why);
|
|
273
|
+
if (last && last.role === 'user' && typeof last.content === 'string') last.content += '\n\n' + body;
|
|
274
|
+
else this.messages.push({ role: 'user', content: body });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
break;
|
|
278
|
+
} else {
|
|
279
|
+
// The model produced its answer. A steer typed WHILE that answer was
|
|
280
|
+
// streaming still has to be delivered: drain it and take another step.
|
|
281
|
+
// (Checking only at tool boundaries dropped it, because a final answer
|
|
282
|
+
// has no tool boundary.)
|
|
283
|
+
if (this.steerQueue.length > 0) {
|
|
284
|
+
const steers = this.steerQueue.splice(0);
|
|
285
|
+
for (const msg of steers) this.messages.push({ role: 'user', content: msg });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// execute tools
|
|
292
|
+
let allOk = true;
|
|
293
|
+
// Tool-subset enforcement: in Plan/Focus mode the model may still emit a
|
|
294
|
+
// tool that is not exposed. Block it with a fixed message so the model
|
|
295
|
+
// learns it must stick to the allowed subset.
|
|
296
|
+
const modeName = Array.isArray(this.cfg.toolFilter)
|
|
297
|
+
? (this.cfg.toolFilter.includes('Bash') ? 'Focus' : 'Plan')
|
|
298
|
+
: undefined;
|
|
299
|
+
for (const tc of toolCalls) {
|
|
300
|
+
if (this.stopRequested) break;
|
|
301
|
+
// Tag output produced by THIS tool call so onOutput can route it to the
|
|
302
|
+
// right "Using …" row (a turn can run several tools in sequence).
|
|
303
|
+
this._currentToolId = tc.id;
|
|
304
|
+
|
|
305
|
+
// Permission check before executing each tool
|
|
306
|
+
let result;
|
|
307
|
+
if (this.onApproval) {
|
|
308
|
+
const ok = await this.onApproval(tc.name, tc.args);
|
|
309
|
+
if (!ok) {
|
|
310
|
+
result = `Blocked by user: tool "${tc.name}" was rejected.`;
|
|
311
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
312
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
313
|
+
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
314
|
+
allOk = false;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const tool = getTool(tc.name);
|
|
320
|
+
if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
|
|
321
|
+
result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
|
|
322
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
323
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
324
|
+
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
325
|
+
allOk = false;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!tool) {
|
|
329
|
+
result = `Error: unknown tool "${tc.name}".`;
|
|
330
|
+
allOk = false;
|
|
331
|
+
} else {
|
|
332
|
+
if (this.onToolStart) this.onToolStart(tc.name, tc.args);
|
|
333
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
334
|
+
try {
|
|
335
|
+
result = await tool.execute(tc.args, this.ctx);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
result = `Error running ${tc.name}: ${err.message}`;
|
|
338
|
+
allOk = false;
|
|
339
|
+
}
|
|
340
|
+
this.ctx.lastResult = result;
|
|
341
|
+
// Surface the live TODO list so the TUI can render its panel.
|
|
342
|
+
if (tc.name === 'TodoList') {
|
|
343
|
+
this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
347
|
+
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Did THIS iteration still have tool calls? If the loop runs out of budget
|
|
351
|
+
// right after running them, the turn really is unfinished.
|
|
352
|
+
hadToolCallsOnFinalStep = toolCalls.length > 0;
|
|
353
|
+
|
|
354
|
+
// Report the context after this step's tool results were appended: that is
|
|
355
|
+
// exactly what the next request will carry, so the gauge grows with history.
|
|
356
|
+
this.onEvent({ type: 'context', tokens: estimateMessagesTokens(this.messages, this.cfg), max: maxCtx });
|
|
357
|
+
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// With the cap removed this can only trigger for a caller that set an
|
|
361
|
+
// explicit finite maxSteps: the final iteration still produced tool calls,
|
|
362
|
+
// so the turn genuinely ran out of rounds. Checking the step index alone (as
|
|
363
|
+
// this used to) reported "budget exhausted" for turns that had finished.
|
|
364
|
+
if (Number.isFinite(stepLimit) && !this.stopRequested && hadToolCallsOnFinalStep) {
|
|
365
|
+
this.onEvent({
|
|
366
|
+
type: 'incomplete',
|
|
367
|
+
reason: 'the step budget was exhausted while tools were still being called',
|
|
368
|
+
usedNudges: nudges,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
|
372
|
+
}
|
|
373
|
+
}
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Clipboard access, fast enough for a keypress.
|
|
2
|
+
//
|
|
3
|
+
// Spawning a fresh PowerShell costs ~1.5-3s on Windows (cold start), which made
|
|
4
|
+
// Ctrl+Shift+C and every paste feel broken. So:
|
|
5
|
+
// copy -> clip.exe with UTF-16LE input (~100ms, no PowerShell at all).
|
|
6
|
+
// read -> ONE long-lived PowerShell child, pre-warmed at TUI start; a command
|
|
7
|
+
// on an already-running shell costs ~100-230ms instead of seconds.
|
|
8
|
+
// If the helper is unusable, fall back to a one-shot PowerShell.
|
|
9
|
+
// Non-Windows paths (pbcopy/pbpaste/xclip) are cheap enough to run per call.
|
|
10
|
+
|
|
11
|
+
import cp from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const SENT = '@@HNCODE_CLIP@@';
|
|
14
|
+
let helper = null; // long-lived PowerShell child
|
|
15
|
+
let helperBuf = ''; // stdout accumulated since the last sentinel
|
|
16
|
+
let helperQueue = []; // pending read() resolvers
|
|
17
|
+
let helperBusy = false; // one command in flight at a time
|
|
18
|
+
|
|
19
|
+
function win() { return process.platform === 'win32'; }
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------- copy
|
|
22
|
+
export function copyText(text) {
|
|
23
|
+
const s = String(text == null ? '' : text);
|
|
24
|
+
if (!s) return false;
|
|
25
|
+
if (win()) {
|
|
26
|
+
// clip.exe takes UTF-16LE. (`cmd /c echo "…" | clip` mangled quotes, '%'
|
|
27
|
+
// and newlines; plain UTF-8 input turned non-ASCII into mojibake.)
|
|
28
|
+
cp.execSync('clip', { input: Buffer.from(s, 'utf16le'), windowsHide: true, timeout: 5000 });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
|
|
32
|
+
cp.execSync(cmd, { input: Buffer.from(s, 'utf8'), timeout: 5000 });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ------------------------------------------------------- PowerShell helper
|
|
37
|
+
export function warmClipboard() {
|
|
38
|
+
if (!win() || helper) return;
|
|
39
|
+
try {
|
|
40
|
+
helper = cp.spawn('powershell', ['-NoProfile', '-NoLogo', '-Command', '-'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
41
|
+
helper.stdout.on('data', (d) => {
|
|
42
|
+
helperBuf += d.toString('binary');
|
|
43
|
+
let i;
|
|
44
|
+
while ((i = helperBuf.indexOf(SENT)) >= 0) {
|
|
45
|
+
const chunk = helperBuf.slice(0, i);
|
|
46
|
+
helperBuf = helperBuf.slice(i + SENT.length);
|
|
47
|
+
const w = helperQueue.shift();
|
|
48
|
+
helperBusy = false;
|
|
49
|
+
if (w) w(chunk);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
helper.on('exit', () => { helper = null; helperBusy = false; });
|
|
53
|
+
helper.on('error', () => { helper = null; helperBusy = false; });
|
|
54
|
+
helper.stdin.write('$ProgressPreference = "SilentlyContinue"\n');
|
|
55
|
+
} catch { helper = null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function helperRun(script, timeoutMs) {
|
|
59
|
+
warmClipboard();
|
|
60
|
+
if (!helper) return Promise.resolve(null);
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const timer = setTimeout(() => { resolve(null); }, timeoutMs);
|
|
63
|
+
helperQueue.push((out) => { clearTimeout(timer); resolve(out); });
|
|
64
|
+
try { helper.stdin.write(script + `\n'${SENT}'\n`); }
|
|
65
|
+
catch { clearTimeout(timer); resolve(null); }
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- read
|
|
70
|
+
// Base64 both ways: decoding PowerShell's stdout as UTF-8 garbled non-ASCII
|
|
71
|
+
// text (its pipe encoding follows the console code page), so instead of reading
|
|
72
|
+
// text we read base64 ASCII and decode it ourselves.
|
|
73
|
+
function readOnce() {
|
|
74
|
+
if (win()) {
|
|
75
|
+
const b64 = cp.execSync(
|
|
76
|
+
'powershell -NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
|
|
77
|
+
{ encoding: 'ascii', windowsHide: true, timeout: 8000 },
|
|
78
|
+
);
|
|
79
|
+
const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
|
|
80
|
+
return Buffer.from(s, 'base64').toString('utf8');
|
|
81
|
+
}
|
|
82
|
+
const cmd = process.platform === 'darwin' ? 'pbpaste' : 'xclip -selection clipboard -o';
|
|
83
|
+
return cp.execSync(cmd, { encoding: 'utf8', timeout: 5000 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Async read: uses the warm helper when available. Returns { text, via } so the
|
|
87
|
+
// caller can tell the user which path answered.
|
|
88
|
+
export async function readText(timeoutMs = 3000) {
|
|
89
|
+
if (win() && helper && !helperBusy) {
|
|
90
|
+
helperBusy = true;
|
|
91
|
+
const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
|
|
92
|
+
if (out != null) {
|
|
93
|
+
const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
|
|
94
|
+
try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
|
|
95
|
+
}
|
|
96
|
+
helperBusy = false;
|
|
97
|
+
}
|
|
98
|
+
try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read clipboard as image (base64 PNG). Returns null if clipboard has no image.
|
|
102
|
+
export function readImage() {
|
|
103
|
+
if (!win()) return null;
|
|
104
|
+
try {
|
|
105
|
+
// PowerShell: Get-Clipboard -Image, save to temp file, read as base64
|
|
106
|
+
const b64 = cp.execSync(
|
|
107
|
+
'powershell -NoProfile -Command "$img = Get-Clipboard -Image; if ($null -ne $img) { $path = [System.IO.Path]::GetTempFileName() + \'.png\'; $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); $bytes = [System.IO.File]::ReadAllBytes($path); Remove-Item $path; Write-Host ([Convert]::ToBase64String($bytes)) }"',
|
|
108
|
+
{ encoding: 'ascii', windowsHide: true, timeout: 8000 },
|
|
109
|
+
);
|
|
110
|
+
const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
|
|
111
|
+
if (s.length > 0) return s;
|
|
112
|
+
return null;
|
|
113
|
+
} catch { return null; }
|
|
114
|
+
}
|