mocode-ai 0.4.0 → 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.
@@ -0,0 +1,284 @@
1
+ // Relevance Pruner:read_file 相关性裁剪(纯静态分析,零 LLM 调用)。
2
+ //
3
+ // 场景(用户描述):
4
+ // 1) read foo.ts → 后来又 read foo.ts → 旧结果无价值 → 替换为存根
5
+ // 2) read foo.ts → edit foo.ts → read foo.ts → 中间那次 read 之前的所有旧 read
6
+ // 在 mutation 之后已失效 → 替换为存根
7
+ //
8
+ // 与现有子系统的关系:
9
+ // - Context Optimization Pipeline(`pipeline.ts`):单条上限 + 类型化编码。本层在其外,
10
+ // 在 pushToolResult 出口再做一次"跨条"裁剪。
11
+ // - drop_context(`session/drop.ts`):agent 主动剔除已知无关的旧 tool 结果。本层是被动自动,
12
+ // 不需要 agent 调;两者并存不冲突。
13
+ // - compact(`session/compact.ts`):阈值触发的整体微压缩+摘要。本层只裁"明确失效"的旧 read,
14
+ // 不触发摘要;门槛更低、零成本。
15
+ //
16
+ // 不变量(对齐 drop_context / compact):
17
+ // - 只改 .content,不删消息、不动 tool_call_id、不动 tool_calls 数组结构。
18
+ // - 当前轮保护区:不剔除"最后一个 user 消息及其之后"的 read_file 结果(agent 本轮还在用,
19
+ // 剔除会破坏正在进行的推理)。实现复用 drop.ts 的 lastUserIndex 思路。
20
+ // - 幂等:已 stub(含「已过时」标记)不重复 stub,避免反复重写同一条消息。
21
+ // - 永不抛错(对齐「调度器永不抛错」契约);无匹配 / 解析失败 / 异常 → 静默 no-op。
22
+ // - TUI 渲染(hooks.onToolResult)用原始 output,与本层解耦——屏上看全量,LLM 看裁剪后版。
23
+ //
24
+ // 零行为变化兜底:开关 `config.contextRelprune` 关闭时,pipeline 路径完全不调本模块。
25
+ /** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
26
+ * 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
27
+ const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
28
+ /** 解析工具 arguments(只关心 path);非法返 null。 */
29
+ function extractPath(argsRaw) {
30
+ if (!argsRaw)
31
+ return null;
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(argsRaw);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ if (!parsed || typeof parsed !== 'object')
40
+ return null;
41
+ const p = parsed.path;
42
+ if (typeof p !== 'string' || !p)
43
+ return null;
44
+ return p;
45
+ }
46
+ /** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
47
+ * 复用 drop.ts 的思路:user 及其之后的 tool 结果视为当前轮保护区。
48
+ * 在本层里,read_file tool 消息若落在 user 之后,本轮还在用,不能 stub。
49
+ * 注:history[0] 是 system,user 不会落在 0;若 user 就在末尾(即 0 user 之后),
50
+ * protectedFrom=0 时整段历史都不可 stub(实际不会发生:pushToolResult 必在 user 之后)。 */
51
+ function lastUserIndex(history) {
52
+ for (let i = history.length - 1; i >= 1; i--) {
53
+ if (history[i].role === 'user')
54
+ return i;
55
+ }
56
+ return -1;
57
+ }
58
+ /** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。
59
+ * 返回 null 表示找不到(孤儿 tool,极少见),本层保守不动。 */
60
+ function toolNameOf(history, idx) {
61
+ const tcId = history[idx].tool_call_id;
62
+ if (!tcId)
63
+ return null;
64
+ for (let j = idx - 1; j >= 1; j--) {
65
+ const m = history[j];
66
+ if (m.role !== 'assistant')
67
+ continue;
68
+ const tcs = m.tool_calls;
69
+ if (!tcs)
70
+ continue;
71
+ const hit = tcs.find((tc) => tc?.id === tcId);
72
+ if (hit)
73
+ return hit.function?.name ?? null;
74
+ }
75
+ return null;
76
+ }
77
+ /** 把 content 拍平成字符串(对齐 drop.ts)。 */
78
+ function toText(content) {
79
+ if (content == null)
80
+ return '';
81
+ if (typeof content === 'string')
82
+ return content;
83
+ try {
84
+ return JSON.stringify(content);
85
+ }
86
+ catch {
87
+ return String(content);
88
+ }
89
+ }
90
+ /**
91
+ * 维护「path → 该 path 所有 read_file tool 消息的 history index」映射。
92
+ * - observePush:把刚 push 的 read_file tool 消息登记,并把同 path 的"更早" read 全部 stub。
93
+ * - observeMutation:把该 mutation path 的"在 mutation 之前的" read 全部 stub。
94
+ *
95
+ * 设计:每个 agent 会话(每个 runAgentCore 实例)持有一个 pruner。会话结束/换 plan 时
96
+ * 可新建;不持久化(history 重建时索引自然过期)。
97
+ *
98
+ * 零依赖:仅依赖 ChatMessage 形状;不 import llm / tools / agent。
99
+ */
100
+ export class RelevancePruner {
101
+ /** path → [history index, ...] 按插入序;最新在末尾。 */
102
+ readByPath = new Map();
103
+ /** 把刚 push 的消息通知 pruner。
104
+ * - 只处理 tool 消息(role==='tool')。
105
+ * - 只关心 read_file:登记 + 反向 stub 同 path 旧 read。
106
+ * - 非 read_file 的 tool 消息:无操作(本层只管 read_file)。
107
+ * - 非 tool 消息(assistant / user / system):无操作。
108
+ */
109
+ observePush(history, msg) {
110
+ try {
111
+ if (msg.role !== 'tool')
112
+ return;
113
+ const m = msg;
114
+ const tcId = m.tool_call_id;
115
+ if (!tcId)
116
+ return;
117
+ const idx = history.length - 1;
118
+ if (idx < 1 || history[idx] !== msg)
119
+ return; // 防御:必须刚 push 到末尾
120
+ const name = toolNameOf(history, idx);
121
+ if (name !== 'read_file')
122
+ return;
123
+ const content = toText(msg.content);
124
+ if (content.startsWith(STUB_PREFIX))
125
+ return; // 已是存根(防御)
126
+ // 从前导 assistant.tool_calls 找对应 tc.arguments(精确 path 来源)。
127
+ // 退化方案:从消息内容首行解析路径(read_file 输出形如 `\n 1\t...`,无 path;
128
+ // 故必须从 args 取)。找不到则保守不动。
129
+ let path = null;
130
+ for (let j = idx - 1; j >= 1; j--) {
131
+ const mm = history[j];
132
+ if (mm.role !== 'assistant')
133
+ continue;
134
+ const tcs = mm.tool_calls;
135
+ if (!tcs)
136
+ continue;
137
+ const hit = tcs.find((tc) => tc?.id === tcId);
138
+ if (hit) {
139
+ path = extractPath(hit.function?.arguments);
140
+ break;
141
+ }
142
+ }
143
+ if (!path)
144
+ return;
145
+ // 先 stub 旧 read(同 path,idx 之前),再登记新 idx。
146
+ this.stubPriorReads(history, path, idx);
147
+ // 登记新 idx
148
+ const list = this.readByPath.get(path);
149
+ if (list)
150
+ list.push(idx);
151
+ else
152
+ this.readByPath.set(path, [idx]);
153
+ }
154
+ catch {
155
+ /* 永不抛错 */
156
+ }
157
+ }
158
+ /**
159
+ * 把该 mutation path 的"在 mutation 之前的" read 全部 stub。
160
+ * 通常用于 edit_file / write_file 工具:mutation 之后,之前的 read_file(p) 内容
161
+ * 已失效(已不再是文件当前状态),模型后续若依赖旧 read 来 edit_file 会失败,但 edit_file
162
+ * 的 old_string 来自模型记忆/后读,不依赖旧 read 结果文本。
163
+ *
164
+ * 调用时机:agent/core.ts 在 mutation 工具调用的 pushToolResult 之后立即调;
165
+ * 此时 history 末尾就是 mutation 的 tool 消息,prior reads 指 < idx。
166
+ */
167
+ observeMutation(history, path) {
168
+ try {
169
+ if (!path)
170
+ return;
171
+ const idx = history.length - 1;
172
+ if (idx < 1)
173
+ return;
174
+ // mutation 之前的所有同 path read → stub
175
+ this.stubPriorReads(history, path, idx);
176
+ // 该 path 的 read 索引全部作废(mutation 之后再 read 会重新登记)
177
+ this.readByPath.delete(path);
178
+ }
179
+ catch {
180
+ /* 永不抛错 */
181
+ }
182
+ }
183
+ /**
184
+ * 把 history 里 "path 同 + index < beforeIdx + 不在当前轮保护区" 的所有 read_file
185
+ * tool 消息替换为存根(只改 .content,不动 id / 数组结构)。
186
+ *
187
+ * 实现:
188
+ * - 用 readByPath[path] 直接拿到所有 index(已登记过),筛 < beforeIdx 的 stub。
189
+ * - 同时扫一遍 [1, beforeIdx) 区间找未登记的(防御:索引可能漏登;不依赖索引也能 stub,
190
+ * 保证正确性。索引只用于"避免重复扫全表"的优化)。
191
+ * - protectedFrom = lastUserIndex(history):user 之后一律不动。
192
+ * - 幂等:已是 STUB_PREFIX 的跳过。
193
+ */
194
+ stubPriorReads(history, path, beforeIdx) {
195
+ const STUB_PREFIX_LOCAL = STUB_PREFIX;
196
+ const guard = lastUserIndex(history);
197
+ // protectedFrom = 最后一个 user index(若 >0);user 之后(>= guard)的 read 永不动。
198
+ // protectedFrom=0 表示无 user(history 只有 system),整段都可 stub。
199
+ const protectedFrom = guard > 0 ? guard : 0;
200
+ const stubOne = (i) => {
201
+ if (i >= beforeIdx)
202
+ return;
203
+ if (i >= protectedFrom && protectedFrom > 0)
204
+ return; // 当前轮保护区
205
+ const m = history[i];
206
+ if (!m || m.role !== 'tool')
207
+ return;
208
+ const content = toText(m.content);
209
+ if (content.startsWith(STUB_PREFIX_LOCAL))
210
+ return; // 幂等
211
+ const name = toolNameOf(history, i);
212
+ if (name !== 'read_file')
213
+ return;
214
+ // 校验 tool_call_id 配对(防御:孤儿子消息不动)
215
+ const tcId = m.tool_call_id;
216
+ if (!tcId)
217
+ return;
218
+ const stub = `${STUB_PREFIX_LOCAL} read_file(${path}) ${content.length} 字符 → 已被新 read / mutation 替代 · id …${tcId.slice(-6)}⌫`;
219
+ m.content = stub;
220
+ };
221
+ // 1) 用 Map 索引(快路径)
222
+ const indexed = this.readByPath.get(path);
223
+ if (indexed) {
224
+ for (const i of indexed)
225
+ stubOne(i);
226
+ }
227
+ // 2) 全表扫一遍(防御:索引可能漏登 / 历史来自 resume)
228
+ // 仅扫 [1, beforeIdx) 且不在保护区内的 range,成本可控。
229
+ const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
230
+ for (let i = 1; i < scanEnd; i++) {
231
+ stubOne(i);
232
+ }
233
+ }
234
+ }
235
+ /** 默认单例:每个 agent 循环一个。runAgentCore 入口 new 一个,后续 observe 共享。 */
236
+ export function createRelevancePruner() {
237
+ return new RelevancePruner();
238
+ }
239
+ /** 解析一条 stub 字符串,提取原 content 长度(若可解析)。失败返 null。 */
240
+ function parseStubOriginalLen(stub) {
241
+ // 格式:⌦[已过时:同 path 已有新 read / 已被 mutation 覆写] read_file(<path>) <N> 字符 → ...
242
+ const m = / read_file\([^)]+\) (\d+) 字符 /.exec(stub);
243
+ if (!m)
244
+ return null;
245
+ const n = Number(m[1]);
246
+ return Number.isFinite(n) && n >= 0 ? n : null;
247
+ }
248
+ /**
249
+ * 扫 history,统计被相关性裁剪 stub 的 read_file tool 消息(条数 + 原字节数)。
250
+ * 供 /context 渲染统计行用(让用户直观看到「prune 帮了多少」)。
251
+ * 永不抛错(对齐本模块契约);history 为空 / 无 stub 时返零值。
252
+ *
253
+ * 注意:stub 后只剩 stub 字符串(原 content 已丢失),故只能从 stub 字符串里 parse
254
+ * 原字节数,误差 = stub 时记录的 content.length(精确);token 估算走 estimateTokens。
255
+ */
256
+ export function computePruneStats(history) {
257
+ let stubbed = 0;
258
+ let originalChars = 0;
259
+ let stubChars = 0;
260
+ for (const m of history) {
261
+ if (m.role !== 'tool')
262
+ continue;
263
+ const c = toText(m.content);
264
+ if (!c.startsWith(STUB_PREFIX))
265
+ continue;
266
+ stubbed++;
267
+ stubChars += c.length;
268
+ const orig = parseStubOriginalLen(c);
269
+ if (orig != null)
270
+ originalChars += orig;
271
+ }
272
+ // token 估算:用 estimateTokens(懒导入,避免循环依赖 llm)
273
+ // 这里偷懒:走粗略 chars/4(中文混合下会过估,安全侧)
274
+ // 准确应调 estimateTokens,但 /context 已经是粗算,误差可接受
275
+ const originalTokens = Math.ceil(originalChars / 4);
276
+ const stubTokens = Math.ceil(stubChars / 4);
277
+ return {
278
+ stubbed,
279
+ originalChars,
280
+ originalTokens,
281
+ stubChars,
282
+ freedTokens: Math.max(0, originalTokens - stubTokens),
283
+ };
284
+ }
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';
@@ -16,7 +16,7 @@ import { promptIntervention } from '../ui/intervention.js';
16
16
  import { tools } from '../tools/registry.js';
17
17
  import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
18
18
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
19
- import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
19
+ import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
20
20
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
21
21
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
22
22
  import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.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()));
@@ -867,16 +886,69 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
867
886
  continue;
868
887
  }
869
888
  if (line === '/compact' || line.startsWith('/compact ')) {
870
- const focus = line.startsWith('/compact ')
871
- ? line.slice('/compact '.length).trim()
872
- : undefined;
873
- const r = await compactHistory(history, {
874
- window: config.contextWindowTokens,
875
- threshold: config.compactThreshold,
876
- focus,
877
- });
878
- if (r.reason === 'noop') {
879
- layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
889
+ // /compact 可选语法:/compact [focus] 或 /compact --force [focus]
890
+ // --force:即便 oldGroups 空(history 全在保护区)也强行把早期消息降级压一次。
891
+ const rest = line.slice('/compact'.length).trim();
892
+ let force = false;
893
+ let focus;
894
+ if (rest === '--force')
895
+ force = true;
896
+ else if (rest.startsWith('--force ')) {
897
+ force = true;
898
+ focus = rest.slice('--force '.length).trim() || undefined;
899
+ }
900
+ else if (rest)
901
+ focus = rest;
902
+ // 走调度器路径:与自动每步压缩完全一致——五区按 ROI 压(cold tools 优先 → history 摘要最后)。
903
+ // focus 透传到 compact_history action 的 LLM 摘要 prompt。
904
+ // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
905
+ const log = await manualCompact(history, focus, { force });
906
+ const d = log.compactDetail;
907
+ if (!d) {
908
+ // 兜底(旧调用):只显示 old 文案
909
+ if (!log.compactHistoryCalled) {
910
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
911
+ }
912
+ else if (focus) {
913
+ layout.contentWrite(`${ui.dim}(带焦点压缩:${focus})${ui.reset}\n`);
914
+ }
915
+ continue;
916
+ }
917
+ // 详细文案:按 reason 分类
918
+ const reason = d.reason;
919
+ const before = d.estimateBefore;
920
+ const after = d.estimateAfter;
921
+ const proto = d.protectedRatio !== undefined ? `保护区占比 ${(d.protectedRatio * 100).toFixed(0)}%` : '';
922
+ const oldCt = d.oldGroupCount !== undefined ? `旧区组数 ${d.oldGroupCount}` : '';
923
+ const focusNote = focus ? `焦点:${focus}` : '';
924
+ const stats = [proto, oldCt].filter(Boolean).join(' · ');
925
+ if (reason === 'microcompact') {
926
+ layout.contentWrite(`${ui.cyan}✓ 微压缩:${ui.reset} ${before} → ${after} tokens${stats ? ` (${ui.dim}${stats}${ui.reset})` : ''}\n`);
927
+ }
928
+ else if (reason === 'summarize') {
929
+ layout.contentWrite(`${ui.cyan}✓ LLM 摘要:${ui.reset} ${before} → ${after} tokens${focusNote ? ` (${ui.dim}${focusNote}${ui.reset})` : ''}\n`);
930
+ }
931
+ else if (reason === 'noop-empty') {
932
+ layout.contentWrite(`${ui.dim}(history 太短,只有 system 提示,无可压旧区)${ui.reset}\n`);
933
+ }
934
+ else if (reason === 'noop-protected') {
935
+ layout.contentWrite(`${ui.dim}(无可压旧区:全部在保护区 system + 当前轮)${ui.reset}${stats ? ` ${ui.dim}(${stats})${ui.reset}` : ''}\n`);
936
+ layout.contentWrite(`${ui.dim}提示:/compact --force 强行把早期对话压成摘要${ui.reset}\n`);
937
+ }
938
+ else if (reason === 'noop-ml-only') {
939
+ layout.contentWrite(`${ui.dim}(LLM 摘要失败,且无超大单条可微压;可能是后端不可用)${ui.reset}\n`);
940
+ layout.contentWrite(`${ui.dim}回退:只跑了 keep-current 结构,history 未变${ui.reset}\n`);
941
+ }
942
+ else if (reason === 'noop-shrunk-too-large') {
943
+ layout.contentWrite(`${ui.yellow}● 上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
944
+ if (stats)
945
+ layout.contentWrite(`${ui.dim}(${stats})${ui.reset}\n`);
946
+ }
947
+ else if (reason === 'noop-noold-noop') {
948
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息,且不在手动触发)${ui.reset}\n`);
949
+ }
950
+ else {
951
+ layout.contentWrite(`${ui.dim}(reason=${reason},${before} → ${after} tokens)${ui.reset}\n`);
880
952
  }
881
953
  continue;
882
954
  }
@@ -918,6 +990,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
918
990
  if (!loadSnapshots(loaded.id))
919
991
  rebuildFromHistory(history);
920
992
  contextState.lastUsage = undefined;
993
+ lastTurnUsage = undefined; // /resume:旧会话的 token 累计已无意义,清空等下轮覆写
921
994
  layout.clearContent();
922
995
  renderHistory(history);
923
996
  layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
@@ -1153,6 +1226,78 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1153
1226
  await rollbackFlow();
1154
1227
  continue;
1155
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
+ }
1156
1301
  const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
1157
1302
  const ok = await runTurn(joined, initialPlan, placeholder);
1158
1303
  // plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定: