@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/src/session.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Session store. Sessions persist under ~/.hncode/sessions/<id>.json.
|
|
2
|
+
// A session records: id, title, cwd (workspace), model, createdAt, updatedAt,
|
|
3
|
+
// and the message list (canonical, same shape the agent loop uses).
|
|
4
|
+
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_DIR = () => path.join(os.homedir(), '.hncode', 'sessions');
|
|
11
|
+
function dir(custom) { return custom || process.env.HNCODE_SESSIONS_DIR || DEFAULT_DIR(); }
|
|
12
|
+
|
|
13
|
+
export function newId() {
|
|
14
|
+
// compact, mostly-sorted id (like a timestamp+rand ULID-ish)
|
|
15
|
+
return Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sessionFile(id, storeDir) {
|
|
19
|
+
return path.join(dir(storeDir), `${id}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveSession(session, storeDir) {
|
|
23
|
+
session.updatedAt = Date.now();
|
|
24
|
+
fs.mkdirSync(dir(storeDir), { recursive: true });
|
|
25
|
+
const tmp = sessionFile(session.id, storeDir) + '.tmp';
|
|
26
|
+
fs.writeFileSync(tmp, JSON.stringify(session, null, 2), 'utf8');
|
|
27
|
+
fs.renameSync(tmp, sessionFile(session.id, storeDir));
|
|
28
|
+
return session;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadSession(id, storeDir) {
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(sessionFile(id, storeDir), 'utf8')); }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Most recent session for a workspace, or globally.
|
|
37
|
+
// `opts.skipEmpty` ignores sessions with no messages: hncode creates an empty
|
|
38
|
+
// session shell on every launch, so without this `--continue` would "resume"
|
|
39
|
+
// a brand-new empty session instead of the user's last real conversation.
|
|
40
|
+
export function latestSession(cwd, storeDir, opts = {}) {
|
|
41
|
+
const d = dir(storeDir);
|
|
42
|
+
let files;
|
|
43
|
+
try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return null; }
|
|
44
|
+
let best = null;
|
|
45
|
+
let bestEmpty = null;
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
try {
|
|
48
|
+
const s = JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'));
|
|
49
|
+
if (cwd !== undefined && s.workspace !== path.resolve(cwd)) continue;
|
|
50
|
+
if (!best || s.updatedAt > best.updatedAt) best = s;
|
|
51
|
+
const hasMsgs = Array.isArray(s.messages) && s.messages.length > 0;
|
|
52
|
+
if (hasMsgs && (!bestEmpty || s.updatedAt > bestEmpty.updatedAt)) bestEmpty = s;
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
if (opts.skipEmpty) return bestEmpty || best;
|
|
56
|
+
return best;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listSessions(storeDir) {
|
|
60
|
+
const d = dir(storeDir);
|
|
61
|
+
let files;
|
|
62
|
+
try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const f of files) {
|
|
65
|
+
try { out.push(JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'))); } catch {}
|
|
66
|
+
}
|
|
67
|
+
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deleteSession(id, storeDir) {
|
|
72
|
+
try { fs.unlinkSync(sessionFile(id, storeDir)); return true; } catch { return false; }
|
|
73
|
+
}
|
package/src/term.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Small TTY / terminal utilities.
|
|
2
|
+
// Test file for Write size display - this comment should be long enough to measure
|
|
3
|
+
|
|
4
|
+
export function isTTY() {
|
|
5
|
+
return !!process.stdout.isTTY;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function size() {
|
|
9
|
+
try {
|
|
10
|
+
const [cols, rows] = process.stdout.getWindowSize();
|
|
11
|
+
return { cols: cols || 80, rows: rows || 24 };
|
|
12
|
+
} catch {
|
|
13
|
+
return { cols: 80, rows: 24 };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeRaw(str) {
|
|
18
|
+
if (!process.stdout.write(str)) return false;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Enable raw mode around a callback, restoring on exit/error.
|
|
23
|
+
export function withRawMode(cb) {
|
|
24
|
+
const stream = process.stdin;
|
|
25
|
+
if (!stream.isTTY) {
|
|
26
|
+
throw new Error('hncode: interactive mode requires a TTY (stdin is not a terminal).');
|
|
27
|
+
}
|
|
28
|
+
const wasRaw = stream.isRaw;
|
|
29
|
+
stream.setRawMode(true);
|
|
30
|
+
stream.resume();
|
|
31
|
+
stream.setEncoding('utf8');
|
|
32
|
+
const cleanup = () => {
|
|
33
|
+
try { stream.setRawMode(wasRaw || false); } catch {}
|
|
34
|
+
stream.pause();
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
return cb(cleanup);
|
|
38
|
+
} finally {
|
|
39
|
+
cleanup();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Visible width ignoring ANSI escapes (approx; no CJK width handling).
|
|
44
|
+
// NOTE: kept for back-compat; prefer visualWidth() for display-accurate width.
|
|
45
|
+
export function strWidth(s) {
|
|
46
|
+
return (s || '').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// True visual column-width of a plain string (no ANSI), assuming a terminal that
|
|
50
|
+
// renders East Asian / wide / some symbol glyphs as 2 columns. Must be used for any
|
|
51
|
+
// cursor positioning and line wrapping so an I-shaped (insert) caret lines up.
|
|
52
|
+
// Source of truth: Unicode wide ranges + a small set of CJK-fused symbol glyphs.
|
|
53
|
+
export function visualWidth(s) {
|
|
54
|
+
if (!s) return 0;
|
|
55
|
+
// Measured per GRAPHEME CLUSTER (see clusterWidth): summing charWidth per
|
|
56
|
+
// codepoint made a ZWJ emoji sequence count 8 columns instead of 2.
|
|
57
|
+
return clusterWidth(s);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- East Asian Width tables (Unicode 15) --------------------------------
|
|
61
|
+
// Wide/Fullwidth ranges -> 2 columns. Everything else is 1 unless it is a
|
|
62
|
+
// zero-width character (see ZERO_WIDTH) or emoji presentation (see below).
|
|
63
|
+
const WIDE = [
|
|
64
|
+
[0x1100, 0x115F], [0x231A, 0x231B], [0x2329, 0x232A], [0x23E9, 0x23EC],
|
|
65
|
+
[0x23F0, 0x23F0], [0x23F3, 0x23F3], [0x25FD, 0x25FE], [0x2614, 0x2615],
|
|
66
|
+
[0x2648, 0x2653], [0x267F, 0x267F], [0x2693, 0x2693], [0x26A1, 0x26A1],
|
|
67
|
+
[0x26AA, 0x26AB], [0x26BD, 0x26BE], [0x26C4, 0x26C5], [0x26CE, 0x26CE],
|
|
68
|
+
[0x26D4, 0x26D4], [0x26EA, 0x26EA], [0x26F2, 0x26F3], [0x26F5, 0x26F5],
|
|
69
|
+
[0x26FA, 0x26FA], [0x26FD, 0x26FD], [0x2705, 0x2705], [0x270A, 0x270B],
|
|
70
|
+
[0x2728, 0x2728], [0x274C, 0x274C], [0x274E, 0x274E], [0x2753, 0x2755],
|
|
71
|
+
[0x2757, 0x2757], [0x2795, 0x2797], [0x27B0, 0x27B0], [0x27BF, 0x27BF],
|
|
72
|
+
[0x2B1B, 0x2B1C], [0x2B50, 0x2B50], [0x2B55, 0x2B55],
|
|
73
|
+
[0x2E80, 0x303E], [0x3041, 0x33FF], [0x3400, 0x4DBF], [0x4E00, 0x9FFF],
|
|
74
|
+
[0xA000, 0xA4CF], [0xA960, 0xA97F], [0xAC00, 0xD7A3], [0xF900, 0xFAFF],
|
|
75
|
+
[0xFE10, 0xFE19], [0xFE30, 0xFE6F], [0xFF00, 0xFF60], [0xFFE0, 0xFFE6],
|
|
76
|
+
[0x16FE0, 0x16FE4], [0x17000, 0x187F7], [0x18800, 0x18CD5],
|
|
77
|
+
[0x1B000, 0x1B152], [0x1B164, 0x1B167], [0x1B170, 0x1B2FB],
|
|
78
|
+
[0x1F004, 0x1F004], [0x1F0CF, 0x1F0CF], [0x1F18E, 0x1F18E],
|
|
79
|
+
[0x1F191, 0x1F19A], [0x1F200, 0x1F320], [0x1F32D, 0x1F335],
|
|
80
|
+
[0x1F337, 0x1F37C], [0x1F37E, 0x1F393], [0x1F3A0, 0x1F3CA],
|
|
81
|
+
[0x1F3CF, 0x1F3D3], [0x1F3E0, 0x1F3F0], [0x1F3F4, 0x1F3F4],
|
|
82
|
+
[0x1F3F8, 0x1F43E], [0x1F440, 0x1F440], [0x1F442, 0x1F4FC],
|
|
83
|
+
[0x1F4FF, 0x1F53D], [0x1F54B, 0x1F54E], [0x1F550, 0x1F567],
|
|
84
|
+
[0x1F57A, 0x1F57A], [0x1F595, 0x1F596], [0x1F5A4, 0x1F5A4],
|
|
85
|
+
[0x1F5FB, 0x1F64F], [0x1F680, 0x1F6C5], [0x1F6CC, 0x1F6CC],
|
|
86
|
+
[0x1F6D0, 0x1F6D2], [0x1F6D5, 0x1F6D7], [0x1F6DC, 0x1F6DF],
|
|
87
|
+
[0x1F6EB, 0x1F6EC], [0x1F6F4, 0x1F6FC], [0x1F7E0, 0x1F7EB],
|
|
88
|
+
[0x1F7F0, 0x1F7F0], [0x1F90C, 0x1F93A], [0x1F93C, 0x1F945],
|
|
89
|
+
[0x1F947, 0x1F9FF], [0x1FA70, 0x1FAFF],
|
|
90
|
+
[0x20000, 0x2FFFD], [0x30000, 0x3FFFD],
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// Combining / formatting characters that occupy no cell.
|
|
94
|
+
function isZeroWidth(c) {
|
|
95
|
+
return (c >= 0x200B && c <= 0x200F) // ZWSP..RLM (incl. ZWJ U+200D)
|
|
96
|
+
|| (c >= 0x0300 && c <= 0x036F)
|
|
97
|
+
|| (c >= 0x0483 && c <= 0x0489)
|
|
98
|
+
|| (c >= 0x0591 && c <= 0x05BD) || c === 0x05BF || (c >= 0x05C1 && c <= 0x05C2)
|
|
99
|
+
|| (c >= 0x0610 && c <= 0x061A)
|
|
100
|
+
|| (c >= 0x064B && c <= 0x065F) || c === 0x0670
|
|
101
|
+
|| (c >= 0x06D6 && c <= 0x06DC)
|
|
102
|
+
|| (c >= 0x0E31 && c <= 0x0E3A) || (c >= 0x0E47 && c <= 0x0E4E)
|
|
103
|
+
|| (c >= 0x1AB0 && c <= 0x1AFF) || (c >= 0x1DC0 && c <= 0x1DFF)
|
|
104
|
+
|| (c >= 0x20D0 && c <= 0x20FF) || (c >= 0xFE00 && c <= 0xFE0F)
|
|
105
|
+
|| (c >= 0xFE20 && c <= 0xFE2F) || (c >= 0xE0100 && c <= 0xE01EF)
|
|
106
|
+
|| (c >= 0x1160 && c <= 0x11FF); // Hangul Jamo medial/final
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Regional Indicator (flags) — two of them form one glyph.
|
|
110
|
+
const isRegional = (c) => c >= 0x1F1E6 && c <= 0x1F1FF;
|
|
111
|
+
|
|
112
|
+
// Emoji that occupy TWO cells by default (Emoji_Presentation=Yes), even
|
|
113
|
+
// without U+FE0F. Text-default symbols (U+276F ❯, U+2603 ☃, U+00B7 ·, …) are
|
|
114
|
+
// NOT in here: they are 1 cell unless followed by U+FE0F.
|
|
115
|
+
function isWideByDefault(c) {
|
|
116
|
+
return (c >= 0x1F300 && c <= 0x1F5FF)
|
|
117
|
+
|| (c >= 0x1F600 && c <= 0x1F64F)
|
|
118
|
+
|| (c >= 0x1F680 && c <= 0x1F6FF)
|
|
119
|
+
|| (c >= 0x1F900 && c <= 0x1F9FF)
|
|
120
|
+
|| (c >= 0x1FA70 && c <= 0x1FAFF)
|
|
121
|
+
|| (c >= 0x1F000 && c <= 0x1F0FF)
|
|
122
|
+
|| isRegional(c)
|
|
123
|
+
|| c === 0x231A || c === 0x231B || c === 0x23E9 || c === 0x23EC
|
|
124
|
+
|| c === 0x23F0 || c === 0x23F3 || c === 0x25FD || c === 0x25FE
|
|
125
|
+
|| c === 0x2614 || c === 0x2615 || (c >= 0x2648 && c <= 0x2653)
|
|
126
|
+
|| c === 0x267F || c === 0x2693 || c === 0x26A1 || (c >= 0x26AA && c <= 0x26AB)
|
|
127
|
+
|| (c >= 0x26BD && c <= 0x26BE) || (c >= 0x26C4 && c <= 0x26C5)
|
|
128
|
+
|| c === 0x26CE || c === 0x26D4 || c === 0x26EA
|
|
129
|
+
|| (c >= 0x26F2 && c <= 0x26F3) || c === 0x26F5 || c === 0x26FA || c === 0x26FD
|
|
130
|
+
|| c === 0x2705 || (c >= 0x270A && c <= 0x270B) || c === 0x2728
|
|
131
|
+
|| c === 0x274C || c === 0x274E || (c >= 0x2753 && c <= 0x2755) || c === 0x2757
|
|
132
|
+
|| (c >= 0x2795 && c <= 0x2797) || c === 0x27B0 || c === 0x27BF
|
|
133
|
+
|| (c >= 0x2B1B && c <= 0x2B1C) || c === 0x2B50 || c === 0x2B55;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const inRanges = (c, ranges) => {
|
|
137
|
+
let lo = 0, hi = ranges.length - 1;
|
|
138
|
+
while (lo <= hi) {
|
|
139
|
+
const mid = (lo + hi) >> 1;
|
|
140
|
+
const [s, e] = ranges[mid];
|
|
141
|
+
if (c < s) hi = mid - 1;
|
|
142
|
+
else if (c > e) lo = mid + 1;
|
|
143
|
+
else return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function charWidth(ch) {
|
|
149
|
+
const c = String(ch).codePointAt(0);
|
|
150
|
+
if (c === undefined) return 0;
|
|
151
|
+
// Tab renders as 4 spaces in most terminals.
|
|
152
|
+
if (c === 0x09) return 4;
|
|
153
|
+
if (c < 0x20 || c === 0x7F) return 0;
|
|
154
|
+
if (isZeroWidth(c)) return 0;
|
|
155
|
+
if (isWideByDefault(c)) return 2;
|
|
156
|
+
if (inRanges(c, WIDE)) return 2;
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Split a string into grapheme clusters that a terminal renders as ONE glyph.
|
|
161
|
+
// Minimal but covers what actually appears in agent output: ZWJ sequences
|
|
162
|
+
// (👨👩👧), variation selectors (❤️), and flag pairs (🇺🇸).
|
|
163
|
+
function nextCluster(s, i) {
|
|
164
|
+
// NOTE: every offset here is a UTF-16 INDEX (what slice() consumes). Mixing
|
|
165
|
+
// in codepoint offsets desynced the scan across surrogate pairs, so a ZWJ
|
|
166
|
+
// family measured 5 columns instead of 2.
|
|
167
|
+
const at = (idx) => s.codePointAt(idx);
|
|
168
|
+
const len = (cp) => cp > 0xFFFF ? 2 : 1;
|
|
169
|
+
|
|
170
|
+
const firstCp = at(i);
|
|
171
|
+
let end = i + len(firstCp);
|
|
172
|
+
const startedRegional = isRegional(firstCp);
|
|
173
|
+
|
|
174
|
+
for (;;) {
|
|
175
|
+
if (end >= s.length) break;
|
|
176
|
+
const cp = at(end);
|
|
177
|
+
if (cp === 0x200D) { // ZWJ: absorb the next base too
|
|
178
|
+
end += 1;
|
|
179
|
+
if (end >= s.length) break;
|
|
180
|
+
end += len(at(end));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (isZeroWidth(cp) || cp === 0xFE0F) { end += len(cp); continue; }
|
|
184
|
+
// A regional-indicator PAIR forms one flag glyph.
|
|
185
|
+
if (startedRegional && isRegional(cp)) { end += len(cp); break; }
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
return { text: s.slice(i, end), next: end };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Width of a whole string measured by GRAPHEME CLUSTER: a ZWJ sequence or a
|
|
192
|
+
// flag counts 2 columns total, not the sum of its codepoints (which used to
|
|
193
|
+
// make 👨👩👧 measure 8 columns and break every row it appeared on).
|
|
194
|
+
export function clusterWidth(s) {
|
|
195
|
+
const str = String(s || '');
|
|
196
|
+
let w = 0;
|
|
197
|
+
for (let i = 0; i < str.length;) {
|
|
198
|
+
const { text, next } = nextCluster(str, i);
|
|
199
|
+
i = next;
|
|
200
|
+
if (!text) { i++; continue; }
|
|
201
|
+
w += clusterGlyphWidth(text);
|
|
202
|
+
}
|
|
203
|
+
return w;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function clusterGlyphWidth(cluster) {
|
|
207
|
+
const cps = [...cluster].map((c) => c.codePointAt(0));
|
|
208
|
+
// ZWJ sequence / flag pair / VS16-presented symbol => 2 columns.
|
|
209
|
+
if (cps.includes(0x200D)) return 2;
|
|
210
|
+
if (cps.filter(isRegional).length >= 1) return 2;
|
|
211
|
+
const base = cps[0];
|
|
212
|
+
if (cps.includes(0xFE0F)) return 2; // explicit emoji presentation
|
|
213
|
+
const baseW = charWidth(String.fromCodePoint(base));
|
|
214
|
+
let extra = 0;
|
|
215
|
+
for (let k = 1; k < cps.length; k++) extra += charWidth(String.fromCodePoint(cps[k]));
|
|
216
|
+
return baseW + extra;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Estimate the LLM token count of a plain string, matching kimi-code's
|
|
220
|
+
// algorithm in `llm-adapter/contract/tokens.ts`:
|
|
221
|
+
// tokens = ceil(ASCII chars / 4) + non-ASCII chars
|
|
222
|
+
// ASCII characters average ~4 per token; CJK and other non-ASCII are ~1 token
|
|
223
|
+
// each. This is far more accurate than a uniform chars/4 ratio.
|
|
224
|
+
export function estimateTokens(text) {
|
|
225
|
+
let ascii = 0, nonAscii = 0;
|
|
226
|
+
for (const ch of String(text ?? '')) {
|
|
227
|
+
if (ch.codePointAt(0) <= 127) ascii++;
|
|
228
|
+
else nonAscii++;
|
|
229
|
+
}
|
|
230
|
+
return Math.ceil(ascii / 4) + nonAscii;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Estimate tokens for the whole request payload: system + tools + messages.
|
|
234
|
+
export function estimateMessagesTokens(messages, cfg) {
|
|
235
|
+
let total = estimateTokens(cfg && cfg.systemPrompt);
|
|
236
|
+
if (Array.isArray(cfg && cfg.toolFilter)) total += cfg.toolFilter.length * 8; // rough tool-def overhead
|
|
237
|
+
for (const m of messages || []) {
|
|
238
|
+
total += estimateTokens(m.role || '');
|
|
239
|
+
const c = m.content;
|
|
240
|
+
total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
|
|
241
|
+
if (m.toolCalls) for (const tc of m.toolCalls) {
|
|
242
|
+
total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return total;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Expand TAB characters to spaces at 8-column tab stops. A terminal advances a
|
|
249
|
+
// tab to the NEXT tab stop, but our width math (visualWidth) counts '\t' as 0,
|
|
250
|
+
// so a row containing a tab is padded short and the terminal wraps it onto the
|
|
251
|
+
// next line — the "text bleeding onto another row" corruption. Tool output
|
|
252
|
+
// (e.g. `dir`, `ls -l`, git) contains tabs, so expand before measuring.
|
|
253
|
+
export function expandTabs(s, tabSize = 8) {
|
|
254
|
+
const str = String(s ?? '');
|
|
255
|
+
if (str.indexOf('\t') === -1) return str;
|
|
256
|
+
let out = '';
|
|
257
|
+
let col = 0;
|
|
258
|
+
for (const ch of str) {
|
|
259
|
+
if (ch === '\n') { out += ch; col = 0; continue; }
|
|
260
|
+
if (ch === '\t') {
|
|
261
|
+
const next = tabSize - (col % tabSize);
|
|
262
|
+
out += ' '.repeat(next);
|
|
263
|
+
col += next;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
out += ch;
|
|
267
|
+
col += clusterWidth(ch);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Truncate a string to `width` visible columns, stripping escapes for measurement
|
|
273
|
+
// and padding if shorter. Used for status/header lines. Honors 2-col glyphs.
|
|
274
|
+
export function fit(s, width) {
|
|
275
|
+
const plain = s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
276
|
+
const plainW = visualWidth(plain);
|
|
277
|
+
if (plainW <= width) return s + ' '.repeat(width - plainW);
|
|
278
|
+
let kept = '';
|
|
279
|
+
let w = 0;
|
|
280
|
+
for (const ch of s) {
|
|
281
|
+
if (ch === '\x1b') { kept += ch; continue; }
|
|
282
|
+
const cw = charWidth(ch);
|
|
283
|
+
if (w + cw > width) break;
|
|
284
|
+
w += cw;
|
|
285
|
+
kept += ch;
|
|
286
|
+
}
|
|
287
|
+
return kept;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function emit(str) {
|
|
291
|
+
process.stdout.write(str);
|
|
292
|
+
}
|
package/src/toml.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Minimal but correct TOML parser (subset sufficient for hncode + ~/.kimi-code/config.toml).
|
|
2
|
+
// Supports: comments, basic & literal strings (incl. multiline), arrays (incl. multiline
|
|
3
|
+
// and trailing commas), inline tables, dotted keys, tables [a.b."c"], booleans, ints,
|
|
4
|
+
// floats, and dates (returned as strings). Arrays of tables [[...]] are NOT supported.
|
|
5
|
+
|
|
6
|
+
const T = (s) => new _T(s);
|
|
7
|
+
|
|
8
|
+
class _T {
|
|
9
|
+
constructor(s) { this.s = s; this.pos = 0; this.n = s.length; }
|
|
10
|
+
peek() { return this.pos < this.n ? this.s[this.pos] : ''; }
|
|
11
|
+
cur() { return this.pos < this.n ? this.s[this.pos] : ''; }
|
|
12
|
+
next() { return this.pos < this.n ? this.s[this.pos++] : ''; }
|
|
13
|
+
eof() { return this.pos >= this.n; }
|
|
14
|
+
copyUntil(re) {
|
|
15
|
+
let out = '';
|
|
16
|
+
while (!this.eof() && !re.test(this.cur())) out += this.next();
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isWhitespace(c) { return c === ' ' || c === '\t'; }
|
|
22
|
+
function isBareKey(c) { return /[A-Za-z0-9_-]/.test(c); }
|
|
23
|
+
|
|
24
|
+
export function parse(toml) {
|
|
25
|
+
if (toml.length === 0) return {};
|
|
26
|
+
const t = T(toml);
|
|
27
|
+
const root = {};
|
|
28
|
+
let current = root;
|
|
29
|
+
while (!t.eof()) {
|
|
30
|
+
skipTrivia(t);
|
|
31
|
+
if (t.eof()) break;
|
|
32
|
+
if (t.peek() === '[') {
|
|
33
|
+
// table header (or array-of-tables [[...]] which we reject)
|
|
34
|
+
t.next(); // consume '['
|
|
35
|
+
if (t.peek() === '[') {
|
|
36
|
+
t.next();
|
|
37
|
+
throw new Error('TOML: arrays of tables ([[...]]) are not supported');
|
|
38
|
+
}
|
|
39
|
+
const keys = parseKeyPath(t);
|
|
40
|
+
expect(t, ']');
|
|
41
|
+
t.next();
|
|
42
|
+
current = ensureTable(root, keys);
|
|
43
|
+
requireLineEnd(t);
|
|
44
|
+
} else {
|
|
45
|
+
// key = value
|
|
46
|
+
const keys = parseKeyPath(t);
|
|
47
|
+
skipTrivia(t);
|
|
48
|
+
expect(t, '=');
|
|
49
|
+
t.next();
|
|
50
|
+
skipTrivia(t);
|
|
51
|
+
const value = parseValue(t);
|
|
52
|
+
assign(current, keys, value);
|
|
53
|
+
requireLineEnd(t);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function skipTrivia(t) {
|
|
60
|
+
while (!t.eof()) {
|
|
61
|
+
const c = t.cur();
|
|
62
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { t.next(); continue; }
|
|
63
|
+
if (c === '#') {
|
|
64
|
+
// comment until end of line
|
|
65
|
+
while (!t.eof() && t.cur() !== '\n' && t.cur() !== '\r') t.next();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseKeyPath(t) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
do {
|
|
75
|
+
skipTrivia(t);
|
|
76
|
+
parts.push(parseKeyPart(t));
|
|
77
|
+
skipTrivia(t);
|
|
78
|
+
if (t.cur() === '.') { t.next(); continue; }
|
|
79
|
+
break;
|
|
80
|
+
} while (!t.eof());
|
|
81
|
+
if (parts.length === 0) throw new Error('TOML: expected key');
|
|
82
|
+
return parts;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseKeyPart(t) {
|
|
86
|
+
const c = t.cur();
|
|
87
|
+
if (c === '"') return parseBasicString(t, true);
|
|
88
|
+
if (c === "'") return parseLiteralString(t, true);
|
|
89
|
+
// bare key
|
|
90
|
+
let out = '';
|
|
91
|
+
while (!t.eof() && isBareKey(t.cur())) out += t.next();
|
|
92
|
+
if (!out) throw new Error(`TOML: invalid key char ${JSON.stringify(t.cur())}`);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseValue(t) {
|
|
97
|
+
skipTrivia(t);
|
|
98
|
+
const c = t.cur();
|
|
99
|
+
if (c === '"') return parseBasicString(t, false);
|
|
100
|
+
if (c === "'") return parseLiteralString(t, false);
|
|
101
|
+
if (c === '[') return parseArray(t);
|
|
102
|
+
if (c === '{') return parseInlineTable(t);
|
|
103
|
+
// scalar until delimiter
|
|
104
|
+
let raw = '';
|
|
105
|
+
while (!t.eof()) {
|
|
106
|
+
const cc = t.cur();
|
|
107
|
+
if (cc === '\n' || cc === '\r' || cc === ',' || cc === '}' || cc === ']' || cc === '#') break;
|
|
108
|
+
if (isWhitespace(cc)) break;
|
|
109
|
+
raw += t.next();
|
|
110
|
+
}
|
|
111
|
+
return parseScalar(raw);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseScalar(raw) {
|
|
115
|
+
if (raw === '') throw new Error('TOML: empty value');
|
|
116
|
+
const l = raw.toLowerCase();
|
|
117
|
+
if (l === 'true') return true;
|
|
118
|
+
if (l === 'false') return false;
|
|
119
|
+
// int
|
|
120
|
+
if (/^[+-]?\d+$/.test(raw)) return Number(raw);
|
|
121
|
+
// float (incl. exponents)
|
|
122
|
+
if (/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(raw) && !/^0[0-9]/.test(raw.replace(/^[+-]/,''))) {
|
|
123
|
+
const num = Number(raw);
|
|
124
|
+
if (!Number.isNaN(num)) return num;
|
|
125
|
+
}
|
|
126
|
+
// date/time (keep as string)
|
|
127
|
+
if (/^\d{4}-\d{2}-\d{2}([Tt]|\s)\d{2}:\d{2}:\d{2}/.test(raw) || /^\d{4}-\d{2}-\d{2}/.test(raw)) {
|
|
128
|
+
return raw;
|
|
129
|
+
}
|
|
130
|
+
return raw; // bare word fallback as string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseBasicString(t, isKey) {
|
|
134
|
+
t.next(); // opening "
|
|
135
|
+
if (t.peek() === '"' && t.next() === '"' && t.next() === '"') {
|
|
136
|
+
// multiline basic """
|
|
137
|
+
// line-ending-trim: skip a newline immediately following the opening """
|
|
138
|
+
if (t.cur() === '\n') t.next();
|
|
139
|
+
else if (t.cur() === '\r') { t.next(); if (t.cur() === '\n') t.next(); }
|
|
140
|
+
let out = '';
|
|
141
|
+
while (!t.eof()) {
|
|
142
|
+
const c = t.next();
|
|
143
|
+
if (c === '\\') {
|
|
144
|
+
const e = t.next();
|
|
145
|
+
out += unescapeBasic(e, t);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (c === '"' && t.peek() === '"' && t.next() === '"' && t.next() === '"') break;
|
|
149
|
+
if (c === '\r' && t.cur() === '\n') { t.next(); out += '\n'; continue; }
|
|
150
|
+
out += c;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
// single-line basic
|
|
155
|
+
let out = '';
|
|
156
|
+
while (!t.eof()) {
|
|
157
|
+
const c = t.next();
|
|
158
|
+
if (c === '"') { if (isKey) { break; } else { return out; } }
|
|
159
|
+
if (c === '\\') { const e = t.next(); out += unescapeBasic(e, t); continue; }
|
|
160
|
+
if (c === '\n' || c === '\r') throw new Error('TOML: unterminated basic string');
|
|
161
|
+
out += c;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function unescapeBasic(e, t) {
|
|
167
|
+
const m = {
|
|
168
|
+
b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\',
|
|
169
|
+
' ' : ' ', '\t': '\t', '/': '/',
|
|
170
|
+
};
|
|
171
|
+
if (e in m) return m[e];
|
|
172
|
+
if (e === 'u') return readHex(t, 4);
|
|
173
|
+
if (e === 'U') return readHex(t, 8);
|
|
174
|
+
throw new Error(`TOML: invalid escape \\${e}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readHex(t, count) {
|
|
178
|
+
let hex = '';
|
|
179
|
+
for (let i = 0; i < count; i++) hex += t.next();
|
|
180
|
+
return String.fromCodePoint(parseInt(hex, 16));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseLiteralString(t, isKey) {
|
|
184
|
+
t.next(); // opening '
|
|
185
|
+
if (t.peek() === "'" && t.next() === "'" && t.next() === "'") {
|
|
186
|
+
// multiline literal '''
|
|
187
|
+
if (t.cur() === '\n') t.next();
|
|
188
|
+
else if (t.cur() === '\r') { t.next(); if (t.cur() === '\n') t.next(); }
|
|
189
|
+
let out = '';
|
|
190
|
+
while (!t.eof()) {
|
|
191
|
+
const c = t.next();
|
|
192
|
+
if (c === "'" && t.peek() === "'" && t.next() === "'" && t.next() === "'") break;
|
|
193
|
+
out += c;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
let out = '';
|
|
198
|
+
while (!t.eof()) {
|
|
199
|
+
const c = t.next();
|
|
200
|
+
if (c === "'") break;
|
|
201
|
+
if (c === '\n' || c === '\r') throw new Error('TOML: unterminated literal string');
|
|
202
|
+
out += c;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseArray(t) {
|
|
208
|
+
t.next(); // [
|
|
209
|
+
const arr = [];
|
|
210
|
+
while (true) {
|
|
211
|
+
skipTrivia(t);
|
|
212
|
+
if (t.cur() === ']') { t.next(); break; }
|
|
213
|
+
if (t.eof()) throw new Error('TOML: unterminated array');
|
|
214
|
+
arr.push(parseValue(t));
|
|
215
|
+
skipTrivia(t);
|
|
216
|
+
if (t.cur() === ',') { t.next(); continue; }
|
|
217
|
+
if (t.cur() === ']') { t.next(); break; }
|
|
218
|
+
if (t.eof()) throw new Error('TOML: unterminated array');
|
|
219
|
+
throw new Error(`TOML: expected ',' or ']' in array, got ${JSON.stringify(t.cur())}`);
|
|
220
|
+
}
|
|
221
|
+
return arr;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseInlineTable(t) {
|
|
225
|
+
t.next(); // {
|
|
226
|
+
const obj = {};
|
|
227
|
+
while (true) {
|
|
228
|
+
skipTrivia(t);
|
|
229
|
+
if (t.cur() === '}') { t.next(); break; }
|
|
230
|
+
if (t.eof()) throw new Error('TOML: unterminated inline table');
|
|
231
|
+
const keys = parseKeyPath(t);
|
|
232
|
+
skipTrivia(t);
|
|
233
|
+
expect(t, '=');
|
|
234
|
+
t.next();
|
|
235
|
+
const value = parseValue(t);
|
|
236
|
+
assign(obj, keys, value);
|
|
237
|
+
skipTrivia(t);
|
|
238
|
+
if (t.cur() === ',') { t.next(); continue; }
|
|
239
|
+
if (t.cur() === '}') { t.next(); break; }
|
|
240
|
+
}
|
|
241
|
+
return obj;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function expect(t, ch) {
|
|
245
|
+
if (t.cur() !== ch) throw new Error(`TOML: expected '${ch}' got ${JSON.stringify(t.cur())} at ${t.pos}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function requireLineEnd(t) {
|
|
249
|
+
// After a key=value or table header: only skip trailing spaces/tabs on this
|
|
250
|
+
// line (and an optional trailing comment), then require a line boundary.
|
|
251
|
+
// We do NOT consume the newline here; the main loop's skipTrivia() will.
|
|
252
|
+
while (!t.eof() && (t.cur() === ' ' || t.cur() === '\t')) t.next();
|
|
253
|
+
if (t.cur() === '#') {
|
|
254
|
+
while (!t.eof() && t.cur() !== '\n' && t.cur() !== '\r') t.next();
|
|
255
|
+
}
|
|
256
|
+
if (!t.eof() && t.cur() !== '\n' && t.cur() !== '\r') {
|
|
257
|
+
throw new Error(`TOML: expected end of line at ${t.pos}, got ${JSON.stringify(t.cur())}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ensureTable(root, keys) {
|
|
262
|
+
let o = root;
|
|
263
|
+
for (const k of keys) {
|
|
264
|
+
if (!(k in o) || typeof o[k] !== 'object' || Array.isArray(o[k])) o[k] = {};
|
|
265
|
+
o = o[k];
|
|
266
|
+
}
|
|
267
|
+
return o;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function assign(table, keys, value) {
|
|
271
|
+
let o = table;
|
|
272
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
273
|
+
const k = keys[i];
|
|
274
|
+
if (!(k in o) || typeof o[k] !== 'object' || Array.isArray(o[k])) o[k] = {};
|
|
275
|
+
o = o[k];
|
|
276
|
+
}
|
|
277
|
+
const last = keys[keys.length - 1];
|
|
278
|
+
o[last] = value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default { parse };
|