@jpssff/vanor 0.1.0 → 0.1.1

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 CHANGED
@@ -135,7 +135,7 @@ Vanor 使用三层记忆:
135
135
  | 层级 | 说明 |
136
136
  |------|------|
137
137
  | 短期 | 当前会话消息,存于 `sessions/`;压缩后内存只保留摘要与最近消息,但磁盘保留完整历史 |
138
- | 长期 | `memory/MEMORY.md` 与 `memory/USER.md`,记录环境事实和用户偏好 |
138
+ | 长期 | `memory/MEMORY.md` 与 `memory/USER.md`,记录环境事实和用户偏好;每个主文件限制 10KB,过多时用索引指向 `MEMORY-xxx.md` / `USER-xxx.md` |
139
139
  | 工作 | `memory/working/`,保存任务中的计划和中间状态 |
140
140
 
141
141
  会话恢复优先读取 `state.json` 中当前工作区的最近 session id,避免 session 文件过多时启动扫描变慢。若索引失效,再回退扫描 `sessions/`。
package/README.md CHANGED
@@ -106,7 +106,7 @@ Vanor asks before potentially risky tool actions by default. `/auto-run` only ch
106
106
  ~/.vanor/config.json provider, model, security, logging config
107
107
  ~/.vanor/state.json latest session index per workspace
108
108
  ~/.vanor/sessions/ JSONL session history
109
- ~/.vanor/memory/ long-term and working memory
109
+ ~/.vanor/memory/ long-term and working memory; MEMORY.md / USER.md are 10KB index files
110
110
  ~/.vanor/skills/ user skills
111
111
  ~/.vanor/logs/ JSONL logs
112
112
  ```
package/base/config.js CHANGED
@@ -15,13 +15,13 @@ export const DEFAULT_CONFIG = {
15
15
  fallback: [],
16
16
  },
17
17
  agent: {
18
- maxIterations: 25,
18
+ maxIterations: 200,
19
19
  thinking: "off",
20
20
  contextWindow: 256000,
21
21
  },
22
22
  memory: {
23
- memoryMaxChars: 2200,
24
- userMaxChars: 1375,
23
+ memoryMaxChars: 10240,
24
+ userMaxChars: 10240,
25
25
  compactThreshold: 0.75,
26
26
  },
27
27
  security: {
@@ -14,6 +14,7 @@ import { buildSystemPrompt } from "./prompt.js";
14
14
  import { latestSession, saveLatestSessionId, Session } from "./session.js";
15
15
  import { runLoop } from "./loop.js";
16
16
  import { compact } from "./compaction.js";
17
+ import { createVanorTool } from "./vanor_tool.js";
17
18
 
18
19
  export function createHarness({ config, paths, logger, channel = "cli", sessionId, restoreLatest = false }) {
19
20
  let configState = config;
@@ -50,9 +51,25 @@ export function createHarness({ config, paths, logger, channel = "cli", sessionI
50
51
  registry = defaultRegistry(i18n);
51
52
  registry.register(memory.tool);
52
53
  registry.register(createSkillsTool(paths, logger, { getSkills: () => skills, reloadSkills, getI18n: () => i18n }));
54
+ registry.register(createVanorTool(vanorRuntime(), i18n));
53
55
  reloadSkills();
54
56
  }
55
57
 
58
+ function vanorRuntime() {
59
+ return {
60
+ getModel: () => model,
61
+ setModel,
62
+ getConfig: () => configState,
63
+ getSession: () => session,
64
+ getSecurity: () => security,
65
+ getI18n: () => i18n,
66
+ getSkills: () => skills,
67
+ reloadSkills,
68
+ reloadConfig,
69
+ setLanguage,
70
+ };
71
+ }
72
+
56
73
  rebuildRuntime(configState);
57
74
 
58
75
  let model = configState.llm.defaultModel;
@@ -155,6 +172,7 @@ export function createHarness({ config, paths, logger, channel = "cli", sessionI
155
172
  toolCtx,
156
173
  config: configState,
157
174
  model,
175
+ getModel: () => model,
158
176
  tools: registry.schemas(),
159
177
  buildSystem,
160
178
  logger,
package/base/core/loop.js CHANGED
@@ -15,11 +15,12 @@ import { shouldCompact, compact } from "./compaction.js";
15
15
  * @returns {Promise<{assistant?, aborted?, error?, maxIterations?}>}
16
16
  */
17
17
  export async function runLoop(session, deps, handlers = {}, signal) {
18
- const { llm, registry, toolCtx, config, model, tools, buildSystem, logger } = deps;
18
+ const { llm, registry, toolCtx, config, tools, buildSystem, logger } = deps;
19
19
  const maxIter = config.agent?.maxIterations ?? 25;
20
- logger.info(EVENTS.agent.turnStart, { model, sessionId: session.id });
20
+ logger.info(EVENTS.agent.turnStart, { model: deps.getModel?.() || deps.model, sessionId: session.id });
21
21
 
22
22
  for (let i = 0; i < maxIter; i++) {
23
+ const model = deps.getModel?.() || deps.model;
23
24
  if (signal?.aborted) {
24
25
  logger.info(EVENTS.agent.turnAborted, { iteration: i });
25
26
  return { aborted: true };
@@ -64,7 +65,12 @@ export async function runLoop(session, deps, handlers = {}, signal) {
64
65
  const results = await runToolCalls(toolCalls, registry, toolCtx, handlers.onToolResult);
65
66
  for (const r of results) session.addMessage(r);
66
67
 
67
- if (shouldCompact(session, config)) await compact(session, deps);
68
+ if (signal?.aborted) {
69
+ logger.info(EVENTS.agent.turnAborted, { iteration: i, phase: "tools" });
70
+ return { aborted: true };
71
+ }
72
+
73
+ if (shouldCompact(session, config)) await compact(session, { ...deps, model: deps.getModel?.() || deps.model });
68
74
  }
69
75
 
70
76
  logger.warn(EVENTS.agent.turnMaxIterations, { maxIter });
@@ -2,7 +2,6 @@
2
2
 
3
3
  import os from "node:os";
4
4
  import { createI18n } from "../i18n/index.js";
5
- import { nowIso } from "../utils.js";
6
5
  import { renderSkills } from "../skills/loader.js";
7
6
  import { systemMessage } from "../transport/message.js";
8
7
 
@@ -34,7 +33,7 @@ function runtimeEnv(workspaceRoot, t) {
34
33
  t("prompt.node", { version: process.version }),
35
34
  t("prompt.shell", { shell }),
36
35
  t("prompt.workspace", { workspace: workspaceRoot }),
37
- t("prompt.time", { time: nowIso(), timezone: tz, offset: tzOffset() }),
36
+ t("prompt.timezone", { timezone: tz, offset: tzOffset() }),
38
37
  ].join("\n");
39
38
  }
40
39
 
@@ -90,6 +89,7 @@ export function buildSystemPrompt({ memory, skills, summary, workspaceRoot, chan
90
89
  const parts = [t("prompt.persona"), t("prompt.language")];
91
90
  parts.push(runtimeEnv(workspaceRoot, t));
92
91
  if (instance) parts.push(vanorContext(instance, t));
92
+ parts.push(t("prompt.memoryPolicy"));
93
93
  const guide = channel === "cli" ? t("prompt.channelCli") : t("prompt.channelDefault");
94
94
  parts.push(t("prompt.communication", { guide }));
95
95
 
@@ -0,0 +1,178 @@
1
+ import { createI18n } from "../i18n/index.js";
2
+ import { summarizeSkills } from "../skills/loader.js";
3
+ import { listUserMessages } from "./session.js";
4
+
5
+ const defaultI18n = createI18n("en");
6
+
7
+ function ok(content) {
8
+ return { content: String(content) };
9
+ }
10
+
11
+ function fail(content) {
12
+ return { content: String(content), isError: true };
13
+ }
14
+
15
+ function parseCommand(input) {
16
+ const raw = String(input || "").trim();
17
+ if (!raw) return { raw, cmd: "", arg: "" };
18
+ const slash = raw.startsWith("/") ? raw : `/${raw}`;
19
+ const [cmd, ...rest] = slash.slice(1).split(/\s+/);
20
+ return { raw: slash, cmd, arg: rest.join(" ").trim() };
21
+ }
22
+
23
+ function availableModels(config, currentModel = "") {
24
+ const out = [];
25
+ const seen = new Set();
26
+ const add = (model) => {
27
+ const value = String(model || "").trim();
28
+ if (!value || seen.has(value)) return;
29
+ seen.add(value);
30
+ out.push(value);
31
+ };
32
+
33
+ add(currentModel);
34
+ add(config.llm?.defaultModel);
35
+ for (const model of config.llm?.fallback || []) add(model);
36
+
37
+ for (const [providerName, provider] of Object.entries(config.llm?.providers || {})) {
38
+ for (const model of provider.models || []) {
39
+ const id = typeof model === "string" ? model : model?.id || model?.name || "";
40
+ if (!id) continue;
41
+ add(id.startsWith(`${providerName}/`) ? id : `${providerName}/${id}`);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function searchModels(models, query) {
48
+ const q = String(query || "").trim().toLowerCase();
49
+ if (!q) return models;
50
+ return models.filter((model) => model.toLowerCase().includes(q));
51
+ }
52
+
53
+ function formatUserMessage(message) {
54
+ const content = String(message.content || "").replace(/\s+/g, " ").trim();
55
+ return `${String(message.time || "").slice(0, 19)} ${content}`;
56
+ }
57
+
58
+ export function createVanorTool(runtime, i18n = defaultI18n) {
59
+ const currentI18n = () => runtime.getI18n?.() || i18n;
60
+ const t = (key, vars) => currentI18n().t(key, vars);
61
+
62
+ return {
63
+ name: "vanor",
64
+ description: t("vanor.toolDescription"),
65
+ danger(args = {}) {
66
+ const { cmd, arg } = parseCommand(args.command);
67
+ if (cmd === "auto-run" && arg !== "off") return "confirm";
68
+ return "safe";
69
+ },
70
+ parameters: {
71
+ type: "object",
72
+ properties: {
73
+ command: { type: "string", description: t("vanor.paramCommand") },
74
+ },
75
+ required: ["command"],
76
+ },
77
+ async handler(args) {
78
+ const { raw, cmd, arg } = parseCommand(args.command);
79
+ if (!cmd) return fail(t("vanor.emptyCommand"));
80
+
81
+ switch (cmd) {
82
+ case "help":
83
+ return ok(t("vanor.help"));
84
+ case "model":
85
+ return runModel(arg);
86
+ case "language":
87
+ return runLanguage(arg);
88
+ case "reload":
89
+ return runReload();
90
+ case "auto-run":
91
+ return runAutoRun(arg);
92
+ case "usage":
93
+ return runUsage();
94
+ case "messages":
95
+ return runMessages();
96
+ case "skills":
97
+ return runSkills();
98
+ case "exit":
99
+ case "quit":
100
+ case "retry":
101
+ case "new":
102
+ case "reset":
103
+ case "compact":
104
+ return fail(t("vanor.unsupportedCommand", { command: raw }));
105
+ default:
106
+ return fail(t("vanor.unknownCommand", { command: raw }));
107
+ }
108
+ },
109
+ };
110
+
111
+ function runModel(arg) {
112
+ if (!arg) return ok(t("vanor.currentModel", { model: runtime.getModel() }));
113
+ const models = availableModels(runtime.getConfig(), runtime.getModel());
114
+ const matches = searchModels(models, arg);
115
+ const exact = matches.find((model) => model.toLowerCase() === arg.toLowerCase());
116
+ const target = exact || (matches.length === 1 ? matches[0] : "");
117
+ if (!target && matches.length > 1) {
118
+ return ok(t("vanor.modelMatches", { count: matches.length, models: matches.map((m, i) => `${i + 1}. ${m}`).join("\n") }));
119
+ }
120
+ try {
121
+ const next = runtime.setModel(target || arg);
122
+ return ok(t("vanor.modelChanged", { model: next }));
123
+ } catch (e) {
124
+ const suffix = models.length ? `\n${t("vanor.availableModels", { models: models.map((m) => `- ${m}`).join("\n") })}` : "";
125
+ return fail(t("vanor.modelSwitchFailed", { message: e.message }) + suffix);
126
+ }
127
+ }
128
+
129
+ function runLanguage(arg) {
130
+ const lang = currentI18n();
131
+ if (!arg) return ok(t("vanor.languageCurrent", { language: lang.language, configured: lang.configured || "auto", source: lang.source || "" }));
132
+ try {
133
+ const next = runtime.setLanguage(arg);
134
+ return ok(next.t("vanor.languageChanged", { language: next.language, configured: next.configured || "auto", source: next.source || "" }));
135
+ } catch (e) {
136
+ return fail(e.message);
137
+ }
138
+ }
139
+
140
+ function runReload() {
141
+ const r = runtime.reloadConfig({ force: true });
142
+ if (r.error) return fail(t("vanor.reloadFailed", { error: r.error }));
143
+ if (r.reloaded) return ok(t("vanor.reloadDone", { model: runtime.getModel() }));
144
+ return ok(t("vanor.reloadUnchanged"));
145
+ }
146
+
147
+ function runAutoRun(arg) {
148
+ if (arg === "off") {
149
+ runtime.getSecurity().setApproval(runtime.getSecurity().initialApproval);
150
+ return ok(t("vanor.approvalRestored", { approval: runtime.getSecurity().approval }));
151
+ }
152
+ if (arg && arg !== "on") return fail(t("vanor.autoRunUsage"));
153
+ runtime.getSecurity().setApproval("auto");
154
+ return ok(t("vanor.autoRunEnabled"));
155
+ }
156
+
157
+ function runUsage() {
158
+ const usage = runtime.getSession().usage;
159
+ return ok(t("vanor.usage", { prompt: usage.promptTokens, completion: usage.completionTokens, total: usage.totalTokens }));
160
+ }
161
+
162
+ function runMessages() {
163
+ const session = runtime.getSession();
164
+ const messages = listUserMessages(session.paths, session.id);
165
+ if (!messages.length) return ok(t("vanor.messagesNone"));
166
+ return ok([t("vanor.messagesHeader"), ...messages.map(formatUserMessage)].join("\n"));
167
+ }
168
+
169
+ function runSkills() {
170
+ runtime.reloadSkills();
171
+ const skills = runtime.getSkills();
172
+ const summary = summarizeSkills(skills);
173
+ const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
174
+ lines.push("", t("vanor.skillsTotal", { count: summary.total }));
175
+ for (const item of summary.byDir) lines.push(`- ${item.dir}: ${item.count}`);
176
+ return ok(lines.join("\n"));
177
+ }
178
+ }
@@ -176,11 +176,13 @@ export default {
176
176
  describeWriteFile: "Write file: {path}",
177
177
  describeEditFile: "Edit file: {path}",
178
178
  describeSkills: "Manage skills: {action}{name}",
179
+ describeVanor: "Run Vanor command: {command}",
179
180
  describeGeneric: "Call tool {name}",
180
181
  unknownTool: "Unknown tool: {name}",
181
182
  invalidArgs: "Invalid arguments: {errors}",
182
183
  denied: "The operation was denied by the user or policy",
183
184
  exception: "Tool exception: {message}",
185
+ aborted: "Tool execution was interrupted",
184
186
  },
185
187
  builtin: {
186
188
  readFileDescription: "Read text content from a workspace file, optionally by line offset/limit.",
@@ -209,6 +211,34 @@ export default {
209
211
  noOutput: "(no output)",
210
212
  exitCode: "[exit code {code}]",
211
213
  },
214
+ vanor: {
215
+ toolDescription:
216
+ "Run Vanor slash commands without leaving the conversation. Supports runtime commands such as /model, /language, /reload, /auto-run, /usage, /messages, /skills, and /help.",
217
+ paramCommand: 'Slash command to run, for example "/model deepseek", "/language zh-CN", "/reload", or "/auto-run off".',
218
+ emptyCommand: "command is required",
219
+ help:
220
+ "Supported Vanor commands: /model [query], /language [auto|en|zh-CN], /reload, /auto-run [on|off], /usage, /messages, /skills, /help.\n" +
221
+ "Process or session control commands such as /exit, /quit, /retry, /new, /reset, and /compact must be run directly in the CLI.",
222
+ unsupportedCommand: "{command} is not supported through the vanor tool. Run it directly in the CLI.",
223
+ unknownCommand: "Unknown Vanor command: {command}",
224
+ currentModel: "Current model: {model}",
225
+ modelChanged: "Switched model: {model}",
226
+ modelMatches: "Found {count} matching models. Please choose one explicitly:\n{models}",
227
+ availableModels: "Available models:\n{models}",
228
+ modelSwitchFailed: "Switch failed: {message}",
229
+ languageCurrent: "Language: {language} (configured: {configured}, source: {source})",
230
+ languageChanged: "Language changed to {language} (configured: {configured}, source: {source})",
231
+ reloadFailed: "Config reload failed: {error}",
232
+ reloadDone: "Config reloaded. Current model: {model}",
233
+ reloadUnchanged: "Config unchanged.",
234
+ approvalRestored: "Restored approval mode: {approval}",
235
+ autoRunEnabled: "Auto-run enabled for this session. Denylisted dangerous commands are still blocked.",
236
+ autoRunUsage: "Usage: /auto-run [on|off]",
237
+ usage: "prompt {prompt} / completion {completion} / total {total}",
238
+ messagesNone: "(no user messages)",
239
+ messagesHeader: "Time User message",
240
+ skillsTotal: "Total: {count} skills",
241
+ },
212
242
  prompt: {
213
243
  persona:
214
244
  "You are Vanor, a general-purpose agent by Wanyou Intelligence.\n" +
@@ -223,7 +253,7 @@ export default {
223
253
  node: "- Runtime: Node.js {version}; Vanor Agent is built on Node.js and can call node / npm via exec",
224
254
  shell: "- Default shell: {shell}",
225
255
  workspace: "- Workspace: {workspace}",
226
- time: "- Current time: {time} ({timezone}, {offset})",
256
+ timezone: "- Time zone: {timezone} ({offset})",
227
257
  contextTitle: "Vanor instance context:",
228
258
  currentModel: "- Current model: {model}",
229
259
  provider: "- Current provider: {provider}",
@@ -241,6 +271,13 @@ export default {
241
271
  skillsCount: "- Loaded skills: {count}",
242
272
  answerContext: "- If the user asks about model, config file, session, directories, language, or approval mode, answer directly from this context.",
243
273
  noSecrets: "- Do not reveal API keys, Authorization headers, cookies, tokens, private keys, or other secrets. Only say whether a secret is configured or redacted.",
274
+ memoryPolicy:
275
+ "Long-term memory policy:\n" +
276
+ "- Treat ~/.vanor/memory/MEMORY.md and ~/.vanor/memory/USER.md as the most important memory files; their current snapshots are included in every interaction when present.\n" +
277
+ "- At the end of each task, decide whether new stable facts, project conventions, user preferences, or reusable lessons should be saved to MEMORY.md or USER.md.\n" +
278
+ "- Each primary file is limited to 10KB. Keep entries concise and maintain them as durable indexed summaries.\n" +
279
+ "- If more memory is needed, keep an index entry in MEMORY.md or USER.md and store details in secondary files such as ~/.vanor/memory/MEMORY-xxx.md or ~/.vanor/memory/USER-xxx.md.\n" +
280
+ "- Use a two-level lookup: first check the primary index, then read the referenced secondary file only when the current task needs that detail.",
244
281
  terminal:
245
282
  "Terminal capabilities: TERM={term}, viewport about {cols} columns x {rows} rows, {color}. " +
246
283
  "Fit output to the terminal: keep lines under {cols} columns when practical; avoid code blocks / tables wider than the viewport; {ansi}split long content into sections and avoid flooding the screen.",
@@ -174,11 +174,13 @@ export default {
174
174
  describeWriteFile: "写入文件:{path}",
175
175
  describeEditFile: "编辑文件:{path}",
176
176
  describeSkills: "管理技能:{action}{name}",
177
+ describeVanor: "执行 Vanor 命令:{command}",
177
178
  describeGeneric: "调用工具 {name}",
178
179
  unknownTool: "未知工具:{name}",
179
180
  invalidArgs: "参数错误:{errors}",
180
181
  denied: "用户或策略拒绝了该操作",
181
182
  exception: "工具异常:{message}",
183
+ aborted: "工具执行已中断",
182
184
  },
183
185
  builtin: {
184
186
  readFileDescription: "读取工作区内文件的文本内容,可选按行 offset/limit 截取。",
@@ -207,6 +209,34 @@ export default {
207
209
  noOutput: "(无输出)",
208
210
  exitCode: "[退出码 {code}]",
209
211
  },
212
+ vanor: {
213
+ toolDescription:
214
+ "在对话中执行 Vanor slash 命令,无需离开当前对话。支持 /model、/language、/reload、/auto-run、/usage、/messages、/skills、/help 等运行时命令。",
215
+ paramCommand: '要执行的 slash 命令,例如 "/model deepseek"、"/language zh-CN"、"/reload" 或 "/auto-run off"。',
216
+ emptyCommand: "command 不能为空",
217
+ help:
218
+ "支持的 Vanor 命令:/model [query]、/language [auto|en|zh-CN]、/reload、/auto-run [on|off]、/usage、/messages、/skills、/help。\n" +
219
+ "进程或会话控制命令(如 /exit、/quit、/retry、/new、/reset、/compact)需在 CLI 中直接执行。",
220
+ unsupportedCommand: "{command} 不支持通过 vanor 工具执行,请在 CLI 中直接运行。",
221
+ unknownCommand: "未知 Vanor 命令:{command}",
222
+ currentModel: "当前模型:{model}",
223
+ modelChanged: "已切换模型:{model}",
224
+ modelMatches: "匹配到 {count} 个模型,请明确选择其中一个:\n{models}",
225
+ availableModels: "可用模型:\n{models}",
226
+ modelSwitchFailed: "切换失败:{message}",
227
+ languageCurrent: "语言:{language}(配置:{configured},来源:{source})",
228
+ languageChanged: "语言已切换为 {language}(配置:{configured},来源:{source})",
229
+ reloadFailed: "配置重载失败:{error}",
230
+ reloadDone: "配置已重载。当前模型:{model}",
231
+ reloadUnchanged: "配置未变化。",
232
+ approvalRestored: "已恢复审批模式:{approval}",
233
+ autoRunEnabled: "已开启自动执行:本次对话不再逐次确认操作(denylist 危险命令仍会拦截)。",
234
+ autoRunUsage: "用法:/auto-run [on|off]",
235
+ usage: "prompt {prompt} / completion {completion} / total {total}",
236
+ messagesNone: "(暂无用户消息)",
237
+ messagesHeader: "时间 用户内容",
238
+ skillsTotal: "总计:{count} 个技能",
239
+ },
210
240
  prompt: {
211
241
  persona:
212
242
  "你是 Vanor(万佑),万佑智算的通用智能体。\n" +
@@ -221,7 +251,7 @@ export default {
221
251
  node: "- 运行时:Node.js {version};Vanor Agent 基于 Node.js 构建,可通过 exec 调用 node / npm 等工具",
222
252
  shell: "- 默认 Shell:{shell}",
223
253
  workspace: "- 工作区:{workspace}",
224
- time: "- 当前时间:{time}({timezone},{offset})",
254
+ timezone: "- 时区:{timezone}({offset})",
225
255
  contextTitle: "Vanor 实例上下文:",
226
256
  currentModel: "- 当前模型:{model}",
227
257
  provider: "- 当前 provider:{provider}",
@@ -239,6 +269,13 @@ export default {
239
269
  skillsCount: "- 已加载技能数:{count}",
240
270
  answerContext: "- 如果用户询问模型、配置文件、会话、目录、语言或审批模式,可直接依据本上下文回答。",
241
271
  noSecrets: "- 不要输出 API Key、Authorization、Cookie、token、私钥等敏感配置;涉及密钥时只说明是否已配置或已脱敏。",
272
+ memoryPolicy:
273
+ "长期记忆策略:\n" +
274
+ "- 将 ~/.vanor/memory/MEMORY.md 与 ~/.vanor/memory/USER.md 视为最重要的记忆文件;只要存在内容,每次交互都会携带其当前快照。\n" +
275
+ "- 每次任务完成后,判断是否需要把新的稳定事实、项目约定、用户偏好或可复用经验写入 MEMORY.md 或 USER.md。\n" +
276
+ "- 每个主文件大小限制为 10KB。条目应简洁、稳定,并优先维护为可持久使用的索引摘要。\n" +
277
+ "- 当记忆信息过多时,在 MEMORY.md 或 USER.md 中保留索引条目,把详细内容保存到 ~/.vanor/memory/MEMORY-xxx.md 或 ~/.vanor/memory/USER-xxx.md。\n" +
278
+ "- 采用二级查找:先查看主文件索引,只有当前任务需要相关细节时,再读取索引指向的二级文件。",
242
279
  terminal:
243
280
  "终端能力:TERM={term},可视区约 {cols} 列 × {rows} 行,{color}。" +
244
281
  "输出请适应终端:单行尽量不超过 {cols} 列,代码块 / 表格不要超出宽度;{ansi}较长内容分段,避免一次性刷屏。",
@@ -12,8 +12,8 @@ export function createMemory(paths, config, logger) {
12
12
  const memFile = path.join(paths.memory, "MEMORY.md");
13
13
  const userFile = path.join(paths.memory, "USER.md");
14
14
  const limits = {
15
- memory: config.memory?.memoryMaxChars ?? 2200,
16
- user: config.memory?.userMaxChars ?? 1375,
15
+ memory: config.memory?.memoryMaxChars ?? 10240,
16
+ user: config.memory?.userMaxChars ?? 10240,
17
17
  };
18
18
 
19
19
  function fileOf(target) {
@@ -42,14 +42,15 @@ export function createMemory(paths, config, logger) {
42
42
  const text = entries.join(SEP);
43
43
  const used = text.length;
44
44
  const pct = Math.round((used / limits[target]) * 100);
45
- return `${LINE}\n${label} [${pct}% — ${used}/${limits[target]} chars]\n${LINE}\n${text}`;
45
+ const fileName = path.basename(fileOf(target));
46
+ return `${LINE}\n${fileName}(${label}) [${pct}% — ${used}/${limits[target]} chars]\n${LINE}\n${text}`;
46
47
  }
47
48
 
48
49
  /** 渲染注入系统 prompt 的记忆快照(空记忆返回空串)。 */
49
50
  function snapshot() {
50
51
  const blocks = [
51
- renderBlock("MEMORY(环境与经验)", "memory"),
52
- renderBlock("USER PROFILE(用户偏好)", "user"),
52
+ renderBlock("环境与经验", "memory"),
53
+ renderBlock("用户偏好", "user"),
53
54
  ].filter(Boolean);
54
55
  return blocks.join("\n\n");
55
56
  }
@@ -27,7 +27,13 @@ test("系统提示词在 CLI 渠道下提示终端格式", () => {
27
27
  assert.match(sys, /运行环境/);
28
28
  assert.match(sys, /Node\.js/);
29
29
  assert.match(sys, /操作系统/);
30
+ assert.match(sys, /时区/);
30
31
  assert.match(sys, /UTC[+-]\d{2}:\d{2}/);
32
+ assert.doesNotMatch(sys, /当前时间/);
33
+ assert.match(sys, /MEMORY\.md/);
34
+ assert.match(sys, /USER\.md/);
35
+ assert.match(sys, /10KB/);
36
+ assert.match(sys, /MEMORY-xxx\.md/);
31
37
  });
32
38
 
33
39
  test("系统提示词注入 Vanor 实例上下文", () => {
@@ -77,7 +83,7 @@ test("系统提示词在 Intl 缺失时仍可构造时区信息", () => {
77
83
  channel: "cli",
78
84
  i18n: createI18n("zh-CN"),
79
85
  });
80
- assert.match(sys, /当前时间/);
86
+ assert.match(sys, /时区/);
81
87
  assert.match(sys, /UTC[+-]\d{2}:\d{2}/);
82
88
  } finally {
83
89
  if (desc) Object.defineProperty(globalThis, "Intl", desc);
@@ -149,6 +155,10 @@ test("系统提示词支持英文语言", () => {
149
155
  });
150
156
  assert.match(sys, /Runtime environment/);
151
157
  assert.match(sys, /Default response language: English/);
158
+ assert.match(sys, /Time zone/);
159
+ assert.doesNotMatch(sys, /Current time/);
160
+ assert.match(sys, /Long-term memory policy/);
161
+ assert.match(sys, /USER-xxx\.md/);
152
162
  assert.match(sys, /Terminal capabilities/);
153
163
  assert.doesNotMatch(sys, /运行环境/);
154
164
  });
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { createSecurity } from "../security/index.js";
9
- import { defaultRegistry, createToolContext } from "../tools/index.js";
9
+ import { defaultRegistry, createToolContext, ToolRegistry } from "../tools/index.js";
10
10
  import { Session } from "../core/session.js";
11
11
  import { runLoop } from "../core/loop.js";
12
12
  import { NullLogger } from "../logger.js";
@@ -95,3 +95,92 @@ test("达到最大迭代次数会停止", async () => {
95
95
  const result = await runLoop(session, deps, {});
96
96
  assert.equal(result.maxIterations, true);
97
97
  });
98
+
99
+ test("工具执行阶段中断后 loop 返回 aborted 且不继续下一轮", async () => {
100
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-loop-abort-"));
101
+ const ac = new AbortController();
102
+ const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
103
+ const registry = new ToolRegistry().register({
104
+ name: "abort_tool",
105
+ description: "abort",
106
+ danger: "safe",
107
+ parameters: { type: "object", properties: {} },
108
+ handler() {
109
+ ac.abort();
110
+ return { content: "aborted" };
111
+ },
112
+ });
113
+ const toolCtx = createToolContext({ security, logger: new NullLogger(), signal: ac.signal, confirm: async () => true });
114
+ const session = new Session({ sessions: path.join(ws, "s") }, new NullLogger(), {});
115
+ session.start();
116
+ session.addMessage({ id: "u1", role: "user", content: "abort", meta: {} });
117
+ let calls = 0;
118
+ const llm = {
119
+ async *stream() {
120
+ calls++;
121
+ yield { type: "tool_call", call: { id: "c", name: "abort_tool", arguments: {} } };
122
+ },
123
+ };
124
+ const deps = {
125
+ llm,
126
+ registry,
127
+ toolCtx,
128
+ config: { agent: { maxIterations: 5 }, memory: {} },
129
+ model: "mock/m",
130
+ tools: registry.schemas(),
131
+ buildSystem: () => "system",
132
+ logger: new NullLogger(),
133
+ };
134
+
135
+ const result = await runLoop(session, deps, {}, ac.signal);
136
+ assert.equal(result.aborted, true);
137
+ assert.equal(calls, 1);
138
+ });
139
+
140
+ test("工具切换模型后 loop 后续迭代使用最新模型", async () => {
141
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-loop-model-"));
142
+ const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
143
+ let currentModel = "mock/old";
144
+ const registry = new ToolRegistry().register({
145
+ name: "switch_model",
146
+ description: "switch model",
147
+ danger: "safe",
148
+ parameters: { type: "object", properties: {} },
149
+ handler() {
150
+ currentModel = "mock/new";
151
+ return { content: "switched" };
152
+ },
153
+ });
154
+ const toolCtx = createToolContext({ security, logger: new NullLogger(), confirm: async () => true });
155
+ const session = new Session({ sessions: path.join(ws, "s") }, new NullLogger(), {});
156
+ session.start();
157
+ session.addMessage({ id: "u1", role: "user", content: "switch", meta: {} });
158
+ const requestedModels = [];
159
+ let calls = 0;
160
+ const llm = {
161
+ async *stream(req) {
162
+ requestedModels.push(req.model);
163
+ calls++;
164
+ if (calls === 1) {
165
+ yield { type: "tool_call", call: { id: "c", name: "switch_model", arguments: {} } };
166
+ } else {
167
+ yield { type: "text", delta: "ok" };
168
+ }
169
+ },
170
+ };
171
+ const deps = {
172
+ llm,
173
+ registry,
174
+ toolCtx,
175
+ config: { agent: { maxIterations: 5 }, memory: {} },
176
+ model: currentModel,
177
+ getModel: () => currentModel,
178
+ tools: registry.schemas(),
179
+ buildSystem: () => "system",
180
+ logger: new NullLogger(),
181
+ };
182
+
183
+ const result = await runLoop(session, deps, {});
184
+ assert.equal(result.assistant.content, "ok");
185
+ assert.deepEqual(requestedModels, ["mock/old", "mock/new"]);
186
+ });
@@ -11,7 +11,7 @@ function tempPaths() {
11
11
  return { memory: path.join(root, "memory"), working: path.join(root, "memory", "working") };
12
12
  }
13
13
 
14
- const config = { memory: { memoryMaxChars: 2200, userMaxChars: 1375 } };
14
+ const config = { memory: { memoryMaxChars: 10240, userMaxChars: 10240 } };
15
15
 
16
16
  test("add 后 snapshot 含条目", () => {
17
17
  const mem = createMemory(tempPaths(), config, new NullLogger());
@@ -19,6 +19,15 @@ test("add 后 snapshot 含条目", () => {
19
19
  assert.match(mem.snapshot(), /macOS/);
20
20
  });
21
21
 
22
+ test("snapshot 标题包含长期记忆文件名", () => {
23
+ const mem = createMemory(tempPaths(), config, new NullLogger());
24
+ mem.apply({ action: "add", target: "memory", content: "项目经验" });
25
+ mem.apply({ action: "add", target: "user", content: "用户偏好" });
26
+ const snapshot = mem.snapshot();
27
+ assert.match(snapshot, /MEMORY\.md(环境与经验)/);
28
+ assert.match(snapshot, /USER\.md(用户偏好)/);
29
+ });
30
+
22
31
  test("replace 唯一匹配", () => {
23
32
  const mem = createMemory(tempPaths(), config, new NullLogger());
24
33
  mem.apply({ action: "add", target: "user", content: "偏好深色模式" });
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { readSessionState, saveLatestSessionId, Session, latestSession, listSessions, listUserMessages } from "../core/session.js";
7
7
  import { createHarness } from "../core/harness.js";
8
+ import { createToolContext, runToolCall } from "../tools/index.js";
8
9
  import { NullLogger } from "../logger.js";
9
10
 
10
11
  function tempPaths() {
@@ -256,6 +257,49 @@ test("createHarness 支持切换语言并写回配置", () => {
256
257
  assert.equal(raw.llm.providers.p.apiKey, "env:VANOR_TEST_KEY");
257
258
  });
258
259
 
260
+ test("createHarness 注册 vanor 工具并支持通过 slash 命令切换模型", async () => {
261
+ const paths = tempPaths();
262
+ const config = {
263
+ llm: {
264
+ defaultModel: "p/deepseek/deepseek-v4-pro",
265
+ providers: {
266
+ p: {
267
+ type: "openai",
268
+ baseURL: "http://example",
269
+ apiKey: "k",
270
+ models: ["deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash"],
271
+ },
272
+ },
273
+ },
274
+ logging: {},
275
+ memory: {},
276
+ agent: {},
277
+ security: { approval: "ask", workspaceRoot: paths.root },
278
+ };
279
+ const harness = createHarness({ paths, logger: new NullLogger(), config });
280
+ assert.ok(harness.registry.schemas().some((tool) => tool.name === "vanor"));
281
+
282
+ const ctx = createToolContext({ security: harness.security, logger: new NullLogger(), i18n: harness.i18n, confirm: async () => true });
283
+ const switched = await runToolCall({ id: "v1", name: "vanor", arguments: { command: "/model flash" } }, harness.registry, ctx);
284
+ assert.ok(!switched.meta.isError);
285
+ assert.equal(harness.model, "p/deepseek/deepseek-v4-flash");
286
+
287
+ let confirmCalls = 0;
288
+ const denyCtx = createToolContext({
289
+ security: harness.security,
290
+ logger: new NullLogger(),
291
+ i18n: harness.i18n,
292
+ confirm: async () => {
293
+ confirmCalls++;
294
+ return false;
295
+ },
296
+ });
297
+ const denied = await runToolCall({ id: "v2", name: "vanor", arguments: { command: "/auto-run" } }, harness.registry, denyCtx);
298
+ assert.ok(denied.meta.isError);
299
+ assert.equal(confirmCalls, 1);
300
+ assert.equal(harness.security.approval, "ask");
301
+ });
302
+
259
303
  test("热重载遇到无效配置时保留旧配置", () => {
260
304
  const paths = tempPaths();
261
305
  paths.config = path.join(paths.root, "config.json");
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { createSecurity } from "../security/index.js";
7
- import { defaultRegistry, createToolContext, runToolCall } from "../tools/index.js";
7
+ import { defaultRegistry, createToolContext, runToolCall, runToolCalls, ToolRegistry } from "../tools/index.js";
8
8
  import { NullLogger } from "../logger.js";
9
9
 
10
10
  function tempWorkspace() {
@@ -125,3 +125,63 @@ test("denylist 命中 exec 被拒", async () => {
125
125
  );
126
126
  assert.ok(r.meta.isError);
127
127
  });
128
+
129
+ test("AbortSignal 会中断正在执行的 exec 命令及其子进程", async () => {
130
+ const ws = tempWorkspace();
131
+ const reg = defaultRegistry();
132
+ const ac = new AbortController();
133
+ const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
134
+ const ctx = createToolContext({ security, logger: new NullLogger(), signal: ac.signal, confirm: async () => true });
135
+ const script = "setTimeout(()=>require('fs').writeFileSync('done.txt','x'),500)";
136
+ const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`;
137
+
138
+ const pending = runToolCall({ id: "1", name: "exec", arguments: { command, timeout: 5000 } }, reg, ctx);
139
+ setTimeout(() => ac.abort(), 50);
140
+ const r = await pending;
141
+
142
+ assert.ok(r.meta.isError);
143
+ assert.match(r.content, /aborted|中断/i);
144
+ await new Promise((resolve) => setTimeout(resolve, 700));
145
+ assert.ok(!fs.existsSync(path.join(ws, "done.txt")));
146
+ });
147
+
148
+ test("AbortSignal 触发后不继续执行后续工具", async () => {
149
+ const ws = tempWorkspace();
150
+ const ac = new AbortController();
151
+ const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
152
+ const ctx = createToolContext({ security, logger: new NullLogger(), signal: ac.signal, confirm: async () => true });
153
+ let secondRan = false;
154
+ const reg = new ToolRegistry()
155
+ .register({
156
+ name: "first",
157
+ description: "first",
158
+ danger: "safe",
159
+ parameters: { type: "object", properties: {} },
160
+ handler() {
161
+ ac.abort();
162
+ return { content: "aborted" };
163
+ },
164
+ })
165
+ .register({
166
+ name: "second",
167
+ description: "second",
168
+ danger: "safe",
169
+ parameters: { type: "object", properties: {} },
170
+ handler() {
171
+ secondRan = true;
172
+ return { content: "second" };
173
+ },
174
+ });
175
+
176
+ const results = await runToolCalls(
177
+ [
178
+ { id: "1", name: "first", arguments: {} },
179
+ { id: "2", name: "second", arguments: {} },
180
+ ],
181
+ reg,
182
+ ctx,
183
+ );
184
+
185
+ assert.equal(results.length, 1);
186
+ assert.equal(secondRan, false);
187
+ });
@@ -240,3 +240,72 @@ test("TerminalUI confirm 中 Ctrl-C 取消当前确认而不触发全局中断",
240
240
  assert.equal(interrupted, false);
241
241
  assert.equal(tui.buffer, "draft");
242
242
  });
243
+
244
+ test("TerminalUI 支持 Bash 风格编辑快捷键", () => {
245
+ const input = new FakeInput();
246
+ const output = new FakeOutput();
247
+ const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
248
+ tui.enable();
249
+
250
+ input.emit("data", "abcdef");
251
+ input.emit("data", "\x02"); // Ctrl-B
252
+ input.emit("data", "\x02");
253
+ input.emit("data", "\x02");
254
+ input.emit("data", "\x0b"); // Ctrl-K 删除光标后内容
255
+ assert.equal(tui.buffer, "abc");
256
+
257
+ input.emit("data", " def ghi");
258
+ input.emit("data", "\x17"); // Ctrl-W 删除光标前单词
259
+ assert.equal(tui.buffer, "abc def ");
260
+
261
+ input.emit("data", "\x01"); // Ctrl-A
262
+ input.emit("data", "\x04"); // Ctrl-D 删除光标处字符
263
+ assert.equal(tui.buffer, "bc def ");
264
+
265
+ input.emit("data", "\x05"); // Ctrl-E
266
+ input.emit("data", "\x06"); // Ctrl-F 位于末尾时保持不变
267
+ assert.equal(tui.cursor, Array.from(tui.buffer).length);
268
+
269
+ input.emit("data", "\x02");
270
+ input.emit("data", "\x15"); // Ctrl-U 删除光标前内容
271
+ assert.equal(tui.buffer, " ");
272
+ assert.equal(tui.cursor, 0);
273
+ });
274
+
275
+ test("TerminalUI 支持 Ctrl-P/Ctrl-N 导航历史", async () => {
276
+ const input = new FakeInput();
277
+ const output = new FakeOutput();
278
+ const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
279
+ tui.enable();
280
+
281
+ input.emit("data", "first");
282
+ input.emit("data", "\r");
283
+ input.emit("data", "second");
284
+ input.emit("data", "\r");
285
+ assert.equal(await tui.readLine(), "first");
286
+ assert.equal(await tui.readLine(), "second");
287
+
288
+ input.emit("data", "\x10"); // Ctrl-P
289
+ assert.equal(tui.buffer, "second");
290
+ input.emit("data", "\x10");
291
+ assert.equal(tui.buffer, "first");
292
+ input.emit("data", "\x0e"); // Ctrl-N
293
+ assert.equal(tui.buffer, "second");
294
+ });
295
+
296
+ test("TerminalUI Ctrl-L 清空消息区并保留输入草稿", () => {
297
+ const input = new FakeInput();
298
+ const output = new FakeOutput();
299
+ const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
300
+ tui.enable();
301
+ tui.write("agent output\n");
302
+ input.emit("data", "draft");
303
+
304
+ input.emit("data", "\x0c"); // Ctrl-L
305
+
306
+ assert.equal(tui.buffer, "draft");
307
+ assert.equal(tui.cursor, 5);
308
+ assert.match(output.text(), /\x1b\[1;1H\x1b\[2K/);
309
+ assert.match(output.text(), /\x1b\[22;1H\x1b\[2K/);
310
+ assert.match(output.text(), /› draft/);
311
+ });
@@ -150,6 +150,7 @@ const exec = {
150
150
  required: ["command"],
151
151
  },
152
152
  handler(args, ctx) {
153
+ if (ctx.signal?.aborted) return fail(ctx.t("builtin.commandAborted"));
153
154
  let cwd = ctx.security.workspaceRoot;
154
155
  if (args.cwd) {
155
156
  const r = ctx.security.checkPath(args.cwd, { mustExist: true, dir: true });
@@ -158,11 +159,42 @@ const exec = {
158
159
  }
159
160
  return new Promise((resolve) => {
160
161
  let child;
162
+ let settled = false;
163
+ let aborted = false;
164
+ let abortKillTimer = null;
165
+ const detached = process.platform !== "win32";
166
+ const finish = (result) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ if (abortKillTimer) clearTimeout(abortKillTimer);
170
+ ctx.signal?.removeEventListener?.("abort", onAbort);
171
+ resolve(result);
172
+ };
173
+ const killChild = (signalName) => {
174
+ if (!child?.pid) return;
175
+ try {
176
+ if (detached) process.kill(-child.pid, signalName);
177
+ else child.kill(signalName);
178
+ } catch {
179
+ try {
180
+ child.kill(signalName);
181
+ } catch {
182
+ // Process may have already exited.
183
+ }
184
+ }
185
+ };
186
+ const onAbort = () => {
187
+ aborted = true;
188
+ killChild("SIGTERM");
189
+ abortKillTimer = setTimeout(() => killChild("SIGKILL"), 1000);
190
+ };
161
191
  try {
162
- child = spawn(args.command, { shell: true, cwd, signal: ctx.signal });
192
+ child = spawn(args.command, { shell: true, cwd, signal: ctx.signal, detached });
163
193
  } catch (e) {
164
- return resolve(fail(ctx.t("builtin.spawnFailed", { message: e.message })));
194
+ return finish(fail(ctx.t("builtin.spawnFailed", { message: e.message })));
165
195
  }
196
+ if (ctx.signal?.aborted) onAbort();
197
+ else ctx.signal?.addEventListener?.("abort", onAbort, { once: true });
166
198
  let out = "";
167
199
  let truncated = false;
168
200
  const onData = (d) => {
@@ -171,16 +203,17 @@ const exec = {
171
203
  };
172
204
  child.stdout?.on("data", onData);
173
205
  child.stderr?.on("data", onData);
174
- const timer = setTimeout(() => child.kill("SIGKILL"), args.timeout || 60000);
206
+ const timer = setTimeout(() => killChild("SIGKILL"), args.timeout || 60000);
175
207
  child.on("error", (e) => {
176
208
  clearTimeout(timer);
177
- if (e.name === "AbortError") resolve(fail(ctx.t("builtin.commandAborted")));
178
- else resolve(fail(ctx.t("builtin.execFailed", { message: e.message })));
209
+ if (e.name === "AbortError" || aborted) finish(fail(ctx.t("builtin.commandAborted")));
210
+ else finish(fail(ctx.t("builtin.execFailed", { message: e.message })));
179
211
  });
180
212
  child.on("close", (code) => {
181
213
  clearTimeout(timer);
214
+ if (aborted) return finish(fail(ctx.t("builtin.commandAborted")));
182
215
  const note = truncated ? `\n${ctx.t("builtin.outputTruncated")}` : "";
183
- resolve({
216
+ finish({
184
217
  content: `${out || ctx.t("builtin.noOutput")}${note}\n${ctx.t("builtin.exitCode", { code })}`,
185
218
  isError: code !== 0,
186
219
  });
@@ -70,6 +70,8 @@ function describe(call, t) {
70
70
  return t("tools.describeEditFile", { path: a.path });
71
71
  case "skills":
72
72
  return t("tools.describeSkills", { action: a.action, name: a.name ? ` ${a.name}` : "" });
73
+ case "vanor":
74
+ return t("tools.describeVanor", { command: a.command });
73
75
  default:
74
76
  return t("tools.describeGeneric", { name: call.name });
75
77
  }
@@ -100,6 +102,15 @@ export async function runToolCall(call, registry, ctx) {
100
102
  });
101
103
  }
102
104
 
105
+ if (ctx.signal?.aborted) {
106
+ return toolMessage({
107
+ toolCallId: call.id,
108
+ name: call.name,
109
+ content: ctx.t("tools.aborted"),
110
+ isError: true,
111
+ });
112
+ }
113
+
103
114
  const errs = validateSchema(tool.parameters, call.arguments || {}, "", ctx.t);
104
115
  if (errs.length) {
105
116
  return toolMessage({
@@ -149,9 +160,11 @@ export async function runToolCall(call, registry, ctx) {
149
160
  export async function runToolCalls(toolCalls, registry, ctx, onResult) {
150
161
  const out = [];
151
162
  for (const call of toolCalls) {
163
+ if (ctx.signal?.aborted) break;
152
164
  const msg = await runToolCall(call, registry, ctx);
153
165
  if (onResult) onResult(call, msg);
154
166
  out.push(msg);
167
+ if (ctx.signal?.aborted) break;
155
168
  }
156
169
  return out;
157
170
  }
@@ -373,7 +373,7 @@ export async function startCli(harness, { config }) {
373
373
  break; // rl 关闭
374
374
  }
375
375
  if (!input) continue;
376
- tui?.echoInput(input);
376
+ tui?.echoInput(c.bold(c.cyan(input)));
377
377
 
378
378
  if (input.startsWith("/")) {
379
379
  const consumed = await handleSlash(input);
@@ -173,6 +173,19 @@ export class TerminalUI {
173
173
  this.write(`${this.prompt}${line}\n`);
174
174
  }
175
175
 
176
+ clearOutput() {
177
+ if (!this.enabled) return;
178
+ const bottom = this._scrollBottom();
179
+ let seq = "\x1b7";
180
+ for (let row = 1; row <= bottom; row++) seq += `\x1b[${row};1H\x1b[2K`;
181
+ seq += "\x1b8";
182
+ this.output.write(seq);
183
+ this.outRow = 1;
184
+ this.outCol = 1;
185
+ this.statusBar?.render?.(true);
186
+ this.renderInput(true);
187
+ }
188
+
176
189
  readLine() {
177
190
  const queued = this.queue.shift();
178
191
  if (queued !== undefined) return Promise.resolve(queued);
@@ -271,13 +284,22 @@ export class TerminalUI {
271
284
  this._setBuffer(next, { fromHistory: true });
272
285
  }
273
286
 
287
+ _resetHistoryDraft() {
288
+ this.historyIndex = null;
289
+ this._draftBeforeHistory = "";
290
+ }
291
+
292
+ _moveCursor(delta) {
293
+ this.cursor = Math.max(0, Math.min(chars(this.buffer).length, this.cursor + delta));
294
+ this.renderInput(true);
295
+ }
296
+
274
297
  _insert(s, { multiline = false } = {}) {
275
298
  const all = chars(this.buffer);
276
299
  const text = multiline ? String(s).replace(/\r\n/g, "\n").replace(/\r/g, "\n") : String(s);
277
300
  const ins = chars(text).filter((ch) => (multiline && ch === "\n") || printable(ch));
278
301
  if (!ins.length) return;
279
- this.historyIndex = null;
280
- this._draftBeforeHistory = "";
302
+ this._resetHistoryDraft();
281
303
  all.splice(this.cursor, 0, ...ins);
282
304
  this.buffer = all.join("");
283
305
  this.cursor += ins.length;
@@ -286,8 +308,7 @@ export class TerminalUI {
286
308
 
287
309
  _backspace() {
288
310
  if (this.cursor <= 0) return;
289
- this.historyIndex = null;
290
- this._draftBeforeHistory = "";
311
+ this._resetHistoryDraft();
291
312
  const all = chars(this.buffer);
292
313
  all.splice(this.cursor - 1, 1);
293
314
  this.buffer = all.join("");
@@ -298,13 +319,44 @@ export class TerminalUI {
298
319
  _delete() {
299
320
  const all = chars(this.buffer);
300
321
  if (this.cursor >= all.length) return;
301
- this.historyIndex = null;
302
- this._draftBeforeHistory = "";
322
+ this._resetHistoryDraft();
303
323
  all.splice(this.cursor, 1);
304
324
  this.buffer = all.join("");
305
325
  this.renderInput(true);
306
326
  }
307
327
 
328
+ _killBeforeCursor() {
329
+ if (this.cursor <= 0) return;
330
+ this._resetHistoryDraft();
331
+ const all = chars(this.buffer);
332
+ all.splice(0, this.cursor);
333
+ this.buffer = all.join("");
334
+ this.cursor = 0;
335
+ this.renderInput(true);
336
+ }
337
+
338
+ _killAfterCursor() {
339
+ const all = chars(this.buffer);
340
+ if (this.cursor >= all.length) return;
341
+ this._resetHistoryDraft();
342
+ all.splice(this.cursor);
343
+ this.buffer = all.join("");
344
+ this.renderInput(true);
345
+ }
346
+
347
+ _killWordBeforeCursor() {
348
+ if (this.cursor <= 0) return;
349
+ this._resetHistoryDraft();
350
+ const all = chars(this.buffer);
351
+ let start = this.cursor;
352
+ while (start > 0 && /\s/.test(all[start - 1])) start--;
353
+ while (start > 0 && !/\s/.test(all[start - 1])) start--;
354
+ all.splice(start, this.cursor - start);
355
+ this.buffer = all.join("");
356
+ this.cursor = start;
357
+ this.renderInput(true);
358
+ }
359
+
308
360
  _handleEscape(s) {
309
361
  if (s === "\x1b[A") {
310
362
  this._navigateHistory(-1);
@@ -315,12 +367,20 @@ export class TerminalUI {
315
367
  return true;
316
368
  }
317
369
  if (s === "\x1b[D") {
318
- this.cursor = Math.max(0, this.cursor - 1);
319
- this.renderInput(true);
370
+ this._moveCursor(-1);
320
371
  return true;
321
372
  }
322
373
  if (s === "\x1b[C") {
323
- this.cursor = Math.min(chars(this.buffer).length, this.cursor + 1);
374
+ this._moveCursor(1);
375
+ return true;
376
+ }
377
+ if (s === "\x1b[H" || s === "\x1b[1~") {
378
+ this.cursor = 0;
379
+ this.renderInput(true);
380
+ return true;
381
+ }
382
+ if (s === "\x1b[F" || s === "\x1b[4~") {
383
+ this.cursor = chars(this.buffer).length;
324
384
  this.renderInput(true);
325
385
  return true;
326
386
  }
@@ -383,15 +443,19 @@ export class TerminalUI {
383
443
  this.cursor = 0;
384
444
  return this.renderInput(true);
385
445
  }
446
+ if (s === "\x02") return this._moveCursor(-1);
447
+ if (s === "\x04") return this._delete();
386
448
  if (s === "\x05") {
387
449
  this.cursor = chars(this.buffer).length;
388
450
  return this.renderInput(true);
389
451
  }
390
- if (s === "\x15") {
391
- this.buffer = "";
392
- this.cursor = 0;
393
- return this.renderInput(true);
394
- }
452
+ if (s === "\x06") return this._moveCursor(1);
453
+ if (s === "\x0b") return this._killAfterCursor();
454
+ if (s === "\x0c") return this.clearOutput();
455
+ if (s === "\x0e") return this._navigateHistory(1);
456
+ if (s === "\x10") return this._navigateHistory(-1);
457
+ if (s === "\x15") return this._killBeforeCursor();
458
+ if (s === "\x17") return this._killWordBeforeCursor();
395
459
  return this._insert(s);
396
460
  }
397
461
  }
@@ -279,11 +279,14 @@ interface ToolResult { content: string; isError?: boolean; }
279
279
 
280
280
  | 文件 | 用途 | 上限 |
281
281
  |------|------|------|
282
- | `MEMORY.md` | 环境事实、约定、经验 | ~2200 字符 |
283
- | `USER.md` | 用户偏好、沟通风格 | ~1375 字符 |
282
+ | `MEMORY.md` | 环境事实、约定、经验;过多时作为索引指向 `MEMORY-xxx.md` | 10KB |
283
+ | `USER.md` | 用户偏好、沟通风格;过多时作为索引指向 `USER-xxx.md` | 10KB |
284
284
 
285
285
  - 条目以 `§` 分隔;超限时模型自行整合/替换。
286
286
  - **冻结快照**:会话开始时注入系统 prompt,会话中途变更立即落盘但下一会话才进 prompt(保 prefix cache)。
287
+ - **每轮注入**:`MEMORY.md` 与 `USER.md` 是最高优先级长期记忆;只要存在内容,每次构造 system prompt 都会携带其当前快照。
288
+ - **任务结束更新判断**:system prompt 明确要求 Agent 在每次任务完成后判断是否需要写入稳定事实、项目约定、用户偏好或可复用经验。
289
+ - **二级索引**:两个主文件各限制 10KB;当信息过多时,主文件只保留索引摘要,详细内容写入 `MEMORY-xxx.md` / `USER-xxx.md`,需要时再二级读取。
287
290
  - `memory` 工具:`add` / `replace` / `remove`(子串唯一匹配),无 `read`(已在 prompt 中)。
288
291
  - **检索**:MVP 用关键词倒排(Node 实现)补充召回;`[v2]` 可选向量(sqlite-vec / 本地 embedding)。
289
292
 
@@ -329,7 +332,7 @@ type SessionEntry =
329
332
  "fallback": ["wanyou/deepseek/deepseek-v4-flash"] // 主模型失败后依次尝试
330
333
  },
331
334
  "agent": { "maxIterations": 25, "thinking": "off", "contextWindow": 256000 },
332
- "memory": { "memoryMaxChars": 2200, "userMaxChars": 1375, "compactThreshold": 0.75 },
335
+ "memory": { "memoryMaxChars": 10240, "userMaxChars": 10240, "compactThreshold": 0.75 },
333
336
  "security": {
334
337
  "approval": "ask", // ask | auto | deny
335
338
  "allowlist": ["git *", "ls *", "cat *"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpssff/vanor",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A zero-dependency LLM-powered CLI agent for developers by Wanyou Intelligence",
5
5
  "type": "module",
6
6
  "bin": {