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,225 @@
1
+ // 五区 Context Budget Scheduler。
2
+ //
3
+ // 目的:把当前四道独立闸(cap / pipeline / relevance / maybeCompact)统一为
4
+ // 「先看预算报告,再按 ROI 排序调度」的单一入口。
5
+ //
6
+ // 设计原则(对应用户拍板的设计方案):
7
+ // 1. 五区分账:不是把 history 当一个黑盒,而是把上下文切成 5 区逐区配预算
8
+ // (System / History / Tool-Recent / Tool-Old / Summary) + 1 个 Reserve。
9
+ // Tool 内部再分 Hot/Cold:
10
+ // - Cold Tool(老化区,scheduler 优先压缩——ROI 最低,LLM 复述成本最低)
11
+ // - Hot Tool(当前±N 步内,scheduler **不主动 stub**——避免干扰 agent 当前步)
12
+ // 注:**Hot 区「scheduler 不主动」≠ 「绝对不动」**。lifecycle age stub、pruner
13
+ // same-path 替代、agent 的 drop_context 工具仍可动 Hot 区;它们语义更精细(知道
14
+ // 哪条已无关),不会盲目 stub。Scheduler 只在更粗的层面决策,粗判断不踩精细判断。
15
+ // Hot 区超预算时 scheduler 仅 cap(降单条上限,不丢内容)。
16
+ // 2. ROI 排序:History > Summary > Hot Tool > Cold Tool。压缩时先动 Cold Tool
17
+ // (LLM 复述成本最低),再动 History(摘要成本高);Hot Tool 与 System 雷打不动(指 scheduler 层面)。
18
+ // 3. 零行为变化兜底:调度器不是闸,而是「报告 + 决策」——执行仍复用现有
19
+ // cap / pipeline / relevance / compact / lifecycle / drop_context 实现,只是触发条件更精准。
20
+ //
21
+ // 依赖:本文件是叶子级,只依赖 ChatMessage / estimateTokens,绝不反向依赖
22
+ // agent / session / compact(避免循环与耦合)。具体执行动作由 agent/core.ts
23
+ // 拿 ScheduleAction[] 去调现有闸。
24
+ //
25
+ // 开关(MOCODE_BUDGET_SCHEDULER):默认 true。false 时 agent/core.ts 走老路径
26
+ // (直接 maybeCompact),完全跳过本模块,零行为变化。
27
+ import { estimateMessagesTokens, estimateTokens } from '../llm/index.js';
28
+ /** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
29
+ export const BUDGET_LAYERS = [
30
+ 'system',
31
+ 'history',
32
+ 'toolRecent', // Hot Tool
33
+ 'toolOld', // Cold Tool
34
+ 'summary',
35
+ 'reserve', // Reserve(不占内容,只占预算分配;5%)
36
+ ];
37
+ /** 占比(总和 = 0.95,留 5% 给 Reserve)。对齐用户修正版:
38
+ * Recent Tool 25%(原 40% 偏大,因 Hot 区不该被压)+ Old Tool 25% 同等 +
39
+ * History 20% + System 15% + Summary 10%(平时 0 占用,触发后才用) */
40
+ export const BUDGET_RATIO = {
41
+ system: 0.15,
42
+ history: 0.20,
43
+ toolRecent: 0.25,
44
+ toolOld: 0.25,
45
+ summary: 0.10,
46
+ reserve: 0.05,
47
+ };
48
+ /** Hot/Cold 划分:当前 step 起往前 HOT_TURN_WINDOW 个 user turn 之内的工具结果视为 Hot,
49
+ * 之外的视为 Cold。0 = 全 Cold(等同老路径);越短 Hot 越小,压缩越激进。 */
50
+ export const HOT_TURN_WINDOW = 4;
51
+ /** 工具消息推入历史后,经过的「消费者 push 次数」即 age。
52
+ * Cold 区内:age ≥ TOOL_OLD_AGE 的非观察类工具结果可被调度器就地 stub。
53
+ * 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
54
+ export const TOOL_OLD_AGE = 2;
55
+ /** 把每条 token 拍平成字符串(只估 token,不深解析工具调用)。 */
56
+ function toText(content) {
57
+ if (content == null)
58
+ return '';
59
+ if (typeof content === 'string')
60
+ return content;
61
+ try {
62
+ return JSON.stringify(content);
63
+ }
64
+ catch {
65
+ return String(content);
66
+ }
67
+ }
68
+ function msgTokens(m) {
69
+ const c = m.content;
70
+ const tcs = m.tool_calls;
71
+ let extra = toText(c);
72
+ if (tcs)
73
+ for (const tc of tcs)
74
+ extra += tc?.function?.arguments ?? '';
75
+ // 与 llm.estimateTokens 同公式(CJK 1/字,ASCII 1/4字);保证调度器评估与系统估算口径一致。
76
+ return 4 + estimateTokens(extra);
77
+ }
78
+ /** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。 */
79
+ export function lastUserIndex(history) {
80
+ for (let i = history.length - 1; i >= 1; i--) {
81
+ if (history[i].role === 'user')
82
+ return i;
83
+ }
84
+ return -1;
85
+ }
86
+ /** 从 idx 处向前数第 N 个 user turn 的边界 index(含该 user 之后的内容)。
87
+ * 用于把 history 切成 Hot 区(tail 一段,endExclusive=history.length)与 Cold 区(0..endExclusive)。
88
+ * 若 N 个 user 不足,Hot 区 = history.length(全保护);Cold 区空,无压缩目标。 */
89
+ export function userTurnBoundary(history, window) {
90
+ let seen = 0;
91
+ for (let i = history.length - 1; i >= 1; i--) {
92
+ if (history[i].role === 'user') {
93
+ seen++;
94
+ if (seen >= window)
95
+ return i;
96
+ }
97
+ }
98
+ return 1; // 没攒够 N 个 user 之前的全归 Cold(history[0] system 不动)
99
+ }
100
+ /** 评估当前 history 的五区预算(纯函数,改不动 history)。
101
+ * 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。 */
102
+ export function evaluateBudget(history, window, step = 0) {
103
+ const layers = {};
104
+ for (const k of BUDGET_LAYERS) {
105
+ const budget = Math.floor(BUDGET_RATIO[k] * window);
106
+ layers[k] = { actual: 0, budget, overBudget: false, overRatio: 0 };
107
+ }
108
+ const sysMsg = history[0];
109
+ if (sysMsg)
110
+ layers.system.actual = msgTokens(sysMsg);
111
+ // Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
112
+ // 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
113
+ // 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
114
+ let summaryHit = false;
115
+ if (history.length > 1 && history[1].role === 'system') {
116
+ const c1 = toText(history[1].content);
117
+ if (c1.startsWith('# 会话摘要') || c1.includes('会话摘要')) {
118
+ layers.summary.actual = msgTokens(history[1]);
119
+ summaryHit = true;
120
+ }
121
+ }
122
+ // 划 Hot/Cold 边界
123
+ const hotStart = userTurnBoundary(history, HOT_TURN_WINDOW);
124
+ // history 区 = 减去 summary + tool 单算;tool 按 Hot/Cold 分。
125
+ for (let i = 1; i < history.length; i++) {
126
+ const m = history[i];
127
+ if (i === 1 && summaryHit)
128
+ continue; // summary 已单独算过
129
+ if (m.role === 'tool') {
130
+ const t = msgTokens(m);
131
+ if (i >= hotStart)
132
+ layers.toolRecent.actual += t;
133
+ else
134
+ layers.toolOld.actual += t;
135
+ }
136
+ else if (m.role !== 'system') {
137
+ // user / assistant 全部计入 history(对话轨迹)
138
+ layers.history.actual += msgTokens(m);
139
+ }
140
+ // 其它 system(几乎不存在)跳过
141
+ }
142
+ // 计算 overBudget + overRatio
143
+ const triggers = [];
144
+ for (const k of BUDGET_LAYERS) {
145
+ const lb = layers[k];
146
+ if (lb.actual > lb.budget) {
147
+ lb.overBudget = true;
148
+ lb.overRatio = (lb.actual - lb.budget) / Math.max(lb.budget, 1);
149
+ triggers.push(k);
150
+ }
151
+ }
152
+ // 按 overRatio 降序
153
+ triggers.sort((a, b) => layers[b].overRatio - layers[a].overRatio);
154
+ const total = BUDGET_LAYERS.reduce((s, k) => s + (k === 'reserve' ? 0 : layers[k].actual), 0);
155
+ const totalOver = total >= 0.85 * window;
156
+ return {
157
+ step,
158
+ total,
159
+ window,
160
+ layers,
161
+ triggers,
162
+ totalOver,
163
+ hotBoundary: hotStart,
164
+ };
165
+ }
166
+ /** 根据 BudgetReport 生成调度动作(从轻到重,直至总占用回落到 0.85 以下)。
167
+ * 规则:
168
+ * - system 超 → warn(不压,配置问题不是内容问题)
169
+ * - toolOld 超 → 先 L1(中截超大)→ L2(same-path 已有 relevance)→ L3(age stub,新增)
170
+ * - toolRecent 超 → cap(只降低单条上限,不 stub)
171
+ * - history 超 或 totalOver → compact_history(调 maybeCompact / compactHistory)
172
+ * - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算) */
173
+ export function scheduleActions(report) {
174
+ const actions = [];
175
+ const { layers, totalOver, total } = report;
176
+ const headroom = 0.85 * report.window - total;
177
+ // system 超 → warn,不是 schedule 目标
178
+ if (layers.system.overBudget) {
179
+ actions.push({
180
+ kind: 'warn',
181
+ layer: 'system',
182
+ reason: `System prompt 超预算(${layers.system.actual} > ${layers.system.budget}),请检查配置/MOCODE.md`,
183
+ });
184
+ }
185
+ // 渐进:toolOld(轻→重)
186
+ if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.1) {
187
+ actions.push({ kind: 'shrink_cold_tools', level: 1 });
188
+ }
189
+ if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.3) {
190
+ actions.push({ kind: 'shrink_cold_tools', level: 2 });
191
+ }
192
+ if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.6) {
193
+ actions.push({ kind: 'shrink_cold_tools', level: 3 });
194
+ }
195
+ // Hot 区只 cap
196
+ if (layers.toolRecent.overBudget && layers.toolRecent.overRatio > 0.15) {
197
+ actions.push({ kind: 'cap_hot_tools', aggressive: layers.toolRecent.overRatio > 0.5 });
198
+ }
199
+ // History / total 超 → 摘要(最贵);headroom < -2K 真正触发,让 cold tools 先动
200
+ if ((layers.history.overBudget || totalOver) && headroom < -2000) {
201
+ actions.push({ kind: 'compact_history' });
202
+ }
203
+ // 排序(同 kind 已在上面排好):warn → cold L1→L2→L3 → cap_hot → compact_history
204
+ return actions;
205
+ }
206
+ /** 拍平成人类可读(供 /context 命令与 check-budget 脚本用)。 */
207
+ export function formatReport(report) {
208
+ const lines = [];
209
+ lines.push(`step ${report.step} total ${report.total}/${report.window} (${((report.total / report.window) * 100).toFixed(1)}%)`);
210
+ for (const k of BUDGET_LAYERS) {
211
+ const lb = report.layers[k];
212
+ const pct = lb.budget > 0 ? ((lb.actual / lb.budget) * 100).toFixed(0) : '-';
213
+ const flag = lb.overBudget ? '⚠' : ' ';
214
+ lines.push(` ${flag} ${k.padEnd(10)} ${String(lb.actual).padStart(6)} / ${String(lb.budget).padStart(6)} (${pct.padStart(3)}%)`);
215
+ }
216
+ if (report.triggers.length > 0) {
217
+ lines.push(` triggers: ${report.triggers.join(' → ')}`);
218
+ }
219
+ return lines.join('\n');
220
+ }
221
+ /** 便捷:把 history 一把估成总 token 数(给 cap.js 等复用,避免重复实现)。
222
+ * 注意:此处是粗估(只看 content 长度),不区分五区——只用于「系统层整体还剩多少」快查。 */
223
+ export function quickEstimate(history) {
224
+ return estimateMessagesTokens(history);
225
+ }
@@ -1,10 +1,13 @@
1
- // context/ barrel:Context Optimization Pipeline。
1
+ // context/ barrel:Context Optimization Pipeline + 五区 Budget Scheduler
2
2
  //
3
3
  // 单一入口 optimizeToolResult(agent/core.ts pushToolResult 调)接管"工具结果进 LLM 前"的表示。
4
+ // 单一入口 runScheduler(agent/core.ts 步前调)接管"何时调用哪一闸"的调度。
4
5
  // 不调 LLM、不碰 Tool Calling schema / executeTool / tool_call_id 配对 / TUI 渲染
5
6
  // (叶子级:仅 stdlib + tools/constants + session/compact 的 capToolResultForHistory 兜底 + config 开关)。
6
7
  //
7
- // 见 CLAUDE.md「Context Optimization Pipeline」节。
8
+ // 见 CLAUDE.md「Context Optimization Pipeline」节 +「Context Budget Scheduler」节。
8
9
  export { optimizeToolResult } from './pipeline.js';
9
10
  export { classify, knownToolKinds } from './classifier.js';
10
11
  export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
12
+ // ── Context Budget Scheduler ───────────────────────────────────────────────
13
+ export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, lastUserIndex, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
@@ -0,0 +1,384 @@
1
+ // Observation Lifecycle Engine:tool 消息的「观察者生命周期」状态机。
2
+ //
3
+ // 在 Relevance Pruner 之上的第二层被动裁剪。Relevance Pruner 只管 read_file 的「同 path 新旧替换 +
4
+ // mutation 覆写」,本层补足「grep/glob/codegraph 这类观察类工具」的引用追踪。
5
+ //
6
+ // 四态机(LIVE → REFERENCED → OBSOLETE → STUB):
7
+ // - LIVE:刚 push 进 history 的工具结果,尚未被任何下游工具消费。
8
+ // - REFERENCED:被某个下游 read_file/edit_file/write_file 引用过(基于 path 字符串匹配)。
9
+ // - OBSOLETE:无任何消费者引用,且距离当前 push 已老化 N 步(默认 2)。
10
+ // - STUB:已被替换为存根(物理上 content 变成「⌦[无消费者:...]」)。
11
+ //
12
+ // 用户拍板的激进风险护栏(避免误伤):
13
+ // - grep/glob/codegraph/web_search/web_fetch 等「观察/检索类」工具,**永远只到 REFERENCED**,
14
+ // 不参与自动 STUB。理由:返回多个候选(grep 10 文件但你只读 1 个),剩余候选可能后续被消费。
15
+ // - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
16
+ // - 已 STUB(含「⌦[已过时:...]」或「⌦[已剔除:...]」或本层的「⌦[无消费者:...]」)不重复处理。
17
+ // - 永不抛错(对齐上下文管道的「调度器永不抛错」契约)。
18
+ // - 只改 .content,不动 tool_call_id / 不删消息 / 不动 tool_calls 数组。
19
+ //
20
+ // 与 Relevance Pruner 的分工(不重复):
21
+ // - Relevance Pruner:管 read_file 同 path 旧 read + mutation 覆写 → 直接 STUB。
22
+ // - 本层:管「无消费者的观察类工具老化后 → STUB」 + 「被消费的工具 → REFERENCED 标记(可视)」。
23
+ //
24
+ // 触发点(agent/core.ts):
25
+ // - pushToolResult 出口,新 message idx = history.length - 1。
26
+ // - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
27
+ //
28
+ // 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
29
+ /** 观察类工具(永远只到 REFERENCED,不参与自动 STUB)。 */
30
+ const OBSERVER_TOOLS = new Set([
31
+ 'grep',
32
+ 'glob',
33
+ 'codegraph',
34
+ 'web_search',
35
+ 'web_fetch',
36
+ ]);
37
+ /** 消费者工具(这些工具的 push 会触发「上游被消费」标记)。
38
+ * 只列能基于 path 静态判定消费的;run_command/memory_* 等不参与(避免误伤)。 */
39
+ const CONSUMER_TOOLS = new Set(['read_file', 'edit_file', 'write_file']);
40
+ /** mutation 工具:pushTool 跳过 autoStubOrphans(让 pushMutation 标完 read REFERENCED 再统一老化)。 */
41
+ const MUTATION_TOOLS = new Set(['edit_file', 'write_file']);
42
+ /** 存根前缀(区分 Relevance Pruner 与 drop_context)。 */
43
+ const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值]';
44
+ /** 老化阈值:某条工具消息自 push 以来经历的「消费者 push」次数。
45
+ * ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
46
+ * 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
47
+ const DEFAULT_AGE_THRESHOLD = 2;
48
+ /** 复用 drop.ts 的取 path 思路,但本层要支持更多字段名(read_file/edit_file/write_file 都有 path;
49
+ * edit_file 还可能有 file_path,但这里只看 path,保持单一)。 */
50
+ function extractPath(argsRaw) {
51
+ if (!argsRaw)
52
+ return null;
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(argsRaw);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ if (!parsed || typeof parsed !== 'object')
61
+ return null;
62
+ const p = parsed.path;
63
+ return typeof p === 'string' && p ? p : null;
64
+ }
65
+ /** 从 tool 消息往前找匹配的 assistant.tool_calls 拿 tool 名。找不到返 null(保守跳过)。 */
66
+ function toolNameOf(history, idx) {
67
+ const tcId = history[idx].tool_call_id;
68
+ if (!tcId)
69
+ return null;
70
+ for (let j = idx - 1; j >= 1; j--) {
71
+ const m = history[j];
72
+ if (m.role !== 'assistant')
73
+ continue;
74
+ const tcs = m.tool_calls;
75
+ if (!tcs)
76
+ continue;
77
+ const hit = tcs.find((tc) => tc?.id === tcId);
78
+ if (hit)
79
+ return hit.function?.name ?? null;
80
+ }
81
+ return null;
82
+ }
83
+ /** 找最后一个 user 消息索引;无 user 返 -1。 */
84
+ function lastUserIndex(history) {
85
+ for (let i = history.length - 1; i >= 1; i--) {
86
+ if (history[i].role === 'user')
87
+ return i;
88
+ }
89
+ return -1;
90
+ }
91
+ function toText(content) {
92
+ if (content == null)
93
+ return '';
94
+ if (typeof content === 'string')
95
+ return content;
96
+ try {
97
+ return JSON.stringify(content);
98
+ }
99
+ catch {
100
+ return String(content);
101
+ }
102
+ }
103
+ /** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
104
+ * - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
105
+ * - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
106
+ * 简化:把所有看起来像「相对路径 + 文件名」的 token 抽出,留 narrow。
107
+ * - glob:content 是路径列表,按行 / 空格拆。
108
+ * - codegraph:content 里通常含 `path/to/file.ts:line`,按行拆,提 file 段。
109
+ *
110
+ * 返回值:命中过的 path 字符串集合(已 dedup)。失败返空集。 */
111
+ function extractProducerPaths(toolName, content) {
112
+ if (!content)
113
+ return [];
114
+ const out = new Set();
115
+ try {
116
+ if (toolName === 'grep') {
117
+ // 典型行:`src/foo.ts:42: hello world` 或 `path\to\file.ts:42: ...`
118
+ // 取冒号前段(冒号必须跟在数字前面避免切到路径里的冒号)。
119
+ const re = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/gm;
120
+ let m;
121
+ while ((m = re.exec(content)))
122
+ out.add(m[1]);
123
+ }
124
+ else if (toolName === 'glob') {
125
+ // glob 输出一般是「paths:」+ 换行 + 多路径;每行一个绝对或相对路径。
126
+ // 简化:按行切,跳过含空格的(避免命中 prose),取看起来像路径的行。
127
+ for (const line of content.split(/\r?\n/)) {
128
+ const t = line.trim();
129
+ if (!t || t.includes(' '))
130
+ continue;
131
+ // 含扩展名或含路径分隔符
132
+ if (/\.[A-Za-z0-9]+$/.test(t) || t.includes('/') || t.includes('\\'))
133
+ out.add(t);
134
+ }
135
+ }
136
+ else if (toolName === 'codegraph') {
137
+ // codegraph 输出通常 `path\to\file.ts:line:col symbol` 或类似;按行 + 冒号分隔。
138
+ for (const line of content.split(/\r?\n/)) {
139
+ const m = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/.exec(line);
140
+ if (m)
141
+ out.add(m[1]);
142
+ }
143
+ }
144
+ else if (toolName === 'web_search' || toolName === 'web_fetch') {
145
+ // 网络结果不在文件系统路径范畴;不参与 producer 路径索引(避免误匹配)。
146
+ }
147
+ }
148
+ catch {
149
+ // 永不抛错:任何解析失败返当前累积。
150
+ }
151
+ return [...out];
152
+ }
153
+ /**
154
+ * Observation Lifecycle Engine。
155
+ * 每个 runAgentCore 实例持一个;pushToolResult 出口调 pushTool、mutation 分支调 pushMutation。
156
+ * 内部 try/catch 兜底,对外永不抛错。
157
+ */
158
+ export class LifecycleEngine {
159
+ ageThreshold;
160
+ /** 工具消息 idx → 状态。 */
161
+ states = new Map();
162
+ /** 工具消息 idx → 被消费的次数(同一上游被多次消费也只算 REFERENCED,不计并发)。 */
163
+ consumerCount = new Map();
164
+ /** 工具消息 idx → 自 push 以来的「消费者 push」次数(用于老化判定)。
165
+ * 每次 pushTool 触发,所有 LIVE 工具消息 age++。 */
166
+ age = new Map();
167
+ /** producer 路径 → 生产者工具消息 idx 列表(逆查用:某个 path 被消费时,反查上游 producer)。
168
+ * 注意:不存 read_file,因为 read 自身就是消费者不充当 producer。 */
169
+ producersByPath = new Map();
170
+ /** 当前步序号(用于 age 老化:每次 pushTool 自增,对比 age 阈值)。 */
171
+ step = 0;
172
+ /** 最后 user 索引缓存(pushTool 时重算;pushMutation 时也重算,因为 mutation 可能跟 user 同行)。 */
173
+ lastUser = -1;
174
+ constructor(ageThreshold = DEFAULT_AGE_THRESHOLD) {
175
+ this.ageThreshold = ageThreshold;
176
+ }
177
+ /** 新工具结果 push 进 history 时调;idx = history.length - 1。
178
+ * mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
179
+ * pushMutation 标完 read REFERENCED 之后再触发),避免刚被 mutation 消费的 read 被提前 STUB。 */
180
+ pushTool(history, idx) {
181
+ try {
182
+ const m = history[idx];
183
+ if (!m || m.role !== 'tool')
184
+ return;
185
+ const toolName = toolNameOf(history, idx);
186
+ if (!toolName)
187
+ return;
188
+ // 已 stub 的不重复登记(幂等)。
189
+ const c = toText(m.content);
190
+ if (c.startsWith('⌦['))
191
+ return;
192
+ // 登记为 LIVE。
193
+ this.states.set(idx, 'LIVE');
194
+ this.consumerCount.set(idx, 0);
195
+ this.age.set(idx, 0);
196
+ // 如果是 producer 类工具(grep/glob/codegraph),登记其命中的路径。
197
+ if (OBSERVER_TOOLS.has(toolName)) {
198
+ const paths = extractProducerPaths(toolName, c);
199
+ for (const p of paths) {
200
+ const arr = this.producersByPath.get(p) ?? [];
201
+ if (!arr.includes(idx))
202
+ arr.push(idx);
203
+ this.producersByPath.set(p, arr);
204
+ }
205
+ }
206
+ // 如果是 consumer 类工具(read/edit/write),找出上游「被消费的 producer」,标 REFERENCED。
207
+ if (CONSUMER_TOOLS.has(toolName)) {
208
+ const argsRaw = (() => {
209
+ // tool 消息本身没有 args;args 在前导 assistant.tool_calls 里;直接走同 idx 前的 assistant。
210
+ const tcId = m.tool_call_id;
211
+ for (let j = idx - 1; j >= 1; j--) {
212
+ const mm = history[j];
213
+ if (mm.role !== 'assistant')
214
+ continue;
215
+ const tcs = mm.tool_calls;
216
+ const hit = tcs?.find((tc) => tc?.id === tcId);
217
+ if (hit)
218
+ return hit.function?.arguments ?? '';
219
+ }
220
+ return '';
221
+ })();
222
+ const path = extractPath(argsRaw);
223
+ if (path) {
224
+ // 1) 找该 path 的所有上游 producer(grep/glob/codegraph)→ 标 REFERENCED。
225
+ const producers = this.producersByPath.get(path);
226
+ if (producers) {
227
+ for (const pidx of producers) {
228
+ if (this.states.get(pidx) === 'LIVE') {
229
+ this.states.set(pidx, 'REFERENCED');
230
+ }
231
+ this.consumerCount.set(pidx, (this.consumerCount.get(pidx) ?? 0) + 1);
232
+ }
233
+ }
234
+ // 2) 同 path 的旧 read_file(被本 read「替代」)→ 也标 REFERENCED(由 Relevance Pruner 已 stub)。
235
+ // 这里不重复操作,Relevance Pruner 那边管「内容已被新 read 替代」的语义。
236
+ }
237
+ }
238
+ // 所有 LIVE 工具消息 age++(本次 push 算一步)。stale 的 READ 消息也涨 age,直到 ≥ 阈值才可能 STUB。
239
+ for (const k of this.states.keys()) {
240
+ this.age.set(k, (this.age.get(k) ?? 0) + 1);
241
+ }
242
+ this.step++;
243
+ this.lastUser = lastUserIndex(history);
244
+ // mutation 工具跳过本轮 autoStubOrphans:调用方会调 pushMutation 标完 read REFERENCED 后,
245
+ // 再调 flushAutoStub 触发老化检查,避免 read 在被标 REFERENCED 之前被提前 STUB。
246
+ if (MUTATION_TOOLS.has(toolName))
247
+ return;
248
+ // 老化检查:本次 push 完,扫描 LIVE(且非观察类)的工具消息,age ≥ 阈值 → 标 OBSOLETE → STUB。
249
+ this.autoStubOrphans(history);
250
+ }
251
+ catch {
252
+ // 永不抛错。
253
+ }
254
+ }
255
+ /** mutation 工具(edit_file/write_file)push 后调。语义与 pushTool 一致,但额外标记「被 mutation 消费」的 read。
256
+ * 注意:本层不直接 stub read(那是 Relevance Pruner 的职责);本层只更新状态图。
257
+ * 注意:puhToolResult 出口已经登记过 mutation 本身,这里不再调 pushTool(避免 age 翻倍)。 */
258
+ pushMutation(history, mutationIdx, path) {
259
+ try {
260
+ // mutation 自身已在 pushToolResult 出口登记(若 lifecycle 存在);此处仅做「mutation 是
261
+ // path 的消费者」语义:把该 path 在 mutation 之前的所有 read_file(未被 stub 的 LIVE/REFERENCED)
262
+ // 标 REFERENCED。
263
+ const protectedFrom = Math.max(0, this.lastUser);
264
+ for (let i = 1; i < mutationIdx; i++) {
265
+ if (i >= protectedFrom)
266
+ continue;
267
+ const m = history[i];
268
+ if (m?.role !== 'tool')
269
+ continue;
270
+ const tn = toolNameOf(history, i);
271
+ if (tn !== 'read_file')
272
+ continue;
273
+ const c = toText(m.content);
274
+ if (c.startsWith('⌦['))
275
+ continue;
276
+ const argsRaw = this.findToolArgs(history, i);
277
+ if (extractPath(argsRaw) === path) {
278
+ if (this.states.get(i) === 'LIVE')
279
+ this.states.set(i, 'REFERENCED');
280
+ this.consumerCount.set(i, (this.consumerCount.get(i) ?? 0) + 1);
281
+ }
282
+ }
283
+ // 标完 read REFERENCED 后,统一跑老化检查(本次 mutation push 之前 pushTool 已跳过)。
284
+ this.autoStubOrphans(history);
285
+ }
286
+ catch {
287
+ // 永不抛错。
288
+ }
289
+ }
290
+ /** 老化自动 STUB:扫描所有 LIVE(且非观察类)且 age ≥ 阈值且不在保护区的工具消息 → OBSOLETE → STUB。 */
291
+ autoStubOrphans(history) {
292
+ try {
293
+ const protectedFrom = Math.max(0, this.lastUser);
294
+ for (const [idx, state] of this.states) {
295
+ if (state !== 'LIVE')
296
+ continue;
297
+ if (idx >= protectedFrom)
298
+ continue; // 当前轮保护区
299
+ const age = this.age.get(idx) ?? 0;
300
+ if (age < this.ageThreshold)
301
+ continue;
302
+ const tn = toolNameOf(history, idx);
303
+ if (!tn)
304
+ continue;
305
+ // 观察类工具永远只到 REFERENCED,不自动 STUB(用户拍板)。
306
+ if (OBSERVER_TOOLS.has(tn)) {
307
+ this.states.set(idx, 'REFERENCED');
308
+ continue;
309
+ }
310
+ // 执行 STUB。
311
+ this.stubOne(history, idx, tn);
312
+ }
313
+ }
314
+ catch {
315
+ // 永不抛错。
316
+ }
317
+ }
318
+ /** 实际替换 content 为存根。 */
319
+ stubOne(history, idx, toolName) {
320
+ try {
321
+ const m = history[idx];
322
+ if (!m)
323
+ return;
324
+ const c = toText(m.content);
325
+ if (c.startsWith('⌦['))
326
+ return; // 幂等
327
+ const origLen = c.length;
328
+ const stub = `${STUB_PREFIX_NO_CONSUMER} ${toolName} ${origLen} 字符 → 老化无消费者,自动归档`;
329
+ m.content = stub;
330
+ this.states.set(idx, 'STUB');
331
+ }
332
+ catch {
333
+ // 永不抛错。
334
+ }
335
+ }
336
+ /** 找某条 tool 消息对应的 assistant.tool_calls.arguments。 */
337
+ findToolArgs(history, idx) {
338
+ try {
339
+ const tcId = history[idx].tool_call_id;
340
+ if (!tcId)
341
+ return '';
342
+ for (let j = idx - 1; j >= 1; j--) {
343
+ const mm = history[j];
344
+ if (mm.role !== 'assistant')
345
+ continue;
346
+ const tcs = mm.tool_calls;
347
+ const hit = tcs?.find((tc) => tc?.id === tcId);
348
+ if (hit)
349
+ return hit.function?.arguments ?? '';
350
+ }
351
+ }
352
+ catch {
353
+ // 永不抛错。
354
+ }
355
+ return '';
356
+ }
357
+ // ── 观测 API(供 /context 面板、调试脚本用) ─────────────────────────────
358
+ /** 拿某 idx 的当前状态;不在图里返 null。 */
359
+ getState(idx) {
360
+ return this.states.get(idx) ?? null;
361
+ }
362
+ /** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, obsolete=K, stubbed=S」。 */
363
+ stats() {
364
+ let live = 0;
365
+ let referenced = 0;
366
+ let obsolete = 0;
367
+ let stubbed = 0;
368
+ for (const s of this.states.values()) {
369
+ if (s === 'LIVE')
370
+ live++;
371
+ else if (s === 'REFERENCED')
372
+ referenced++;
373
+ else if (s === 'OBSOLETE')
374
+ obsolete++;
375
+ else if (s === 'STUB')
376
+ stubbed++;
377
+ }
378
+ return { live, referenced, obsolete, stubbed };
379
+ }
380
+ }
381
+ /** 默认单例工厂。runAgentCore 入口 new 一个,后续 pushTool / pushMutation 共享。 */
382
+ export function createLifecycleEngine(ageThreshold) {
383
+ return new LifecycleEngine(ageThreshold);
384
+ }