@jpssff/vanor 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/README-cn.md +166 -0
- package/README.md +120 -0
- package/base/config.js +162 -0
- package/base/core/compaction.js +58 -0
- package/base/core/harness.js +246 -0
- package/base/core/loop.js +72 -0
- package/base/core/prompt.js +126 -0
- package/base/core/session.js +255 -0
- package/base/events.js +54 -0
- package/base/i18n/index.js +80 -0
- package/base/i18n/locales/en.js +254 -0
- package/base/i18n/locales/zh-CN.js +252 -0
- package/base/llm/index.js +119 -0
- package/base/llm/providers/anthropic.js +147 -0
- package/base/llm/providers/openai.js +155 -0
- package/base/llm/sse.js +27 -0
- package/base/llm/trace.js +64 -0
- package/base/logger.js +57 -0
- package/base/memory/index.js +139 -0
- package/base/security/index.js +77 -0
- package/base/skills/loader.js +297 -0
- package/base/test/cli.test.js +91 -0
- package/base/test/config.test.js +63 -0
- package/base/test/core.test.js +154 -0
- package/base/test/i18n.test.js +32 -0
- package/base/test/loop.test.js +97 -0
- package/base/test/memory.test.js +47 -0
- package/base/test/message.test.js +38 -0
- package/base/test/session.test.js +324 -0
- package/base/test/skills.test.js +236 -0
- package/base/test/statusbar.test.js +143 -0
- package/base/test/tools.test.js +127 -0
- package/base/test/trace.test.js +62 -0
- package/base/test/tui.test.js +242 -0
- package/base/test/utils.test.js +35 -0
- package/base/tools/builtin.js +221 -0
- package/base/tools/index.js +157 -0
- package/base/transport/cli.js +417 -0
- package/base/transport/message.js +81 -0
- package/base/transport/statusbar.js +117 -0
- package/base/transport/tui.js +397 -0
- package/base/utils.js +150 -0
- package/docs/TECH_DESIGN.md +544 -0
- package/index.js +175 -0
- package/package.json +33 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// 轻量终端输入控制器:将输出滚动区与底部输入行分离。
|
|
2
|
+
// Agent 运行时用户可以继续编辑下一轮输入,输出滚动不会带走输入草稿。
|
|
3
|
+
|
|
4
|
+
const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
5
|
+
const PASTE_START = "\x1b[200~";
|
|
6
|
+
const PASTE_END = "\x1b[201~";
|
|
7
|
+
const ENABLE_BRACKETED_PASTE = "\x1b[?2004h";
|
|
8
|
+
const DISABLE_BRACKETED_PASTE = "\x1b[?2004l";
|
|
9
|
+
|
|
10
|
+
export function stripAnsi(s) {
|
|
11
|
+
return String(s || "").replace(ANSI_RE, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function chars(s) {
|
|
15
|
+
return Array.from(String(s || ""));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function charWidth(ch) {
|
|
19
|
+
if (!ch) return 0;
|
|
20
|
+
const cp = ch.codePointAt(0);
|
|
21
|
+
if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
|
|
22
|
+
// 常见组合附加符号不前进光标
|
|
23
|
+
if (
|
|
24
|
+
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
25
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
26
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
27
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
28
|
+
(cp >= 0xfe20 && cp <= 0xfe2f)
|
|
29
|
+
) {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
// CJK、全角标点、Hangul、常见 emoji 在终端通常占 2 列
|
|
33
|
+
if (
|
|
34
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
35
|
+
cp === 0x2329 ||
|
|
36
|
+
cp === 0x232a ||
|
|
37
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
|
|
38
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
39
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
40
|
+
(cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
41
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
42
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
43
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
44
|
+
(cp >= 0x1f300 && cp <= 0x1faff)
|
|
45
|
+
) {
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function displayWidth(s) {
|
|
52
|
+
return chars(stripAnsi(s)).reduce((n, ch) => n + charWidth(ch), 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function printable(s) {
|
|
56
|
+
return s && !s.startsWith("\x1b") && !/[\x00-\x08\x0b-\x1f\x7f]/.test(s);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inputVisual(ch) {
|
|
60
|
+
return ch === "\n" ? "↵ " : ch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inputSliceWidth(all, start, end) {
|
|
64
|
+
return displayWidth(all.slice(start, end).map(inputVisual).join(""));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function advanceCursor(pos, chunk) {
|
|
68
|
+
const cols = Math.max(1, pos.cols || 80);
|
|
69
|
+
const bottom = Math.max(1, pos.bottom || pos.row || 1);
|
|
70
|
+
let row = Math.max(1, pos.row || bottom);
|
|
71
|
+
let col = Math.max(1, pos.col || 1);
|
|
72
|
+
const text = stripAnsi(chunk);
|
|
73
|
+
|
|
74
|
+
for (const ch of chars(text)) {
|
|
75
|
+
if (ch === "\r") {
|
|
76
|
+
col = 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === "\n") {
|
|
80
|
+
col = 1;
|
|
81
|
+
row = row >= bottom ? bottom : row + 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const w = charWidth(ch);
|
|
85
|
+
if (w === 0) continue;
|
|
86
|
+
col += w;
|
|
87
|
+
if (col > cols) {
|
|
88
|
+
col = 1;
|
|
89
|
+
row = row >= bottom ? bottom : row + 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { row, col };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class TerminalUI {
|
|
96
|
+
constructor({ input, output, statusBar, prompt = "› " }) {
|
|
97
|
+
this.input = input;
|
|
98
|
+
this.output = output;
|
|
99
|
+
this.statusBar = statusBar;
|
|
100
|
+
this.prompt = prompt;
|
|
101
|
+
this.buffer = "";
|
|
102
|
+
this.cursor = 0;
|
|
103
|
+
this.enabled = false;
|
|
104
|
+
this.queue = [];
|
|
105
|
+
this.waiters = [];
|
|
106
|
+
this._onData = (data) => this._handleData(data);
|
|
107
|
+
this.onSigint = null;
|
|
108
|
+
this._ask = null;
|
|
109
|
+
this.outRow = 1;
|
|
110
|
+
this.outCol = 1;
|
|
111
|
+
this._pasting = false;
|
|
112
|
+
this._pendingEsc = "";
|
|
113
|
+
this.history = [];
|
|
114
|
+
this.historyIndex = null;
|
|
115
|
+
this._draftBeforeHistory = "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
enable() {
|
|
119
|
+
if (!this.input.isTTY || !this.output.isTTY) return false;
|
|
120
|
+
this.enabled = true;
|
|
121
|
+
this._resetOutputCursor();
|
|
122
|
+
this.input.setEncoding?.("utf8");
|
|
123
|
+
this.input.setRawMode?.(true);
|
|
124
|
+
this.input.resume?.();
|
|
125
|
+
this.input.on("data", this._onData);
|
|
126
|
+
this.output.write(ENABLE_BRACKETED_PASTE);
|
|
127
|
+
this.renderInput(true);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
disable() {
|
|
132
|
+
if (!this.enabled) return;
|
|
133
|
+
this.input.off("data", this._onData);
|
|
134
|
+
this.input.setRawMode?.(false);
|
|
135
|
+
this.output.write(DISABLE_BRACKETED_PASTE);
|
|
136
|
+
this.enabled = false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_rows() {
|
|
140
|
+
return this.output.rows || 24;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_cols() {
|
|
144
|
+
return this.output.columns || 80;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_inputRow() {
|
|
148
|
+
return this.statusBar?.inputRow?.() || Math.max(1, this._rows() - 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_scrollBottom() {
|
|
152
|
+
return this.statusBar?.scrollBottom?.() || Math.max(1, this._rows() - 2);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_resetOutputCursor() {
|
|
156
|
+
this.outRow = this._scrollBottom();
|
|
157
|
+
this.outCol = 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
write(chunk) {
|
|
161
|
+
if (!this.enabled) return this.output.write(chunk);
|
|
162
|
+
const text = String(chunk ?? "");
|
|
163
|
+
if (!text) return true;
|
|
164
|
+
this.output.write(`\x1b7\x1b[${this.outRow};${this.outCol}H${text}\x1b8`);
|
|
165
|
+
const next = advanceCursor({ row: this.outRow, col: this.outCol, cols: this._cols(), bottom: this._scrollBottom() }, text);
|
|
166
|
+
this.outRow = next.row;
|
|
167
|
+
this.outCol = next.col;
|
|
168
|
+
this.renderInput(true);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
echoInput(line) {
|
|
173
|
+
this.write(`${this.prompt}${line}\n`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
readLine() {
|
|
177
|
+
const queued = this.queue.shift();
|
|
178
|
+
if (queued !== undefined) return Promise.resolve(queued);
|
|
179
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ask(prompt) {
|
|
183
|
+
const saved = { prompt: this.prompt, buffer: this.buffer, cursor: this.cursor };
|
|
184
|
+
this.prompt = prompt;
|
|
185
|
+
this.buffer = "";
|
|
186
|
+
this.cursor = 0;
|
|
187
|
+
this.renderInput(true);
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
this._ask = {
|
|
190
|
+
resolve: (line) => {
|
|
191
|
+
this._ask = null;
|
|
192
|
+
this.prompt = saved.prompt;
|
|
193
|
+
this.buffer = saved.buffer;
|
|
194
|
+
this.cursor = saved.cursor;
|
|
195
|
+
this.renderInput(true);
|
|
196
|
+
resolve(line);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
renderInput(force = false) {
|
|
203
|
+
if (!this.enabled && !force) return;
|
|
204
|
+
const row = this._inputRow();
|
|
205
|
+
const cols = this._cols();
|
|
206
|
+
const promptCols = displayWidth(this.prompt);
|
|
207
|
+
const available = Math.max(1, cols - promptCols - 1);
|
|
208
|
+
const all = chars(this.buffer);
|
|
209
|
+
const cursor = Math.min(this.cursor, all.length);
|
|
210
|
+
let start = 0;
|
|
211
|
+
while (inputSliceWidth(all, start, cursor) > available) start++;
|
|
212
|
+
let end = start;
|
|
213
|
+
let used = 0;
|
|
214
|
+
while (end < all.length) {
|
|
215
|
+
const w = displayWidth(inputVisual(all[end]));
|
|
216
|
+
if (used + w > available) break;
|
|
217
|
+
used += w;
|
|
218
|
+
end++;
|
|
219
|
+
}
|
|
220
|
+
const visible = all.slice(start, end).map(inputVisual).join("");
|
|
221
|
+
const visibleCursorCols = inputSliceWidth(all, start, cursor);
|
|
222
|
+
const cursorCol = Math.max(1, Math.min(cols, promptCols + visibleCursorCols + 1));
|
|
223
|
+
this.output.write(`\x1b[${row};1H\x1b[2K${this.prompt}${visible}\x1b[${row};${cursorCol}H`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_resolveLine(line) {
|
|
227
|
+
const waiter = this.waiters.shift();
|
|
228
|
+
if (waiter) waiter(line);
|
|
229
|
+
else this.queue.push(line);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_submit() {
|
|
233
|
+
const line = this.buffer;
|
|
234
|
+
this.buffer = "";
|
|
235
|
+
this.cursor = 0;
|
|
236
|
+
this.renderInput(true);
|
|
237
|
+
if (this._ask) {
|
|
238
|
+
this._ask.resolve(line);
|
|
239
|
+
} else {
|
|
240
|
+
this._remember(line);
|
|
241
|
+
this._resolveLine(line);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_remember(line) {
|
|
246
|
+
if (!String(line).trim()) return;
|
|
247
|
+
if (this.history.at(-1) !== line) this.history.push(line);
|
|
248
|
+
if (this.history.length > 200) this.history.shift();
|
|
249
|
+
this.historyIndex = null;
|
|
250
|
+
this._draftBeforeHistory = "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_setBuffer(text, { fromHistory = false } = {}) {
|
|
254
|
+
this.buffer = String(text || "");
|
|
255
|
+
this.cursor = chars(this.buffer).length;
|
|
256
|
+
if (!fromHistory) {
|
|
257
|
+
this.historyIndex = null;
|
|
258
|
+
this._draftBeforeHistory = "";
|
|
259
|
+
}
|
|
260
|
+
this.renderInput(true);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_navigateHistory(delta) {
|
|
264
|
+
if (this._ask || !this.history.length) return;
|
|
265
|
+
if (this.historyIndex === null) {
|
|
266
|
+
this._draftBeforeHistory = this.buffer;
|
|
267
|
+
this.historyIndex = this.history.length;
|
|
268
|
+
}
|
|
269
|
+
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + delta));
|
|
270
|
+
const next = this.historyIndex === this.history.length ? this._draftBeforeHistory : this.history[this.historyIndex];
|
|
271
|
+
this._setBuffer(next, { fromHistory: true });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_insert(s, { multiline = false } = {}) {
|
|
275
|
+
const all = chars(this.buffer);
|
|
276
|
+
const text = multiline ? String(s).replace(/\r\n/g, "\n").replace(/\r/g, "\n") : String(s);
|
|
277
|
+
const ins = chars(text).filter((ch) => (multiline && ch === "\n") || printable(ch));
|
|
278
|
+
if (!ins.length) return;
|
|
279
|
+
this.historyIndex = null;
|
|
280
|
+
this._draftBeforeHistory = "";
|
|
281
|
+
all.splice(this.cursor, 0, ...ins);
|
|
282
|
+
this.buffer = all.join("");
|
|
283
|
+
this.cursor += ins.length;
|
|
284
|
+
this.renderInput(true);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_backspace() {
|
|
288
|
+
if (this.cursor <= 0) return;
|
|
289
|
+
this.historyIndex = null;
|
|
290
|
+
this._draftBeforeHistory = "";
|
|
291
|
+
const all = chars(this.buffer);
|
|
292
|
+
all.splice(this.cursor - 1, 1);
|
|
293
|
+
this.buffer = all.join("");
|
|
294
|
+
this.cursor -= 1;
|
|
295
|
+
this.renderInput(true);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_delete() {
|
|
299
|
+
const all = chars(this.buffer);
|
|
300
|
+
if (this.cursor >= all.length) return;
|
|
301
|
+
this.historyIndex = null;
|
|
302
|
+
this._draftBeforeHistory = "";
|
|
303
|
+
all.splice(this.cursor, 1);
|
|
304
|
+
this.buffer = all.join("");
|
|
305
|
+
this.renderInput(true);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_handleEscape(s) {
|
|
309
|
+
if (s === "\x1b[A") {
|
|
310
|
+
this._navigateHistory(-1);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (s === "\x1b[B") {
|
|
314
|
+
this._navigateHistory(1);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
if (s === "\x1b[D") {
|
|
318
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
319
|
+
this.renderInput(true);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
if (s === "\x1b[C") {
|
|
323
|
+
this.cursor = Math.min(chars(this.buffer).length, this.cursor + 1);
|
|
324
|
+
this.renderInput(true);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (s === "\x1b[3~") {
|
|
328
|
+
this._delete();
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_handleData(data) {
|
|
335
|
+
let s = String(data);
|
|
336
|
+
if (this._pendingEsc) {
|
|
337
|
+
s = this._pendingEsc + s;
|
|
338
|
+
this._pendingEsc = "";
|
|
339
|
+
}
|
|
340
|
+
if (s === "\x1b" || s === "\x1b[") {
|
|
341
|
+
this._pendingEsc = s;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (this._pasting) {
|
|
345
|
+
const end = s.indexOf(PASTE_END);
|
|
346
|
+
if (end === -1) {
|
|
347
|
+
this._insert(s, { multiline: true });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this._insert(s.slice(0, end), { multiline: true });
|
|
351
|
+
this._pasting = false;
|
|
352
|
+
const rest = s.slice(end + PASTE_END.length);
|
|
353
|
+
if (rest) this._handleData(rest);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const start = s.indexOf(PASTE_START);
|
|
358
|
+
if (start !== -1) {
|
|
359
|
+
const before = s.slice(0, start);
|
|
360
|
+
if (before) this._handleData(before);
|
|
361
|
+
this._pasting = true;
|
|
362
|
+
const rest = s.slice(start + PASTE_START.length);
|
|
363
|
+
if (rest) this._handleData(rest);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (s.startsWith("\x1b")) {
|
|
368
|
+
if (this._handleEscape(s)) return;
|
|
369
|
+
return; // 未识别的控制序列直接忽略,避免拆包时漏出 [A/[B 等字符
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (chars(s).length > 1 && !s.startsWith("\x1b")) {
|
|
373
|
+
for (const ch of chars(s)) this._handleData(ch);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (s === "\x03") {
|
|
377
|
+
if (this._ask) return this._ask.resolve("");
|
|
378
|
+
return this.onSigint?.();
|
|
379
|
+
}
|
|
380
|
+
if (s === "\r" || s === "\n") return this._submit();
|
|
381
|
+
if (s === "\x7f" || s === "\b") return this._backspace();
|
|
382
|
+
if (s === "\x01") {
|
|
383
|
+
this.cursor = 0;
|
|
384
|
+
return this.renderInput(true);
|
|
385
|
+
}
|
|
386
|
+
if (s === "\x05") {
|
|
387
|
+
this.cursor = chars(this.buffer).length;
|
|
388
|
+
return this.renderInput(true);
|
|
389
|
+
}
|
|
390
|
+
if (s === "\x15") {
|
|
391
|
+
this.buffer = "";
|
|
392
|
+
this.cursor = 0;
|
|
393
|
+
return this.renderInput(true);
|
|
394
|
+
}
|
|
395
|
+
return this._insert(s);
|
|
396
|
+
}
|
|
397
|
+
}
|
package/base/utils.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// 通用工具函数:零依赖,被各层只读引用。
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createI18n } from "./i18n/index.js";
|
|
6
|
+
|
|
7
|
+
const defaultT = createI18n("en").t;
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
/** 生成带前缀的短唯一 id,如 msg_lr3k2a9x。 */
|
|
11
|
+
export function genId(prefix = "id") {
|
|
12
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function nowIso() {
|
|
16
|
+
return new Date().toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 将路径中的 ~ 展开为用户主目录。 */
|
|
24
|
+
export function expandHome(p) {
|
|
25
|
+
if (!p || typeof p !== "string") return p;
|
|
26
|
+
if (p === "~") return homedir();
|
|
27
|
+
if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ensureDir(dir) {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readJsonSafe(file, fallback = null) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
38
|
+
} catch {
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeJson(file, value) {
|
|
44
|
+
ensureDir(path.dirname(file));
|
|
45
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 粗略 token 估算:约 4 字符 / token。 */
|
|
49
|
+
export function estimateTokens(text) {
|
|
50
|
+
if (!text) return 0;
|
|
51
|
+
return Math.ceil(String(text).length / 4);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- 终端颜色(无依赖,遵循 NO_COLOR 与非 TTY 自动降级)----
|
|
55
|
+
|
|
56
|
+
const colorEnabled = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
57
|
+
|
|
58
|
+
function wrap(code) {
|
|
59
|
+
return (s) => (colorEnabled() ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const c = {
|
|
63
|
+
red: wrap("31"),
|
|
64
|
+
green: wrap("32"),
|
|
65
|
+
yellow: wrap("33"),
|
|
66
|
+
blue: wrap("34"),
|
|
67
|
+
magenta: wrap("35"),
|
|
68
|
+
cyan: wrap("36"),
|
|
69
|
+
gray: wrap("90"),
|
|
70
|
+
bold: wrap("1"),
|
|
71
|
+
dim: wrap("2"),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---- 最小 JSON Schema 校验器(够工具参数使用)----
|
|
75
|
+
|
|
76
|
+
function checkType(t, v) {
|
|
77
|
+
switch (t) {
|
|
78
|
+
case "string":
|
|
79
|
+
return typeof v === "string";
|
|
80
|
+
case "number":
|
|
81
|
+
return typeof v === "number" && !Number.isNaN(v);
|
|
82
|
+
case "integer":
|
|
83
|
+
return Number.isInteger(v);
|
|
84
|
+
case "boolean":
|
|
85
|
+
return typeof v === "boolean";
|
|
86
|
+
case "object":
|
|
87
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
88
|
+
case "array":
|
|
89
|
+
return Array.isArray(v);
|
|
90
|
+
case "null":
|
|
91
|
+
return v === null;
|
|
92
|
+
default:
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 校验 value 是否符合简化版 JSON Schema。
|
|
99
|
+
* 支持 type / required / properties / items / enum。
|
|
100
|
+
* @returns {string[]} 错误信息数组,空数组表示通过。
|
|
101
|
+
*/
|
|
102
|
+
export function validateSchema(schema, value, p = "", t = defaultT) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
if (!schema || typeof schema !== "object") return errors;
|
|
105
|
+
const loc = p || "(root)";
|
|
106
|
+
|
|
107
|
+
if (schema.type && !checkType(schema.type, value)) {
|
|
108
|
+
errors.push(t("schema.expectedType", { loc, type: schema.type }));
|
|
109
|
+
return errors;
|
|
110
|
+
}
|
|
111
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
112
|
+
errors.push(t("schema.enumValue", { loc, values: JSON.stringify(schema.enum) }));
|
|
113
|
+
}
|
|
114
|
+
if (schema.type === "object" && checkType("object", value)) {
|
|
115
|
+
for (const req of schema.required || []) {
|
|
116
|
+
if (!(req in value)) errors.push(t("schema.required", { loc, field: req }));
|
|
117
|
+
}
|
|
118
|
+
for (const [k, sub] of Object.entries(schema.properties || {})) {
|
|
119
|
+
if (k in value) {
|
|
120
|
+
errors.push(...validateSchema(sub, value[k], p ? `${p}.${k}` : k, t));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (schema.type === "array" && Array.isArray(value) && schema.items) {
|
|
125
|
+
value.forEach((item, i) => {
|
|
126
|
+
errors.push(...validateSchema(schema.items, item, `${p}[${i}]`, t));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return errors;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 深合并普通对象(数组与标量直接覆盖)。 */
|
|
133
|
+
export function deepMerge(base, override) {
|
|
134
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return override ?? base;
|
|
135
|
+
const out = { ...base };
|
|
136
|
+
for (const [k, v] of Object.entries(override)) {
|
|
137
|
+
out[k] = isPlainObject(v) && isPlainObject(base[k]) ? deepMerge(base[k], v) : v;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isPlainObject(v) {
|
|
143
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 将字符串按 glob(仅支持 *)转为匹配函数,用于 allow/deny。 */
|
|
147
|
+
export function globToRegExp(pattern) {
|
|
148
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
149
|
+
return new RegExp(`^${escaped}$`);
|
|
150
|
+
}
|