mocode-ai 0.4.0 → 0.4.1

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,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
+ }
@@ -0,0 +1,284 @@
1
+ // Relevance Pruner:read_file 相关性裁剪(纯静态分析,零 LLM 调用)。
2
+ //
3
+ // 场景(用户描述):
4
+ // 1) read foo.ts → 后来又 read foo.ts → 旧结果无价值 → 替换为存根
5
+ // 2) read foo.ts → edit foo.ts → read foo.ts → 中间那次 read 之前的所有旧 read
6
+ // 在 mutation 之后已失效 → 替换为存根
7
+ //
8
+ // 与现有子系统的关系:
9
+ // - Context Optimization Pipeline(`pipeline.ts`):单条上限 + 类型化编码。本层在其外,
10
+ // 在 pushToolResult 出口再做一次"跨条"裁剪。
11
+ // - drop_context(`session/drop.ts`):agent 主动剔除已知无关的旧 tool 结果。本层是被动自动,
12
+ // 不需要 agent 调;两者并存不冲突。
13
+ // - compact(`session/compact.ts`):阈值触发的整体微压缩+摘要。本层只裁"明确失效"的旧 read,
14
+ // 不触发摘要;门槛更低、零成本。
15
+ //
16
+ // 不变量(对齐 drop_context / compact):
17
+ // - 只改 .content,不删消息、不动 tool_call_id、不动 tool_calls 数组结构。
18
+ // - 当前轮保护区:不剔除"最后一个 user 消息及其之后"的 read_file 结果(agent 本轮还在用,
19
+ // 剔除会破坏正在进行的推理)。实现复用 drop.ts 的 lastUserIndex 思路。
20
+ // - 幂等:已 stub(含「已过时」标记)不重复 stub,避免反复重写同一条消息。
21
+ // - 永不抛错(对齐「调度器永不抛错」契约);无匹配 / 解析失败 / 异常 → 静默 no-op。
22
+ // - TUI 渲染(hooks.onToolResult)用原始 output,与本层解耦——屏上看全量,LLM 看裁剪后版。
23
+ //
24
+ // 零行为变化兜底:开关 `config.contextRelprune` 关闭时,pipeline 路径完全不调本模块。
25
+ /** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
26
+ * 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
27
+ const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
28
+ /** 解析工具 arguments(只关心 path);非法返 null。 */
29
+ function extractPath(argsRaw) {
30
+ if (!argsRaw)
31
+ return null;
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(argsRaw);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ if (!parsed || typeof parsed !== 'object')
40
+ return null;
41
+ const p = parsed.path;
42
+ if (typeof p !== 'string' || !p)
43
+ return null;
44
+ return p;
45
+ }
46
+ /** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
47
+ * 复用 drop.ts 的思路:user 及其之后的 tool 结果视为当前轮保护区。
48
+ * 在本层里,read_file tool 消息若落在 user 之后,本轮还在用,不能 stub。
49
+ * 注:history[0] 是 system,user 不会落在 0;若 user 就在末尾(即 0 user 之后),
50
+ * protectedFrom=0 时整段历史都不可 stub(实际不会发生:pushToolResult 必在 user 之后)。 */
51
+ function lastUserIndex(history) {
52
+ for (let i = history.length - 1; i >= 1; i--) {
53
+ if (history[i].role === 'user')
54
+ return i;
55
+ }
56
+ return -1;
57
+ }
58
+ /** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。
59
+ * 返回 null 表示找不到(孤儿 tool,极少见),本层保守不动。 */
60
+ function toolNameOf(history, idx) {
61
+ const tcId = history[idx].tool_call_id;
62
+ if (!tcId)
63
+ return null;
64
+ for (let j = idx - 1; j >= 1; j--) {
65
+ const m = history[j];
66
+ if (m.role !== 'assistant')
67
+ continue;
68
+ const tcs = m.tool_calls;
69
+ if (!tcs)
70
+ continue;
71
+ const hit = tcs.find((tc) => tc?.id === tcId);
72
+ if (hit)
73
+ return hit.function?.name ?? null;
74
+ }
75
+ return null;
76
+ }
77
+ /** 把 content 拍平成字符串(对齐 drop.ts)。 */
78
+ function toText(content) {
79
+ if (content == null)
80
+ return '';
81
+ if (typeof content === 'string')
82
+ return content;
83
+ try {
84
+ return JSON.stringify(content);
85
+ }
86
+ catch {
87
+ return String(content);
88
+ }
89
+ }
90
+ /**
91
+ * 维护「path → 该 path 所有 read_file tool 消息的 history index」映射。
92
+ * - observePush:把刚 push 的 read_file tool 消息登记,并把同 path 的"更早" read 全部 stub。
93
+ * - observeMutation:把该 mutation path 的"在 mutation 之前的" read 全部 stub。
94
+ *
95
+ * 设计:每个 agent 会话(每个 runAgentCore 实例)持有一个 pruner。会话结束/换 plan 时
96
+ * 可新建;不持久化(history 重建时索引自然过期)。
97
+ *
98
+ * 零依赖:仅依赖 ChatMessage 形状;不 import llm / tools / agent。
99
+ */
100
+ export class RelevancePruner {
101
+ /** path → [history index, ...] 按插入序;最新在末尾。 */
102
+ readByPath = new Map();
103
+ /** 把刚 push 的消息通知 pruner。
104
+ * - 只处理 tool 消息(role==='tool')。
105
+ * - 只关心 read_file:登记 + 反向 stub 同 path 旧 read。
106
+ * - 非 read_file 的 tool 消息:无操作(本层只管 read_file)。
107
+ * - 非 tool 消息(assistant / user / system):无操作。
108
+ */
109
+ observePush(history, msg) {
110
+ try {
111
+ if (msg.role !== 'tool')
112
+ return;
113
+ const m = msg;
114
+ const tcId = m.tool_call_id;
115
+ if (!tcId)
116
+ return;
117
+ const idx = history.length - 1;
118
+ if (idx < 1 || history[idx] !== msg)
119
+ return; // 防御:必须刚 push 到末尾
120
+ const name = toolNameOf(history, idx);
121
+ if (name !== 'read_file')
122
+ return;
123
+ const content = toText(msg.content);
124
+ if (content.startsWith(STUB_PREFIX))
125
+ return; // 已是存根(防御)
126
+ // 从前导 assistant.tool_calls 找对应 tc.arguments(精确 path 来源)。
127
+ // 退化方案:从消息内容首行解析路径(read_file 输出形如 `\n 1\t...`,无 path;
128
+ // 故必须从 args 取)。找不到则保守不动。
129
+ let path = null;
130
+ for (let j = idx - 1; j >= 1; j--) {
131
+ const mm = history[j];
132
+ if (mm.role !== 'assistant')
133
+ continue;
134
+ const tcs = mm.tool_calls;
135
+ if (!tcs)
136
+ continue;
137
+ const hit = tcs.find((tc) => tc?.id === tcId);
138
+ if (hit) {
139
+ path = extractPath(hit.function?.arguments);
140
+ break;
141
+ }
142
+ }
143
+ if (!path)
144
+ return;
145
+ // 先 stub 旧 read(同 path,idx 之前),再登记新 idx。
146
+ this.stubPriorReads(history, path, idx);
147
+ // 登记新 idx
148
+ const list = this.readByPath.get(path);
149
+ if (list)
150
+ list.push(idx);
151
+ else
152
+ this.readByPath.set(path, [idx]);
153
+ }
154
+ catch {
155
+ /* 永不抛错 */
156
+ }
157
+ }
158
+ /**
159
+ * 把该 mutation path 的"在 mutation 之前的" read 全部 stub。
160
+ * 通常用于 edit_file / write_file 工具:mutation 之后,之前的 read_file(p) 内容
161
+ * 已失效(已不再是文件当前状态),模型后续若依赖旧 read 来 edit_file 会失败,但 edit_file
162
+ * 的 old_string 来自模型记忆/后读,不依赖旧 read 结果文本。
163
+ *
164
+ * 调用时机:agent/core.ts 在 mutation 工具调用的 pushToolResult 之后立即调;
165
+ * 此时 history 末尾就是 mutation 的 tool 消息,prior reads 指 < idx。
166
+ */
167
+ observeMutation(history, path) {
168
+ try {
169
+ if (!path)
170
+ return;
171
+ const idx = history.length - 1;
172
+ if (idx < 1)
173
+ return;
174
+ // mutation 之前的所有同 path read → stub
175
+ this.stubPriorReads(history, path, idx);
176
+ // 该 path 的 read 索引全部作废(mutation 之后再 read 会重新登记)
177
+ this.readByPath.delete(path);
178
+ }
179
+ catch {
180
+ /* 永不抛错 */
181
+ }
182
+ }
183
+ /**
184
+ * 把 history 里 "path 同 + index < beforeIdx + 不在当前轮保护区" 的所有 read_file
185
+ * tool 消息替换为存根(只改 .content,不动 id / 数组结构)。
186
+ *
187
+ * 实现:
188
+ * - 用 readByPath[path] 直接拿到所有 index(已登记过),筛 < beforeIdx 的 stub。
189
+ * - 同时扫一遍 [1, beforeIdx) 区间找未登记的(防御:索引可能漏登;不依赖索引也能 stub,
190
+ * 保证正确性。索引只用于"避免重复扫全表"的优化)。
191
+ * - protectedFrom = lastUserIndex(history):user 之后一律不动。
192
+ * - 幂等:已是 STUB_PREFIX 的跳过。
193
+ */
194
+ stubPriorReads(history, path, beforeIdx) {
195
+ const STUB_PREFIX_LOCAL = STUB_PREFIX;
196
+ const guard = lastUserIndex(history);
197
+ // protectedFrom = 最后一个 user index(若 >0);user 之后(>= guard)的 read 永不动。
198
+ // protectedFrom=0 表示无 user(history 只有 system),整段都可 stub。
199
+ const protectedFrom = guard > 0 ? guard : 0;
200
+ const stubOne = (i) => {
201
+ if (i >= beforeIdx)
202
+ return;
203
+ if (i >= protectedFrom && protectedFrom > 0)
204
+ return; // 当前轮保护区
205
+ const m = history[i];
206
+ if (!m || m.role !== 'tool')
207
+ return;
208
+ const content = toText(m.content);
209
+ if (content.startsWith(STUB_PREFIX_LOCAL))
210
+ return; // 幂等
211
+ const name = toolNameOf(history, i);
212
+ if (name !== 'read_file')
213
+ return;
214
+ // 校验 tool_call_id 配对(防御:孤儿子消息不动)
215
+ const tcId = m.tool_call_id;
216
+ if (!tcId)
217
+ return;
218
+ const stub = `${STUB_PREFIX_LOCAL} read_file(${path}) ${content.length} 字符 → 已被新 read / mutation 替代 · id …${tcId.slice(-6)}⌫`;
219
+ m.content = stub;
220
+ };
221
+ // 1) 用 Map 索引(快路径)
222
+ const indexed = this.readByPath.get(path);
223
+ if (indexed) {
224
+ for (const i of indexed)
225
+ stubOne(i);
226
+ }
227
+ // 2) 全表扫一遍(防御:索引可能漏登 / 历史来自 resume)
228
+ // 仅扫 [1, beforeIdx) 且不在保护区内的 range,成本可控。
229
+ const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
230
+ for (let i = 1; i < scanEnd; i++) {
231
+ stubOne(i);
232
+ }
233
+ }
234
+ }
235
+ /** 默认单例:每个 agent 循环一个。runAgentCore 入口 new 一个,后续 observe 共享。 */
236
+ export function createRelevancePruner() {
237
+ return new RelevancePruner();
238
+ }
239
+ /** 解析一条 stub 字符串,提取原 content 长度(若可解析)。失败返 null。 */
240
+ function parseStubOriginalLen(stub) {
241
+ // 格式:⌦[已过时:同 path 已有新 read / 已被 mutation 覆写] read_file(<path>) <N> 字符 → ...
242
+ const m = / read_file\([^)]+\) (\d+) 字符 /.exec(stub);
243
+ if (!m)
244
+ return null;
245
+ const n = Number(m[1]);
246
+ return Number.isFinite(n) && n >= 0 ? n : null;
247
+ }
248
+ /**
249
+ * 扫 history,统计被相关性裁剪 stub 的 read_file tool 消息(条数 + 原字节数)。
250
+ * 供 /context 渲染统计行用(让用户直观看到「prune 帮了多少」)。
251
+ * 永不抛错(对齐本模块契约);history 为空 / 无 stub 时返零值。
252
+ *
253
+ * 注意:stub 后只剩 stub 字符串(原 content 已丢失),故只能从 stub 字符串里 parse
254
+ * 原字节数,误差 = stub 时记录的 content.length(精确);token 估算走 estimateTokens。
255
+ */
256
+ export function computePruneStats(history) {
257
+ let stubbed = 0;
258
+ let originalChars = 0;
259
+ let stubChars = 0;
260
+ for (const m of history) {
261
+ if (m.role !== 'tool')
262
+ continue;
263
+ const c = toText(m.content);
264
+ if (!c.startsWith(STUB_PREFIX))
265
+ continue;
266
+ stubbed++;
267
+ stubChars += c.length;
268
+ const orig = parseStubOriginalLen(c);
269
+ if (orig != null)
270
+ originalChars += orig;
271
+ }
272
+ // token 估算:用 estimateTokens(懒导入,避免循环依赖 llm)
273
+ // 这里偷懒:走粗略 chars/4(中文混合下会过估,安全侧)
274
+ // 准确应调 estimateTokens,但 /context 已经是粗算,误差可接受
275
+ const originalTokens = Math.ceil(originalChars / 4);
276
+ const stubTokens = Math.ceil(stubChars / 4);
277
+ return {
278
+ stubbed,
279
+ originalChars,
280
+ originalTokens,
281
+ stubChars,
282
+ freedTokens: Math.max(0, originalTokens - stubTokens),
283
+ };
284
+ }