@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.
Files changed (45) hide show
  1. package/README-cn.md +166 -0
  2. package/README.md +120 -0
  3. package/base/config.js +162 -0
  4. package/base/core/compaction.js +58 -0
  5. package/base/core/harness.js +246 -0
  6. package/base/core/loop.js +72 -0
  7. package/base/core/prompt.js +126 -0
  8. package/base/core/session.js +255 -0
  9. package/base/events.js +54 -0
  10. package/base/i18n/index.js +80 -0
  11. package/base/i18n/locales/en.js +254 -0
  12. package/base/i18n/locales/zh-CN.js +252 -0
  13. package/base/llm/index.js +119 -0
  14. package/base/llm/providers/anthropic.js +147 -0
  15. package/base/llm/providers/openai.js +155 -0
  16. package/base/llm/sse.js +27 -0
  17. package/base/llm/trace.js +64 -0
  18. package/base/logger.js +57 -0
  19. package/base/memory/index.js +139 -0
  20. package/base/security/index.js +77 -0
  21. package/base/skills/loader.js +297 -0
  22. package/base/test/cli.test.js +91 -0
  23. package/base/test/config.test.js +63 -0
  24. package/base/test/core.test.js +154 -0
  25. package/base/test/i18n.test.js +32 -0
  26. package/base/test/loop.test.js +97 -0
  27. package/base/test/memory.test.js +47 -0
  28. package/base/test/message.test.js +38 -0
  29. package/base/test/session.test.js +324 -0
  30. package/base/test/skills.test.js +236 -0
  31. package/base/test/statusbar.test.js +143 -0
  32. package/base/test/tools.test.js +127 -0
  33. package/base/test/trace.test.js +62 -0
  34. package/base/test/tui.test.js +242 -0
  35. package/base/test/utils.test.js +35 -0
  36. package/base/tools/builtin.js +221 -0
  37. package/base/tools/index.js +157 -0
  38. package/base/transport/cli.js +417 -0
  39. package/base/transport/message.js +81 -0
  40. package/base/transport/statusbar.js +117 -0
  41. package/base/transport/tui.js +397 -0
  42. package/base/utils.js +150 -0
  43. package/docs/TECH_DESIGN.md +544 -0
  44. package/index.js +175 -0
  45. package/package.json +33 -0
@@ -0,0 +1,139 @@
1
+ // 记忆:长期(有界 Markdown)+ 工作记忆 + 关键词检索。
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { EVENTS } from "../events.js";
6
+ import { ensureDir } from "../utils.js";
7
+
8
+ const SEP = "\n§\n";
9
+ const LINE = "═".repeat(46);
10
+
11
+ export function createMemory(paths, config, logger) {
12
+ const memFile = path.join(paths.memory, "MEMORY.md");
13
+ const userFile = path.join(paths.memory, "USER.md");
14
+ const limits = {
15
+ memory: config.memory?.memoryMaxChars ?? 2200,
16
+ user: config.memory?.userMaxChars ?? 1375,
17
+ };
18
+
19
+ function fileOf(target) {
20
+ return target === "user" ? userFile : memFile;
21
+ }
22
+
23
+ function loadEntries(target) {
24
+ const file = fileOf(target);
25
+ if (!fs.existsSync(file)) return [];
26
+ const text = fs.readFileSync(file, "utf8").trim();
27
+ if (!text) return [];
28
+ return text
29
+ .split(SEP)
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ function saveEntries(target, entries) {
35
+ ensureDir(paths.memory);
36
+ fs.writeFileSync(fileOf(target), entries.join(SEP) + (entries.length ? "\n" : ""));
37
+ }
38
+
39
+ function renderBlock(label, target) {
40
+ const entries = loadEntries(target);
41
+ if (!entries.length) return "";
42
+ const text = entries.join(SEP);
43
+ const used = text.length;
44
+ const pct = Math.round((used / limits[target]) * 100);
45
+ return `${LINE}\n${label} [${pct}% — ${used}/${limits[target]} chars]\n${LINE}\n${text}`;
46
+ }
47
+
48
+ /** 渲染注入系统 prompt 的记忆快照(空记忆返回空串)。 */
49
+ function snapshot() {
50
+ const blocks = [
51
+ renderBlock("MEMORY(环境与经验)", "memory"),
52
+ renderBlock("USER PROFILE(用户偏好)", "user"),
53
+ ].filter(Boolean);
54
+ return blocks.join("\n\n");
55
+ }
56
+
57
+ /** 应用 memory 工具操作。 */
58
+ function apply({ action, target = "memory", content, old_text }) {
59
+ if (!["memory", "user"].includes(target)) return { content: "target 必须为 memory 或 user", isError: true };
60
+ const entries = loadEntries(target);
61
+
62
+ if (action === "add") {
63
+ if (!content) return { content: "add 需要 content", isError: true };
64
+ entries.push(content.trim());
65
+ } else if (action === "replace") {
66
+ if (!old_text || !content) return { content: "replace 需要 old_text 与 content", isError: true };
67
+ const matched = entries.filter((e) => e.includes(old_text));
68
+ if (matched.length === 0) return { content: "未匹配到 old_text", isError: true };
69
+ if (matched.length > 1) return { content: "old_text 匹配多条,请更具体", isError: true };
70
+ const i = entries.findIndex((e) => e.includes(old_text));
71
+ entries[i] = content.trim();
72
+ } else if (action === "remove") {
73
+ if (!old_text) return { content: "remove 需要 old_text", isError: true };
74
+ const matched = entries.filter((e) => e.includes(old_text));
75
+ if (matched.length === 0) return { content: "未匹配到 old_text", isError: true };
76
+ if (matched.length > 1) return { content: "old_text 匹配多条,请更具体", isError: true };
77
+ const i = entries.findIndex((e) => e.includes(old_text));
78
+ entries.splice(i, 1);
79
+ } else {
80
+ return { content: `未知 action:${action}`, isError: true };
81
+ }
82
+
83
+ saveEntries(target, entries);
84
+ logger.info(EVENTS.memory.store, { action, target });
85
+ const used = entries.join(SEP).length;
86
+ const over = used > limits[target] ? `(已超出上限 ${limits[target]},建议整合)` : "";
87
+ return { content: `记忆已更新:${action} → ${target}${over}` };
88
+ }
89
+
90
+ /** 关键词检索长期记忆,返回评分最高的若干条。 */
91
+ function search(query, topN = 5) {
92
+ const words = String(query || "").toLowerCase().split(/\s+/).filter(Boolean);
93
+ if (!words.length) return [];
94
+ const all = [...loadEntries("memory"), ...loadEntries("user")];
95
+ const scored = all
96
+ .map((e) => {
97
+ const lc = e.toLowerCase();
98
+ const score = words.reduce((s, w) => s + (lc.includes(w) ? 1 : 0), 0);
99
+ return { entry: e, score };
100
+ })
101
+ .filter((x) => x.score > 0)
102
+ .sort((a, b) => b.score - a.score)
103
+ .slice(0, topN);
104
+ logger.info(EVENTS.memory.retrieve, { query, hits: scored.length });
105
+ return scored.map((x) => x.entry);
106
+ }
107
+
108
+ function writeWorking(name, content) {
109
+ ensureDir(paths.working);
110
+ fs.writeFileSync(path.join(paths.working, name), content);
111
+ }
112
+ function readWorking(name) {
113
+ const f = path.join(paths.working, name);
114
+ return fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
115
+ }
116
+
117
+ /** 绑定到本实例的 memory 工具。 */
118
+ const tool = {
119
+ name: "memory",
120
+ description:
121
+ "管理长期记忆。target=memory(环境/经验)或 user(用户偏好);action=add|replace|remove。",
122
+ danger: "safe",
123
+ parameters: {
124
+ type: "object",
125
+ properties: {
126
+ action: { type: "string", enum: ["add", "replace", "remove"] },
127
+ target: { type: "string", enum: ["memory", "user"] },
128
+ content: { type: "string" },
129
+ old_text: { type: "string", description: "replace/remove 时用于子串匹配" },
130
+ },
131
+ required: ["action"],
132
+ },
133
+ handler(args) {
134
+ return apply(args);
135
+ },
136
+ };
137
+
138
+ return { snapshot, apply, search, writeWorking, readWorking, tool };
139
+ }
@@ -0,0 +1,77 @@
1
+ // 安全:命令审批、allow/deny、工作区边界。
2
+
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ import { createI18n } from "../i18n/index.js";
6
+ import { expandHome, globToRegExp } from "../utils.js";
7
+
8
+ export function createSecurity(config) {
9
+ const sec = config.security || {};
10
+ const i18n = config.i18n || createI18n(config);
11
+ const { t } = i18n;
12
+ const workspaceRoot = sec.workspaceRoot || process.cwd();
13
+ const allow = (sec.allowlist || []).map(globToRegExp);
14
+ const deny = (sec.denylist || []).map(globToRegExp);
15
+ const initialApproval = sec.approval || "ask";
16
+ let approval = initialApproval;
17
+ const allowOutside = !!sec.allowOutsideWorkspace;
18
+
19
+ function within(abs) {
20
+ return abs === workspaceRoot || abs.startsWith(workspaceRoot + path.sep);
21
+ }
22
+
23
+ /** 命令审批决策:allow | deny | ask。 */
24
+ function checkCommand(cmd) {
25
+ const command = String(cmd || "").trim();
26
+ if (deny.some((re) => re.test(command))) return "deny";
27
+ if (allow.some((re) => re.test(command))) return "allow";
28
+ if (approval === "auto") return "allow";
29
+ if (approval === "deny") return "deny";
30
+ return "ask";
31
+ }
32
+
33
+ /** 非命令类的需确认操作(写文件等):allow | deny | ask。 */
34
+ function approvalMode() {
35
+ if (approval === "auto") return "allow";
36
+ if (approval === "deny") return "deny";
37
+ return "ask";
38
+ }
39
+
40
+ /**
41
+ * 解析并校验路径是否在工作区内。
42
+ * @returns {{ ok:boolean, abs:string, reason?:string }}
43
+ */
44
+ function checkPath(p, { mustExist = false, dir = false } = {}) {
45
+ if (!p || typeof p !== "string") return { ok: false, abs: "", reason: t("security.invalidPath") };
46
+ const abs = path.resolve(workspaceRoot, expandHome(p));
47
+ if (!allowOutside && !within(abs)) {
48
+ return { ok: false, abs, reason: t("security.outsideWorkspace", { workspace: workspaceRoot }) };
49
+ }
50
+ if (mustExist && !fs.existsSync(abs)) {
51
+ return { ok: false, abs, reason: t("security.pathNotFound") };
52
+ }
53
+ if (mustExist && dir && !fs.statSync(abs).isDirectory()) {
54
+ return { ok: false, abs, reason: t("security.notDirectory") };
55
+ }
56
+ return { ok: true, abs };
57
+ }
58
+
59
+ // 运行时切换审批模式(如 /auto-run)。denylist 始终生效,不受影响。
60
+ function setApproval(mode) {
61
+ if (["ask", "auto", "deny"].includes(mode)) approval = mode;
62
+ return approval;
63
+ }
64
+
65
+ return {
66
+ workspaceRoot,
67
+ initialApproval,
68
+ get approval() {
69
+ return approval;
70
+ },
71
+ setApproval,
72
+ checkCommand,
73
+ approvalMode,
74
+ checkPath,
75
+ within,
76
+ };
77
+ }
@@ -0,0 +1,297 @@
1
+ // 技能加载器:扫描技能目录,解析 SKILL.md,按优先级去重。
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { EVENTS } from "../events.js";
7
+ import { createI18n } from "../i18n/index.js";
8
+ import { ensureDir } from "../utils.js";
9
+
10
+ const defaultI18n = createI18n("en");
11
+
12
+ function ok(content) {
13
+ return { content: String(content) };
14
+ }
15
+ function fail(content) {
16
+ return { content: String(content), isError: true };
17
+ }
18
+
19
+ /** 默认技能目录,优先级从高到低。 */
20
+ export function defaultSkillDirs(paths, workspaceRoot) {
21
+ const workspaceSkillDirs = workspaceRoot
22
+ ? [path.join(workspaceRoot, ".skills"), path.join(workspaceRoot, "skills")]
23
+ : [];
24
+ return [
25
+ paths.skills,
26
+ ...workspaceSkillDirs,
27
+ path.join(homedir(), ".cursor", "skills-cursor"),
28
+ path.join(homedir(), ".claude", "skills"),
29
+ path.join(homedir(), ".agents", "skills"),
30
+ path.join(homedir(), ".codex", "skills"),
31
+ path.join(homedir(), ".codex", "skills", ".archive"),
32
+ path.join(homedir(), ".agent", "skills"),
33
+ path.join(homedir(), ".skills"),
34
+ ];
35
+ }
36
+
37
+ /** 极简 frontmatter 解析:支持 `key: value` 与 YAML folded/literal block(如 description: >-)。 */
38
+ function parseFrontmatter(md) {
39
+ const meta = {};
40
+ if (!md.startsWith("---")) return { meta, body: md };
41
+ const end = md.indexOf("\n---", 3);
42
+ if (end < 0) return { meta, body: md };
43
+ const header = md.slice(3, end).trim();
44
+ const lines = header.split("\n");
45
+ for (let idx = 0; idx < lines.length; idx++) {
46
+ const line = lines[idx];
47
+ const i = line.indexOf(":");
48
+ if (i < 0) continue;
49
+ const key = line.slice(0, i).trim();
50
+ let val = line.slice(i + 1).trim();
51
+ if (/^[>|][+-]?$/.test(val)) {
52
+ const block = [];
53
+ while (idx + 1 < lines.length && /^\s+/.test(lines[idx + 1])) {
54
+ block.push(lines[++idx].trim());
55
+ }
56
+ val = val.startsWith(">")
57
+ ? block.join(" ").replace(/\s+/g, " ").trim()
58
+ : block.join("\n").trim();
59
+ }
60
+ val = val.replace(/^["']|["']$/g, "");
61
+ if (key) meta[key] = val;
62
+ }
63
+ return { meta, body: md.slice(end + 4).trim() };
64
+ }
65
+
66
+ function readSkill(dir, name) {
67
+ const file = path.join(dir, name, "SKILL.md");
68
+ if (!fs.existsSync(file)) return null;
69
+ let md = "";
70
+ try {
71
+ md = fs.readFileSync(file, "utf8");
72
+ } catch {
73
+ return null;
74
+ }
75
+ const { meta } = parseFrontmatter(md);
76
+ const firstLine = md.split("\n").find((l) => l.trim() && !l.startsWith("---")) || "";
77
+ return {
78
+ name: meta.name || name,
79
+ description: meta.description || firstLine.replace(/^#+\s*/, "").slice(0, 200),
80
+ path: file,
81
+ dir: path.join(dir, name),
82
+ sourceDir: dir,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * 加载所有技能,同名按目录优先级去重(高优先级先扫描)。
88
+ * @returns {Array<{name,description,path,dir}>}
89
+ */
90
+ export function loadSkills(dirs, logger) {
91
+ const seen = new Set();
92
+ const skills = [];
93
+ for (const dir of dirs) {
94
+ if (!fs.existsSync(dir)) continue;
95
+ let entries;
96
+ try {
97
+ entries = fs.readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ continue;
100
+ }
101
+ for (const e of entries) {
102
+ if (!e.isDirectory()) continue;
103
+ const skill = readSkill(dir, e.name);
104
+ if (!skill || seen.has(skill.name)) continue;
105
+ seen.add(skill.name);
106
+ skills.push(skill);
107
+ }
108
+ }
109
+ logger?.info(EVENTS.skill.load, { count: skills.length });
110
+ return skills;
111
+ }
112
+
113
+ /** 渲染为系统 prompt 中的技能清单。 */
114
+ export function renderSkills(skills, i18n = defaultI18n) {
115
+ if (!skills.length) return "";
116
+ const { t } = i18n;
117
+ const lines = skills.map((s) => t("skills.promptItem", { name: s.name, description: s.description, path: s.path }));
118
+ return `${t("skills.promptIntro")}\n${lines.join("\n")}`;
119
+ }
120
+
121
+ export function summarizeSkills(skills) {
122
+ const byDir = new Map();
123
+ for (const skill of skills || []) {
124
+ const dir = skill.sourceDir || "(unknown)";
125
+ byDir.set(dir, (byDir.get(dir) || 0) + 1);
126
+ }
127
+ return {
128
+ total: (skills || []).length,
129
+ byDir: [...byDir.entries()]
130
+ .map(([dir, count]) => ({ dir, count }))
131
+ .sort((a, b) => b.count - a.count || a.dir.localeCompare(b.dir)),
132
+ };
133
+ }
134
+
135
+ function safeDirName(name) {
136
+ const raw = String(name || "").trim();
137
+ if (!raw) return "";
138
+ let dir = raw
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9._-]+/g, "-")
141
+ .replace(/^-+|-+$/g, "")
142
+ .slice(0, 80);
143
+ if (!dir || dir === "." || dir === "..") {
144
+ dir = `skill-${Buffer.from(raw).toString("hex").slice(0, 64)}`;
145
+ }
146
+ return dir;
147
+ }
148
+
149
+ function localSkillFile(paths, name) {
150
+ const dirName = safeDirName(name);
151
+ if (!dirName) return null;
152
+ return path.join(paths.skills, dirName, "SKILL.md");
153
+ }
154
+
155
+ function ensureSkillMarkdown({ name, description, content }) {
156
+ const text = String(content || "").trim();
157
+ if (!text) return "";
158
+ if (text.startsWith("---")) return text + "\n";
159
+ const title = String(name || "").trim();
160
+ const desc = String(description || "").trim();
161
+ if (!title && !desc) return text + "\n";
162
+ const fm = ["---"];
163
+ if (title) fm.push(`name: ${title}`);
164
+ if (desc) fm.push(`description: ${desc}`);
165
+ fm.push("---", "", text);
166
+ return fm.join("\n") + "\n";
167
+ }
168
+
169
+ export function createSkillsTool(paths, logger, { getSkills, reloadSkills, getI18n } = {}) {
170
+ function i18n() {
171
+ return getI18n?.() || defaultI18n;
172
+ }
173
+ function t(key, vars) {
174
+ return i18n().t(key, vars);
175
+ }
176
+
177
+ function currentSkills() {
178
+ return getSkills ? getSkills() : loadSkills(defaultSkillDirs(paths), logger);
179
+ }
180
+
181
+ function findSkill(name) {
182
+ return currentSkills().find((s) => s.name === name || path.basename(s.dir) === safeDirName(name));
183
+ }
184
+
185
+ function list() {
186
+ const skills = currentSkills();
187
+ if (!skills.length) return ok(t("skills.none"));
188
+ const summary = summarizeSkills(skills);
189
+ const lines = skills.map((s) => {
190
+ const scope = path.resolve(s.sourceDir) === path.resolve(paths.skills) ? t("skills.local") : t("skills.shared");
191
+ return t("skills.listItem", { name: s.name, scope, description: s.description, path: s.path });
192
+ });
193
+ lines.push("", t("skills.total", { count: summary.total }));
194
+ for (const item of summary.byDir) lines.push(t("skills.dirCount", { dir: item.dir, count: item.count }));
195
+ return ok(
196
+ lines.join("\n"),
197
+ );
198
+ }
199
+
200
+ function read(name) {
201
+ const skill = findSkill(name);
202
+ if (!skill) return fail(t("skills.notFound", { name }));
203
+ try {
204
+ return ok(fs.readFileSync(skill.path, "utf8"));
205
+ } catch (e) {
206
+ return fail(t("skills.readFailed", { message: e.message }));
207
+ }
208
+ }
209
+
210
+ function write({ name, description, content }) {
211
+ const file = localSkillFile(paths, name);
212
+ if (!file) return fail(t("skills.invalidName"));
213
+ const md = ensureSkillMarkdown({ name, description, content });
214
+ if (!md) return fail(t("skills.writeNeedsContent"));
215
+ try {
216
+ ensureDir(path.dirname(file));
217
+ fs.writeFileSync(file, md);
218
+ reloadSkills?.();
219
+ logger?.info(EVENTS.skill.save, { name, path: file });
220
+ return ok(t("skills.saved", { name, file }));
221
+ } catch (e) {
222
+ return fail(t("skills.saveFailed", { message: e.message }));
223
+ }
224
+ }
225
+
226
+ function edit({ name, old_text, new_text }) {
227
+ if (!old_text) return fail(t("skills.editNeedsOldText"));
228
+ const file = localSkillFile(paths, name);
229
+ if (!file || !fs.existsSync(file)) return fail(t("skills.localNotFound", { name }));
230
+ let text = "";
231
+ try {
232
+ text = fs.readFileSync(file, "utf8");
233
+ } catch (e) {
234
+ return fail(t("skills.readFailed", { message: e.message }));
235
+ }
236
+ const parts = text.split(old_text);
237
+ if (parts.length === 1) return fail(t("skills.oldTextNotFound"));
238
+ if (parts.length > 2) return fail(t("skills.oldTextNotUnique", { count: parts.length - 1 }));
239
+ try {
240
+ fs.writeFileSync(file, parts.join(new_text ?? ""));
241
+ reloadSkills?.();
242
+ logger?.info(EVENTS.skill.save, { name, path: file, action: "edit" });
243
+ return ok(t("skills.modified", { name }));
244
+ } catch (e) {
245
+ return fail(t("skills.modifyFailed", { message: e.message }));
246
+ }
247
+ }
248
+
249
+ function remove(name) {
250
+ const file = localSkillFile(paths, name);
251
+ if (!file || !fs.existsSync(file)) return fail(t("skills.localNotFound", { name }));
252
+ try {
253
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
254
+ reloadSkills?.();
255
+ logger?.info(EVENTS.skill.save, { name, action: "remove" });
256
+ return ok(t("skills.deleted", { name }));
257
+ } catch (e) {
258
+ return fail(t("skills.deleteFailed", { message: e.message }));
259
+ }
260
+ }
261
+
262
+ return {
263
+ name: "skills",
264
+ description: t("skills.toolDescription"),
265
+ danger(args) {
266
+ return ["list", "read"].includes(args.action) ? "safe" : "confirm";
267
+ },
268
+ parameters: {
269
+ type: "object",
270
+ properties: {
271
+ action: { type: "string", enum: ["list", "read", "write", "edit", "remove"] },
272
+ name: { type: "string", description: t("skills.paramName") },
273
+ description: { type: "string", description: t("skills.paramDescription") },
274
+ content: { type: "string", description: t("skills.paramContent") },
275
+ old_text: { type: "string", description: t("skills.paramOldText") },
276
+ new_text: { type: "string", description: t("skills.paramNewText") },
277
+ },
278
+ required: ["action"],
279
+ },
280
+ handler(args) {
281
+ switch (args.action) {
282
+ case "list":
283
+ return list();
284
+ case "read":
285
+ return args.name ? read(args.name) : fail(t("skills.readNeedsName"));
286
+ case "write":
287
+ return write(args);
288
+ case "edit":
289
+ return args.name ? edit(args) : fail(t("skills.editNeedsName"));
290
+ case "remove":
291
+ return args.name ? remove(args.name) : fail(t("skills.removeNeedsName"));
292
+ default:
293
+ return fail(t("skills.unknownAction", { action: args.action }));
294
+ }
295
+ },
296
+ };
297
+ }
@@ -0,0 +1,91 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ availableModels,
5
+ downgradeClearScreenDown,
6
+ formatConfirmMessage,
7
+ formatUserMessageRow,
8
+ helpText,
9
+ searchModels,
10
+ wrapText,
11
+ } from "../transport/cli.js";
12
+ import { createI18n } from "../i18n/index.js";
13
+
14
+ test("clearScreenDown(ESC[0J/ESC[J) 降级为清到行尾(ESC[K)", () => {
15
+ assert.equal(downgradeClearScreenDown("abc\x1b[0J"), "abc\x1b[K");
16
+ assert.equal(downgradeClearScreenDown("\x1b[J"), "\x1b[K");
17
+ // 退格重绘的典型片段:定位行首 + 写内容 + clearScreenDown
18
+ assert.equal(downgradeClearScreenDown("\x1b[5;1H› hi\x1b[0J"), "\x1b[5;1H› hi\x1b[K");
19
+ });
20
+
21
+ test("不误伤其它清屏序列与光标移动", () => {
22
+ assert.equal(downgradeClearScreenDown("\x1b[2K"), "\x1b[2K"); // 清整行
23
+ assert.equal(downgradeClearScreenDown("\x1b[1J"), "\x1b[1J"); // 清到屏首
24
+ assert.equal(downgradeClearScreenDown("\x1b[2J"), "\x1b[2J"); // 全屏清除
25
+ assert.equal(downgradeClearScreenDown("\x1b[3;1H"), "\x1b[3;1H"); // 光标定位
26
+ });
27
+
28
+ test("非字符串原样返回", () => {
29
+ const buf = Buffer.from("x");
30
+ assert.equal(downgradeClearScreenDown(buf), buf);
31
+ });
32
+
33
+ test("wrapText 按显示宽度折行长文本", () => {
34
+ const wrapped = wrapText("执行一个很长很长的命令 abcdefghijklmnopqrstuvwxyz", 18, " ");
35
+ const lines = wrapped.split("\n");
36
+ assert.ok(lines.length > 1);
37
+ assert.ok(lines.every((line) => line.length <= 18)); // 此用例全 ASCII/中文混合,粗略防止超长单行
38
+ });
39
+
40
+ test("formatConfirmMessage 将长 message 放在多行说明中", () => {
41
+ const msg = "exec: node scripts/very-long-command.js --workspace /Users/zhihui/wanyou/wanyou-vanor --dangerously-write-output";
42
+ const formatted = formatConfirmMessage(msg, 40, createI18n("zh-CN").t);
43
+ const lines = formatted.trimEnd().split("\n");
44
+ assert.equal(lines[0], "确认操作:");
45
+ assert.ok(lines.length > 3);
46
+ assert.match(formatted, /very-long-command/);
47
+ assert.doesNotMatch(lines.at(-1), /\[y\/N\]/); // 输入提示独立显示,避免长文本挤在输入行
48
+ });
49
+
50
+ test("helpText 支持英文语言并包含 /language", () => {
51
+ const text = helpText(createI18n("en").t);
52
+ assert.match(text, /Commands:/);
53
+ assert.match(text, /\/language/);
54
+ assert.doesNotMatch(text, /命令:/);
55
+ });
56
+
57
+ test("formatUserMessageRow 将多行用户消息格式化为单行", () => {
58
+ const row = formatUserMessageRow({
59
+ time: "2026-06-07T08:00:00.000Z",
60
+ content: "第一行\n第二行\t第三行",
61
+ });
62
+ assert.match(row, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} 第一行 第二行 第三行$/);
63
+ });
64
+
65
+ test("availableModels 从当前模型、默认模型、fallback 和 provider.models 推导候选", () => {
66
+ const models = availableModels(
67
+ {
68
+ llm: {
69
+ defaultModel: "wanyou/deepseek/deepseek-v4-pro",
70
+ fallback: ["anthropic/claude-sonnet", "wanyou/deepseek/deepseek-v4-pro"],
71
+ providers: {
72
+ wanyou: { models: ["deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", { id: "qwen-max" }] },
73
+ },
74
+ },
75
+ },
76
+ "wanyou/deepseek/deepseek-v4-flash",
77
+ );
78
+ assert.deepEqual(models, [
79
+ "wanyou/deepseek/deepseek-v4-flash",
80
+ "wanyou/deepseek/deepseek-v4-pro",
81
+ "anthropic/claude-sonnet",
82
+ "wanyou/qwen-max",
83
+ ]);
84
+ });
85
+
86
+ test("searchModels 按关键词忽略大小写搜索", () => {
87
+ assert.deepEqual(
88
+ searchModels(["wanyou/deepseek/deepseek-v4-pro", "wanyou/deepseek/deepseek-v4-flash", "anthropic/claude"], "FLASH"),
89
+ ["wanyou/deepseek/deepseek-v4-flash"],
90
+ );
91
+ });
@@ -0,0 +1,63 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { loadConfig, saveUiLanguage, validateConfig, DEFAULT_CONFIG } from "../config.js";
7
+
8
+ test("缺失配置文件时使用默认值", () => {
9
+ const file = path.join(os.tmpdir(), "vanor-nope-" + Date.now(), "config.json");
10
+ const { config, exists } = loadConfig(file);
11
+ assert.equal(exists, false);
12
+ assert.equal(config.agent.maxIterations, DEFAULT_CONFIG.agent.maxIterations);
13
+ assert.equal(config.ui.language, "auto");
14
+ });
15
+
16
+ test("validateConfig 报告缺失项", () => {
17
+ const issues = validateConfig(DEFAULT_CONFIG);
18
+ assert.ok(issues.some((i) => i.includes("defaultModel")));
19
+ });
20
+
21
+ test("解析 env: 引用与 workspaceRoot 绝对化", () => {
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-cfg-"));
23
+ const file = path.join(dir, "config.json");
24
+ process.env.VANOR_TEST_KEY = "secret123";
25
+ fs.writeFileSync(
26
+ file,
27
+ JSON.stringify({
28
+ llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "u", apiKey: "env:VANOR_TEST_KEY" } } },
29
+ security: { workspaceRoot: dir },
30
+ }),
31
+ );
32
+ const { config } = loadConfig(file);
33
+ assert.equal(config.llm.providers.p.apiKey, "secret123");
34
+ assert.ok(path.isAbsolute(config.security.workspaceRoot));
35
+ assert.deepEqual(validateConfig(config), []);
36
+ });
37
+
38
+ test("validateConfig 校验 ui.language", () => {
39
+ const config = {
40
+ ...DEFAULT_CONFIG,
41
+ llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "u", apiKey: "k" } } },
42
+ ui: { ...DEFAULT_CONFIG.ui, language: "fr" },
43
+ };
44
+ const issues = validateConfig(config);
45
+ assert.ok(issues.some((i) => i.includes("ui.language")));
46
+ });
47
+
48
+ test("saveUiLanguage 只写回语言并保留 env 引用", () => {
49
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-cfg-lang-"));
50
+ const file = path.join(dir, "config.json");
51
+ fs.writeFileSync(
52
+ file,
53
+ JSON.stringify({
54
+ llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "u", apiKey: "env:VANOR_TEST_KEY" } } },
55
+ ui: { color: true },
56
+ }),
57
+ );
58
+ saveUiLanguage(file, "zh-CN");
59
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
60
+ assert.equal(raw.ui.language, "zh-CN");
61
+ assert.equal(raw.llm.providers.p.apiKey, "env:VANOR_TEST_KEY");
62
+ assert.equal(raw.agent, undefined);
63
+ });