mocode-ai 0.6.5 → 0.6.7

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.
@@ -1,68 +1,134 @@
1
- const GREP_RE = /^(.*?):(\d+):(.*)$/;
1
+ const LEGACY_GREP_RE = /^(.*?):(\d+):(.*)$/;
2
+ const STRUCTURED_HEADER_RE = /^(.*): (\d+) 处匹配,行号 \[([0-9,\s]+)\]$/;
3
+ const STRUCTURED_BODY_RE = /^\s{2}L\d+:/;
4
+ const STRUCTURED_FOLDED_RE = /^\s{2}\(body 已折叠/;
5
+ function isAgedCold(input) {
6
+ return input.phase === 'sweep' && input.isCold === true && (input.age ?? 0) >= 2;
7
+ }
8
+ /** Current grep output already has lossless file headers; Cold drops body previews only. */
9
+ function collapseStructuredGrep(output) {
10
+ const lines = output.split('\n');
11
+ const out = [];
12
+ let files = 0;
13
+ let inFile = false;
14
+ for (const line of lines) {
15
+ if (STRUCTURED_HEADER_RE.test(line)) {
16
+ files++;
17
+ inFile = true;
18
+ out.push(line);
19
+ continue;
20
+ }
21
+ if (inFile && (STRUCTURED_BODY_RE.test(line) || STRUCTURED_FOLDED_RE.test(line))) {
22
+ continue;
23
+ }
24
+ inFile = false;
25
+ out.push(line);
26
+ }
27
+ return files > 0 ? { text: out.join('\n'), files } : null;
28
+ }
29
+ function encodeLegacyGrep(output) {
30
+ const lines = output.split('\n');
31
+ const matches = [];
32
+ const tail = [];
33
+ let inTail = false;
34
+ for (const line of lines) {
35
+ if (!line)
36
+ continue;
37
+ if (inTail) {
38
+ tail.push(line);
39
+ continue;
40
+ }
41
+ const match = LEGACY_GREP_RE.exec(line);
42
+ if (match) {
43
+ matches.push({ file: match[1], line: match[2], content: match[3] });
44
+ }
45
+ else {
46
+ inTail = true;
47
+ tail.push(line);
48
+ }
49
+ }
50
+ if (matches.length === 0)
51
+ return null;
52
+ const groups = new Map();
53
+ const order = [];
54
+ for (const match of matches) {
55
+ if (!groups.has(match.file)) {
56
+ groups.set(match.file, []);
57
+ order.push(match.file);
58
+ }
59
+ groups.get(match.file).push({ line: match.line, content: match.content });
60
+ }
61
+ const out = [`# ${matches.length} matches · ${order.length} files · search-encoded`];
62
+ for (const file of order) {
63
+ out.push(`${file}:`);
64
+ for (const item of groups.get(file))
65
+ out.push(` ${item.line}:${item.content}`);
66
+ }
67
+ if (tail.length)
68
+ out.push(...tail);
69
+ return { text: out.join('\n'), matches: matches.length, files: order.length };
70
+ }
71
+ function collapseLegacyBodies(text) {
72
+ if (!text.startsWith('# ') || !text.includes('search-encoded'))
73
+ return text;
74
+ return text
75
+ .split('\n')
76
+ .filter((line) => !/^\s{2}\d+:/.test(line))
77
+ .join('\n');
78
+ }
2
79
  export const searchEncoder = {
3
80
  kind: 'search',
4
- encode({ output }) {
5
- const lines = output.split('\n');
6
- const matches = [];
7
- const tail = [];
8
- let inTail = false;
9
- for (const l of lines) {
10
- if (!l)
11
- continue;
12
- if (inTail) {
13
- tail.push(l);
14
- continue;
15
- }
16
- const m = GREP_RE.exec(l);
17
- if (m) {
18
- matches.push({ file: m[1], line: m[2], content: m[3] });
19
- }
20
- else {
21
- // 首个非 grep 行起视作 tail(grep 上限标记 / 无匹配串 / web_search 非 grep 结构)
22
- inTail = true;
23
- tail.push(l);
24
- }
25
- }
26
- if (matches.length === 0) {
27
- // 非 grep 格式(web_search 等)→ 不动其已格式化结构
81
+ encode(input) {
82
+ const structured = collapseStructuredGrep(input.output);
83
+ if (structured) {
84
+ const text = isAgedCold(input) ? structured.text : input.output;
28
85
  return {
29
- text: output,
86
+ text,
30
87
  meta: {
31
88
  kind: 'search',
32
- originalLen: output.length,
33
- encodedLen: output.length,
34
- note: 'no file:line matches → passthrough',
89
+ originalLen: input.output.length,
90
+ encodedLen: text.length,
91
+ note: isAgedCold(input)
92
+ ? `${structured.files} files · Cold body previews removed`
93
+ : `${structured.files} structured grep files · passthrough`,
35
94
  },
36
95
  };
37
96
  }
38
- const groups = new Map();
39
- const order = [];
40
- for (const m of matches) {
41
- if (!groups.has(m.file)) {
42
- groups.set(m.file, []);
43
- order.push(m.file);
44
- }
45
- groups.get(m.file).push({ line: m.line, content: m.content });
97
+ // A previous pass may already have transformed legacy file:line output.
98
+ if (input.output.startsWith('# ') && input.output.includes('search-encoded')) {
99
+ const text = isAgedCold(input) ? collapseLegacyBodies(input.output) : input.output;
100
+ return {
101
+ text,
102
+ meta: {
103
+ kind: 'search',
104
+ originalLen: input.output.length,
105
+ encodedLen: text.length,
106
+ note: isAgedCold(input) ? 'Cold legacy bodies removed' : 'already encoded',
107
+ },
108
+ };
46
109
  }
47
- const out = [
48
- `# ${matches.length} matches · ${order.length} files · search-encoded`,
49
- ];
50
- for (const file of order) {
51
- out.push(`${file}:`);
52
- for (const { line, content } of groups.get(file)) {
53
- out.push(` ${line}:${content}`);
54
- }
110
+ const legacy = encodeLegacyGrep(input.output);
111
+ if (!legacy) {
112
+ return {
113
+ text: input.output,
114
+ meta: {
115
+ kind: 'search',
116
+ originalLen: input.output.length,
117
+ encodedLen: input.output.length,
118
+ note: 'no recognized grep structure → passthrough',
119
+ },
120
+ };
55
121
  }
56
- if (tail.length)
57
- out.push(...tail);
58
- const text = out.join('\n');
122
+ const text = isAgedCold(input)
123
+ ? collapseLegacyBodies(legacy.text)
124
+ : legacy.text;
59
125
  return {
60
126
  text,
61
127
  meta: {
62
128
  kind: 'search',
63
- originalLen: output.length,
129
+ originalLen: input.output.length,
64
130
  encodedLen: text.length,
65
- note: `${matches.length} matches / ${order.length} files`,
131
+ note: `${legacy.matches} matches / ${legacy.files} files${isAgedCold(input) ? ' · Cold bodies removed' : ''}`,
66
132
  },
67
133
  };
68
134
  },
@@ -8,4 +8,4 @@ export { optimizeToolResult } from './pipeline.js';
8
8
  export { classify, knownToolKinds } from './classifier.js';
9
9
  export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
10
10
  // ── Context Budget Scheduler ───────────────────────────────────────────────
11
- export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
11
+ export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, DEFAULT_BUDGET_POLICY, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
@@ -58,7 +58,7 @@ function budgetFor(name) {
58
58
  * @param argsRaw 工具 arguments 原始 JSON 字符串(tc.arguments,可空;未传则 args=null)
59
59
  * @returns 进 history 的 content 字符串(永不抛错)
60
60
  */
61
- export function optimizeToolResult(name, output, argsRaw) {
61
+ export function optimizeToolResult(name, output, argsRaw, context = {}) {
62
62
  boot();
63
63
  // 总开关关闭:完全走老路径,零行为变化(Phase 1 默认 true,但保留紧急回退开关)。
64
64
  if (!config.contextOptimize) {
@@ -73,6 +73,10 @@ export function optimizeToolResult(name, output, argsRaw) {
73
73
  output,
74
74
  args,
75
75
  budget: budgetFor(name),
76
+ age: context.age ?? 0,
77
+ isCold: context.isCold ?? false,
78
+ isFirstRead: context.isFirstRead,
79
+ phase: context.phase ?? 'push',
76
80
  });
77
81
  // 末尾长度裁剪兜底(同改造前):encoder 已更短则 no-op;use_skill/memory_search 的放宽 cap 由此保留。
78
82
  return capToolResultForHistory(name, text);
@@ -1,93 +1,129 @@
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
- import { canonicalizePath, extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
26
- /** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
27
- * 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
28
- const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
1
+ // Relevance Pruner: statically removes tool results that a newer observation supersedes.
2
+ // It never deletes messages or changes tool_call_id pairing; only tool content is stubbed.
3
+ import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, } from './utils.js';
4
+ /** Shared prefix lets /context count read and observation supersession together. */
5
+ const STUB_PREFIX = '⌦[已过时:';
6
+ const READ_STUB_REASON = '同 path 已有新 read / 已被 mutation 覆写';
7
+ function parseArgs(raw) {
8
+ try {
9
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
10
+ return parsed && typeof parsed === 'object'
11
+ ? parsed
12
+ : null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function normalizedInteger(value, fallback) {
19
+ const n = Number(value);
20
+ return Number.isFinite(n) ? Math.trunc(n) : fallback;
21
+ }
22
+ /** Only complete semantic-query equality is safe for whole-message replacement. */
23
+ function observationKey(call) {
24
+ const args = call.args;
25
+ if (!args)
26
+ return null;
27
+ if (call.name === 'grep') {
28
+ if (typeof args.pattern !== 'string')
29
+ return null;
30
+ const rawMax = normalizedInteger(args.max_per_file, 15);
31
+ return JSON.stringify({
32
+ tool: 'grep',
33
+ pattern: args.pattern,
34
+ glob: typeof args.glob === 'string' ? args.glob : '**/*',
35
+ maxPerFile: Math.min(Math.max(rawMax, 1), 50),
36
+ });
37
+ }
38
+ if (call.name === 'codegraph') {
39
+ if (typeof args.action !== 'string' || typeof args.query !== 'string')
40
+ return null;
41
+ const query = args.action === 'explore'
42
+ ? args.query.trim().replace(/\s+/g, ' ')
43
+ : args.query.trim();
44
+ return JSON.stringify({
45
+ tool: 'codegraph',
46
+ action: args.action,
47
+ query,
48
+ file: typeof args.file === 'string' ? args.file.trim().replace(/\\/g, '/') : '',
49
+ offset: args.offset === undefined ? null : normalizedInteger(args.offset, 0),
50
+ limit: args.limit === undefined ? null : normalizedInteger(args.limit, 0),
51
+ });
52
+ }
53
+ return null;
54
+ }
55
+ function observationLabel(call) {
56
+ const args = call.args ?? {};
57
+ if (call.name === 'grep') {
58
+ const pattern = JSON.stringify(String(args.pattern ?? '')).slice(0, 80);
59
+ const glob = JSON.stringify(String(args.glob ?? '**/*')).slice(0, 80);
60
+ return `grep(pattern=${pattern}, glob=${glob})`;
61
+ }
62
+ const action = String(args.action ?? '');
63
+ const query = JSON.stringify(String(args.query ?? '')).slice(0, 100);
64
+ return `codegraph(action=${action}, query=${query})`;
65
+ }
29
66
  /**
30
- * 维护「path → 该 path 所有 read_file tool 消息的 history index」映射。
31
- * - observePush:把刚 push 的 read_file tool 消息登记,并把同 path 的"更早" read 全部 stub。
32
- * - observeMutation:把该 mutation path 的"在 mutation 之前的" read 全部 stub。
33
- *
34
- * 设计:每个 agent 会话(每个 runAgentCore 实例)持有一个 pruner。会话结束/换 plan 时
35
- * 可新建;不持久化(history 重建时索引自然过期)。
36
- *
37
- * 零依赖:仅依赖 ChatMessage 形状;不 import llm / tools / agent。
67
+ * Cross-message relevance pruning:
68
+ * - read_file: a newer successful read of the same canonical path supersedes old reads.
69
+ * - grep/codegraph: a newer successful call with the exact same semantic arguments
70
+ * supersedes old results. Partial file overlap is intentionally not enough.
38
71
  */
39
72
  export class RelevancePruner {
40
- /** path → [history index, ...] 按插入序;最新在末尾。 */
41
73
  readByPath = new Map();
42
- /** 把刚 push 的消息通知 pruner。
43
- * - 只处理成功的 read_file tool 消息;失败读取不能淘汰旧的有效结果。
44
- * - 登记当前 canonical path,并反向 stub 同 path 旧 read。
45
- */
74
+ observationByKey = new Map();
46
75
  observePush(history, msg, succeeded = true) {
47
76
  try {
48
77
  if (!succeeded || msg.role !== 'tool')
49
78
  return;
50
- const m = msg;
51
79
  const idx = history.length - 1;
52
80
  if (idx < 1 || history[idx] !== msg)
53
- return; // 防御:必须刚 push 到末尾
54
- if (toolNameOf(history, idx) !== 'read_file')
55
81
  return;
56
82
  const content = toText(msg.content);
57
83
  if (content.startsWith(STUB_PREFIX))
58
- return; // 已是存根(防御)
59
- const path = this.pathAt(history, idx);
60
- if (!path)
61
- return;
62
- // 先 stub 旧 read(同 canonical path,idx 之前),再登记新 idx。
63
- this.stubPriorReads(history, path, idx);
64
- const list = this.readByPath.get(path);
65
- if (list)
84
+ return;
85
+ const call = this.callAt(history, idx);
86
+ if (!call)
87
+ return;
88
+ if (call.name === 'read_file') {
89
+ const path = canonicalizePath(extractPath(call.argsRaw));
90
+ if (!path)
91
+ return;
92
+ this.stubPriorReads(history, path, idx);
93
+ const list = this.readByPath.get(path) ?? [];
66
94
  list.push(idx);
67
- else
68
- this.readByPath.set(path, [idx]);
95
+ this.readByPath.set(path, list);
96
+ return;
97
+ }
98
+ const key = observationKey(call);
99
+ if (!key)
100
+ return;
101
+ this.stubPriorObservations(history, call.name, key, idx);
102
+ const list = this.observationByKey.get(key) ?? [];
103
+ list.push(idx);
104
+ this.observationByKey.set(key, list);
69
105
  }
70
106
  catch {
71
- /* 永不抛错 */
107
+ // Relevance pruning must never block tool-result insertion.
72
108
  }
73
109
  }
74
- /** 从 tool_call arguments 取得并规范化某条 read_file 的路径。 */
75
- pathAt(history, idx) {
110
+ callAt(history, idx) {
76
111
  const tcId = history[idx]?.tool_call_id;
77
112
  if (!tcId)
78
113
  return null;
79
114
  for (let j = idx - 1; j >= 1; j--) {
80
- const mm = history[j];
81
- if (mm.role !== 'assistant')
115
+ const message = history[j];
116
+ if (message.role !== 'assistant')
117
+ continue;
118
+ const calls = message.tool_calls;
119
+ const hit = calls?.find((tc) => tc?.id === tcId);
120
+ if (!hit?.function?.name)
82
121
  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));
122
+ const argsRaw = hit.function.arguments ?? '';
123
+ return { name: hit.function.name, argsRaw, args: parseArgs(argsRaw) };
87
124
  }
88
125
  return null;
89
126
  }
90
- /** 成功 mutation 后,把该 canonical path 在 mutation 之前的 read 全部 stub。 */
91
127
  observeMutation(history, path) {
92
128
  try {
93
129
  const canonicalPath = canonicalizePath(path);
@@ -100,93 +136,102 @@ export class RelevancePruner {
100
136
  this.readByPath.delete(canonicalPath);
101
137
  }
102
138
  catch {
103
- /* 永不抛错 */
139
+ // Never throw from mutation cleanup.
104
140
  }
105
141
  }
106
- /**
107
- * 把 history 里 "canonical path 同 + index < beforeIdx + 不在当前轮保护区" 的 read_file
108
- * tool 消息替换为存根。索引是快路径,全表扫描用于恢复 resume 历史;两条路径都重新校验 path。
109
- */
110
142
  stubPriorReads(history, path, beforeIdx) {
111
143
  const targetPath = canonicalizePath(path);
112
144
  if (!targetPath)
113
145
  return;
114
- const guard = lastUserIndex(history);
115
- const protectedFrom = guard > 0 ? guard : 0;
116
- const stubOne = (i) => {
117
- if (i >= beforeIdx)
146
+ const protectedFrom = Math.max(0, lastUserIndex(history));
147
+ const stubOne = (idx) => {
148
+ if (idx >= beforeIdx || (protectedFrom > 0 && idx >= protectedFrom))
118
149
  return;
119
- if (i >= protectedFrom && protectedFrom > 0)
120
- return; // 当前轮保护区
121
- const m = history[i];
122
- if (!m || m.role !== 'tool')
150
+ const message = history[idx];
151
+ if (!message || message.role !== 'tool')
123
152
  return;
124
- const content = toText(m.content);
153
+ const content = toText(message.content);
125
154
  if (content.startsWith(STUB_PREFIX))
126
- return; // 幂等
127
- if (toolNameOf(history, i) !== 'read_file')
128
155
  return;
129
- if (this.pathAt(history, i) !== targetPath)
130
- return; // 防止 fallback 扫描误裁其他文件
131
- const tcId = m.tool_call_id;
132
- if (!tcId)
156
+ const call = this.callAt(history, idx);
157
+ if (call?.name !== 'read_file')
158
+ return;
159
+ if (canonicalizePath(extractPath(call.argsRaw)) !== targetPath)
160
+ return;
161
+ if (!message.tool_call_id)
133
162
  return;
134
- const stub = `${STUB_PREFIX} read_file(${targetPath}) ${content.length} 字符 → 已被新 read / mutation 替代 · id …${tcId.slice(-6)}⌫`;
135
- m.content = stub;
163
+ message.content =
164
+ `${STUB_PREFIX}${READ_STUB_REASON}] read_file(${targetPath}) ${content.length} 字符 ` +
165
+ `→ 已被新 read / mutation 替代 · id …${message.tool_call_id.slice(-6)}⌫`;
136
166
  };
137
- const indexed = this.readByPath.get(targetPath);
138
- if (indexed) {
139
- for (const i of indexed)
140
- stubOne(i);
141
- }
167
+ for (const idx of this.readByPath.get(targetPath) ?? [])
168
+ stubOne(idx);
142
169
  const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
143
- for (let i = 1; i < scanEnd; i++)
144
- stubOne(i);
170
+ for (let idx = 1; idx < scanEnd; idx++)
171
+ stubOne(idx);
172
+ }
173
+ stubPriorObservations(history, toolName, key, beforeIdx) {
174
+ const protectedFrom = Math.max(0, lastUserIndex(history));
175
+ const stubOne = (idx) => {
176
+ if (idx >= beforeIdx || (protectedFrom > 0 && idx >= protectedFrom))
177
+ return;
178
+ const message = history[idx];
179
+ if (!message || message.role !== 'tool')
180
+ return;
181
+ const content = toText(message.content);
182
+ if (content.startsWith(STUB_PREFIX) || !isToolResultSuccess(content))
183
+ return;
184
+ const call = this.callAt(history, idx);
185
+ if (!call || call.name !== toolName || observationKey(call) !== key)
186
+ return;
187
+ if (!message.tool_call_id)
188
+ return;
189
+ const reason = toolName === 'grep'
190
+ ? '相同 grep 查询已有更新结果'
191
+ : '相同 codegraph 查询已有更新结果';
192
+ message.content =
193
+ `${STUB_PREFIX}${reason}] ${observationLabel(call)} ${content.length} 字符 ` +
194
+ `→ 已被更新查询替代 · id …${message.tool_call_id.slice(-6)}⌫`;
195
+ };
196
+ for (const idx of this.observationByKey.get(key) ?? [])
197
+ stubOne(idx);
198
+ // The fallback scan restores correctness after resume/compact when this instance
199
+ // has no index for older messages.
200
+ const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
201
+ for (let idx = 1; idx < scanEnd; idx++)
202
+ stubOne(idx);
145
203
  }
146
204
  }
147
- /** 默认单例:每个 agent 循环一个。runAgentCore 入口 new 一个,后续 observe 共享。 */
148
205
  export function createRelevancePruner() {
149
206
  return new RelevancePruner();
150
207
  }
151
- /** 解析一条 stub 字符串,提取原 content 长度(若可解析)。失败返 null。 */
208
+ /** Parse the original content length recorded by any relevance stub. */
152
209
  function parseStubOriginalLen(stub) {
153
- // 格式:⌦[已过时:同 path 已有新 read / 已被 mutation 覆写] read_file(<path>) <N> 字符 → ...
154
- const m = / read_file\([^)]+\) (\d+) 字符 /.exec(stub);
155
- if (!m)
210
+ const match = /\) (\d+) 字符 →/.exec(stub);
211
+ if (!match)
156
212
  return null;
157
- const n = Number(m[1]);
158
- return Number.isFinite(n) && n >= 0 ? n : null;
213
+ const value = Number(match[1]);
214
+ return Number.isFinite(value) && value >= 0 ? value : null;
159
215
  }
160
- /**
161
- * 扫 history,统计被相关性裁剪 stub 的 read_file tool 消息(条数 + 原字节数)。
162
- * 供 /context 渲染统计行用(让用户直观看到「prune 帮了多少」)。
163
- * 永不抛错(对齐本模块契约);history 为空 / 无 stub 时返零值。
164
- *
165
- * 注意:stub 后只剩 stub 字符串(原 content 已丢失),故只能从 stub 字符串里 parse
166
- * 原字节数,误差 = stub 时记录的 content.length(精确);token 估算走 estimateTokens。
167
- */
216
+ /** Aggregate relevance/lifecycle compression for the /context panel. */
168
217
  export function computePruneStats(history) {
169
218
  let stubbed = 0;
170
219
  let originalChars = 0;
171
220
  let stubChars = 0;
172
- for (const m of history) {
173
- if (m.role !== 'tool')
221
+ for (const message of history) {
222
+ if (message.role !== 'tool')
174
223
  continue;
175
- const c = toText(m.content);
176
- // Relevance Pruner 的 stub(⌦[已过时:...) 和 Lifecycle Engine 的 DIGEST(⌦[摘要:...) 都统计。
177
- const isPruneStub = c.startsWith(STUB_PREFIX);
178
- const isDigest = c.startsWith('⌦[摘要:');
224
+ const content = toText(message.content);
225
+ const isPruneStub = content.startsWith(STUB_PREFIX);
226
+ const isDigest = content.startsWith('⌦[摘要:');
179
227
  if (!isPruneStub && !isDigest)
180
228
  continue;
181
229
  stubbed++;
182
- stubChars += c.length;
183
- const orig = parseStubOriginalLen(c);
184
- if (orig != null)
185
- originalChars += orig;
230
+ stubChars += content.length;
231
+ const original = parseStubOriginalLen(content);
232
+ if (original != null)
233
+ originalChars += original;
186
234
  }
187
- // token 估算:用 estimateTokens(懒导入,避免循环依赖 llm)
188
- // 这里偷懒:走粗略 chars/4(中文混合下会过估,安全侧)
189
- // 准确应调 estimateTokens,但 /context 已经是粗算,误差可接受
190
235
  const originalTokens = Math.ceil(originalChars / 4);
191
236
  const stubTokens = Math.ceil(stubChars / 4);
192
237
  return {