mocode-ai 0.6.2 → 0.6.4

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.
@@ -14,6 +14,7 @@ import { maybeCompact, contextState, dropContextFromHistory } from '../session/i
14
14
  import { createBudgetScheduler } from '../session/scheduler.js';
15
15
  import { optimizeToolResult } from '../context/index.js';
16
16
  import { createRelevancePruner } from '../context/relevance.js';
17
+ import { isToolResultSuccess } from '../context/utils.js';
17
18
  import { config } from '../config/index.js';
18
19
  import { jailResolve } from '../sandbox/index.js';
19
20
  import { createLifecycleEngine } from '../context/lifecycle.js';
@@ -103,7 +104,7 @@ function readDiffContext(tc, parsed) {
103
104
  * - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
104
105
  * 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
105
106
  * - 开关关闭时 lifecycle=null 完全跳过。 */
106
- function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
107
+ function pushToolResult(history, tc, output, pruner, lifecycle, scheduler, runtimeContextState = contextState) {
107
108
  const msg = {
108
109
  role: 'tool',
109
110
  tool_call_id: tc.id,
@@ -112,14 +113,13 @@ function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
112
113
  content: optimizeToolResult(tc.name, output, tc.arguments),
113
114
  };
114
115
  history.push(msg);
115
- // 相关性裁剪:只动 read_file / edit_file / write_file 三类(其它 tool 与本层无关)
116
- // pruner 内部 try/catch + 幂等,永不抛错;开关关闭时 pruner=null 完全跳过。
116
+ const succeeded = isToolResultSuccess(output);
117
+ // 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
117
118
  if (pruner)
118
- pruner.observePush(history, msg);
119
- // 观察者生命周期:新 push 一律先登记 LIVE;内部自动维护 producer/consumer 图 + 老化 STUB。
120
- // lifecycle 内部 try/catch + 幂等;开关关闭时 lifecycle=null 完全跳过。
119
+ pruner.observePush(history, msg, succeeded);
121
120
  if (lifecycle)
122
- lifecycle.pushTool(history, history.length - 1);
121
+ lifecycle.pushTool(history, history.length - 1, succeeded);
122
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
123
123
  }
124
124
  /**
125
125
  * agent 核心循环(纯逻辑):
@@ -186,7 +186,12 @@ export async function runAgentCore(opts) {
186
186
  // 观察者生命周期引擎:每个 runAgentCore 实例一个,纯静态、自动维护 grep/glob/codegraph 等
187
187
  // producer 与 read/edit/write 的 consumer 引用关系;孤立+老化的非观察类工具自动 STUB。
188
188
  // 开关关闭时为 null,所有 pushToolResult / mutation 调用走无 lifecycle 路径(零行为变化)。
189
- const lifecycle = config.contextLifecycle ? createLifecycleEngine() : null;
189
+ // 引擎需要从已有会话 history 恢复观察结果的年龄和 path 索引;不能只追踪本次
190
+ // runAgentCore,否则跨用户轮次的 grep/glob 永远不会衰减。
191
+ let lifecycle = config.contextLifecycle
192
+ ? createLifecycleEngine(history)
193
+ : null;
194
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
190
195
  // 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
191
196
  // 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
192
197
  const scheduler = config.contextBudget !== false
@@ -234,11 +239,19 @@ export async function runAgentCore(opts) {
234
239
  // 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
235
240
  // 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
236
241
  // 此时 spinner 已停,通知行干净。
242
+ let historyRebuilt = false;
237
243
  if (scheduler) {
238
- await scheduler.runStep(history, step);
244
+ historyRebuilt = await scheduler.runStep(history, step);
239
245
  }
240
246
  else {
241
- await maybeCompact(history, undefined, undefined, runtimeContextState);
247
+ const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState);
248
+ historyRebuilt = compactResult?.historyRebuilt === true;
249
+ }
250
+ // compact 用新消息数组原地重建 history 后,旧 lifecycle 的数字 index 已全部失效。
251
+ // 立即从新 history 恢复状态和 producer 索引,再允许后续 pushTool/pushMutation 使用。
252
+ if (historyRebuilt && lifecycle) {
253
+ lifecycle = createLifecycleEngine(history);
254
+ runtimeContextState.lifecycleStats = lifecycle.stats();
242
255
  }
243
256
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
244
257
  mode = 'idle';
@@ -438,16 +451,13 @@ export async function runAgentCore(opts) {
438
451
  // Thrashing:同上(history 附 hint,UI 干净)
439
452
  const hint = recordAndHint(tc.name, tc.arguments);
440
453
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
441
- // 相关性裁剪 mutation 通知:edit_file/write_file 后,该 path 之前的所有 read_file
442
- // 结果已失效(已不再是文件当前状态) stub 为存根。pruner 内部 try/catch + 幂等。
443
- // 非 mutation 工具(run_command/use_skill/memory_* 等)此处 path="" 不触发。
444
- // 观察者生命周期:mutation push 后通知 lifecycle 把同 path 的旧 read 标 REFERENCED。
445
- if (relprune && isMutationTool(tc.name)) {
454
+ // 只有成功 mutation 才会使旧 read 失效;pruner lifecycle 独立启停。
455
+ if (isMutationTool(tc.name) && isToolResultSuccess(output)) {
446
456
  const mp = mutationParsed?.path;
447
457
  if (typeof mp === 'string' && mp) {
448
- relprune.observeMutation(history, mp);
449
- if (lifecycle)
450
- lifecycle.pushMutation(history, history.length - 1, mp);
458
+ relprune?.observeMutation(history, mp);
459
+ lifecycle?.pushMutation(history, history.length - 1, mp);
460
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
451
461
  }
452
462
  }
453
463
  i++;
@@ -9,9 +9,11 @@
9
9
  // - OBSOLETE:无任何消费者引用,且距离当前 push 已老化 N 步(默认 2)。
10
10
  // - STUB:已被替换为存根(物理上 content 变成「⌦[无消费者:...]」)。
11
11
  //
12
- // 用户拍板的激进风险护栏(避免误伤):
13
- // - grep/glob/codegraph/web_search/web_fetch 等「观察/检索类」工具,**永远只到 REFERENCED**,
14
- // 不参与自动 STUB。理由:返回多个候选(grep 10 文件但你只读 1 ),剩余候选可能后续被消费。
12
+ // 观察类工具两阶段衰减(避免误伤):
13
+ // - grep/glob/codegraph/web_search/web_fetch 等「观察/检索类」工具两阶段衰减:
14
+ // Phase 1(10 步):LIVE REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)
15
+ // Phase 2(+5 步):REFERENCED → DIGEST,替换为摘要存根(保留文件列表+命中数+参数,丢弃详情),
16
+ // 释放 ~90% token。states 仍为 REFERENCED,不引入新状态。
15
17
  // - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
16
18
  // - 已 STUB(含「⌦[已过时:...]」或「⌦[已剔除:...]」或本层的「⌦[无消费者:...]」)不重复处理。
17
19
  // - 永不抛错(对齐上下文管道的「调度器永不抛错」契约)。
@@ -19,15 +21,16 @@
19
21
  //
20
22
  // 与 Relevance Pruner 的分工(不重复):
21
23
  // - Relevance Pruner:管 read_file 同 path 旧 read + mutation 覆写 → 直接 STUB。
22
- // - 本层:管「无消费者的观察类工具老化后STUB」 + 「被消费的工具 REFERENCED 标记(可视)」。
24
+ // - 本层:管「观察类工具老化后DIGEST、普通孤立工具老化后STUB」+
25
+ // 「被消费的工具 → REFERENCED 标记(可视)」。
23
26
  //
24
27
  // 触发点(agent/core.ts):
25
28
  // - pushToolResult 出口,新 message idx = history.length - 1。
26
29
  // - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
27
30
  //
28
31
  // 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
29
- import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
30
- /** 观察类工具(永远只到 REFERENCED,不参与自动 STUB)。 */
32
+ import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, toolNameOf, } from './utils.js';
33
+ /** 观察类工具(LIVE REFERENCED → DIGEST 两阶段衰减,永不自动 STUB)。 */
31
34
  const OBSERVER_TOOLS = new Set([
32
35
  'grep',
33
36
  'glob',
@@ -42,10 +45,19 @@ const CONSUMER_TOOLS = new Set(['read_file', 'edit_file', 'write_file']);
42
45
  const MUTATION_TOOLS = new Set(['edit_file', 'write_file']);
43
46
  /** 存根前缀(区分 Relevance Pruner 与 drop_context)。 */
44
47
  const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值]';
48
+ /** 观察类工具摘要前缀(DIGEST 状态标记)。 */
49
+ const DIGEST_PREFIX = '⌦[摘要:';
45
50
  /** 老化阈值:某条工具消息自 push 以来经历的「消费者 push」次数。
46
51
  * ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
47
52
  * 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
48
53
  const DEFAULT_AGE_THRESHOLD = 2;
54
+ /** 观察类工具 Phase 1:LIVE → REFERENCED 的老化阈值(普通工具用 DEFAULT_AGE_THRESHOLD=2)。
55
+ * 10 步后才降为 REFERENCED,保留完整内容;理由:返回多个候选(grep 10 文件但只读 1 个),
56
+ * 剩余候选可能后续被消费,给足够时间窗口。 */
57
+ const OBSERVER_REFERENCED_AGE = 10;
58
+ /** 观察类工具 Phase 2:REFERENCED → DIGEST 的老化阈值(从 REFERENCED 起再累积)。
59
+ * +5 步后替换为摘要存根,保留文件列表+命中数+参数,丢弃详情;释放 ~90% token。 */
60
+ const OBSERVER_DIGEST_AGE = 5;
49
61
  /** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
50
62
  * - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
51
63
  * - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
@@ -58,14 +70,22 @@ function extractProducerPaths(toolName, content) {
58
70
  if (!content)
59
71
  return [];
60
72
  const out = new Set();
73
+ const addPath = (raw) => {
74
+ const canonical = canonicalizePath(raw);
75
+ if (canonical)
76
+ out.add(canonical);
77
+ };
61
78
  try {
62
79
  if (toolName === 'grep') {
63
- // 典型行:`src/foo.ts:42: hello world` 或 `path\to\file.ts:42: ...`
64
- // 取冒号前段(冒号必须跟在数字前面避免切到路径里的冒号)。
65
- const re = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/gm;
80
+ // grep 内置工具的结构化输出是 `path: N 处匹配,行号 [...]`;旧格式也可能是
81
+ // `path:line: content`。两者都必须建立 producer 索引,供后续 read_file 关联。
82
+ const rawLine = /^(.+?\.[A-Za-z0-9]+):\d+:/gm;
83
+ const summaryHeader = /^(.+?\.[A-Za-z0-9]+):\s*\d+\s*(?:处匹配|matches?)[,,]/gmi;
66
84
  let m;
67
- while ((m = re.exec(content)))
68
- out.add(m[1]);
85
+ while ((m = rawLine.exec(content)))
86
+ addPath(m[1]);
87
+ while ((m = summaryHeader.exec(content)))
88
+ addPath(m[1]);
69
89
  }
70
90
  else if (toolName === 'glob') {
71
91
  // glob 输出一般是「paths:」+ 换行 + 多路径;每行一个绝对或相对路径。
@@ -76,7 +96,7 @@ function extractProducerPaths(toolName, content) {
76
96
  continue;
77
97
  // 含扩展名或含路径分隔符
78
98
  if (/\.[A-Za-z0-9]+$/.test(t) || t.includes('/') || t.includes('\\'))
79
- out.add(t);
99
+ addPath(t);
80
100
  }
81
101
  }
82
102
  else if (toolName === 'codegraph') {
@@ -84,7 +104,7 @@ function extractProducerPaths(toolName, content) {
84
104
  for (const line of content.split(/\r?\n/)) {
85
105
  const m = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/.exec(line);
86
106
  if (m)
87
- out.add(m[1]);
107
+ addPath(m[1]);
88
108
  }
89
109
  }
90
110
  else if (toolName === 'web_search' || toolName === 'web_fetch') {
@@ -113,17 +133,114 @@ export class LifecycleEngine {
113
133
  /** producer 路径 → 生产者工具消息 idx 列表(逆查用:某个 path 被消费时,反查上游 producer)。
114
134
  * 注意:不存 read_file,因为 read 自身就是消费者不充当 producer。 */
115
135
  producersByPath = new Map();
136
+ /** 已被 DIGEST 的 tool 消息 idx 集合(避免重复检查;states 保持 REFERENCED)。 */
137
+ digestedIdxs = new Set();
138
+ /** 摘要不比原文短而保留原文的 idx;仅抑制当前 run 内的重复计算。 */
139
+ digestRetainedIdxs = new Set();
116
140
  /** 当前步序号(用于 age 老化:每次 pushTool 自增,对比 age 阈值)。 */
117
141
  step = 0;
118
142
  /** 最后 user 索引缓存(pushTool 时重算;pushMutation 时也重算,因为 mutation 可能跟 user 同行)。 */
119
143
  lastUser = -1;
120
- constructor(ageThreshold = DEFAULT_AGE_THRESHOLD) {
144
+ constructor(ageThreshold = DEFAULT_AGE_THRESHOLD, history) {
121
145
  this.ageThreshold = ageThreshold;
146
+ if (history)
147
+ this.rehydrate(history);
148
+ }
149
+ /**
150
+ * 从会话历史恢复生命周期状态。
151
+ *
152
+ * 旧轮只恢复 observer,使检索结果能跨轮继续老化;最后一个 user 之后则恢复全部成功、
153
+ * 未归档的工具结果,保证同一 runAgentCore 内 compact 重建 history 后不丢当前轮状态。
154
+ */
155
+ rehydrate(history) {
156
+ try {
157
+ const currentTurnStart = lastUserIndex(history);
158
+ let replayLastUser = -1;
159
+ const pendingDigests = new Set();
160
+ for (let idx = 1; idx < history.length; idx++) {
161
+ const m = history[idx];
162
+ if (m.role === 'user') {
163
+ replayLastUser = idx;
164
+ continue;
165
+ }
166
+ if (m.role !== 'tool')
167
+ continue;
168
+ const toolName = toolNameOf(history, idx);
169
+ if (!toolName)
170
+ continue;
171
+ const content = toText(m.content);
172
+ const isObserver = OBSERVER_TOOLS.has(toolName);
173
+ const isDigest = content.startsWith(DIGEST_PREFIX);
174
+ const isCurrentTurnTool = currentTurnStart >= 0 && idx > currentTurnStart;
175
+ const shouldRestoreLive = isToolResultSuccess(content) &&
176
+ !content.startsWith('⌦[') &&
177
+ (isObserver || isCurrentTurnTool);
178
+ if (shouldRestoreLive) {
179
+ this.states.set(idx, 'LIVE');
180
+ this.consumerCount.set(idx, 0);
181
+ this.age.set(idx, 0);
182
+ // 只有 observer 是 producer;当前轮普通工具只恢复自身状态。
183
+ if (isObserver) {
184
+ for (const path of extractProducerPaths(toolName, content)) {
185
+ const producers = this.producersByPath.get(path) ?? [];
186
+ producers.push(idx);
187
+ this.producersByPath.set(path, producers);
188
+ }
189
+ }
190
+ }
191
+ else if (isObserver && isDigest) {
192
+ this.states.set(idx, 'REFERENCED');
193
+ this.consumerCount.set(idx, 0);
194
+ this.age.set(idx, 0);
195
+ this.digestedIdxs.add(idx);
196
+ }
197
+ if (isToolResultSuccess(content) && CONSUMER_TOOLS.has(toolName)) {
198
+ const path = canonicalizePath(extractPath(this.findToolArgs(history, idx)));
199
+ if (path) {
200
+ for (const producerIdx of this.producersByPath.get(path) ?? []) {
201
+ if (this.states.get(producerIdx) === 'LIVE') {
202
+ this.states.set(producerIdx, 'REFERENCED');
203
+ }
204
+ this.consumerCount.set(producerIdx, (this.consumerCount.get(producerIdx) ?? 0) + 1);
205
+ }
206
+ }
207
+ }
208
+ for (const key of this.states.keys())
209
+ this.age.set(key, (this.age.get(key) ?? 0) + 1);
210
+ this.step++;
211
+ // 和 pushTool 一致:本轮 user 及之后的结果不处理;旧轮结果可以推进。
212
+ for (const [observerIdx, state] of this.states) {
213
+ if (observerIdx >= replayLastUser || this.isDigestFinalized(observerIdx))
214
+ continue;
215
+ const observerName = toolNameOf(history, observerIdx);
216
+ if (!observerName || !OBSERVER_TOOLS.has(observerName))
217
+ continue;
218
+ const age = this.age.get(observerIdx) ?? 0;
219
+ if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
220
+ this.states.set(observerIdx, 'REFERENCED');
221
+ }
222
+ else if (state === 'REFERENCED'
223
+ && age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
224
+ pendingDigests.add(observerIdx);
225
+ }
226
+ }
227
+ }
228
+ this.lastUser = lastUserIndex(history);
229
+ // 回放期间只计算状态;最后统一落盘,避免迭代时破坏 path 提取。
230
+ for (const idx of pendingDigests) {
231
+ const toolName = toolNameOf(history, idx);
232
+ if (toolName && idx < Math.max(0, this.lastUser))
233
+ this.digestOne(history, idx, toolName);
234
+ }
235
+ }
236
+ catch {
237
+ // 历史中出现非标准消息时降级为本轮行为,不能影响 agent 主循环。
238
+ }
122
239
  }
123
240
  /** 新工具结果 push 进 history 时调;idx = history.length - 1。
124
241
  * mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
125
242
  * pushMutation 标完 read REFERENCED 之后再触发),避免刚被 mutation 消费的 read 被提前 STUB。 */
126
- pushTool(history, idx) {
243
+ pushTool(history, idx, succeeded = true) {
127
244
  try {
128
245
  const m = history[idx];
129
246
  if (!m || m.role !== 'tool')
@@ -131,6 +248,9 @@ export class LifecycleEngine {
131
248
  const toolName = toolNameOf(history, idx);
132
249
  if (!toolName)
133
250
  return;
251
+ // 失败工具结果只保留给模型诊断,不登记为 observation、不会推动老化或污染统计。
252
+ if (!succeeded)
253
+ return;
134
254
  // 已 stub 的不重复登记(幂等)。
135
255
  const c = toText(m.content);
136
256
  if (c.startsWith('⌦['))
@@ -139,8 +259,8 @@ export class LifecycleEngine {
139
259
  this.states.set(idx, 'LIVE');
140
260
  this.consumerCount.set(idx, 0);
141
261
  this.age.set(idx, 0);
142
- // 如果是 producer 类工具(grep/glob/codegraph),登记其命中的路径。
143
- if (OBSERVER_TOOLS.has(toolName)) {
262
+ // 成功的 producer 才建立路径索引;失败结果仍可进入生命周期,但不能成为证据来源。
263
+ if (succeeded && OBSERVER_TOOLS.has(toolName)) {
144
264
  const paths = extractProducerPaths(toolName, c);
145
265
  for (const p of paths) {
146
266
  const arr = this.producersByPath.get(p) ?? [];
@@ -149,8 +269,8 @@ export class LifecycleEngine {
149
269
  this.producersByPath.set(p, arr);
150
270
  }
151
271
  }
152
- // 如果是 consumer 类工具(read/edit/write),找出上游「被消费的 producer」,标 REFERENCED。
153
- if (CONSUMER_TOOLS.has(toolName)) {
272
+ // 只有成功 consumer 才能消费上游;失败 read/edit/write 不改变旧数据状态。
273
+ if (succeeded && CONSUMER_TOOLS.has(toolName)) {
154
274
  const argsRaw = (() => {
155
275
  // tool 消息本身没有 args;args 在前导 assistant.tool_calls 里;直接走同 idx 前的 assistant。
156
276
  const tcId = m.tool_call_id;
@@ -165,7 +285,7 @@ export class LifecycleEngine {
165
285
  }
166
286
  return '';
167
287
  })();
168
- const path = extractPath(argsRaw);
288
+ const path = canonicalizePath(extractPath(argsRaw));
169
289
  if (path) {
170
290
  // 1) 找该 path 的所有上游 producer(grep/glob/codegraph)→ 标 REFERENCED。
171
291
  const producers = this.producersByPath.get(path);
@@ -187,8 +307,7 @@ export class LifecycleEngine {
187
307
  }
188
308
  this.step++;
189
309
  this.lastUser = lastUserIndex(history);
190
- // mutation 工具跳过本轮 autoStubOrphans:调用方会调 pushMutation 标完 read REFERENCED 后,
191
- // 再调 flushAutoStub 触发老化检查,避免 read 在被标 REFERENCED 之前被提前 STUB。
310
+ // 成功 mutation 跳过本轮 autoStubOrphans;调用方会在 pushMutation 标完旧 read 后统一检查。
192
311
  if (MUTATION_TOOLS.has(toolName))
193
312
  return;
194
313
  // 老化检查:本次 push 完,扫描 LIVE(且非观察类)的工具消息,age ≥ 阈值 → 标 OBSOLETE → STUB。
@@ -203,6 +322,11 @@ export class LifecycleEngine {
203
322
  * 注意:puhToolResult 出口已经登记过 mutation 本身,这里不再调 pushTool(避免 age 翻倍)。 */
204
323
  pushMutation(history, mutationIdx, path) {
205
324
  try {
325
+ const mutationPath = canonicalizePath(path);
326
+ if (!mutationPath) {
327
+ this.autoStubOrphans(history);
328
+ return;
329
+ }
206
330
  // mutation 自身已在 pushToolResult 出口登记(若 lifecycle 存在);此处仅做「mutation 是
207
331
  // path 的消费者」语义:把该 path 在 mutation 之前的所有 read_file(未被 stub 的 LIVE/REFERENCED)
208
332
  // 标 REFERENCED。
@@ -220,7 +344,7 @@ export class LifecycleEngine {
220
344
  if (c.startsWith('⌦['))
221
345
  continue;
222
346
  const argsRaw = this.findToolArgs(history, i);
223
- if (extractPath(argsRaw) === path) {
347
+ if (canonicalizePath(extractPath(argsRaw)) === mutationPath) {
224
348
  if (this.states.get(i) === 'LIVE')
225
349
  this.states.set(i, 'REFERENCED');
226
350
  this.consumerCount.set(i, (this.consumerCount.get(i) ?? 0) + 1);
@@ -233,27 +357,44 @@ export class LifecycleEngine {
233
357
  // 永不抛错。
234
358
  }
235
359
  }
236
- /** 老化自动 STUB:扫描所有 LIVE(且非观察类)且 age ≥ 阈值且不在保护区的工具消息 → OBSOLETE → STUB。 */
360
+ /** 老化自动处理:
361
+ * - 非观察类 LIVE 且 age ≥ 阈值 → OBSOLETE → STUB(完全丢弃)。
362
+ * - 观察类工具两阶段衰减:
363
+ * Phase 1: LIVE → REFERENCED(age ≥ OBSERVER_REFERENCED_AGE,保留完整内容)。
364
+ * Phase 2: REFERENCED → DIGEST(增量 age ≥ OBSERVER_DIGEST_AGE,替换为摘要存根)。
365
+ * - 当前轮保护区(最后一个 user 之后)完全不动。
366
+ */
237
367
  autoStubOrphans(history) {
238
368
  try {
239
369
  const protectedFrom = Math.max(0, this.lastUser);
240
370
  for (const [idx, state] of this.states) {
241
- if (state !== 'LIVE')
242
- continue;
243
371
  if (idx >= protectedFrom)
244
372
  continue; // 当前轮保护区
245
373
  const age = this.age.get(idx) ?? 0;
246
- if (age < this.ageThreshold)
247
- continue;
248
374
  const tn = toolNameOf(history, idx);
249
375
  if (!tn)
250
376
  continue;
251
- // 观察类工具永远只到 REFERENCED,不自动 STUB(用户拍板)。
252
377
  if (OBSERVER_TOOLS.has(tn)) {
253
- this.states.set(idx, 'REFERENCED');
378
+ // Phase 1: LIVE → REFERENCED(保留完整内容)
379
+ if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
380
+ this.states.set(idx, 'REFERENCED');
381
+ continue;
382
+ }
383
+ // Phase 2: REFERENCED → DIGEST(替换为摘要存根,状态不变)。
384
+ if (state === 'REFERENCED' && !this.isDigestFinalized(idx)) {
385
+ // read_file 可能在很早时就把 producer 标成 REFERENCED;不能因此跳过
386
+ // Phase 1 的保留窗口。摘要始终以 observer 自身的总 age 为准。
387
+ if (age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
388
+ this.digestOne(history, idx, tn);
389
+ }
390
+ }
254
391
  continue;
255
392
  }
256
- // 执行 STUB。
393
+ // 非观察类:只在 LIVE 状态下老化 STUB。
394
+ if (state !== 'LIVE')
395
+ continue;
396
+ if (age < this.ageThreshold)
397
+ continue;
257
398
  this.stubOne(history, idx, tn);
258
399
  }
259
400
  }
@@ -279,6 +420,115 @@ export class LifecycleEngine {
279
420
  // 永不抛错。
280
421
  }
281
422
  }
423
+ /** 观察类工具 Phase 2:替换为摘要存根,保留文件列表+命中数+参数,丢弃详情。
424
+ * 幂等:已是 ⌦[ 前缀的跳过。states 保持 REFERENCED,只替换 content。 */
425
+ digestOne(history, idx, toolName) {
426
+ try {
427
+ const m = history[idx];
428
+ if (!m)
429
+ return;
430
+ const c = toText(m.content);
431
+ if (c.startsWith('⌦['))
432
+ return; // 幂等(已 STUB/DIGEST)
433
+ const origLen = c.length;
434
+ const argsRaw = this.findToolArgs(history, idx);
435
+ const summary = this.buildDigestSummary(toolName, c, argsRaw, origLen, extractProducerPaths(toolName, c));
436
+ // 摘要的唯一目标是降低上下文成本。短结果(如“未命中”)不能被固定文案放大。
437
+ if (summary.length >= origLen) {
438
+ this.digestRetainedIdxs.add(idx);
439
+ return;
440
+ }
441
+ m.content = summary;
442
+ // states 保持 REFERENCED;digestedIdxs 由 stats() 扣除显示。
443
+ this.digestedIdxs.add(idx);
444
+ }
445
+ catch {
446
+ // 永不抛错。
447
+ }
448
+ }
449
+ /** 为各观察类工具生成摘要字符串。只保留统计 + 参数,不保留详情(文件列表/URL/正文),
450
+ * 明确标注"这是历史摘要,需要最新信息请重新调用"。 */
451
+ buildDigestSummary(toolName, content, argsRaw, origLen, paths) {
452
+ const files = this.formatDigestPaths(paths);
453
+ switch (toolName) {
454
+ case 'grep': {
455
+ // 命中行数:content 里形如 `file:line:` 的行数
456
+ let lineCount = 0;
457
+ const re = /^[^\s:][^:]*?\.[A-Za-z0-9]+:\d+:/gm;
458
+ while (re.exec(content))
459
+ lineCount++;
460
+ const args = (() => { try {
461
+ const a = JSON.parse(argsRaw);
462
+ let s = `"${a.pattern ?? ''}"`;
463
+ if (a.glob)
464
+ s += `, glob="${a.glob}"`;
465
+ return s;
466
+ }
467
+ catch {
468
+ return '...';
469
+ } })();
470
+ return `${DIGEST_PREFIX}grep(${args}) — 历史结果 ${lineCount} 行命中,文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 grep`;
471
+ }
472
+ case 'glob': {
473
+ const args = (() => { try {
474
+ return `"${JSON.parse(argsRaw).pattern ?? ''}"`;
475
+ }
476
+ catch {
477
+ return '...';
478
+ } })();
479
+ return `${DIGEST_PREFIX}glob(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整列表或最新信息请重新调用 glob`;
480
+ }
481
+ case 'codegraph': {
482
+ const args = (() => { try {
483
+ const a = JSON.parse(argsRaw);
484
+ return `"${a.query ?? ''}"`;
485
+ }
486
+ catch {
487
+ return '...';
488
+ } })();
489
+ return `${DIGEST_PREFIX}codegraph(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 codegraph`;
490
+ }
491
+ case 'web_search': {
492
+ // 统计结果数
493
+ let resultCount = 0;
494
+ const lines = content.split(/\r?\n/);
495
+ for (const line of lines) {
496
+ if (/^\[\d+\]\s*.+/.test(line))
497
+ resultCount++;
498
+ }
499
+ const query = (() => { try {
500
+ return JSON.parse(argsRaw).query ?? '';
501
+ }
502
+ catch {
503
+ return '';
504
+ } })();
505
+ return `${DIGEST_PREFIX}web_search("${query}") — 历史结果 ${resultCount} 条, ${origLen}→摘要]\n如需最新信息请重新调用 web_search`;
506
+ }
507
+ case 'web_fetch': {
508
+ const url = (() => { try {
509
+ return JSON.parse(argsRaw).url ?? '';
510
+ }
511
+ catch {
512
+ return '';
513
+ } })();
514
+ return `${DIGEST_PREFIX}web_fetch(${url}) — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 web_fetch`;
515
+ }
516
+ default:
517
+ return `${DIGEST_PREFIX}${toolName} — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 ${toolName}`;
518
+ }
519
+ }
520
+ isDigestFinalized(idx) {
521
+ return this.digestedIdxs.has(idx) || this.digestRetainedIdxs.has(idx);
522
+ }
523
+ /** 文件候选是摘要的关键可用信息;限量且去重,避免摘要本身重新膨胀。 */
524
+ formatDigestPaths(paths) {
525
+ const unique = [...new Set(paths)];
526
+ if (unique.length === 0)
527
+ return '0 个(未能从输出解析路径)';
528
+ const limit = 12;
529
+ const shown = unique.slice(0, limit).join(', ');
530
+ return unique.length > limit ? `${unique.length} 个 [${shown}, …]` : `${unique.length} 个 [${shown}]`;
531
+ }
282
532
  /** 找某条 tool 消息对应的 assistant.tool_calls.arguments。 */
283
533
  findToolArgs(history, idx) {
284
534
  try {
@@ -305,7 +555,8 @@ export class LifecycleEngine {
305
555
  getState(idx) {
306
556
  return this.states.get(idx) ?? null;
307
557
  }
308
- /** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, obsolete=K, stubbed=S」。 */
558
+ /** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, digest=K, obsolete=J, stubbed=S」。
559
+ * digested:已被摘要的观察类工具(states 仍为 REFERENCED,但 content 已替换为摘要)。 */
309
560
  stats() {
310
561
  let live = 0;
311
562
  let referenced = 0;
@@ -321,10 +572,12 @@ export class LifecycleEngine {
321
572
  else if (s === 'STUB')
322
573
  stubbed++;
323
574
  }
324
- return { live, referenced, obsolete, stubbed };
575
+ // digested states 仍为 REFERENCED,从总 referenced 中扣除得到纯 REFERENCED 数。
576
+ return { live, referenced: referenced - this.digestedIdxs.size, digested: this.digestedIdxs.size, obsolete, stubbed };
325
577
  }
326
578
  }
327
- /** 默认单例工厂。runAgentCore 入口 new 一个,后续 pushTool / pushMutation 共享。 */
328
- export function createLifecycleEngine(ageThreshold) {
329
- return new LifecycleEngine(ageThreshold);
579
+ export function createLifecycleEngine(historyOrThreshold, ageThreshold) {
580
+ const history = Array.isArray(historyOrThreshold) ? historyOrThreshold : undefined;
581
+ const threshold = typeof historyOrThreshold === 'number' ? historyOrThreshold : ageThreshold;
582
+ return new LifecycleEngine(threshold, history);
330
583
  }
@@ -22,7 +22,7 @@
22
22
  // - TUI 渲染(hooks.onToolResult)用原始 output,与本层解耦——屏上看全量,LLM 看裁剪后版。
23
23
  //
24
24
  // 零行为变化兜底:开关 `config.contextRelprune` 关闭时,pipeline 路径完全不调本模块。
25
- import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
25
+ import { canonicalizePath, extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
26
26
  /** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
27
27
  * 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
28
28
  const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
@@ -40,50 +40,27 @@ export class RelevancePruner {
40
40
  /** path → [history index, ...] 按插入序;最新在末尾。 */
41
41
  readByPath = new Map();
42
42
  /** 把刚 push 的消息通知 pruner。
43
- * - 只处理 tool 消息(role==='tool')。
44
- * - 只关心 read_file:登记 + 反向 stub 同 path 旧 read。
45
- * - 非 read_file 的 tool 消息:无操作(本层只管 read_file)。
46
- * - 非 tool 消息(assistant / user / system):无操作。
43
+ * - 只处理成功的 read_file tool 消息;失败读取不能淘汰旧的有效结果。
44
+ * - 登记当前 canonical path,并反向 stub 同 path 旧 read。
47
45
  */
48
- observePush(history, msg) {
46
+ observePush(history, msg, succeeded = true) {
49
47
  try {
50
- if (msg.role !== 'tool')
48
+ if (!succeeded || msg.role !== 'tool')
51
49
  return;
52
50
  const m = msg;
53
- const tcId = m.tool_call_id;
54
- if (!tcId)
55
- return;
56
51
  const idx = history.length - 1;
57
52
  if (idx < 1 || history[idx] !== msg)
58
53
  return; // 防御:必须刚 push 到末尾
59
- const name = toolNameOf(history, idx);
60
- if (name !== 'read_file')
54
+ if (toolNameOf(history, idx) !== 'read_file')
61
55
  return;
62
56
  const content = toText(msg.content);
63
57
  if (content.startsWith(STUB_PREFIX))
64
58
  return; // 已是存根(防御)
65
- // 从前导 assistant.tool_calls 找对应 tc.arguments(精确 path 来源)
66
- // 退化方案:从消息内容首行解析路径(read_file 输出形如 `\n 1\t...`,无 path;
67
- // 故必须从 args 取)。找不到则保守不动。
68
- let path = null;
69
- for (let j = idx - 1; j >= 1; j--) {
70
- const mm = history[j];
71
- if (mm.role !== 'assistant')
72
- continue;
73
- const tcs = mm.tool_calls;
74
- if (!tcs)
75
- continue;
76
- const hit = tcs.find((tc) => tc?.id === tcId);
77
- if (hit) {
78
- path = extractPath(hit.function?.arguments);
79
- break;
80
- }
81
- }
59
+ const path = this.pathAt(history, idx);
82
60
  if (!path)
83
61
  return;
84
- // 先 stub 旧 read(同 path,idx 之前),再登记新 idx。
62
+ // 先 stub 旧 read(同 canonical path,idx 之前),再登记新 idx。
85
63
  this.stubPriorReads(history, path, idx);
86
- // 登记新 idx
87
64
  const list = this.readByPath.get(path);
88
65
  if (list)
89
66
  list.push(idx);
@@ -94,47 +71,47 @@ export class RelevancePruner {
94
71
  /* 永不抛错 */
95
72
  }
96
73
  }
97
- /**
98
- * 把该 mutation path 的"在 mutation 之前的" read 全部 stub。
99
- * 通常用于 edit_file / write_file 工具:mutation 之后,之前的 read_file(p) 内容
100
- * 已失效(已不再是文件当前状态),模型后续若依赖旧 read 来 edit_file 会失败,但 edit_file
101
- * 的 old_string 来自模型记忆/后读,不依赖旧 read 结果文本。
102
- *
103
- * 调用时机:agent/core.ts mutation 工具调用的 pushToolResult 之后立即调;
104
- * 此时 history 末尾就是 mutation 的 tool 消息,prior reads 指 < idx。
105
- */
74
+ /** 从 tool_call arguments 取得并规范化某条 read_file 的路径。 */
75
+ pathAt(history, idx) {
76
+ const tcId = history[idx]?.tool_call_id;
77
+ if (!tcId)
78
+ return null;
79
+ for (let j = idx - 1; j >= 1; j--) {
80
+ const mm = history[j];
81
+ if (mm.role !== 'assistant')
82
+ continue;
83
+ const tcs = mm.tool_calls;
84
+ const hit = tcs?.find((tc) => tc?.id === tcId);
85
+ if (hit)
86
+ return canonicalizePath(extractPath(hit.function?.arguments));
87
+ }
88
+ return null;
89
+ }
90
+ /** 成功 mutation 后,把该 canonical path 在 mutation 之前的 read 全部 stub。 */
106
91
  observeMutation(history, path) {
107
92
  try {
108
- if (!path)
93
+ const canonicalPath = canonicalizePath(path);
94
+ if (!canonicalPath)
109
95
  return;
110
96
  const idx = history.length - 1;
111
97
  if (idx < 1)
112
98
  return;
113
- // mutation 之前的所有同 path read → stub
114
- this.stubPriorReads(history, path, idx);
115
- // 该 path 的 read 索引全部作废(mutation 之后再 read 会重新登记)
116
- this.readByPath.delete(path);
99
+ this.stubPriorReads(history, canonicalPath, idx);
100
+ this.readByPath.delete(canonicalPath);
117
101
  }
118
102
  catch {
119
103
  /* 永不抛错 */
120
104
  }
121
105
  }
122
106
  /**
123
- * 把 history 里 "path 同 + index < beforeIdx + 不在当前轮保护区" 的所有 read_file
124
- * tool 消息替换为存根(只改 .content,不动 id / 数组结构)
125
- *
126
- * 实现:
127
- * - 用 readByPath[path] 直接拿到所有 index(已登记过),筛 < beforeIdx 的 stub。
128
- * - 同时扫一遍 [1, beforeIdx) 区间找未登记的(防御:索引可能漏登;不依赖索引也能 stub,
129
- * 保证正确性。索引只用于"避免重复扫全表"的优化)。
130
- * - protectedFrom = lastUserIndex(history):user 之后一律不动。
131
- * - 幂等:已是 STUB_PREFIX 的跳过。
107
+ * 把 history 里 "canonical path 同 + index < beforeIdx + 不在当前轮保护区" read_file
108
+ * tool 消息替换为存根。索引是快路径,全表扫描用于恢复 resume 历史;两条路径都重新校验 path
132
109
  */
133
110
  stubPriorReads(history, path, beforeIdx) {
134
- const STUB_PREFIX_LOCAL = STUB_PREFIX;
111
+ const targetPath = canonicalizePath(path);
112
+ if (!targetPath)
113
+ return;
135
114
  const guard = lastUserIndex(history);
136
- // protectedFrom = 最后一个 user index(若 >0);user 之后(>= guard)的 read 永不动。
137
- // protectedFrom=0 表示无 user(history 只有 system),整段都可 stub。
138
115
  const protectedFrom = guard > 0 ? guard : 0;
139
116
  const stubOne = (i) => {
140
117
  if (i >= beforeIdx)
@@ -145,30 +122,26 @@ export class RelevancePruner {
145
122
  if (!m || m.role !== 'tool')
146
123
  return;
147
124
  const content = toText(m.content);
148
- if (content.startsWith(STUB_PREFIX_LOCAL))
125
+ if (content.startsWith(STUB_PREFIX))
149
126
  return; // 幂等
150
- const name = toolNameOf(history, i);
151
- if (name !== 'read_file')
127
+ if (toolNameOf(history, i) !== 'read_file')
152
128
  return;
153
- // 校验 tool_call_id 配对(防御:孤儿子消息不动)
129
+ if (this.pathAt(history, i) !== targetPath)
130
+ return; // 防止 fallback 扫描误裁其他文件
154
131
  const tcId = m.tool_call_id;
155
132
  if (!tcId)
156
133
  return;
157
- const stub = `${STUB_PREFIX_LOCAL} read_file(${path}) ${content.length} 字符 → 已被新 read / mutation 替代 · id …${tcId.slice(-6)}⌫`;
134
+ const stub = `${STUB_PREFIX} read_file(${targetPath}) ${content.length} 字符 → 已被新 read / mutation 替代 · id …${tcId.slice(-6)}⌫`;
158
135
  m.content = stub;
159
136
  };
160
- // 1) Map 索引(快路径)
161
- const indexed = this.readByPath.get(path);
137
+ const indexed = this.readByPath.get(targetPath);
162
138
  if (indexed) {
163
139
  for (const i of indexed)
164
140
  stubOne(i);
165
141
  }
166
- // 2) 全表扫一遍(防御:索引可能漏登 / 历史来自 resume)
167
- // 仅扫 [1, beforeIdx) 且不在保护区内的 range,成本可控。
168
142
  const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
169
- for (let i = 1; i < scanEnd; i++) {
143
+ for (let i = 1; i < scanEnd; i++)
170
144
  stubOne(i);
171
- }
172
145
  }
173
146
  }
174
147
  /** 默认单例:每个 agent 循环一个。runAgentCore 入口 new 一个,后续 observe 共享。 */
@@ -200,7 +173,10 @@ export function computePruneStats(history) {
200
173
  if (m.role !== 'tool')
201
174
  continue;
202
175
  const c = toText(m.content);
203
- if (!c.startsWith(STUB_PREFIX))
176
+ // Relevance Pruner 的 stub(⌦[已过时:...) 和 Lifecycle Engine 的 DIGEST(⌦[摘要:...) 都统计。
177
+ const isPruneStub = c.startsWith(STUB_PREFIX);
178
+ const isDigest = c.startsWith('⌦[摘要:');
179
+ if (!isPruneStub && !isDigest)
204
180
  continue;
205
181
  stubbed++;
206
182
  stubChars += c.length;
@@ -6,6 +6,24 @@
6
6
  // 不变量:
7
7
  // - 永不抛错(对齐「调度器永不抛错」契约)。
8
8
  // - 仅依赖 `ChatMessage` 的最小形状,不反向 import agent / session / tools。
9
+ import path from 'node:path';
10
+ /** 将文件路径统一成可用于跨工具关联的绝对 key。
11
+ * Windows 路径不区分大小写,并统一使用 `/`,避免相对/绝对路径及分隔符差异导致漏配。 */
12
+ export function canonicalizePath(input) {
13
+ if (!input?.trim())
14
+ return null;
15
+ try {
16
+ const normalized = path.resolve(input.trim()).replace(/\\/g, '/');
17
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /** 工具层以 `错误:` 作为统一失败前缀;失败结果不得消费或淘汰已有观察数据。 */
24
+ export function isToolResultSuccess(output) {
25
+ return !output.trimStart().startsWith('错误:');
26
+ }
9
27
  /** 把消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。
10
28
  * 用于估算 token、内容匹配、stub 拼接等场景。 */
11
29
  export function toText(content) {
@@ -19,6 +19,7 @@ import { tools } from '../tools/registry.js';
19
19
  import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
20
20
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
21
21
  import { modelSupportsVision } from '../llm/capabilities.js';
22
+ import { computePruneStats } from '../context/relevance.js';
22
23
  import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
23
24
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
24
25
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
@@ -148,7 +149,13 @@ function renderContextBar(history) {
148
149
  const src = contextState.lastUsage ? '实测' : '估算';
149
150
  const k = (n) => `${Math.round(n / 1000)}k`;
150
151
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
151
- return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}`;
152
+ const lifecycle = contextState.lifecycleStats;
153
+ const archived = computePruneStats(history);
154
+ const lifecycleLine = lifecycle
155
+ ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
156
+ : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
157
+ const archiveLine = `\n archived tool results · ${archived.stubbed}`;
158
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}${lifecycleLine}${archiveLine}`;
152
159
  }
153
160
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
154
161
  * 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
@@ -882,6 +889,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
882
889
  rebuildFromHistory(history);
883
890
  contextState.lastUsage = undefined;
884
891
  contextState.correction = 1;
892
+ contextState.lifecycleStats = undefined;
885
893
  lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
886
894
  layout.clearContent();
887
895
  renderHistory(history);
@@ -1017,6 +1025,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1017
1025
  turnCount = 0; // 反思 cadence 重新计数
1018
1026
  contextState.lastUsage = undefined;
1019
1027
  contextState.correction = 1;
1028
+ contextState.lifecycleStats = undefined;
1020
1029
  lastTurnUsage = undefined; // 清空旧轮的 token 累计
1021
1030
  pendingAttachments = []; // 一并清空待发图片
1022
1031
  layout.clearContent();
@@ -320,6 +320,7 @@ export async function compactHistory(history, opts) {
320
320
  return {
321
321
  compacted: true,
322
322
  summarized: false,
323
+ historyRebuilt: true,
323
324
  estimateBefore,
324
325
  estimateAfter,
325
326
  reason: microcompactDone2 ? 'microcompact' : 'summarize',
@@ -411,6 +412,7 @@ export async function compactHistory(history, opts) {
411
412
  return {
412
413
  compacted: true,
413
414
  summarized: true,
415
+ historyRebuilt: true,
414
416
  estimateBefore,
415
417
  estimateAfter,
416
418
  reason: 'summarize',
@@ -487,6 +489,5 @@ export async function maybeCompact(history, report, manualOpts, state = contextS
487
489
  force: manualOpts?.force,
488
490
  contextState: state,
489
491
  });
490
- if (isManual)
491
- return r;
492
+ return r;
492
493
  }
@@ -48,15 +48,17 @@ export function createBudgetScheduler(state = contextState) {
48
48
  const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction);
49
49
  const actions = scheduleActions(report);
50
50
  let compactHistoryCalled = false;
51
+ let historyRebuilt = false;
51
52
  for (const a of actions) {
52
53
  if (a.kind === 'warn') {
53
54
  // system 超:写一行提示(配置漂移应由用户处理,不是调度器压)
54
55
  layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}调度器警告:${a.layer} ${a.reason}${ui.reset}\n`);
55
56
  }
56
57
  else if (a.kind === 'compact_history') {
57
- // 路由到 maybeCompact(history, report)——按 ROI 调度(只有 history 超 / totalOver 才真压)
58
- await maybeCompact(history, report, undefined, state);
58
+ // 路由到 maybeCompact;把结构重建信号传回 core,使 lifecycle 按新 index 恢复。
59
+ const result = await maybeCompact(history, report, undefined, state);
59
60
  compactHistoryCalled = true;
61
+ historyRebuilt ||= result?.historyRebuilt === true;
60
62
  }
61
63
  // shrink_cold_tools L1/L2/L3 与 cap_hot_tools:已由 push-time 闸在每次 push 自动跑
62
64
  // (cap = MAX_HISTORY_RESULT;pruner = same-path 新旧替换;lifecycle = age stub)。
@@ -72,6 +74,7 @@ export function createBudgetScheduler(state = contextState) {
72
74
  obs.lastRunLog = log;
73
75
  // 暴露给 /context 共享读(repl / context 命令)
74
76
  state.schedulerLog = log;
77
+ return historyRebuilt;
75
78
  },
76
79
  };
77
80
  return obs;
@@ -79,7 +82,7 @@ export function createBudgetScheduler(state = contextState) {
79
82
  /** 便捷:agent/core.ts 不需要每次 createBudgetScheduler,直接 runScheduler(history, step)。 */
80
83
  export async function runScheduler(history, step, state = contextState) {
81
84
  const s = createBudgetScheduler(state);
82
- await s.runStep(history, step);
85
+ return s.runStep(history, step);
83
86
  }
84
87
  /** 手动 /compact 入口(repl):与自动路径完全一致——五区 ROI 调度,但 history 摘要强制执行。
85
88
  * 即便 layers.history.overBudget=false 或 totalOver=false,manual 仍产 compact_history action
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {