mocode-ai 0.6.2 → 0.6.3

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.
@@ -186,7 +186,9 @@ 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
+ const lifecycle = config.contextLifecycle ? createLifecycleEngine(history) : null;
190
192
  // 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
191
193
  // 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
192
194
  const scheduler = config.contextBudget !== false
@@ -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(8 步):LIVE REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)
15
+ // Phase 2(+5 步):REFERENCED → DIGEST,替换为摘要存根(保留文件列表+命中数+参数,丢弃详情),
16
+ // 释放 ~90% token。states 仍为 REFERENCED,不引入新状态。
15
17
  // - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
16
18
  // - 已 STUB(含「⌦[已过时:...]」或「⌦[已剔除:...]」或本层的「⌦[无消费者:...]」)不重复处理。
17
19
  // - 永不抛错(对齐上下文管道的「调度器永不抛错」契约)。
@@ -27,7 +29,7 @@
27
29
  //
28
30
  // 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
29
31
  import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
30
- /** 观察类工具(永远只到 REFERENCED,不参与自动 STUB)。 */
32
+ /** 观察类工具(LIVE REFERENCED → DIGEST 两阶段衰减,永不自动 STUB)。 */
31
33
  const OBSERVER_TOOLS = new Set([
32
34
  'grep',
33
35
  'glob',
@@ -42,10 +44,19 @@ const CONSUMER_TOOLS = new Set(['read_file', 'edit_file', 'write_file']);
42
44
  const MUTATION_TOOLS = new Set(['edit_file', 'write_file']);
43
45
  /** 存根前缀(区分 Relevance Pruner 与 drop_context)。 */
44
46
  const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值]';
47
+ /** 观察类工具摘要前缀(DIGEST 状态标记)。 */
48
+ const DIGEST_PREFIX = '⌦[摘要:';
45
49
  /** 老化阈值:某条工具消息自 push 以来经历的「消费者 push」次数。
46
50
  * ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
47
51
  * 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
48
52
  const DEFAULT_AGE_THRESHOLD = 2;
53
+ /** 观察类工具 Phase 1:LIVE → REFERENCED 的老化阈值(普通工具用 DEFAULT_AGE_THRESHOLD=2)。
54
+ * 10 步后才降为 REFERENCED,保留完整内容;理由:返回多个候选(grep 10 文件但只读 1 个),
55
+ * 剩余候选可能后续被消费,给足够时间窗口。 */
56
+ const OBSERVER_REFERENCED_AGE = 10;
57
+ /** 观察类工具 Phase 2:REFERENCED → DIGEST 的老化阈值(从 REFERENCED 起再累积)。
58
+ * +5 步后替换为摘要存根,保留文件列表+命中数+参数,丢弃详情;释放 ~90% token。 */
59
+ const OBSERVER_DIGEST_AGE = 5;
49
60
  /** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
50
61
  * - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
51
62
  * - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
@@ -113,12 +124,102 @@ export class LifecycleEngine {
113
124
  /** producer 路径 → 生产者工具消息 idx 列表(逆查用:某个 path 被消费时,反查上游 producer)。
114
125
  * 注意:不存 read_file,因为 read 自身就是消费者不充当 producer。 */
115
126
  producersByPath = new Map();
127
+ /** 已被 DIGEST 的 tool 消息 idx 集合(避免重复检查;states 保持 REFERENCED)。 */
128
+ digestedIdxs = new Set();
129
+ /** 摘要不比原文短而保留原文的 idx;仅抑制当前 run 内的重复计算。 */
130
+ digestRetainedIdxs = new Set();
116
131
  /** 当前步序号(用于 age 老化:每次 pushTool 自增,对比 age 阈值)。 */
117
132
  step = 0;
118
133
  /** 最后 user 索引缓存(pushTool 时重算;pushMutation 时也重算,因为 mutation 可能跟 user 同行)。 */
119
134
  lastUser = -1;
120
- constructor(ageThreshold = DEFAULT_AGE_THRESHOLD) {
135
+ constructor(ageThreshold = DEFAULT_AGE_THRESHOLD, history) {
121
136
  this.ageThreshold = ageThreshold;
137
+ if (history)
138
+ this.rehydrate(history);
139
+ }
140
+ /**
141
+ * 从会话历史恢复观察者状态。
142
+ *
143
+ * LifecycleEngine 是每次 runAgentCore 新建的,但 history 是会话级的;若不回放,
144
+ * 跨轮的 observer 不在 states 中,后续工具调用也就无法使它老化。这里按原始
145
+ * pushTool 的顺序重放登记、消费关系和 age,并使用当时的 user 保护边界推进阶段。
146
+ * 只纳入 observer:非 observer 的短期 orphan 处理刻意保持原有「本轮」语义。
147
+ */
148
+ rehydrate(history) {
149
+ try {
150
+ let replayLastUser = -1;
151
+ const pendingDigests = new Set();
152
+ for (let idx = 1; idx < history.length; idx++) {
153
+ const m = history[idx];
154
+ if (m.role === 'user') {
155
+ replayLastUser = idx;
156
+ continue;
157
+ }
158
+ if (m.role !== 'tool')
159
+ continue;
160
+ const toolName = toolNameOf(history, idx);
161
+ if (!toolName)
162
+ continue;
163
+ const content = toText(m.content);
164
+ const isDigest = content.startsWith(DIGEST_PREFIX);
165
+ if (OBSERVER_TOOLS.has(toolName) && !content.startsWith('⌦[')) {
166
+ this.states.set(idx, 'LIVE');
167
+ this.consumerCount.set(idx, 0);
168
+ 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);
173
+ }
174
+ }
175
+ else if (OBSERVER_TOOLS.has(toolName) && isDigest) {
176
+ this.states.set(idx, 'REFERENCED');
177
+ this.consumerCount.set(idx, 0);
178
+ this.age.set(idx, 0);
179
+ this.digestedIdxs.add(idx);
180
+ }
181
+ if (CONSUMER_TOOLS.has(toolName)) {
182
+ const path = extractPath(this.findToolArgs(history, idx));
183
+ if (path) {
184
+ for (const producerIdx of this.producersByPath.get(path) ?? []) {
185
+ if (this.states.get(producerIdx) === 'LIVE') {
186
+ this.states.set(producerIdx, 'REFERENCED');
187
+ }
188
+ this.consumerCount.set(producerIdx, (this.consumerCount.get(producerIdx) ?? 0) + 1);
189
+ }
190
+ }
191
+ }
192
+ for (const key of this.states.keys())
193
+ this.age.set(key, (this.age.get(key) ?? 0) + 1);
194
+ this.step++;
195
+ // 和 pushTool 一致:本轮 user 及之后的结果不处理;旧轮结果可以推进。
196
+ for (const [observerIdx, state] of this.states) {
197
+ if (observerIdx >= replayLastUser || this.isDigestFinalized(observerIdx))
198
+ continue;
199
+ const observerName = toolNameOf(history, observerIdx);
200
+ if (!observerName || !OBSERVER_TOOLS.has(observerName))
201
+ continue;
202
+ const age = this.age.get(observerIdx) ?? 0;
203
+ if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
204
+ this.states.set(observerIdx, 'REFERENCED');
205
+ }
206
+ else if (state === 'REFERENCED'
207
+ && age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
208
+ pendingDigests.add(observerIdx);
209
+ }
210
+ }
211
+ }
212
+ this.lastUser = lastUserIndex(history);
213
+ // 回放期间只计算状态;最后统一落盘,避免迭代时破坏 path 提取。
214
+ for (const idx of pendingDigests) {
215
+ const toolName = toolNameOf(history, idx);
216
+ if (toolName && idx < Math.max(0, this.lastUser))
217
+ this.digestOne(history, idx, toolName);
218
+ }
219
+ }
220
+ catch {
221
+ // 历史中出现非标准消息时降级为本轮行为,不能影响 agent 主循环。
222
+ }
122
223
  }
123
224
  /** 新工具结果 push 进 history 时调;idx = history.length - 1。
124
225
  * mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
@@ -233,27 +334,44 @@ export class LifecycleEngine {
233
334
  // 永不抛错。
234
335
  }
235
336
  }
236
- /** 老化自动 STUB:扫描所有 LIVE(且非观察类)且 age ≥ 阈值且不在保护区的工具消息 → OBSOLETE → STUB。 */
337
+ /** 老化自动处理:
338
+ * - 非观察类 LIVE 且 age ≥ 阈值 → OBSOLETE → STUB(完全丢弃)。
339
+ * - 观察类工具两阶段衰减:
340
+ * Phase 1: LIVE → REFERENCED(age ≥ OBSERVER_REFERENCED_AGE,保留完整内容)。
341
+ * Phase 2: REFERENCED → DIGEST(增量 age ≥ OBSERVER_DIGEST_AGE,替换为摘要存根)。
342
+ * - 当前轮保护区(最后一个 user 之后)完全不动。
343
+ */
237
344
  autoStubOrphans(history) {
238
345
  try {
239
346
  const protectedFrom = Math.max(0, this.lastUser);
240
347
  for (const [idx, state] of this.states) {
241
- if (state !== 'LIVE')
242
- continue;
243
348
  if (idx >= protectedFrom)
244
349
  continue; // 当前轮保护区
245
350
  const age = this.age.get(idx) ?? 0;
246
- if (age < this.ageThreshold)
247
- continue;
248
351
  const tn = toolNameOf(history, idx);
249
352
  if (!tn)
250
353
  continue;
251
- // 观察类工具永远只到 REFERENCED,不自动 STUB(用户拍板)。
252
354
  if (OBSERVER_TOOLS.has(tn)) {
253
- this.states.set(idx, 'REFERENCED');
355
+ // Phase 1: LIVE → REFERENCED(保留完整内容)
356
+ if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
357
+ this.states.set(idx, 'REFERENCED');
358
+ continue;
359
+ }
360
+ // Phase 2: REFERENCED → DIGEST(替换为摘要存根,状态不变)。
361
+ if (state === 'REFERENCED' && !this.isDigestFinalized(idx)) {
362
+ // read_file 可能在很早时就把 producer 标成 REFERENCED;不能因此跳过
363
+ // Phase 1 的保留窗口。摘要始终以 observer 自身的总 age 为准。
364
+ if (age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
365
+ this.digestOne(history, idx, tn);
366
+ }
367
+ }
254
368
  continue;
255
369
  }
256
- // 执行 STUB。
370
+ // 非观察类:只在 LIVE 状态下老化 STUB。
371
+ if (state !== 'LIVE')
372
+ continue;
373
+ if (age < this.ageThreshold)
374
+ continue;
257
375
  this.stubOne(history, idx, tn);
258
376
  }
259
377
  }
@@ -279,6 +397,115 @@ export class LifecycleEngine {
279
397
  // 永不抛错。
280
398
  }
281
399
  }
400
+ /** 观察类工具 Phase 2:替换为摘要存根,保留文件列表+命中数+参数,丢弃详情。
401
+ * 幂等:已是 ⌦[ 前缀的跳过。states 保持 REFERENCED,只替换 content。 */
402
+ digestOne(history, idx, toolName) {
403
+ try {
404
+ const m = history[idx];
405
+ if (!m)
406
+ return;
407
+ const c = toText(m.content);
408
+ if (c.startsWith('⌦['))
409
+ return; // 幂等(已 STUB/DIGEST)
410
+ const origLen = c.length;
411
+ const argsRaw = this.findToolArgs(history, idx);
412
+ const summary = this.buildDigestSummary(toolName, c, argsRaw, origLen, extractProducerPaths(toolName, c));
413
+ // 摘要的唯一目标是降低上下文成本。短结果(如“未命中”)不能被固定文案放大。
414
+ if (summary.length >= origLen) {
415
+ this.digestRetainedIdxs.add(idx);
416
+ return;
417
+ }
418
+ m.content = summary;
419
+ // states 保持 REFERENCED;digestedIdxs 由 stats() 扣除显示。
420
+ this.digestedIdxs.add(idx);
421
+ }
422
+ catch {
423
+ // 永不抛错。
424
+ }
425
+ }
426
+ /** 为各观察类工具生成摘要字符串。只保留统计 + 参数,不保留详情(文件列表/URL/正文),
427
+ * 明确标注"这是历史摘要,需要最新信息请重新调用"。 */
428
+ buildDigestSummary(toolName, content, argsRaw, origLen, paths) {
429
+ const files = this.formatDigestPaths(paths);
430
+ switch (toolName) {
431
+ case 'grep': {
432
+ // 命中行数:content 里形如 `file:line:` 的行数
433
+ let lineCount = 0;
434
+ const re = /^[^\s:][^:]*?\.[A-Za-z0-9]+:\d+:/gm;
435
+ while (re.exec(content))
436
+ lineCount++;
437
+ const args = (() => { try {
438
+ const a = JSON.parse(argsRaw);
439
+ let s = `"${a.pattern ?? ''}"`;
440
+ if (a.glob)
441
+ s += `, glob="${a.glob}"`;
442
+ return s;
443
+ }
444
+ catch {
445
+ return '...';
446
+ } })();
447
+ return `${DIGEST_PREFIX}grep(${args}) — 历史结果 ${lineCount} 行命中,文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 grep`;
448
+ }
449
+ case 'glob': {
450
+ const args = (() => { try {
451
+ return `"${JSON.parse(argsRaw).pattern ?? ''}"`;
452
+ }
453
+ catch {
454
+ return '...';
455
+ } })();
456
+ return `${DIGEST_PREFIX}glob(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整列表或最新信息请重新调用 glob`;
457
+ }
458
+ case 'codegraph': {
459
+ const args = (() => { try {
460
+ const a = JSON.parse(argsRaw);
461
+ return `"${a.query ?? ''}"`;
462
+ }
463
+ catch {
464
+ return '...';
465
+ } })();
466
+ return `${DIGEST_PREFIX}codegraph(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 codegraph`;
467
+ }
468
+ case 'web_search': {
469
+ // 统计结果数
470
+ let resultCount = 0;
471
+ const lines = content.split(/\r?\n/);
472
+ for (const line of lines) {
473
+ if (/^\[\d+\]\s*.+/.test(line))
474
+ resultCount++;
475
+ }
476
+ const query = (() => { try {
477
+ return JSON.parse(argsRaw).query ?? '';
478
+ }
479
+ catch {
480
+ return '';
481
+ } })();
482
+ return `${DIGEST_PREFIX}web_search("${query}") — 历史结果 ${resultCount} 条, ${origLen}→摘要]\n如需最新信息请重新调用 web_search`;
483
+ }
484
+ case 'web_fetch': {
485
+ const url = (() => { try {
486
+ return JSON.parse(argsRaw).url ?? '';
487
+ }
488
+ catch {
489
+ return '';
490
+ } })();
491
+ return `${DIGEST_PREFIX}web_fetch(${url}) — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 web_fetch`;
492
+ }
493
+ default:
494
+ return `${DIGEST_PREFIX}${toolName} — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 ${toolName}`;
495
+ }
496
+ }
497
+ isDigestFinalized(idx) {
498
+ return this.digestedIdxs.has(idx) || this.digestRetainedIdxs.has(idx);
499
+ }
500
+ /** 文件候选是摘要的关键可用信息;限量且去重,避免摘要本身重新膨胀。 */
501
+ formatDigestPaths(paths) {
502
+ const unique = [...new Set(paths)];
503
+ if (unique.length === 0)
504
+ return '0 个(未能从输出解析路径)';
505
+ const limit = 12;
506
+ const shown = unique.slice(0, limit).join(', ');
507
+ return unique.length > limit ? `${unique.length} 个 [${shown}, …]` : `${unique.length} 个 [${shown}]`;
508
+ }
282
509
  /** 找某条 tool 消息对应的 assistant.tool_calls.arguments。 */
283
510
  findToolArgs(history, idx) {
284
511
  try {
@@ -305,7 +532,8 @@ export class LifecycleEngine {
305
532
  getState(idx) {
306
533
  return this.states.get(idx) ?? null;
307
534
  }
308
- /** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, obsolete=K, stubbed=S」。 */
535
+ /** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, digest=K, obsolete=J, stubbed=S」。
536
+ * digested:已被摘要的观察类工具(states 仍为 REFERENCED,但 content 已替换为摘要)。 */
309
537
  stats() {
310
538
  let live = 0;
311
539
  let referenced = 0;
@@ -321,10 +549,12 @@ export class LifecycleEngine {
321
549
  else if (s === 'STUB')
322
550
  stubbed++;
323
551
  }
324
- return { live, referenced, obsolete, stubbed };
552
+ // digested states 仍为 REFERENCED,从总 referenced 中扣除得到纯 REFERENCED 数。
553
+ return { live, referenced: referenced - this.digestedIdxs.size, digested: this.digestedIdxs.size, obsolete, stubbed };
325
554
  }
326
555
  }
327
- /** 默认单例工厂。runAgentCore 入口 new 一个,后续 pushTool / pushMutation 共享。 */
328
- export function createLifecycleEngine(ageThreshold) {
329
- return new LifecycleEngine(ageThreshold);
556
+ export function createLifecycleEngine(historyOrThreshold, ageThreshold) {
557
+ const history = Array.isArray(historyOrThreshold) ? historyOrThreshold : undefined;
558
+ const threshold = typeof historyOrThreshold === 'number' ? historyOrThreshold : ageThreshold;
559
+ return new LifecycleEngine(threshold, history);
330
560
  }
@@ -200,7 +200,10 @@ export function computePruneStats(history) {
200
200
  if (m.role !== 'tool')
201
201
  continue;
202
202
  const c = toText(m.content);
203
- if (!c.startsWith(STUB_PREFIX))
203
+ // Relevance Pruner 的 stub(⌦[已过时:...) 和 Lifecycle Engine 的 DIGEST(⌦[摘要:...) 都统计。
204
+ const isPruneStub = c.startsWith(STUB_PREFIX);
205
+ const isDigest = c.startsWith('⌦[摘要:');
206
+ if (!isPruneStub && !isDigest)
204
207
  continue;
205
208
  stubbed++;
206
209
  stubChars += c.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {