mocode-ai 0.4.1 → 0.4.2

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.
@@ -7,7 +7,7 @@
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { chat, planChatTools, } from '../llm/index.js';
9
9
  import { executeTool } from '../tools/registry.js';
10
- import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
10
+ import { getPlanDisabledTools } from '../tools/constants.js';
11
11
  import { getAgentMode, setAgentMode } from './mode.js';
12
12
  import { maybeCompact, contextState, dropContextFromHistory } from '../session/index.js';
13
13
  import { createBudgetScheduler } from '../session/scheduler.js';
@@ -125,6 +125,20 @@ export async function runAgentCore(opts) {
125
125
  // 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
126
126
  const t0 = Date.now();
127
127
  let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
128
+ // 本轮 token 累计:每步 chat() 返回后把 result.usage 累加,供 onDone 摘要行 + AgentRunResult.usage
129
+ // 透传给 repl(显示在底栏模式 chip 右边)。未开启 include_usage 或全失败时为 undefined。
130
+ let turnUsage;
131
+ const addUsage = (u) => {
132
+ if (!u)
133
+ return;
134
+ turnUsage = turnUsage
135
+ ? {
136
+ promptTokens: turnUsage.promptTokens + u.promptTokens,
137
+ completionTokens: turnUsage.completionTokens + u.completionTokens,
138
+ totalTokens: turnUsage.totalTokens + u.totalTokens,
139
+ }
140
+ : u;
141
+ };
128
142
  history.push({ role: 'user', content: userInput });
129
143
  // drop_context 工具的上下文剔除回调:闭包捕获 history,原地剔除无关旧 tool 结果。
130
144
  // 保护由 dropContextFromHistory 内部保证:history[0](system)+ 当前轮(最后 user 及其后)永不剔除。
@@ -210,6 +224,7 @@ export async function runAgentCore(opts) {
210
224
  throw e;
211
225
  }
212
226
  contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
227
+ addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
213
228
  hooks.onChatDone?.(); // 主 agent:spinner.stop()
214
229
  // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
215
230
  onContextUpdate?.();
@@ -301,7 +316,7 @@ export async function runAgentCore(opts) {
301
316
  const tc = calls[i];
302
317
  // plan 模式防御 backstop:schema 已剔除这些工具,正常不会进这里;防后端幻觉调用——
303
318
  // 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
304
- if (getAgentMode() === 'plan' && PLAN_DISABLED_TOOLS.has(tc.name)) {
319
+ if (getAgentMode() === 'plan' && getPlanDisabledTools().has(tc.name)) {
305
320
  hooks.onToolHeader?.(tc);
306
321
  const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
307
322
  hooks.onToolResult?.(tc, err, null, null, 1);
@@ -346,16 +361,16 @@ export async function runAgentCore(opts) {
346
361
  hooks.onNoReply?.();
347
362
  history.push({ role: 'assistant', content: result.content });
348
363
  done = true;
349
- return { completed: true, finalText: result.content };
364
+ return { completed: true, finalText: result.content, usage: turnUsage };
350
365
  }
351
366
  hooks.onMaxSteps?.();
352
367
  done = true;
353
- return { completed: true, finalText: null };
368
+ return { completed: true, finalText: null, usage: turnUsage };
354
369
  }
355
370
  finally {
356
371
  // 跑完(正常 / 达上限)在回复末尾打耗时摘要行(仿 Claude Code);中断 done=false 不打。
357
372
  if (done) {
358
- hooks.onDone?.(Date.now() - t0);
373
+ hooks.onDone?.(Date.now() - t0, turnUsage);
359
374
  }
360
375
  }
361
376
  }
@@ -110,15 +110,19 @@ onContextUpdate) {
110
110
  layout.contentWrite('\n');
111
111
  layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
112
112
  },
113
- onDone: (elapsedMs) => layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${ui.reset}\n`),
113
+ onDone: (elapsedMs, usage) => {
114
+ const tok = formatTurnTokens(usage);
115
+ layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${tok}${ui.reset}\n`);
116
+ },
114
117
  };
115
118
  // 桌宠状态广播:与 TUI hooks 并列注入,互不干扰(petHooks 只调 bridge.sendState,不写屏;
116
119
  // 未 /pet 连接时 sendState 内部 no-op)。仅主 agent 走这里——子 agent(spawn.ts)不引用 createPetHooks,
117
120
  // 故子 agent 永不广播桌宠状态。
118
121
  const petHooks = createPetHooks();
119
122
  const combinedHooks = mergeHooks(hooks, petHooks);
123
+ let result;
120
124
  try {
121
- await runAgentCore({
125
+ result = await runAgentCore({
122
126
  history,
123
127
  userInput,
124
128
  signal,
@@ -129,6 +133,7 @@ onContextUpdate) {
129
133
  finally {
130
134
  spinner.stop();
131
135
  }
136
+ return result;
132
137
  }
133
138
  /** 把两组 AgentHooks 合并为一组:每个方法依次调用两侧已定义的实现(顺序不保证跨方法一致,
134
139
  * 但同一事件内先 a 后 b)。用于把桌宠状态广播 hooks 与 TUI 渲染 hooks 并列挂载,互不影响。 */
@@ -145,3 +150,14 @@ function mergeHooks(a, b) {
145
150
  }
146
151
  return merged;
147
152
  }
153
+ /** 摘要行后追加的本轮 token 文本。例:` · 1.5k tokens (↑ 1.2k ↓ 0.3k)`。
154
+ * 关闭 include_usage / 全失败 → usage=undefined → 不输出(保持原摘要行长度,不留空白)。 */
155
+ function formatTurnTokens(usage) {
156
+ if (!usage)
157
+ return '';
158
+ const total = usage.totalTokens;
159
+ if (!total)
160
+ return '';
161
+ const fmt = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(total >= 10000 ? 0 : 1)}k`);
162
+ return ` · ${fmt(total)} tokens (↑ ${fmt(usage.promptTokens)} ↓ ${fmt(usage.completionTokens)})`;
163
+ }
@@ -15,19 +15,19 @@
15
15
  // - 逻辑隔离(回滚):skipRollback=true,子 agent 的 write_file/edit_file 改动不进主回滚快照链,
16
16
  // 主 /rollback 不撤销子 agent 改动(靠 git 兜底)。子 agent 与主 agent 共享 cwd(文件改动可见)。
17
17
  import { chatTools } from '../llm/index.js';
18
- import { config } from '../config/index.js';
18
+ import { config, isMemoryEnabled } from '../config/index.js';
19
19
  import { effectiveSystemPrompt } from '../skills/index.js';
20
20
  import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js';
21
21
  import { ui } from '../ui/theme.js';
22
22
  import { runAgentCore } from './core.js';
23
23
  import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
24
24
  /** 子 agent 系统提示后缀:角色与约束。 */
25
- const SUBAGENT_SUFFIX = `
26
-
27
- ## ⛯ SUB-AGENT MODE (you are a sub-agent)
28
- You are a sub-agent spawned by the main agent to handle an isolated sub-task. You have your own conversation history (independent of the main thread).
29
- - Focus solely on the assigned sub-task. Do NOT attempt to call the "task" tool (no recursive spawning).
30
- - Use the tools available to you to complete the sub-task.
25
+ const SUBAGENT_SUFFIX = `
26
+
27
+ ## ⛯ SUB-AGENT MODE (you are a sub-agent)
28
+ You are a sub-agent spawned by the main agent to handle an isolated sub-task. You have your own conversation history (independent of the main thread).
29
+ - Focus solely on the assigned sub-task. Do NOT attempt to call the "task" tool (no recursive spawning).
30
+ - Use the tools available to you to complete the sub-task.
31
31
  - When done, your final text reply will be returned to the main agent as a summary — make it concise and actionable: what you did, key findings, files changed, and any issues. The main agent will decide the next step based on your summary.`;
32
32
  /**
33
33
  * 派生一个子 agent 执行独立子任务。
@@ -44,9 +44,13 @@ You are a sub-agent spawned by the main agent to handle an isolated sub-task. Yo
44
44
  export async function spawnAgent(opts) {
45
45
  const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps ?? 50;
46
46
  // 构造子 agent 系统提示:复用主 agent 组装链 + 子 agent 角色后缀 + 自定义后缀。
47
+ // config.systemPrompt 是 getter(每次访问现拼 buildBasePrompt,反映 isMemoryEnabled),
48
+ // 所以这里直接读 config.systemPrompt 即可;buildMemoryIndexSection 显式按 isMemoryEnabled() 传参,
49
+ // 关闭时该段不进。注意:不能从 spawn.ts 直接 import buildBasePrompt —— 这会
50
+ // 拉起 config → llm → registry → builtins → task → spawn 形成循环求值死锁。
47
51
  const systemPrompt = effectiveSystemPrompt(config.systemPrompt +
48
52
  buildMemorySection() +
49
- buildMemoryIndexSection() +
53
+ buildMemoryIndexSection(isMemoryEnabled()) +
50
54
  SUBAGENT_SUFFIX +
51
55
  (opts.systemPromptSuffix ? `\n\n${opts.systemPromptSuffix}` : ''));
52
56
  // 工具子集:白名单过滤。无白名单 = 全量 chatTools,但始终剔除 task(防递归派生)。
@@ -102,7 +106,12 @@ export async function spawnAgent(opts) {
102
106
  onToolBatchEnd: () => writeBuf('\n'),
103
107
  onNoReply: () => writeBuf(`${ui.dim}(无回复)${ui.reset}\n`),
104
108
  onMaxSteps: () => writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`),
105
- onDone: (elapsedMs) => writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s\n`),
109
+ onDone: (elapsedMs, usage) => {
110
+ const tok = usage && usage.totalTokens
111
+ ? ` · ${usage.totalTokens} tokens`
112
+ : '';
113
+ writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
114
+ },
106
115
  // onStepStart / onChatDone / onToolStart / onToolDone / onAbort:子 agent 静默,无需 spinner / 中断渲染。
107
116
  // abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
108
117
  };
@@ -71,7 +71,58 @@ const PLATFORM_NOTE = (() => {
71
71
  - You are on ${process.platform}; run_command runs via bash -c. GNU coreutils — standard POSIX/GNU shell syntax is safe.
72
72
  - Still prefer the dedicated tools (read_file/glob/grep) over hand-rolled shell where they fit — they avoid quoting pitfalls and are already wired in.`;
73
73
  })();
74
- const SYSTEM_PROMPT = `You are mocode, a terminal coding agent. You complete programming tasks through a "think → call tool → observe result → think again" loop until the problem is solved. Reply to the user in Chinese.
74
+ /**
75
+ * 基础系统提示的"记忆段落":开 isMemoryEnabled() 时才拼。
76
+ * 默认关(新用户零侵入):这段 + 工具表里的 5 个 memory_* + 系统提示尾部的 Memory Index
77
+ * 都不出现;打开 /memory_switch 后下一次新建 system message 才注入。
78
+ */
79
+ const SYSTEM_PROMPT_MEMORY_SECTION = `
80
+ ## Memory (cross-session long-term facts)
81
+ - A "memory index" (id/title/summary only) is injected into the system prompt. Retrieve full body via memory_search (pass id or keyword); use memory_list to see the entire index.
82
+ - Store non-obvious, cross-session-useful facts/decisions/pitfalls (architecture conventions, gotchas, user preferences, decisions made) with memory_save — only long-term stable items, not current bugs / temp files / undecided TODOs.
83
+ - If an existing memory is outdated or contradicts new facts, correct it in-place with memory_update(id, …) (don't create a duplicate); archive clearly-stale ones with memory_forget(id).
84
+ - Before saving, memory_search to check for an existing similar entry to avoid duplicates. Better to store less than to store trivially correct information.
85
+ - A background reflection pass periodically mines and organizes memories from the session (no manual action needed), but key facts you proactively save are more reliable.`;
86
+ /**
87
+ * plan 模式追加到系统提示末尾的指令(切到 plan 模式时由 repl 拼进 history[0])。
88
+ * 与 SYSTEM_PROMPT 同语种(英文),指示:只读探查、产出步骤化计划、不执行、审批后回 auto。
89
+ *
90
+ * memoryEnabled=false 时:memory_save/update/forget 三个写工具名字 + "memory-write tools" 这
91
+ * 句都不出现,且 read-only 列表里的 memory_search/memory_list 也移除——避免提示词里出现
92
+ * 根本不存在的工具名引起 LLM 调不到。
93
+ */
94
+ function buildPlanModeSuffix() {
95
+ if (!isMemoryEnabled()) {
96
+ return `
97
+
98
+ ## ⛯ PLAN MODE (active now)
99
+ You are in PLAN mode: investigate and design only — do NOT execute or change anything.
100
+ - Your editing / command tools (write_file, edit_file, run_command) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human) to investigate.
101
+ - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index exists — not read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
102
+ - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
103
+ - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command tools become available again immediately). The user will see no approval prompt because you self-switched.
104
+ - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP. Do NOT ask the user for confirmation or approval in your text reply (e.g. "is this plan OK?", "shall I proceed?", "需要你确认") — the REPL automatically shows an approval prompt after you STOP, so asking in text is redundant and forces the user to answer twice. Just present the plan and end your reply.`;
105
+ }
106
+ return `
107
+
108
+ ## ⛯ PLAN MODE (active now)
109
+ You are in PLAN mode: investigate and design only — do NOT execute or change anything.
110
+ - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
111
+ - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index exists — not read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
112
+ - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
113
+ - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command/memory-write tools become available again immediately). The user will see no approval prompt because you self-switched.
114
+ - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP. Do NOT ask the user for confirmation or approval in your text reply (e.g. "is this plan OK?", "shall I proceed?", "需要你确认") — the REPL automatically shows an approval prompt after you STOP, so asking in text is redundant and forces the user to answer twice. Just present the plan and end your reply.`;
115
+ }
116
+ /** 兼容旧名字:repl 的 buildSystemMessage 仍引 PLAN_MODE_SUFFIX(变量)。运行时按需现拼。 */
117
+ export function buildBasePrompt() {
118
+ const autoAllToolsLine = isMemoryEnabled()
119
+ ? '- Default is AUTO mode: you research and execute with all tools (read/edit/run_command/memory/web/skills).'
120
+ : '- Default is AUTO mode: you research and execute with all tools (read/edit/run_command/web/skills).';
121
+ const memorySection = isMemoryEnabled() ? SYSTEM_PROMPT_MEMORY_SECTION : '';
122
+ const planLine = isMemoryEnabled()
123
+ ? '- For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command/memory-write tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.'
124
+ : '- For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.';
125
+ return `You are mocode, a terminal coding agent. You complete programming tasks through a "think → call tool → observe result → think again" loop until the problem is solved. Reply to the user in Chinese.
75
126
 
76
127
  ${PLATFORM_NOTE}
77
128
 
@@ -116,16 +167,9 @@ ${PLATFORM_NOTE}
116
167
  - Confirm with the user before irreversible or outward-facing operations (delete, overwrite existing files, push, request external services), unless explicitly authorized.
117
168
  - Operate only within authorized scope; when unsure, ask — don't guess.
118
169
 
119
- ## Memory (cross-session long-term facts)
120
- - A "memory index" (id/title/summary only) is injected into the system prompt. Retrieve full body via memory_search (pass id or keyword); use memory_list to see the entire index.
121
- - Store non-obvious, cross-session-useful facts/decisions/pitfalls (architecture conventions, gotchas, user preferences, decisions made) with memory_save — only long-term stable items, not current bugs / temp files / undecided TODOs.
122
- - If an existing memory is outdated or contradicts new facts, correct it in-place with memory_update(id, …) (don't create a duplicate); archive clearly-stale ones with memory_forget(id).
123
- - Before saving, memory_search to check for an existing similar entry to avoid duplicates. Better to store less than to store trivially correct information.
124
- - A background reflection pass periodically mines and organizes memories from the session (no manual action needed), but key facts you proactively save are more reliable.
125
-
126
- ## Plan vs Auto modes
127
- - Default is AUTO mode: you research and execute with all tools (read/edit/run_command/memory/web/skills).
128
- - For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command/memory-write tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.
170
+ ${memorySection}
171
+ ${autoAllToolsLine}
172
+ ${planLine}
129
173
 
130
174
  ## Working notepad (todolist) — checklist for complex tasks
131
175
  - For **complex multi-step tasks** (≥3 file changes OR ≥5 tool calls expected OR user says "先计划再执行" / "plan then do" / "按步骤来"), call the \`todolist\` tool FIRST to write a plan to \`.mocode/plans/<id>.md\`, then execute step by step, calling \`todolist update\` to mark progress. For trivial single-step tasks, skip it and just execute.
@@ -137,25 +181,30 @@ ${PLATFORM_NOTE}
137
181
  ## Termination & Reporting
138
182
  - Stop immediately when no more tools are needed; give conclusions directly.
139
183
  - Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
184
+ }
140
185
  /**
141
- * plan 模式追加到系统提示末尾的指令(切到 plan 模式时由 repl 拼进 history[0])。
142
- * SYSTEM_PROMPT 同语种(英文),指示:只读探查、产出步骤化计划、不执行、审批后回 auto。
186
+ * plan 模式追加到系统提示末尾的指令。
187
+ * 历史曾是 `export const PLAN_MODE_SUFFIX`(顶层字面量);现改为按 isMemoryEnabled()
188
+ * 动态拼:false 时不出现 memory_* 工具名,避免 LLM 想调不存在的工具。
189
+ *
190
+ * 注意:已改为 getter(每次访问现拼),让运行时切 /memory_switch 后立即生效。
191
+ * 旧 import `PLAN_MODE_SUFFIX` 路径不变;repl 推荐改用 getPlanModeSuffix()(语义更清晰)。
192
+ * 不能直接 `export const PLAN_MODE_SUFFIX = buildPlanModeSuffix()`:
193
+ * 该表达式在模块初始化时立即求值,而 buildPlanModeSuffix 内部读 config,config 还未求值 → TDZ。
143
194
  */
144
- export const PLAN_MODE_SUFFIX = `
145
-
146
- ## ⛯ PLAN MODE (active now)
147
- You are in PLAN mode: investigate and design only — do NOT execute or change anything.
148
- - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
149
- - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index exists — not read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
150
- - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
151
- - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command/memory-write tools become available again immediately). The user will see no approval prompt because you self-switched.
152
- - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP. Do NOT ask the user for confirmation or approval in your text reply (e.g. "is this plan OK?", "shall I proceed?", "需要你确认") — the REPL automatically shows an approval prompt after you STOP, so asking in text is redundant and forces the user to answer twice. Just present the plan and end your reply.`;
195
+ export function getPlanModeSuffix() {
196
+ return buildPlanModeSuffix();
197
+ }
153
198
  export const config = {
154
199
  baseURL: requireEnv('LLM_BASE_URL'),
155
200
  apiKey: requireEnv('LLM_API_KEY'),
156
201
  model: process.env.LLM_MODEL || 'gpt-4o-mini',
157
202
  maxTokens: process.env.MAX_TOKENS ? Number(process.env.MAX_TOKENS) : undefined,
158
- systemPrompt: SYSTEM_PROMPT,
203
+ // 用 getter 而非 buildBasePrompt() 立即求值:因为本对象字面量求值时 buildBasePrompt 读 config.memoryEnabled,
204
+ // 而 config 还没完成初始化(TDZ)。Getter 让每次访问都现拼,运行时 /memory_switch 立即生效。
205
+ get systemPrompt() {
206
+ return buildBasePrompt();
207
+ },
159
208
  contextWindowTokens: Number(process.env.CONTEXT_WINDOW_TOKENS) || 128000,
160
209
  compactThreshold: Number(process.env.COMPACT_THRESHOLD) || 0.85,
161
210
  includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
@@ -165,6 +214,7 @@ export const config = {
165
214
  contextLifecycle: process.env.MOCODE_LIFECYCLE !== 'false',
166
215
  contextBudget: process.env.MOCODE_BUDGET_SCHEDULER !== 'false',
167
216
  autoReflect: process.env.AUTO_REFLECT !== 'false',
217
+ memoryEnabled: process.env.MEMORY_ENABLED === 'true',
168
218
  reflectEveryN: Number(process.env.REFLECT_EVERY_N) || 5,
169
219
  maxSteps: Number(process.env.MAX_STEPS) || 200,
170
220
  subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || 50,
@@ -205,3 +255,26 @@ export function updateModelConfig(opts) {
205
255
  process.env.CONTEXT_WINDOW_TOKENS = String(opts.contextWindowTokens);
206
256
  }
207
257
  }
258
+ /**
259
+ * 记忆子系统总开关:单一来源。/memory_switch、/memory_status、buildSystemPrompt、
260
+ * tools/builtins/index.ts、tools/constants.ts 的 plan-mode 列表都从这里查。
261
+ * 默认 false(新用户零侵入)。
262
+ */
263
+ export function isMemoryEnabled() {
264
+ return config.memoryEnabled;
265
+ }
266
+ /**
267
+ * 切换记忆子系统开关(/memory_switch on|off 调)。
268
+ * - 更新 config 单例字段(其它模块下次调 isMemoryEnabled() 即拿新值)。
269
+ * - 同步 process.env.MEMORY_ENABLED(下次启动 loadEnvFiles 不被文件回填)。
270
+ * 持久化(写 ~/.mocode/config 的 MEMORY_ENABLED 键)由调用方走 writeConfigKeys。
271
+ *
272
+ * 注:开关切换对当前会话的 tool list / 已拼好的 systemPrompt 不会自动重算 —
273
+ * 工具表在 REPL 启动时构建,systemPrompt 在每轮 chat() 拼时按 isMemoryEnabled()
274
+ * 现查现拼(关掉时该轮拼出来的 prompt 即不带 memory_* 段)。所以切换在「下一轮
275
+ * agent 调用」起即时生效,本轮已发出的请求不会回滚。
276
+ */
277
+ export function updateMemoryConfig(enabled) {
278
+ config.memoryEnabled = enabled;
279
+ process.env.MEMORY_ENABLED = enabled ? 'true' : 'false';
280
+ }
package/dist/llm/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import OpenAI from 'openai';
2
2
  import { config } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
- import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
4
+ import { getPlanDisabledTools } from '../tools/constants.js';
5
5
  /**
6
6
  * LLM 调用重试策略:
7
7
  * 可重试 → 429 (rate limit) / 5xx (server) / APIConnectionError / Node 网络错 (ETIMEDOUT 等)
@@ -155,10 +155,13 @@ export const chatTools = tools.map((t) => ({
155
155
  },
156
156
  }));
157
157
  /**
158
- * plan 模式用的受限工具 schema:剔除写盘 / 命令 / 记忆写入类(PLAN_DISABLED_TOOLS),
158
+ * plan 模式用的受限工具 schema:剔除写盘 / 命令 / 记忆写入类(getPlanDisabledTools())。
159
159
  * 模型在 plan 模式下只看得到只读工具 → 调不到会改文件的工具。runAgent 在 plan 模式传给 chat()。
160
+ *
161
+ * 注意:planChatTools 是顶层 const(模块初始化时一次性求值);若运行时 /memory_switch 关闭
162
+ * 记忆,这里仍是按当前 isMemoryEnabled() 算出的快照——重启 REPL 才完全生效。
160
163
  */
161
- export const planChatTools = chatTools.filter((t) => !PLAN_DISABLED_TOOLS.has(t.function.name));
164
+ export const planChatTools = chatTools.filter((t) => !getPlanDisabledTools().has(t.function.name));
162
165
  /**
163
166
  * 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
164
167
  * tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
@@ -2,6 +2,7 @@
2
2
  // Tier-2:store.ts(叶子,node:fs)做 JSONL CRUD/GC/索引段;reflect.ts(→llm,同 session/)做后台反思 pass。
3
3
  // 被 repl 依赖(注入 systemPrompt + 轮末触发反思 + 退出 drain)。Tier-1 仅依赖 discover.ts。
4
4
  import { loadMemoryFiles } from './discover.js';
5
+ import { isMemoryEnabled } from '../config/index.js';
5
6
  export { buildMemoryIndexSection, loadAll, gcMemories, } from './store.js';
6
7
  export { kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, runReflection, } from './reflect.js';
7
8
  /** system 消息中 memory 段的字符上限(防过大占窗口——system 在 history[0],compactHistory 不压缩)。 */
@@ -30,8 +31,14 @@ export function loadMemory() {
30
31
  }
31
32
  return cache;
32
33
  }
33
- /** 拼进系统提示的 memory 段;无 memory 返空串(零行为变化)。 */
34
+ /**
35
+ * 拼进系统提示的 memory 段(Tier-1 MOCODE.md);无 memory 返空串(零行为变化)。
36
+ * 记忆子系统总开关关闭(isMemoryEnabled()==false)直接返空串:
37
+ * 提示词、Memory Index 段都不进 — 配合 tools/builtins 把 memory_* 工具屏蔽。
38
+ */
34
39
  export function buildMemorySection() {
40
+ if (!isMemoryEnabled())
41
+ return '';
35
42
  const mem = loadMemory();
36
43
  if (!mem)
37
44
  return '';
@@ -315,8 +315,15 @@ export function gcMemories() {
315
315
  /**
316
316
  * active 条目按 updatedAt 降序,封顶 MAX_INDEX_ENTRIES,只注 id/name/summary/type。
317
317
  * 无 active 返空串(零行为变化)。body 不注入——按需 memory_search 取。
318
+ *
319
+ * memoryEnabled=false 时(记忆子系统总开关关闭)直接返空串:Memory Index 段
320
+ * 不进系统提示,LLM 看不到工具使用提示;配合 tools/builtins 屏蔽 memory_* 工具,
321
+ * 实现「关闭时零侵入」(默认行为)。传参由 repl 的 buildSystemMessage 在拼装前调
322
+ * isMemoryEnabled() 注入(本文件是叶子,避免直接引 config 起环)。
318
323
  */
319
- export function buildMemoryIndexSection() {
324
+ export function buildMemoryIndexSection(memoryEnabled = true) {
325
+ if (!memoryEnabled)
326
+ return '';
320
327
  const active = loadAll()
321
328
  .filter((e) => e.status === 'active')
322
329
  .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, PLAN_MODE_SUFFIX, updateModelConfig, isModelConfigured } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
5
5
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
6
6
  import { runAgent } from '../agent/index.js';
7
7
  import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
@@ -35,9 +35,11 @@ const SLASH_COMMANDS = [
35
35
  { name: '/compact', desc: '压缩历史(可带焦点 /compact …)' },
36
36
  { name: '/resume', desc: '续接已保存的会话' },
37
37
  { name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
38
- { name: '/memory', desc: '记忆库:条目计数与近期索引' },
39
- { name: '/reflect', desc: '手动触发后台记忆反思 pass' },
40
- { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆' },
38
+ { name: '/memory', desc: '记忆库:条目计数与近期索引(关闭时提示先开 /memory_switch)' },
39
+ { name: '/memory_switch', desc: '切换记忆子系统开关(无参=切换;/on 或 /off 显式;持久化 MEMORY_ENABLED)' },
40
+ { name: '/memory_status', desc: '查看记忆子系统当前开关与原理' },
41
+ { name: '/reflect', desc: '手动触发后台记忆反思 pass(需先开启记忆)' },
42
+ { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆(需先开启记忆)' },
41
43
  { name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
42
44
  { name: '/model', desc: '配置大模型(baseURL/key/model/窗口)' },
43
45
  { name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
@@ -141,14 +143,15 @@ function renderContextBarInline(history) {
141
143
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
142
144
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
143
145
  }
144
- /** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip。repl 在轮次边界、切模式、plan 变更时调。 */
145
- function refreshStatusBase(history) {
146
+ /** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token。repl 在轮次边界、切模式、plan 变更时调。 */
147
+ function refreshStatusBase(history, lastTurnUsage) {
146
148
  layout.setStatusBase({
147
149
  model: config.model,
148
150
  contextBar: renderContextBarInline(history),
149
151
  cwd: process.cwd(),
150
152
  modeTag: getAgentMode() === 'plan' ? 'plan' : 'auto',
151
153
  planSummary: hasActivePlan() ? getActivePlanSummary(process.stdout.columns ?? 80) : '',
154
+ lastTurnUsage,
152
155
  });
153
156
  }
154
157
  /** 命令 → 运行态状态文字 + 底栏 dim 占位。 */
@@ -174,6 +177,10 @@ function runningStateFor(cmd) {
174
177
  return { status: '配模型', placeholder: '配置中…' };
175
178
  case '/pet':
176
179
  return { status: '桌宠', placeholder: '处理中…' };
180
+ case '/memory_switch':
181
+ return { status: '切记忆开关', placeholder: '切换中…' };
182
+ case '/memory_status':
183
+ return { status: '查记忆状态', placeholder: '…' };
177
184
  default:
178
185
  // 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
179
186
  // 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
@@ -408,13 +415,17 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
408
415
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
409
416
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
410
417
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
411
- // 构造系统提示:auto 用 base;plan 在 config.systemPrompt 后追加 PLAN_MODE_SUFFIX
418
+ // 构造系统提示:auto 用 base;plan 在 base 后追加按当前开关现拼的 plan suffix
412
419
  // 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
413
420
  // 活跃 plan 摘要拼在 memory 段后(systemPrompt 的尾段),todo 工具变更后 listener 重写 history[0]。
414
- const buildSystemMessage = (planMode) => effectiveSystemPrompt(config.systemPrompt +
415
- (planMode ? PLAN_MODE_SUFFIX : '') +
421
+ //
422
+ // 与开关联动:① base buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
423
+ // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
424
+ // ③ buildMemorySection 内已自决 ;④ buildMemoryIndexSection 显式传 isMemoryEnabled() 关闭段。
425
+ const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt() +
426
+ (planMode ? getPlanModeSuffix() : '') +
416
427
  buildMemorySection() +
417
- buildMemoryIndexSection() +
428
+ buildMemoryIndexSection(isMemoryEnabled()) +
418
429
  buildActivePlanSection());
419
430
  // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
420
431
  // 否则新会话只塞 system 提示(默认 auto)。
@@ -434,6 +445,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
434
445
  let currentSessionId = sessionId;
435
446
  // 反思 cadence 计数:每 reflectEveryN 轮 fire-and-forget 一次后台反思 pass。
436
447
  let turnCount = 0;
448
+ // 本轮 token 累计:runAgent 返回后写入,供底栏模式 chip 右边显示。undefined=无实测
449
+ // (后端不开 include_usage / 后端失败时)。
450
+ let lastTurnUsage;
437
451
  const toolsLine = tools.map((t) => t.name).join(' · ');
438
452
  const banner = () => ({
439
453
  model: config.model,
@@ -598,10 +612,14 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
598
612
  setAgentMode(planMode ? 'plan' : 'auto');
599
613
  // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
600
614
  // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
601
- await runAgent(history, userInput, signal, () => {
615
+ const result = await runAgent(history, userInput, signal, () => {
602
616
  refreshStatusBase(history);
603
617
  layout.drawStatusBar();
604
618
  });
619
+ // 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
620
+ lastTurnUsage = result.usage;
621
+ refreshStatusBase(history, lastTurnUsage); // 即时刷状态行显示本轮 token chip
622
+ layout.drawStatusBar();
605
623
  ok = !signal.aborted; // 中断(Ctrl+C)→ runAgent 已还原 history,ok=false 不弹审批
606
624
  // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
607
625
  if (!currentSessionId)
@@ -770,6 +788,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
770
788
  currentSessionId = undefined; // 下轮起新会话文件
771
789
  turnCount = 0; // 反思 cadence 重新计数
772
790
  contextState.lastUsage = undefined;
791
+ lastTurnUsage = undefined; // 清空旧轮的 token 累计
773
792
  pendingAttachments = []; // 一并清空待发图片
774
793
  layout.clearContent();
775
794
  layout.contentWrite(bannerString(banner()));
@@ -971,6 +990,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
971
990
  if (!loadSnapshots(loaded.id))
972
991
  rebuildFromHistory(history);
973
992
  contextState.lastUsage = undefined;
993
+ lastTurnUsage = undefined; // /resume:旧会话的 token 累计已无意义,清空等下轮覆写
974
994
  layout.clearContent();
975
995
  renderHistory(history);
976
996
  layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
@@ -1206,6 +1226,78 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1206
1226
  await rollbackFlow();
1207
1227
  continue;
1208
1228
  }
1229
+ if (line === '/memory_switch' ||
1230
+ line.startsWith('/memory_switch ') ||
1231
+ line === '/memory_status' ||
1232
+ line.startsWith('/memory_status ')) {
1233
+ // /memory_switch — 记忆子系统总开关。无参切换 on/off;/memory_switch on 或 /off 显式;
1234
+ // /memory_switch true|false|1|0|yes|no 等同义。/memory_status 只读查询(不写盘)。
1235
+ //
1236
+ // 设计原则:
1237
+ // - 单一来源 isMemoryEnabled():工具表(builtins)、系统提示词(Memory Index 段 + 工具使用说明)、
1238
+ // plan-mode 提示(tools/constants.ts)三处都从这里查。
1239
+ // - 当前会话的 tool list 是模块初始化时的快照(/memory_switch 不重算 builtinTools)——已发出
1240
+ // 请求的工具列表不会被回滚。要"完全生效"需要重启 REPL。但 buildSystemMessage 每次 chat 现拼,
1241
+ // 所以系统提示词和 plan suffix 会在「下一轮 chat」即时反映新值。
1242
+ // - 持久化字段 MEMORY_ENABLED,默认值 false(新用户零侵入)。
1243
+ try {
1244
+ if (line === '/memory_status' || line.startsWith('/memory_status ')) {
1245
+ const on = isMemoryEnabled();
1246
+ layout.contentWrite(`${ui.cyan}记忆子系统:${ui.reset} ${on ? `${ui.green}开启` : `${ui.yellow}关闭`}${ui.reset}\n`);
1247
+ layout.contentWrite(`${ui.dim} 单一来源 isMemoryEnabled()(${config.memoryEnabled});` +
1248
+ `持久化 ${ui.cyan}MEMORY_ENABLED${ui.dim};` +
1249
+ `配置文件 ${CONFIG_PATH}${ui.reset}\n`);
1250
+ layout.contentWrite(`${ui.dim} 关闭时:memory_*_save/_search/_list/_update/_forget 五个工具整体不进工具表;` +
1251
+ `buildBasePrompt() 不含「## Memory」段;` +
1252
+ `plan-mode 提示词里也不出现 memory_* 工具名。${ui.reset}\n`);
1253
+ layout.contentWrite(`${ui.dim} 切换后下次新建 system message 即时反映;当前会话工具表需重启 REPL 才完整重算。${ui.reset}\n`);
1254
+ continue;
1255
+ }
1256
+ // /memory_switch(无参=on/off 切换;有参=按值设)
1257
+ const arg = line.startsWith('/memory_switch ')
1258
+ ? line.slice('/memory_switch '.length).trim().toLowerCase()
1259
+ : '';
1260
+ let nextEnabled;
1261
+ if (arg === '') {
1262
+ nextEnabled = !isMemoryEnabled();
1263
+ }
1264
+ else if (['on', 'true', '1', 'yes', 'y', 'enable', 'enabled'].includes(arg)) {
1265
+ nextEnabled = true;
1266
+ }
1267
+ else if (['off', 'false', '0', 'no', 'n', 'disable', 'disabled'].includes(arg)) {
1268
+ nextEnabled = false;
1269
+ }
1270
+ else {
1271
+ layout.contentWrite(`${ui.yellow}/memory_switch 用法:${ui.reset}\n` +
1272
+ ` /memory_switch 切换(开↔关)\n` +
1273
+ ` /memory_switch on|off 显式设值\n` +
1274
+ ` /memory_switch status 等同 /memory_status\n`);
1275
+ continue;
1276
+ }
1277
+ const prev = isMemoryEnabled();
1278
+ if (nextEnabled === prev) {
1279
+ layout.contentWrite(`${ui.dim}(已是 ${nextEnabled ? '开启' : '关闭'},未变更 — 持久化字段未写入)${ui.reset}\n`);
1280
+ continue;
1281
+ }
1282
+ updateMemoryConfig(nextEnabled);
1283
+ // 写盘:mode 文件 values,/~/.mocode/config;writeConfigKeys 不会动其它键(主题 / 模型等)
1284
+ updateConfigKey('MEMORY_ENABLED', nextEnabled ? 'true' : 'false');
1285
+ const note = nextEnabled
1286
+ ? `${ui.green}已开启记忆子系统${ui.reset} — memory_save/search/list/update/forget 进入工具表;` +
1287
+ `Memory Index 段会在下次拼 system message 时注入。工具表本身的快照需要重启 REPL 才完整刷新。`
1288
+ : `${ui.yellow}已关闭记忆子系统${ui.reset} — 五个 memory_* 工具将在下次拼 system message 时从工具表过滤;` +
1289
+ `Memory Index 段不再出现;plan-mode 提示词里的 memory_* 字样消失。重启 REPL 后工具表完全不出现。`;
1290
+ layout.contentWrite(`${note}\n`);
1291
+ layout.contentWrite(`${ui.dim}(写入 ${CONFIG_PATH}:MEMORY_ENABLED=${nextEnabled ? 'true' : 'false'};${ui.reset}` +
1292
+ (process.env.MEMORY_ENABLED
1293
+ ? `${ui.dim}同 session shell 未 export,文件写入即时生效)${ui.reset}\n`
1294
+ : `${ui.dim}下次启动仍生效)${ui.reset}\n`));
1295
+ }
1296
+ catch (e) {
1297
+ layout.contentWrite(`${ui.red}/memory_switch 失败:${ui.reset} ${e.message}\n`);
1298
+ }
1299
+ continue;
1300
+ }
1209
1301
  const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
1210
1302
  const ok = await runTurn(joined, initialPlan, placeholder);
1211
1303
  // plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
@@ -1,5 +1,42 @@
1
1
  import { promptIntervention } from '../../ui/intervention.js';
2
2
  import { sendState } from '../../pet/bridge.js';
3
+ /** 将单个选项元素安全地转为可读字符串。
4
+ * LLM 有时会传对象(如 {name/label/title:"xxx", desc/description:"yyy"})而不是纯字符串,
5
+ * 直接 String(obj) 会变成 "[object Object]"——这里智能提取可读字段。 */
6
+ function optionToString(o) {
7
+ if (o === null || o === undefined)
8
+ return '';
9
+ if (typeof o === 'string')
10
+ return o;
11
+ if (typeof o === 'number' || typeof o === 'boolean')
12
+ return String(o);
13
+ if (typeof o === 'object') {
14
+ const obj = o;
15
+ // 优先取常见的标签字段
16
+ const labelKeys = ['label', 'name', 'title', 'text', 'option', 'choice', 'value', 'key'];
17
+ for (const k of labelKeys) {
18
+ const v = obj[k];
19
+ if (typeof v === 'string' && v.trim())
20
+ return v;
21
+ }
22
+ // 其次尝试 "label + description" 组合
23
+ const label = obj.label ?? obj.name ?? obj.title;
24
+ const desc = obj.description ?? obj.desc ?? obj.detail;
25
+ if (typeof label === 'string' && typeof desc === 'string') {
26
+ return `${label}: ${desc}`;
27
+ }
28
+ // 兜底:JSON 序列化(去掉大括号让它看起来不像代码)
29
+ try {
30
+ const s = JSON.stringify(obj);
31
+ // 如果是简单对象尝试美化
32
+ return s;
33
+ }
34
+ catch {
35
+ return String(o);
36
+ }
37
+ }
38
+ return String(o);
39
+ }
3
40
  /** 公开以便 check-ask-human-options.ts 单元测试。 */
4
41
  export function coerceOptions(raw) {
5
42
  // 路径 1:本身就是数组,map 成字符串。
@@ -11,14 +48,14 @@ export function coerceOptions(raw) {
11
48
  try {
12
49
  const parsed = JSON.parse(t);
13
50
  if (Array.isArray(parsed))
14
- return parsed.map((o) => String(o));
51
+ return parsed.map(optionToString);
15
52
  }
16
53
  catch {
17
54
  // 不是合法 JSON 数组,降级原值
18
55
  }
19
56
  }
20
57
  }
21
- return raw.map((o) => (typeof o === 'string' ? o : String(o)));
58
+ return raw.map(optionToString);
22
59
  }
23
60
  // 路径 2:LLM 直接把整个数组 stringify 成单字符串塞 options 字段(JSON.parse 出来是字符串)
24
61
  // 例如 GLM 系经常这么做,arg h['options']='["A","B"]' → args.options='["A","B"]'
@@ -29,7 +66,7 @@ export function coerceOptions(raw) {
29
66
  try {
30
67
  const parsed = JSON.parse(t);
31
68
  if (Array.isArray(parsed))
32
- return parsed.map((o) => String(o));
69
+ return parsed.map(optionToString);
33
70
  }
34
71
  catch {
35
72
  // 不是合法 JSON,保留为单元素数组(对应 input 模式)
@@ -21,7 +21,26 @@ import { todolistTool } from './todolist.js';
21
21
  /**
22
22
  * 所有内置工具,按注册顺序排列。
23
23
  * 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。无需改 agent / llm。
24
+ *
25
+ * 记忆子系统总开关(MEMORY_ENABLED !== 'true'):5 个 memory_* 工具整体不进 builtinTools,
26
+ * 进而不进 LLM 的工具表(模型根本看不到、也不会想着去调)。运行时通过 /memory_switch 切;
27
+ * 切换对当前会话的 tool list 不重算(取的是模块初始化时的快照),所以需要重启 REPL 才生效
28
+ * —— 这是有意为之,避免切开关瞬间把已发出请求的工具列表打乱。
29
+ *
30
+ * 注:这里直接读 env(MEMORY_ENABLED)而不是调 config.isMemoryEnabled(),因为本模块可能在
31
+ * config 单例尚未初始化时被其它模块拉起(import 链路:tools/registry → builtinTools,
32
+ * config 单例字段 getter 在 getPlanDisabledTools 等调用链路上 lazy 求值)。
24
33
  */
34
+ const _memoryEnabledAtBoot = process.env.MEMORY_ENABLED === 'true';
35
+ const _memoryTools = _memoryEnabledAtBoot
36
+ ? [
37
+ memorySaveTool,
38
+ memorySearchTool,
39
+ memoryListTool,
40
+ memoryUpdateTool,
41
+ memoryForgetTool,
42
+ ]
43
+ : [];
25
44
  export const builtinTools = [
26
45
  readFileTool,
27
46
  writeFileTool,
@@ -36,11 +55,7 @@ export const builtinTools = [
36
55
  askHumanTool,
37
56
  switchModeTool, // plan↔auto 自切(两模式都可见,不进 PLAN_DISABLED_TOOLS;副作用控制工具→串行分支)
38
57
  dropContextTool, // 运行中剔除无关 tool 结果(上下文管理,无副作用;两模式都可见,串行分支)
39
- memorySaveTool,
40
- memorySearchTool,
41
- memoryListTool,
42
- memoryUpdateTool,
43
- memoryForgetTool,
58
+ ..._memoryTools,
44
59
  taskTool, // 派生子 agent(独立 history + 可受限工具集);plan 模式禁用(见 PLAN_DISABLED_TOOLS)
45
60
  todolistTool, // 工作记事本(plan 文件:复杂任务 checklist,落盘抗压缩);plan 模式可用(便于「先 plan 再 auto」时落地执行清单)
46
61
  ];
@@ -1,4 +1,5 @@
1
1
  /** 工具共享的截断 / 上限 / 忽略规则。 */
2
+ import { isMemoryEnabled } from '../config/index.js';
2
3
  export const MAX_FILE_LINES = 2000;
3
4
  export const MAX_OUTPUT = 20000;
4
5
  export const MAX_RESULTS = 100;
@@ -55,3 +56,18 @@ export const PLAN_DISABLED_TOOLS = new Set([
55
56
  'memory_forget',
56
57
  'task',
57
58
  ]);
59
+ /**
60
+ * 按当前 isMemoryEnabled() 现算 plan 模式应屏蔽的工具。
61
+ * memoryEnabled=false 时记忆工具整体不在 builtinTools 里,plan 屏蔽集里也无须再列 ——
62
+ * 反之留着只是死名字。统一过滤,避免 Set 里残留与已下架工具不一致的概念性冗余。
63
+ * 调用方(agent/core 串行分支、llm/planChatTools)每次 chat 时调本函数拿当前值。
64
+ */
65
+ export function getPlanDisabledTools() {
66
+ if (isMemoryEnabled())
67
+ return PLAN_DISABLED_TOOLS;
68
+ const next = new Set(PLAN_DISABLED_TOOLS);
69
+ next.delete('memory_save');
70
+ next.delete('memory_update');
71
+ next.delete('memory_forget');
72
+ return next;
73
+ }
package/dist/ui/layout.js CHANGED
@@ -742,29 +742,46 @@ function composeSpinnerLine(status, cols) {
742
742
  const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
743
743
  return twoColumn(lead, leadW, rightStr, tailW, cols);
744
744
  }
745
- /** 下线之下那行(model 行):左 = 模式标识;右 = context + cwd,右端对齐。
745
+ /** 下线之下那行(model 行):左 = 模式标识 + 本轮 token chip;右 = context + cwd,右端对齐。
746
746
  * 活跃 plan chip 不再放这里,改放 spinner 行上方的「虚拟空行」(contentBottom+1,见 drawStatusBar),
747
747
  * 既不挤 model 行,又给输入区上方留出可视分隔带。 */
748
748
  function composeModelLine(status, cols) {
749
749
  const ctx = status.contextBar; // 已带色
750
750
  const ctxW = ansiDisplayWidth(ctx);
751
- // 左段:仅模式标识
751
+ // 左段:模式标识 + 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
752
752
  const modeTag = status.modeTag ?? '';
753
753
  const modeColor = modeTag === 'plan' ? ui.yellow : ui.brightCyan;
754
- const modePart = modeTag
755
- ? `${modeColor}${modeTag}${ui.reset}`
756
- : '';
757
- const leftStr = modePart;
758
- const leftW = modeTag ? displayWidth(modeTag) : 0;
754
+ const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
755
+ const modeW = modeTag ? displayWidth(modeTag) : 0;
756
+ const tokChip = formatTurnTokenChip(status.lastTurnUsage);
757
+ const tokW = displayWidth(stripAnsi(tokChip));
758
+ // 合并左段:chip 前留 2 空格分隔,无 modeTag 也允许仅显示 chip(兜底边角)
759
+ const sep = modePart && tokChip ? ' ' : '';
760
+ const leftStr = `${modePart}${sep}${tokChip}`;
761
+ const leftW = modeW + (modePart && tokChip ? sep.length : 0) + tokW;
759
762
  // 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
763
+ // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再隐藏 token chip。
760
764
  const minGap = 2;
761
- const cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
762
- const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
763
- const cwdW = displayWidth(cwd);
764
- const rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
765
- const rightW = ctxW + STATUS_SEP_W + cwdW;
765
+ let cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
766
+ let cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
767
+ let cwdW = displayWidth(cwd);
768
+ let rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
769
+ let rightW = ctxW + STATUS_SEP_W + cwdW;
770
+ // 极窄(<24 列含 ctx):藏 token chip
771
+ if (modePart && tokChip && rightW + minGap + leftW > cols) {
772
+ return twoColumn(modePart, modeW, rightStr, rightW, cols);
773
+ }
766
774
  return twoColumn(leftStr, leftW, rightStr, rightW, cols);
767
775
  }
776
+ /** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。 */
777
+ function formatTurnTokenChip(usage) {
778
+ if (!usage || !usage.totalTokens)
779
+ return '';
780
+ const n = usage.totalTokens;
781
+ const text = n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
782
+ // 单段:量足够稳定时不显分项,避免无谓视觉负担。chip 用 mid 灰(降优先级)— 模式仍是主色。
783
+ return `${ui.dim}${text} tokens${ui.reset}`;
784
+ }
768
785
  /** spinner 行上方的「虚拟空行」(contentBottom+1)。
769
786
  * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow + dim)
770
787
  * - 无活跃 plan:空(保留原分隔视觉,避免内容贴输入区)
@@ -929,7 +946,7 @@ export function clearLiveAtCursor() {
929
946
  frameRow = 0;
930
947
  frameCol = 0;
931
948
  }
932
- /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip)。repl 在轮次边界与切模式时调。 */
949
+ /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token chip)。repl 在轮次边界与切模式时调。 */
933
950
  export function setStatusBase(b) {
934
951
  base = b;
935
952
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {