mocode-ai 0.6.3 → 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 核心循环(纯逻辑):
@@ -188,7 +188,10 @@ export async function runAgentCore(opts) {
188
188
  // 开关关闭时为 null,所有 pushToolResult / mutation 调用走无 lifecycle 路径(零行为变化)。
189
189
  // 引擎需要从已有会话 history 恢复观察结果的年龄和 path 索引;不能只追踪本次
190
190
  // runAgentCore,否则跨用户轮次的 grep/glob 永远不会衰减。
191
- const lifecycle = config.contextLifecycle ? createLifecycleEngine(history) : null;
191
+ let lifecycle = config.contextLifecycle
192
+ ? createLifecycleEngine(history)
193
+ : null;
194
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
192
195
  // 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
193
196
  // 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
194
197
  const scheduler = config.contextBudget !== false
@@ -236,11 +239,19 @@ export async function runAgentCore(opts) {
236
239
  // 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
237
240
  // 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
238
241
  // 此时 spinner 已停,通知行干净。
242
+ let historyRebuilt = false;
239
243
  if (scheduler) {
240
- await scheduler.runStep(history, step);
244
+ historyRebuilt = await scheduler.runStep(history, step);
241
245
  }
242
246
  else {
243
- 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();
244
255
  }
245
256
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
246
257
  mode = 'idle';
@@ -440,16 +451,13 @@ export async function runAgentCore(opts) {
440
451
  // Thrashing:同上(history 附 hint,UI 干净)
441
452
  const hint = recordAndHint(tc.name, tc.arguments);
442
453
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
443
- // 相关性裁剪 mutation 通知:edit_file/write_file 后,该 path 之前的所有 read_file
444
- // 结果已失效(已不再是文件当前状态) stub 为存根。pruner 内部 try/catch + 幂等。
445
- // 非 mutation 工具(run_command/use_skill/memory_* 等)此处 path="" 不触发。
446
- // 观察者生命周期:mutation push 后通知 lifecycle 把同 path 的旧 read 标 REFERENCED。
447
- if (relprune && isMutationTool(tc.name)) {
454
+ // 只有成功 mutation 才会使旧 read 失效;pruner lifecycle 独立启停。
455
+ if (isMutationTool(tc.name) && isToolResultSuccess(output)) {
448
456
  const mp = mutationParsed?.path;
449
457
  if (typeof mp === 'string' && mp) {
450
- relprune.observeMutation(history, mp);
451
- if (lifecycle)
452
- 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();
453
461
  }
454
462
  }
455
463
  i++;
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // 观察类工具两阶段衰减(避免误伤):
13
13
  // - grep/glob/codegraph/web_search/web_fetch 等「观察/检索类」工具两阶段衰减:
14
- // Phase 1(8 步):LIVE → REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)。
14
+ // Phase 1(10 步):LIVE → REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)。
15
15
  // Phase 2(+5 步):REFERENCED → DIGEST,替换为摘要存根(保留文件列表+命中数+参数,丢弃详情),
16
16
  // 释放 ~90% token。states 仍为 REFERENCED,不引入新状态。
17
17
  // - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
@@ -21,14 +21,15 @@
21
21
  //
22
22
  // 与 Relevance Pruner 的分工(不重复):
23
23
  // - Relevance Pruner:管 read_file 同 path 旧 read + mutation 覆写 → 直接 STUB。
24
- // - 本层:管「无消费者的观察类工具老化后STUB」 + 「被消费的工具 REFERENCED 标记(可视)」。
24
+ // - 本层:管「观察类工具老化后DIGEST、普通孤立工具老化后STUB」+
25
+ // 「被消费的工具 → REFERENCED 标记(可视)」。
25
26
  //
26
27
  // 触发点(agent/core.ts):
27
28
  // - pushToolResult 出口,新 message idx = history.length - 1。
28
29
  // - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
29
30
  //
30
31
  // 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
31
- import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
32
+ import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, toolNameOf, } from './utils.js';
32
33
  /** 观察类工具(LIVE → REFERENCED → DIGEST 两阶段衰减,永不自动 STUB)。 */
33
34
  const OBSERVER_TOOLS = new Set([
34
35
  'grep',
@@ -69,14 +70,22 @@ function extractProducerPaths(toolName, content) {
69
70
  if (!content)
70
71
  return [];
71
72
  const out = new Set();
73
+ const addPath = (raw) => {
74
+ const canonical = canonicalizePath(raw);
75
+ if (canonical)
76
+ out.add(canonical);
77
+ };
72
78
  try {
73
79
  if (toolName === 'grep') {
74
- // 典型行:`src/foo.ts:42: hello world` 或 `path\to\file.ts:42: ...`
75
- // 取冒号前段(冒号必须跟在数字前面避免切到路径里的冒号)。
76
- 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;
77
84
  let m;
78
- while ((m = re.exec(content)))
79
- out.add(m[1]);
85
+ while ((m = rawLine.exec(content)))
86
+ addPath(m[1]);
87
+ while ((m = summaryHeader.exec(content)))
88
+ addPath(m[1]);
80
89
  }
81
90
  else if (toolName === 'glob') {
82
91
  // glob 输出一般是「paths:」+ 换行 + 多路径;每行一个绝对或相对路径。
@@ -87,7 +96,7 @@ function extractProducerPaths(toolName, content) {
87
96
  continue;
88
97
  // 含扩展名或含路径分隔符
89
98
  if (/\.[A-Za-z0-9]+$/.test(t) || t.includes('/') || t.includes('\\'))
90
- out.add(t);
99
+ addPath(t);
91
100
  }
92
101
  }
93
102
  else if (toolName === 'codegraph') {
@@ -95,7 +104,7 @@ function extractProducerPaths(toolName, content) {
95
104
  for (const line of content.split(/\r?\n/)) {
96
105
  const m = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/.exec(line);
97
106
  if (m)
98
- out.add(m[1]);
107
+ addPath(m[1]);
99
108
  }
100
109
  }
101
110
  else if (toolName === 'web_search' || toolName === 'web_fetch') {
@@ -138,15 +147,14 @@ export class LifecycleEngine {
138
147
  this.rehydrate(history);
139
148
  }
140
149
  /**
141
- * 从会话历史恢复观察者状态。
150
+ * 从会话历史恢复生命周期状态。
142
151
  *
143
- * LifecycleEngine 是每次 runAgentCore 新建的,但 history 是会话级的;若不回放,
144
- * 跨轮的 observer 不在 states 中,后续工具调用也就无法使它老化。这里按原始
145
- * pushTool 的顺序重放登记、消费关系和 age,并使用当时的 user 保护边界推进阶段。
146
- * 只纳入 observer:非 observer 的短期 orphan 处理刻意保持原有「本轮」语义。
152
+ * 旧轮只恢复 observer,使检索结果能跨轮继续老化;最后一个 user 之后则恢复全部成功、
153
+ * 未归档的工具结果,保证同一 runAgentCore compact 重建 history 后不丢当前轮状态。
147
154
  */
148
155
  rehydrate(history) {
149
156
  try {
157
+ const currentTurnStart = lastUserIndex(history);
150
158
  let replayLastUser = -1;
151
159
  const pendingDigests = new Set();
152
160
  for (let idx = 1; idx < history.length; idx++) {
@@ -161,25 +169,33 @@ export class LifecycleEngine {
161
169
  if (!toolName)
162
170
  continue;
163
171
  const content = toText(m.content);
172
+ const isObserver = OBSERVER_TOOLS.has(toolName);
164
173
  const isDigest = content.startsWith(DIGEST_PREFIX);
165
- if (OBSERVER_TOOLS.has(toolName) && !content.startsWith('⌦[')) {
174
+ const isCurrentTurnTool = currentTurnStart >= 0 && idx > currentTurnStart;
175
+ const shouldRestoreLive = isToolResultSuccess(content) &&
176
+ !content.startsWith('⌦[') &&
177
+ (isObserver || isCurrentTurnTool);
178
+ if (shouldRestoreLive) {
166
179
  this.states.set(idx, 'LIVE');
167
180
  this.consumerCount.set(idx, 0);
168
181
  this.age.set(idx, 0);
169
- for (const path of extractProducerPaths(toolName, content)) {
170
- const producers = this.producersByPath.get(path) ?? [];
171
- producers.push(idx);
172
- this.producersByPath.set(path, producers);
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
+ }
173
189
  }
174
190
  }
175
- else if (OBSERVER_TOOLS.has(toolName) && isDigest) {
191
+ else if (isObserver && isDigest) {
176
192
  this.states.set(idx, 'REFERENCED');
177
193
  this.consumerCount.set(idx, 0);
178
194
  this.age.set(idx, 0);
179
195
  this.digestedIdxs.add(idx);
180
196
  }
181
- if (CONSUMER_TOOLS.has(toolName)) {
182
- const path = extractPath(this.findToolArgs(history, idx));
197
+ if (isToolResultSuccess(content) && CONSUMER_TOOLS.has(toolName)) {
198
+ const path = canonicalizePath(extractPath(this.findToolArgs(history, idx)));
183
199
  if (path) {
184
200
  for (const producerIdx of this.producersByPath.get(path) ?? []) {
185
201
  if (this.states.get(producerIdx) === 'LIVE') {
@@ -224,7 +240,7 @@ export class LifecycleEngine {
224
240
  /** 新工具结果 push 进 history 时调;idx = history.length - 1。
225
241
  * mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
226
242
  * pushMutation 标完 read REFERENCED 之后再触发),避免刚被 mutation 消费的 read 被提前 STUB。 */
227
- pushTool(history, idx) {
243
+ pushTool(history, idx, succeeded = true) {
228
244
  try {
229
245
  const m = history[idx];
230
246
  if (!m || m.role !== 'tool')
@@ -232,6 +248,9 @@ export class LifecycleEngine {
232
248
  const toolName = toolNameOf(history, idx);
233
249
  if (!toolName)
234
250
  return;
251
+ // 失败工具结果只保留给模型诊断,不登记为 observation、不会推动老化或污染统计。
252
+ if (!succeeded)
253
+ return;
235
254
  // 已 stub 的不重复登记(幂等)。
236
255
  const c = toText(m.content);
237
256
  if (c.startsWith('⌦['))
@@ -240,8 +259,8 @@ export class LifecycleEngine {
240
259
  this.states.set(idx, 'LIVE');
241
260
  this.consumerCount.set(idx, 0);
242
261
  this.age.set(idx, 0);
243
- // 如果是 producer 类工具(grep/glob/codegraph),登记其命中的路径。
244
- if (OBSERVER_TOOLS.has(toolName)) {
262
+ // 成功的 producer 才建立路径索引;失败结果仍可进入生命周期,但不能成为证据来源。
263
+ if (succeeded && OBSERVER_TOOLS.has(toolName)) {
245
264
  const paths = extractProducerPaths(toolName, c);
246
265
  for (const p of paths) {
247
266
  const arr = this.producersByPath.get(p) ?? [];
@@ -250,8 +269,8 @@ export class LifecycleEngine {
250
269
  this.producersByPath.set(p, arr);
251
270
  }
252
271
  }
253
- // 如果是 consumer 类工具(read/edit/write),找出上游「被消费的 producer」,标 REFERENCED。
254
- if (CONSUMER_TOOLS.has(toolName)) {
272
+ // 只有成功 consumer 才能消费上游;失败 read/edit/write 不改变旧数据状态。
273
+ if (succeeded && CONSUMER_TOOLS.has(toolName)) {
255
274
  const argsRaw = (() => {
256
275
  // tool 消息本身没有 args;args 在前导 assistant.tool_calls 里;直接走同 idx 前的 assistant。
257
276
  const tcId = m.tool_call_id;
@@ -266,7 +285,7 @@ export class LifecycleEngine {
266
285
  }
267
286
  return '';
268
287
  })();
269
- const path = extractPath(argsRaw);
288
+ const path = canonicalizePath(extractPath(argsRaw));
270
289
  if (path) {
271
290
  // 1) 找该 path 的所有上游 producer(grep/glob/codegraph)→ 标 REFERENCED。
272
291
  const producers = this.producersByPath.get(path);
@@ -288,8 +307,7 @@ export class LifecycleEngine {
288
307
  }
289
308
  this.step++;
290
309
  this.lastUser = lastUserIndex(history);
291
- // mutation 工具跳过本轮 autoStubOrphans:调用方会调 pushMutation 标完 read REFERENCED 后,
292
- // 再调 flushAutoStub 触发老化检查,避免 read 在被标 REFERENCED 之前被提前 STUB。
310
+ // 成功 mutation 跳过本轮 autoStubOrphans;调用方会在 pushMutation 标完旧 read 后统一检查。
293
311
  if (MUTATION_TOOLS.has(toolName))
294
312
  return;
295
313
  // 老化检查:本次 push 完,扫描 LIVE(且非观察类)的工具消息,age ≥ 阈值 → 标 OBSOLETE → STUB。
@@ -304,6 +322,11 @@ export class LifecycleEngine {
304
322
  * 注意:puhToolResult 出口已经登记过 mutation 本身,这里不再调 pushTool(避免 age 翻倍)。 */
305
323
  pushMutation(history, mutationIdx, path) {
306
324
  try {
325
+ const mutationPath = canonicalizePath(path);
326
+ if (!mutationPath) {
327
+ this.autoStubOrphans(history);
328
+ return;
329
+ }
307
330
  // mutation 自身已在 pushToolResult 出口登记(若 lifecycle 存在);此处仅做「mutation 是
308
331
  // path 的消费者」语义:把该 path 在 mutation 之前的所有 read_file(未被 stub 的 LIVE/REFERENCED)
309
332
  // 标 REFERENCED。
@@ -321,7 +344,7 @@ export class LifecycleEngine {
321
344
  if (c.startsWith('⌦['))
322
345
  continue;
323
346
  const argsRaw = this.findToolArgs(history, i);
324
- if (extractPath(argsRaw) === path) {
347
+ if (canonicalizePath(extractPath(argsRaw)) === mutationPath) {
325
348
  if (this.states.get(i) === 'LIVE')
326
349
  this.states.set(i, 'REFERENCED');
327
350
  this.consumerCount.set(i, (this.consumerCount.get(i) ?? 0) + 1);
@@ -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 共享。 */
@@ -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.3",
3
+ "version": "0.6.4",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {