@hncode/hncode 0.5.5 → 0.6.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/package.json +37 -38
- package/src/agent.js +67 -2
- package/src/image-row.js +111 -0
- package/src/latex.js +1557 -0
- package/src/llm.js +19 -4
- package/src/mermaid/ansi.js +55 -0
- package/src/mermaid/canvas.js +449 -0
- package/src/mermaid/class-style.js +57 -0
- package/src/mermaid/css-colors.js +153 -0
- package/src/mermaid/diagrams/class.js +289 -0
- package/src/mermaid/diagrams/er.js +201 -0
- package/src/mermaid/diagrams/flowchart.js +488 -0
- package/src/mermaid/diagrams/gitgraph.js +205 -0
- package/src/mermaid/diagrams/mindmap.js +101 -0
- package/src/mermaid/diagrams/pie.js +104 -0
- package/src/mermaid/diagrams/sequence.js +282 -0
- package/src/mermaid/diagrams/state.js +292 -0
- package/src/mermaid/diagrams/timeline.js +99 -0
- package/src/mermaid/graph.js +138 -0
- package/src/mermaid/index.js +59 -0
- package/src/mermaid/labels.js +339 -0
- package/src/mermaid/layout-seq.js +225 -0
- package/src/mermaid/layout.js +1454 -0
- package/src/mermaid/registry.js +54 -0
- package/src/mermaid/source-box.js +102 -0
- package/src/mermaid/statements.js +235 -0
- package/src/mermaid/types.js +1 -0
- package/src/mermaid/width.js +29 -0
- package/src/screen-diff.js +512 -0
- package/src/screen-paint.js +128 -0
- package/src/screen.js +277 -0
- package/src/subagent-types.js +136 -147
- package/src/term-caps.js +10 -4
- package/src/tex/font.js +292 -0
- package/src/tex/fonts/LICENSE.txt +11 -0
- package/src/tex/fonts/STIX2Math.ttf +0 -0
- package/src/tex/index.js +293 -0
- package/src/tex/kitty.js +186 -0
- package/src/tex/layout.js +446 -0
- package/src/tex/parse.js +331 -0
- package/src/tex/raster.js +216 -0
- package/src/tex/render.js +167 -0
- package/src/tex/sixel.js +326 -0
- package/src/tex/svg.js +237 -0
- package/src/tool-result.js +2 -2
- package/src/tools/agent-swarm.js +3 -15
- package/src/tools/agent.js +454 -464
- package/src/tools/ask-user-question.js +195 -206
- package/src/tools/bash.js +39 -3
- package/src/tools/edit.js +10 -112
- package/src/tools/fetch-url.js +130 -120
- package/src/tools/memory.js +80 -88
- package/src/tools/read.js +27 -23
- package/src/tools/todo.js +8 -13
- package/src/tools/validate-args.js +78 -0
- package/src/tui.js +1346 -496
package/package.json
CHANGED
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"hncode": "bin/hncode"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"bin/",
|
|
12
|
-
"src/",
|
|
13
|
-
"LICENSE",
|
|
14
|
-
"README.md",
|
|
15
|
-
"README_CN.md",
|
|
16
|
-
"package.json"
|
|
17
|
-
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
-
"install": "node bin/install.cjs",
|
|
21
|
-
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
-
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
-
"test": "node --test \"test/**/*.test.mjs\""
|
|
24
|
-
},
|
|
25
|
-
"engines": {
|
|
26
|
-
"node": ">=20"
|
|
27
|
-
},
|
|
28
|
-
"license": "PolyForm-Noncommercial-1.0.0",
|
|
29
|
-
"author": "NiceHello666",
|
|
30
|
-
"keywords": [
|
|
31
|
-
"cli",
|
|
32
|
-
"agent",
|
|
33
|
-
"coding",
|
|
34
|
-
"ai",
|
|
35
|
-
"tui"
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@hncode/hncode",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hncode": "bin/hncode"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"README_CN.md",
|
|
16
|
+
"package.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
+
"install": "node bin/install.cjs",
|
|
21
|
+
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
+
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
29
|
+
"author": "NiceHello666",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"cli",
|
|
32
|
+
"agent",
|
|
33
|
+
"coding",
|
|
34
|
+
"ai",
|
|
35
|
+
"tui"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
6
|
import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
|
|
7
|
+
import { validateToolArgs } from './tools/validate-args.js';
|
|
7
8
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
9
|
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, measureToolDefTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
|
|
9
10
|
import { runHooks, runPatch, runPatchSync } from './plugin.js';
|
|
@@ -72,10 +73,46 @@ const COMPACTION_INSTRUCTION = [
|
|
|
72
73
|
'',
|
|
73
74
|
'Be concise and proportional: a long multi-step task warrants detail, a nearly finished exchange needs only a sentence or two.',
|
|
74
75
|
'',
|
|
75
|
-
'Respond with text only. Do not call any tools.'
|
|
76
|
+
'Respond with text only. Do not call any tools. Any tool-call syntax in your answer is discarded and scores the handoff as failed.'
|
|
76
77
|
].join('\n');
|
|
77
78
|
|
|
78
79
|
// Trailing note: the model should RESUME, not answer the summary.
|
|
80
|
+
// A summariser can answer with tool-call syntax even when no tools were offered — the format is in its
|
|
81
|
+
// training data, and a model interrupted mid-workflow reaches for it. Nothing was called, so the text is
|
|
82
|
+
// noise, and left in a handoff summary the next turn reads it as a step that happened.
|
|
83
|
+
//
|
|
84
|
+
// The shapes covered are the ones these models actually emit: a special-token block (`<|tool_call|>`), a
|
|
85
|
+
// fenced ```tool_call block, and a bare JSON object that claims a `name` and `arguments`. Only a block that
|
|
86
|
+
// occupies whole lines is removed, so prose that merely mentions calling a tool survives.
|
|
87
|
+
function stripToolCallText(text) {
|
|
88
|
+
if (typeof text !== 'string' || !text) return text || '';
|
|
89
|
+
|
|
90
|
+
// NOTHING WAS CALLED, SO THE BLOCK IS NOISE — but removing it outright can leave nothing at all.
|
|
91
|
+
//
|
|
92
|
+
// A summariser that answers ONLY with a tool call produced no summary: stripping to '' makes the caller
|
|
93
|
+
// treat the compaction as failed, and the fallback there is to drop the history. So the stripped text is
|
|
94
|
+
// measured: if it lost its substance, the original is kept VERBATIM with a note saying what it is. A
|
|
95
|
+
// confusing summary is recoverable; a discarded one is not.
|
|
96
|
+
let out = text;
|
|
97
|
+
// A special-token block: closed form, or an unterminated one running to the end of its line.
|
|
98
|
+
out = out.replace(/<\|tool_call\|>[^\n]*(?:<\|\/tool_call\|>)?/g, '');
|
|
99
|
+
// Fenced block form.
|
|
100
|
+
out = out.replace(/```tool_call[\s\S]*?```/g, '');
|
|
101
|
+
// A bare JSON object on its own lines that declares a tool invocation.
|
|
102
|
+
out = out.replace(/^\s*\{[^\n]*"name"\s*:\s*"[^"]+"[^\n]*"arguments"[^\n]*\}\s*$/gm, '');
|
|
103
|
+
out = out.replace(/\n{3,}/g, '\n\n').trim();
|
|
104
|
+
|
|
105
|
+
if (out.length > 0) return out;
|
|
106
|
+
|
|
107
|
+
// Every remaining character was a tool call: keep the original, marked for what it is.
|
|
108
|
+
return [
|
|
109
|
+
'(The summariser answered with a tool call instead of a summary. No tool was actually run — the text is',
|
|
110
|
+
'reproduced below unchanged so nothing is lost. Treat it as a summary that was never written.)',
|
|
111
|
+
'',
|
|
112
|
+
text.trim(),
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
const COMPACTION_CONTINUATION_TEXT = 'Context compaction is complete — continue the work that was in progress when it began.';
|
|
80
117
|
|
|
81
118
|
// Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
|
|
@@ -842,7 +879,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
842
879
|
// reasoning model they can consume the whole output budget — which
|
|
843
880
|
// returns an EMPTY summary, and an empty summary is what turns a
|
|
844
881
|
// compaction into a silent loss of history.
|
|
845
|
-
try {
|
|
882
|
+
try {
|
|
883
|
+
// A DECLARATION IS NEEDED FOR THE REFUSAL.
|
|
884
|
+
//
|
|
885
|
+
// `tool_choice: 'none'` is rejected by both providers unless a tool list accompanies it, and the
|
|
886
|
+
// summariser is a fresh instance with none. One inert definition is declared so the refusal can be
|
|
887
|
+
// expressed: the model then cannot produce a tool call, rather than being asked not to.
|
|
888
|
+
llm._tools = [{
|
|
889
|
+
name: 'noop',
|
|
890
|
+
description: 'Takes no action. Present only so the request can refuse tool use.',
|
|
891
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
892
|
+
}];
|
|
893
|
+
const raw = await llm.requestText(summaryPrompt, { noReasoning: true, toolChoice: 'none' });
|
|
894
|
+
summary = stripToolCallText(raw);
|
|
895
|
+
} catch { summary = ''; }
|
|
846
896
|
this._summaryLlm = null;
|
|
847
897
|
}
|
|
848
898
|
// Interrupted mid-summary: do NOT trim. Aborting yields an empty summary, and
|
|
@@ -1123,6 +1173,21 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
1123
1173
|
return { ok: false, result, editDiff: null, media: false };
|
|
1124
1174
|
}
|
|
1125
1175
|
|
|
1176
|
+
// THE ARGUMENTS MUST MATCH THE TOOL'S OWN SCHEMA.
|
|
1177
|
+
//
|
|
1178
|
+
// An unknown parameter used to be accepted in silence, and the model had no way to learn that the
|
|
1179
|
+
// argument it took trouble to compute was thrown away: it sends `line_offset` to a tool that takes
|
|
1180
|
+
// `start_line`, gets a plausible answer computed from defaults, and concludes the tool is broken.
|
|
1181
|
+
// Refusing the call names the key and lists the accepted ones, which is the only thing that stops the
|
|
1182
|
+
// same mistake being retried with a different spelling.
|
|
1183
|
+
const argError = validateToolArgs(tc.args, tool.parameters);
|
|
1184
|
+
if (argError) {
|
|
1185
|
+
result = `Error: ${tc.name}: ${argError}`;
|
|
1186
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
1187
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
|
|
1126
1191
|
// SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
|
|
1127
1192
|
// message goes back to the model in place of the tool result — that is
|
|
1128
1193
|
// the whole point of a pre-hook (e.g. refuse a write that fails a lint
|
package/src/image-row.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a bitmap sits in a text frame.
|
|
3
|
+
*
|
|
4
|
+
* The TUI composes a frame as an array of strings and writes only the rows that changed, each addressed
|
|
5
|
+
* absolutely. A bitmap breaks that model twice: it is not text, so it cannot live in a row's bytes, and it
|
|
6
|
+
* is more than one row tall, so the rows under it must stay blank or the next repaint paints over it.
|
|
7
|
+
*
|
|
8
|
+
* So a row that carries an image is written as a sentinel — a single private-use character plus the
|
|
9
|
+
* payload — and this module turns a frame into the bytes that place it: address the row, emit the
|
|
10
|
+
* sequence, then remember which rows the image covers so the following text rows can be skipped.
|
|
11
|
+
*
|
|
12
|
+
* A SIXEL PAYLOAD DRAWN INLINE IS JUST CHARACTERS. That is why the first attempt showed nothing: the
|
|
13
|
+
* terminal consumed the bytes as text, scrolled them, and the formula never appeared. Placement needs a
|
|
14
|
+
* cursor move first, which is what `\x1b[<row>;<col>H` does.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Marks a row whose content is a placed bitmap. A private-use code point no text will contain. */
|
|
18
|
+
export const IMAGE_MARK = '\u0001IMG:';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Separates the payload from the source that produced it.
|
|
22
|
+
*
|
|
23
|
+
* NOT A NUL BYTE. `SPIN_PLACEHOLDER` in the transcript renderer is `\u0000`, and every row is run through a
|
|
24
|
+
* substitution that replaces it with the spinner frame — so a tag built on it arrived as `⠋SRC:…` and the
|
|
25
|
+
* source was unreadable. `\u0001` is what `IMAGE_MARK` already uses and nothing substitutes it.
|
|
26
|
+
*/
|
|
27
|
+
export const SOURCE_TAG = '\u0001SRC:';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Encode an image row: the mark, then the payload and its geometry, base64-free but length-prefixed so a
|
|
32
|
+
* payload containing the mark cannot be misparsed.
|
|
33
|
+
*
|
|
34
|
+
* `source` is optional and travels with the row for pictures that can be REGENERATED: a formula that straddles
|
|
35
|
+
* the top of the window must be redrawn from its first visible row, and only its TeX can produce that raster.
|
|
36
|
+
* It is stored last, after the sequence, so a row written before this field existed still parses.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} sequence The terminal sequence to emit.
|
|
39
|
+
* @param {number} rows Rows of text the image covers.
|
|
40
|
+
* @param {number} cols Columns the image covers.
|
|
41
|
+
* @param {string} [source] What produced the sequence, for a picture that may need cropping later.
|
|
42
|
+
*/
|
|
43
|
+
export function encodeImageRow(sequence, rows, cols, source) {
|
|
44
|
+
const head = IMAGE_MARK + rows + ':' + cols + ':';
|
|
45
|
+
return typeof source === 'string' && source !== ''
|
|
46
|
+
? head + sequence + SOURCE_TAG + source
|
|
47
|
+
: head + sequence;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when a composed row is an image rather than text.
|
|
52
|
+
*
|
|
53
|
+
* The row may carry the transcript's indent prefix — `messageLines` gives every line one — so the marker is
|
|
54
|
+
* not necessarily at index 0. Matching it anywhere in the first few characters is what makes this work
|
|
55
|
+
* without the row builder having to special-case images.
|
|
56
|
+
*/
|
|
57
|
+
export function isImageRow(line) {
|
|
58
|
+
if (typeof line !== 'string') return false;
|
|
59
|
+
const at = line.indexOf(IMAGE_MARK);
|
|
60
|
+
return at >= 0 && at < 8 && /^\s*$/.test(line.slice(0, at));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Split an image row back into its parts.
|
|
65
|
+
* @returns {{sequence: string, rows: number, cols: number, source: string}|null}
|
|
66
|
+
*/
|
|
67
|
+
export function parseImageRow(line) {
|
|
68
|
+
if (!isImageRow(line)) return null;
|
|
69
|
+
const at = line.indexOf(IMAGE_MARK);
|
|
70
|
+
const rest = line.slice(at + IMAGE_MARK.length);
|
|
71
|
+
const firstColon = rest.indexOf(':');
|
|
72
|
+
if (firstColon < 0) return null;
|
|
73
|
+
const secondColon = rest.indexOf(':', firstColon + 1);
|
|
74
|
+
if (secondColon < 0) return null;
|
|
75
|
+
const rows = Number(rest.slice(0, firstColon));
|
|
76
|
+
const cols = Number(rest.slice(firstColon + 1, secondColon));
|
|
77
|
+
if (!Number.isFinite(rows) || !Number.isFinite(cols)) return null;
|
|
78
|
+
let body = rest.slice(secondColon + 1);
|
|
79
|
+
let source = '';
|
|
80
|
+
const tag = body.lastIndexOf(SOURCE_TAG);
|
|
81
|
+
if (tag >= 0) {
|
|
82
|
+
source = body.slice(tag + SOURCE_TAG.length);
|
|
83
|
+
body = body.slice(0, tag);
|
|
84
|
+
}
|
|
85
|
+
return { rows, cols, sequence: body, source };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The bytes that place one image at its row, and how many rows it covers.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} line The image row.
|
|
93
|
+
* @param {number} rowIndex 0-based screen row the row was composed for.
|
|
94
|
+
* @param {string} reset SGR reset to restore after the sequence.
|
|
95
|
+
* @returns {{bytes: string, covered: number}|null}
|
|
96
|
+
*/
|
|
97
|
+
export function imageBytes(line, rowIndex, reset) {
|
|
98
|
+
const parsed = parseImageRow(line);
|
|
99
|
+
if (!parsed) return null;
|
|
100
|
+
|
|
101
|
+
// DEC PRIVATE MODE 80 IS THE SIXEL SCROLLING MODE.
|
|
102
|
+
//
|
|
103
|
+
// With it enabled (the default) a terminal that draws a bitmap pushes the text below it down — and in a
|
|
104
|
+
// full-screen TUI every bitmap therefore scrolls the whole interface. That is what produced the repeated
|
|
105
|
+
// composer: each formula shoved the frame up and the next paint drew it again further down.
|
|
106
|
+
//
|
|
107
|
+
// `?80l` turns the mode off for the duration of the draw and `?80h` restores it. dsh-tui wraps its own
|
|
108
|
+
// placement the same way.
|
|
109
|
+
const bytes = `\x1b[?80l\x1b[${rowIndex + 1};1H${reset}${parsed.sequence}\x1b[?80h`;
|
|
110
|
+
return { bytes, covered: parsed.rows };
|
|
111
|
+
}
|