@hncode/hncode 0.5.6 → 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 -502
|
@@ -1,206 +1,195 @@
|
|
|
1
|
-
// AskUserQuestion tool — ask the user 1-4 structured multiple-choice questions
|
|
2
|
-
// mid-turn and block until they answer. Mirrors kimi-code's AskUserQuestion:
|
|
3
|
-
// the same input shape, the same duplicate validation, and the same result
|
|
4
|
-
// JSON, so a model trained on either behaves the same.
|
|
5
|
-
//
|
|
6
|
-
// The TUI supplies the actual UI through `ctx.askQuestion` (a function injected
|
|
7
|
-
// by the Agent). When it is absent — headless `-p` mode, a plugin harness, or a
|
|
8
|
-
// client that cannot prompt — the tool FAILS with a message telling the model to
|
|
9
|
-
// ask in plain text instead of silently returning an empty answer, which would
|
|
10
|
-
// look like "the user declined".
|
|
11
|
-
|
|
12
|
-
const MAX_QUESTIONS = 4;
|
|
13
|
-
const MIN_OPTIONS = 2;
|
|
14
|
-
const MAX_OPTIONS = 4;
|
|
15
|
-
|
|
16
|
-
// The user always gets an escape hatch for free input, so the model must not
|
|
17
|
-
// waste one of its 2-4 option slots on an "Other" of its own.
|
|
18
|
-
export const OTHER_LABEL = 'Other';
|
|
19
|
-
|
|
20
|
-
// A SECOND free-text row, shown once after the LAST question: not an answer to
|
|
21
|
-
// anything, just somewhere to add context that no option covered. Reported as
|
|
22
|
-
// `additional` on the result (NOT `note`, which already means "dismissed").
|
|
23
|
-
export const SUPPLEMENT_LABEL = 'Add a note';
|
|
24
|
-
|
|
25
|
-
const UNIQUENESS_HINT =
|
|
26
|
-
'Question texts must be unique across questions, and option labels must be unique within each question.';
|
|
27
|
-
|
|
28
|
-
// Validate the two uniqueness rules kimi enforces. Returns an error string, or
|
|
29
|
-
// null when the input is acceptable. Checks run BEFORE any UI is shown so a
|
|
30
|
-
// malformed call never interrupts the user.
|
|
31
|
-
export function questionUniquenessError(questions) {
|
|
32
|
-
const texts = new Set();
|
|
33
|
-
for (const q of questions) {
|
|
34
|
-
if (texts.has(q.question)) {
|
|
35
|
-
return `Invalid questions: duplicate question text ${JSON.stringify(q.question)}. ${UNIQUENESS_HINT} Rephrase the duplicates and call the tool again.`;
|
|
36
|
-
}
|
|
37
|
-
texts.add(q.question);
|
|
38
|
-
const labels = new Set();
|
|
39
|
-
for (const o of q.options) {
|
|
40
|
-
if (labels.has(o.label)) {
|
|
41
|
-
return `Invalid questions: duplicate option label ${JSON.stringify(o.label)} in question ${JSON.stringify(q.question)}. ${UNIQUENESS_HINT} Rephrase the duplicates and call the tool again.`;
|
|
42
|
-
}
|
|
43
|
-
labels.add(o.label);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const DESCRIPTION = `
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
return 'Error:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
answers: {},
|
|
197
|
-
note: 'User dismissed the question without answering.',
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
// `additional` is OPTIONAL: omit the key entirely when the user left the
|
|
201
|
-
// supplement blank, so the model does not see a meaningless empty field.
|
|
202
|
-
const out = { answers };
|
|
203
|
-
if (additional) out.additional = additional;
|
|
204
|
-
return JSON.stringify(out);
|
|
205
|
-
},
|
|
206
|
-
};
|
|
1
|
+
// AskUserQuestion tool — ask the user 1-4 structured multiple-choice questions
|
|
2
|
+
// mid-turn and block until they answer. Mirrors kimi-code's AskUserQuestion:
|
|
3
|
+
// the same input shape, the same duplicate validation, and the same result
|
|
4
|
+
// JSON, so a model trained on either behaves the same.
|
|
5
|
+
//
|
|
6
|
+
// The TUI supplies the actual UI through `ctx.askQuestion` (a function injected
|
|
7
|
+
// by the Agent). When it is absent — headless `-p` mode, a plugin harness, or a
|
|
8
|
+
// client that cannot prompt — the tool FAILS with a message telling the model to
|
|
9
|
+
// ask in plain text instead of silently returning an empty answer, which would
|
|
10
|
+
// look like "the user declined".
|
|
11
|
+
|
|
12
|
+
const MAX_QUESTIONS = 4;
|
|
13
|
+
const MIN_OPTIONS = 2;
|
|
14
|
+
const MAX_OPTIONS = 4;
|
|
15
|
+
|
|
16
|
+
// The user always gets an escape hatch for free input, so the model must not
|
|
17
|
+
// waste one of its 2-4 option slots on an "Other" of its own.
|
|
18
|
+
export const OTHER_LABEL = 'Other';
|
|
19
|
+
|
|
20
|
+
// A SECOND free-text row, shown once after the LAST question: not an answer to
|
|
21
|
+
// anything, just somewhere to add context that no option covered. Reported as
|
|
22
|
+
// `additional` on the result (NOT `note`, which already means "dismissed").
|
|
23
|
+
export const SUPPLEMENT_LABEL = 'Add a note';
|
|
24
|
+
|
|
25
|
+
const UNIQUENESS_HINT =
|
|
26
|
+
'Question texts must be unique across questions, and option labels must be unique within each question.';
|
|
27
|
+
|
|
28
|
+
// Validate the two uniqueness rules kimi enforces. Returns an error string, or
|
|
29
|
+
// null when the input is acceptable. Checks run BEFORE any UI is shown so a
|
|
30
|
+
// malformed call never interrupts the user.
|
|
31
|
+
export function questionUniquenessError(questions) {
|
|
32
|
+
const texts = new Set();
|
|
33
|
+
for (const q of questions) {
|
|
34
|
+
if (texts.has(q.question)) {
|
|
35
|
+
return `Invalid questions: duplicate question text ${JSON.stringify(q.question)}. ${UNIQUENESS_HINT} Rephrase the duplicates and call the tool again.`;
|
|
36
|
+
}
|
|
37
|
+
texts.add(q.question);
|
|
38
|
+
const labels = new Set();
|
|
39
|
+
for (const o of q.options) {
|
|
40
|
+
if (labels.has(o.label)) {
|
|
41
|
+
return `Invalid questions: duplicate option label ${JSON.stringify(o.label)} in question ${JSON.stringify(q.question)}. ${UNIQUENESS_HINT} Rephrase the duplicates and call the tool again.`;
|
|
42
|
+
}
|
|
43
|
+
labels.add(o.label);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DESCRIPTION = `Ask the user a question with structured options, during execution. Use it when the answer
|
|
50
|
+
genuinely changes what you do next: a preference, an ambiguous instruction, a choice between approaches.
|
|
51
|
+
|
|
52
|
+
Do NOT use it for what you can infer from context, or for trivial decisions — overusing it interrupts the user's flow.
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
- Users always get an "Other" option; do not create one.
|
|
56
|
+
- 2-4 distinct options per question, labels 1-5 words, descriptions carry the trade-offs. Labels unique within a question, question texts unique across the call.
|
|
57
|
+
- 1-4 questions at a time, related ones grouped. multi_select allows several answers.
|
|
58
|
+
- Put a recommended option first and append "(Recommended)" to its label.
|
|
59
|
+
- The result is JSON: an \`answers\` object keyed by question text, each value the chosen label (comma-separated for multi_select, or the user's own words for "Other"). An empty \`answers\` with a \`note\` means they dismissed it — that is NOT a vote for the recommended option; decide from context and do not re-ask. A free-form \`additional\` note, when present, is extra context for the whole request.`;
|
|
60
|
+
|
|
61
|
+
export const spec = {
|
|
62
|
+
name: 'AskUserQuestion',
|
|
63
|
+
description: DESCRIPTION,
|
|
64
|
+
parameters: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
questions: {
|
|
68
|
+
type: 'array',
|
|
69
|
+
minItems: 1,
|
|
70
|
+
maxItems: MAX_QUESTIONS,
|
|
71
|
+
description: 'The questions to ask the user (1-4 questions).',
|
|
72
|
+
items: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
question: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: "A specific, actionable question. End with '?'.",
|
|
78
|
+
},
|
|
79
|
+
header: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: "Short category tag (max 12 chars, e.g. 'Auth', 'Style').",
|
|
82
|
+
},
|
|
83
|
+
options: {
|
|
84
|
+
type: 'array',
|
|
85
|
+
minItems: MIN_OPTIONS,
|
|
86
|
+
maxItems: MAX_OPTIONS,
|
|
87
|
+
description:
|
|
88
|
+
"2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically.",
|
|
89
|
+
items: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
label: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: "Concise display text (1-5 words). If recommended, append '(Recommended)'.",
|
|
95
|
+
},
|
|
96
|
+
description: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'Brief explanation of trade-offs or implications.',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
required: ['label'],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
multi_select: {
|
|
105
|
+
type: 'boolean',
|
|
106
|
+
description: 'Whether the user can select multiple options.',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
required: ['question', 'options'],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ['questions'],
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async execute(args, ctx) {
|
|
117
|
+
// AUTO ("Never Ask") forbids asking by definition — the whole point of the
|
|
118
|
+
// mode is that the agent decides for itself. Checked FIRST so the model gets
|
|
119
|
+
// a clear instruction back instead of a dialog the user never wanted. The
|
|
120
|
+
// wording mirrors kimi-code's `auto-mode-ask-user-question-deny` policy; the
|
|
121
|
+
// `Error:` prefix is ours — it is what marks the call as failed to the TUI, so
|
|
122
|
+
// the status bullet turns RED instead of staying green on a refusal.
|
|
123
|
+
if (ctx && ctx.permissionMode === 'auto') {
|
|
124
|
+
return 'Error: AskUserQuestion is disabled while auto permission mode is active. Make a reasonable decision and continue without asking the user.';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const raw = (args && args.questions) || [];
|
|
128
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
129
|
+
return 'Error: `questions` must be a non-empty array (1-4 questions).';
|
|
130
|
+
}
|
|
131
|
+
if (raw.length > MAX_QUESTIONS) {
|
|
132
|
+
return `Error: at most ${MAX_QUESTIONS} questions per call (got ${raw.length}). Group related questions to minimize interruptions.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Normalize + validate BEFORE showing any UI: a malformed call must not
|
|
136
|
+
// interrupt the user, it must come straight back to the model.
|
|
137
|
+
const questions = [];
|
|
138
|
+
for (const q of raw) {
|
|
139
|
+
const text = String((q && q.question) || '').trim();
|
|
140
|
+
if (!text) return 'Error: every question needs a non-empty `question` string.';
|
|
141
|
+
const opts = Array.isArray(q && q.options) ? q.options : [];
|
|
142
|
+
if (opts.length < MIN_OPTIONS || opts.length > MAX_OPTIONS) {
|
|
143
|
+
return `Error: question ${JSON.stringify(text)} needs ${MIN_OPTIONS}-${MAX_OPTIONS} options (got ${opts.length}).`;
|
|
144
|
+
}
|
|
145
|
+
const options = [];
|
|
146
|
+
for (const o of opts) {
|
|
147
|
+
const label = String((o && o.label) || '').trim();
|
|
148
|
+
if (!label) return `Error: every option in ${JSON.stringify(text)} needs a non-empty \`label\`.`;
|
|
149
|
+
options.push({ label, description: String((o && o.description) || '') });
|
|
150
|
+
}
|
|
151
|
+
questions.push({
|
|
152
|
+
question: text,
|
|
153
|
+
header: String((q && q.header) || '').trim().slice(0, 12),
|
|
154
|
+
options,
|
|
155
|
+
multiSelect: !!(q && q.multi_select),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const dup = questionUniquenessError(questions);
|
|
160
|
+
if (dup) return dup;
|
|
161
|
+
|
|
162
|
+
// No interactive client: say so explicitly. Returning `{answers:{}}` would be
|
|
163
|
+
// read as "the user refused to answer", which is a different signal.
|
|
164
|
+
if (typeof ctx.askQuestion !== 'function') {
|
|
165
|
+
return 'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let result;
|
|
169
|
+
try {
|
|
170
|
+
result = await ctx.askQuestion(questions, ctx);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// An aborted turn (Esc / Ctrl-C) is not a dismissal — let the agent see the
|
|
173
|
+
// interruption instead of a fabricated empty answer.
|
|
174
|
+
if (ctx.signal && ctx.signal.aborted) throw err;
|
|
175
|
+
return `Error: could not ask the user: ${(err && err.message) || String(err)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Dismissed (Esc) or nothing answered: kimi's exact semantics — an empty
|
|
179
|
+
// answers object plus a note telling the model NOT to treat it as consent.
|
|
180
|
+
// A dismissal also drops any note the user had started typing.
|
|
181
|
+
const answers = (result && typeof result === 'object' && result.answers) || null;
|
|
182
|
+
const additional = (result && typeof result === 'object' && result.additional) || '';
|
|
183
|
+
if (!answers || Object.keys(answers).length === 0) {
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
answers: {},
|
|
186
|
+
note: 'User dismissed the question without answering.',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// `additional` is OPTIONAL: omit the key entirely when the user left the
|
|
190
|
+
// supplement blank, so the model does not see a meaningless empty field.
|
|
191
|
+
const out = { answers };
|
|
192
|
+
if (additional) out.additional = additional;
|
|
193
|
+
return JSON.stringify(out);
|
|
194
|
+
},
|
|
195
|
+
};
|
package/src/tools/bash.js
CHANGED
|
@@ -197,17 +197,53 @@ const command = args.command;
|
|
|
197
197
|
// Line-by-line is also what keeps an escape sequence from being cut in half:
|
|
198
198
|
// a sequence never contains a newline, so a complete line is always safe to
|
|
199
199
|
// sanitize, and the trailing partial line waits for more input.
|
|
200
|
+
// THE LIVE STREAM MUST DECODE EXACTLY THE WAY THE FINAL RESULT DOES.
|
|
201
|
+
//
|
|
202
|
+
// The finished output goes through `decodeBuffer`, which tries strict UTF-8 and FALLS BACK TO GBK. The
|
|
203
|
+
// live sink used a plain non-fatal UTF-8 decoder, so a command whose output is not UTF-8 — PowerShell on a
|
|
204
|
+
// Chinese Windows writes GBK for native tools — streamed as replacement characters while it ran and then
|
|
205
|
+
// turned correct the moment it finished. That is the "garbled while running, fine afterwards" report.
|
|
206
|
+
//
|
|
207
|
+
// The live path cannot buffer the whole run, so it decides from the first chunk that proves itself: valid
|
|
208
|
+
// UTF-8 keeps the UTF-8 decoder, anything else pins GBK for the rest of the stream. A chunk boundary
|
|
209
|
+
// through the middle of a multi-byte character looks invalid too, so a failed probe is retried once with
|
|
210
|
+
// the remaining bytes before the encoding is pinned.
|
|
200
211
|
const sink = typeof ctx.onOutput === 'function' ? ctx.onOutput : null;
|
|
201
|
-
|
|
212
|
+
let liveDecoder = sink ? new TextDecoder('utf-8') : null;
|
|
202
213
|
let livePending = '';
|
|
203
|
-
|
|
214
|
+
let liveUndecided = !!sink; // the probe has not committed yet
|
|
215
|
+
const push = (d) => {
|
|
204
216
|
chunks.push(d);
|
|
205
217
|
const dropped = trimChunkRing(chunks, LIVE_RING_BYTES);
|
|
206
218
|
if (dropped) chunksDropped += dropped;
|
|
207
219
|
|
|
208
220
|
if (!sink || settled) return;
|
|
209
221
|
try {
|
|
210
|
-
|
|
222
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(String(d));
|
|
223
|
+
if (liveUndecided) {
|
|
224
|
+
// A trailing partial character makes a valid UTF-8 chunk look invalid. Only a chunk that still
|
|
225
|
+
// fails after its own tail is set aside is evidence.
|
|
226
|
+
let valid = false;
|
|
227
|
+
try {
|
|
228
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
229
|
+
valid = true;
|
|
230
|
+
} catch {
|
|
231
|
+
// Trim up to three trailing bytes and try once more: that is the longest an incomplete UTF-8
|
|
232
|
+
// sequence can be.
|
|
233
|
+
for (let cut = 1; cut <= 3 && cut < buf.length; cut++) {
|
|
234
|
+
try {
|
|
235
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buf.subarray(0, buf.length - cut));
|
|
236
|
+
valid = true;
|
|
237
|
+
break;
|
|
238
|
+
} catch { /* keep trimming */ }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!valid) liveDecoder = new TextDecoder('gbk');
|
|
242
|
+
liveUndecided = false;
|
|
243
|
+
}
|
|
244
|
+
// One decoder for the whole stream, in streaming mode: a GBK character is two bytes and will be split
|
|
245
|
+
// across chunks just as often as a UTF-8 one.
|
|
246
|
+
livePending += liveDecoder.decode(buf, { stream: true });
|
|
211
247
|
const nl = livePending.lastIndexOf('\n');
|
|
212
248
|
if (nl < 0) return;
|
|
213
249
|
const complete = livePending.slice(0, nl + 1);
|