mocode-ai 0.6.6 → 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.
@@ -12,13 +12,24 @@ import { getPlanDisabledTools } from '../tools/constants.js';
12
12
  import { getAgentMode, setAgentMode } from './mode.js';
13
13
  import { maybeCompact, contextState, dropContextFromHistory } from '../session/index.js';
14
14
  import { createBudgetScheduler } from '../session/scheduler.js';
15
- import { optimizeToolResult } from '../context/index.js';
15
+ import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary } from '../context/index.js';
16
+ import { createAgeAwareEncodingState, } from '../context/age-aware.js';
16
17
  import { createRelevancePruner } from '../context/relevance.js';
17
18
  import { isToolResultSuccess } from '../context/utils.js';
18
19
  import { config } from '../config/index.js';
19
20
  import { jailResolve } from '../sandbox/index.js';
20
21
  import { createLifecycleEngine } from '../context/lifecycle.js';
21
22
  import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
23
+ /** Stable per-history age state survives user turns; WeakMap avoids retaining closed sessions. */
24
+ const ageAwareStateByHistory = new WeakMap();
25
+ function ageAwareStateFor(history) {
26
+ const existing = ageAwareStateByHistory.get(history);
27
+ if (existing)
28
+ return existing;
29
+ const created = createAgeAwareEncodingState(history);
30
+ ageAwareStateByHistory.set(history, created);
31
+ return created;
32
+ }
22
33
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
23
34
  function parseArgs(raw) {
24
35
  try {
@@ -105,16 +116,17 @@ function readDiffContext(tc, parsed) {
105
116
  * - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
106
117
  * 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
107
118
  * - 开关关闭时 lifecycle=null 完全跳过。 */
108
- function pushToolResult(history, tc, output, pruner, lifecycle, scheduler, runtimeContextState = contextState) {
119
+ function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState) {
120
+ const succeeded = isToolResultSuccess(output);
121
+ const ageAware = config.contextOptimize ? ageAwareStateFor(history) : null;
122
+ const encodingContext = ageAware?.preparePush(tc, succeeded);
109
123
  const msg = {
110
124
  role: 'tool',
111
125
  tool_call_id: tc.id,
112
- // optimizeToolResult:classifier encoder → encode(保不变量压缩) capToolResultForHistory 兜底。
113
- // tc.arguments 透传给 encoder(上下文感知编码,如 read_file 的 offset/limit)。永不抛错。
114
- content: optimizeToolResult(tc.name, output, tc.arguments),
126
+ // 初次 push 始终保守(age=0);旧 Cold 结果在下一 step 的 sweep 中按类型降级。
127
+ content: optimizeToolResult(tc.name, output, tc.arguments, encodingContext),
115
128
  };
116
129
  history.push(msg);
117
- const succeeded = isToolResultSuccess(output);
118
130
  // 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
119
131
  if (pruner)
120
132
  pruner.observePush(history, msg, succeeded);
@@ -193,11 +205,14 @@ export async function runAgentCore(opts) {
193
205
  ? createLifecycleEngine(history)
194
206
  : null;
195
207
  runtimeContextState.lifecycleStats = lifecycle?.stats();
196
- // 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions
197
- // 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
208
+ // 预算调度器:每个 runAgentCore 实例一个,在 age-aware sweep 后评估并执行 warn / compact
209
+ // contextBudget 开关关闭时为 null。
198
210
  const scheduler = config.contextBudget !== false
199
211
  ? createBudgetScheduler(runtimeContextState) // 在 step 循环之外实例化一次,跨步持有 lastRunLog
200
212
  : null;
213
+ // age-aware encoder 与 history 数组同寿命;每轮重建一次以覆盖外部 /compact 等原地修改。
214
+ const ageAware = config.contextOptimize ? ageAwareStateFor(history) : null;
215
+ ageAware?.rehydrate(history);
201
216
  // 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
202
217
  let mode = 'idle';
203
218
  let gotText = false;
@@ -228,6 +243,7 @@ export async function runAgentCore(opts) {
228
243
  hooks.onAbort?.();
229
244
  history.length = 0;
230
245
  history.push(...savedHistory);
246
+ ageAware?.rehydrate(history);
231
247
  setAgentMode(savedMode);
232
248
  };
233
249
  try {
@@ -245,8 +261,10 @@ export async function runAgentCore(opts) {
245
261
  const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
246
262
  runtimeContextState.correction = storedCalibration.correction;
247
263
  runtimeContextState.calibrationSamples = storedCalibration.samples;
248
- // 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
249
- // 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
264
+ // 初次 tool push 只做保守编码;预算评估前先对 Cold age 达阈值的旧结果降级,
265
+ // 避免 scheduler 根据马上会被 sweep 的陈旧占用误触发 history compact。
266
+ ageAware?.sweep(history, userTurnBoundary(history, HOT_TURN_WINDOW));
267
+ // 步前:五区 Budget Scheduler 在优化后的 history 上决策;开关关闭时退化回原 maybeCompact 路径。
250
268
  // 此时 spinner 已停,通知行干净。
251
269
  let historyRebuilt = false;
252
270
  if (scheduler) {
@@ -256,11 +274,13 @@ export async function runAgentCore(opts) {
256
274
  const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools);
257
275
  historyRebuilt = compactResult?.historyRebuilt === true;
258
276
  }
259
- // compact 用新消息数组原地重建 history 后,旧 lifecycle 的数字 index 已全部失效。
260
- // 立即从新 history 恢复状态和 producer 索引,再允许后续 pushTool/pushMutation 使用。
261
- if (historyRebuilt && lifecycle) {
262
- lifecycle = createLifecycleEngine(history);
263
- runtimeContextState.lifecycleStats = lifecycle.stats();
277
+ // compact 用新消息数组原地重建 history 后,所有按消息位置恢复的状态都需重建。
278
+ if (historyRebuilt) {
279
+ if (lifecycle) {
280
+ lifecycle = createLifecycleEngine(history);
281
+ runtimeContextState.lifecycleStats = lifecycle.stats();
282
+ }
283
+ ageAware?.rehydrate(history);
264
284
  }
265
285
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
266
286
  mode = 'idle';
@@ -0,0 +1,136 @@
1
+ // Age-aware tool-result encoding coordinator.
2
+ // Initial pushes stay conservative; old Cold results are re-encoded before chat.
3
+ import { TOOL_OLD_AGE } from './budget.js';
4
+ import { optimizeToolResult } from './pipeline.js';
5
+ import { canonicalizePath, extractPath, isToolResultSuccess, toText, } from './utils.js';
6
+ /**
7
+ * Tracks successful first reads and tool-result age without coupling encoders to
8
+ * lifecycle's mutable history indexes. All methods are fail-safe and idempotent.
9
+ */
10
+ export class AgeAwareEncodingState {
11
+ pushOrdinal = 0;
12
+ records = new Map();
13
+ seenReadPaths = new Set();
14
+ constructor(history = []) {
15
+ this.rehydrate(history);
16
+ }
17
+ /** Build the conservative context for a newly completed tool result. */
18
+ preparePush(tc, succeeded) {
19
+ const path = tc.name === 'read_file'
20
+ ? canonicalizePath(extractPath(tc.arguments))
21
+ : null;
22
+ const isFirstRead = path ? !this.seenReadPaths.has(path) : undefined;
23
+ this.records.set(tc.id, {
24
+ toolCallId: tc.id,
25
+ toolName: tc.name,
26
+ argsRaw: tc.arguments,
27
+ pushOrdinal: this.pushOrdinal,
28
+ succeeded,
29
+ isFirstRead,
30
+ agedEncoded: false,
31
+ });
32
+ this.pushOrdinal++;
33
+ // Failed reads must not consume the "first successful read" privilege.
34
+ if (succeeded && path)
35
+ this.seenReadPaths.add(path);
36
+ return {
37
+ age: 0,
38
+ isCold: false,
39
+ isFirstRead,
40
+ phase: 'push',
41
+ };
42
+ }
43
+ /** Re-encode eligible tool messages in the Cold prefix in place. */
44
+ sweep(history, hotBoundary) {
45
+ try {
46
+ const end = Math.min(Math.max(hotBoundary, 1), history.length);
47
+ for (let idx = 1; idx < end; idx++) {
48
+ const message = history[idx];
49
+ if (message.role !== 'tool')
50
+ continue;
51
+ const toolMessage = message;
52
+ const id = toolMessage.tool_call_id;
53
+ const record = id ? this.records.get(id) : undefined;
54
+ if (!record || !record.succeeded || record.agedEncoded)
55
+ continue;
56
+ const content = toText(toolMessage.content);
57
+ if (!content || content.startsWith('⌦[')) {
58
+ record.agedEncoded = true;
59
+ continue;
60
+ }
61
+ // Exclude the result's own push: immediately after insertion its age is 0.
62
+ const age = Math.max(0, this.pushOrdinal - record.pushOrdinal - 1);
63
+ if (age < TOOL_OLD_AGE)
64
+ continue;
65
+ const encoded = optimizeToolResult(record.toolName, content, record.argsRaw, {
66
+ age,
67
+ isCold: true,
68
+ isFirstRead: record.isFirstRead,
69
+ phase: 'sweep',
70
+ });
71
+ // Aged encoding is a degradation step: never replace content with a
72
+ // representation that is equal-sized or larger.
73
+ if (encoded.length < content.length)
74
+ toolMessage.content = encoded;
75
+ record.agedEncoded = true;
76
+ }
77
+ }
78
+ catch {
79
+ // Context optimization must never block an agent request.
80
+ }
81
+ }
82
+ /** Rebuild stable state after resume or structural history compaction. */
83
+ rehydrate(history) {
84
+ this.pushOrdinal = 0;
85
+ this.records.clear();
86
+ this.seenReadPaths.clear();
87
+ try {
88
+ const calls = new Map();
89
+ for (const message of history) {
90
+ if (message.role === 'assistant') {
91
+ const toolCalls = message.tool_calls;
92
+ for (const tc of toolCalls ?? []) {
93
+ if (!tc.id || !tc.function?.name)
94
+ continue;
95
+ calls.set(tc.id, {
96
+ name: tc.function.name,
97
+ argsRaw: tc.function.arguments ?? '',
98
+ });
99
+ }
100
+ continue;
101
+ }
102
+ if (message.role !== 'tool')
103
+ continue;
104
+ const toolMessage = message;
105
+ const id = toolMessage.tool_call_id;
106
+ const call = id ? calls.get(id) : undefined;
107
+ if (!id || !call)
108
+ continue;
109
+ const content = toText(toolMessage.content);
110
+ const succeeded = isToolResultSuccess(content);
111
+ const path = call.name === 'read_file'
112
+ ? canonicalizePath(extractPath(call.argsRaw))
113
+ : null;
114
+ const isFirstRead = path ? !this.seenReadPaths.has(path) : undefined;
115
+ this.records.set(id, {
116
+ toolCallId: id,
117
+ toolName: call.name,
118
+ argsRaw: call.argsRaw,
119
+ pushOrdinal: this.pushOrdinal,
120
+ succeeded,
121
+ isFirstRead,
122
+ agedEncoded: false,
123
+ });
124
+ this.pushOrdinal++;
125
+ if (succeeded && path)
126
+ this.seenReadPaths.add(path);
127
+ }
128
+ }
129
+ catch {
130
+ // A partial rebuild is conservative: unknown records simply stay full.
131
+ }
132
+ }
133
+ }
134
+ export function createAgeAwareEncodingState(history = []) {
135
+ return new AgeAwareEncodingState(history);
136
+ }
@@ -1,29 +1,12 @@
1
1
  // 五区 Context Budget Scheduler。
2
2
  //
3
- // 目的:把当前四道独立闸(cap / pipeline / relevance / maybeCompact)统一为
4
- // 「先看预算报告,再按 ROI 排序调度」的单一入口。
3
+ // 目的:把当前请求拆成 System / History / Tool-Recent / Tool-Old / Summary + Reserve,
4
+ // 统一报告各区占用,并只调度执行层能够真正落地的 warn / compact_history。
5
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),完全跳过本模块,零行为变化。
6
+ // push-time cap、pipeline、relevance、lifecycle 与 age-aware sweep 负责工具结果优化;
7
+ // scheduler 在这些处理完成后评估,不重复生成 Cold/Hot tool 压缩动作。
8
+ // 本文件保持叶子级,只依赖 ChatMessage / token estimate,具体执行由 session/scheduler.ts 完成。
9
+ // contextBudget 开关关闭时,agent/core.ts 退化为直接调用 maybeCompact。
27
10
  import { chatTools, estimateMessagesTokens, estimateToolSchemaTokens, messageTokens, } from '../llm/index.js';
28
11
  import { toText } from './utils.js';
29
12
  /** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
@@ -35,24 +18,27 @@ export const BUDGET_LAYERS = [
35
18
  'summary',
36
19
  'reserve', // Reserve(不占内容,只占预算分配;5%)
37
20
  ];
38
- /** 占比(总和 = 0.95,留 5% 给 Reserve)。对齐用户修正版:
39
- * Recent Tool 25%(原 40% 偏大,因 Hot 区不该被压)+ Old Tool 25% 同等 +
40
- * History 20% + System 15% + Summary 10%(平时 0 占用,触发后才用) */
41
- export const BUDGET_RATIO = {
42
- system: 0.15,
43
- history: 0.20,
44
- toolRecent: 0.25,
45
- toolOld: 0.25,
46
- summary: 0.10,
47
- reserve: 0.05,
21
+ export const DEFAULT_BUDGET_POLICY = {
22
+ ratios: {
23
+ system: 0.15,
24
+ history: 0.20,
25
+ toolRecent: 0.25,
26
+ toolOld: 0.25,
27
+ summary: 0.10,
28
+ reserve: 0.05,
29
+ },
30
+ hotTurnWindow: 4,
31
+ toolOldAge: 2,
32
+ compactKeepRatio: 0.40,
33
+ totalTriggerRatio: 0.82,
34
+ schedulerTargetRatio: 0.80,
35
+ estimateSafetyFactor: 1.05,
36
+ compactHeadroomTokens: 1500,
48
37
  };
49
- /** Hot/Cold 划分:当前 step 起往前 HOT_TURN_WINDOW 个 user turn 之内的工具结果视为 Hot,
50
- * 之外的视为 Cold。0 = 全 Cold(等同老路径);越短 Hot 越小,压缩越激进。 */
51
- export const HOT_TURN_WINDOW = 4;
52
- /** 工具消息推入历史后,经过的「消费者 push 次数」即 age。
53
- * Cold 区内:age ≥ TOOL_OLD_AGE 的非观察类工具结果可被调度器就地 stub。
54
- * 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
55
- export const TOOL_OLD_AGE = 2;
38
+ /** 兼容既有调用方的只读别名;配置只在 DEFAULT_BUDGET_POLICY 中维护。 */
39
+ export const BUDGET_RATIO = DEFAULT_BUDGET_POLICY.ratios;
40
+ export const HOT_TURN_WINDOW = DEFAULT_BUDGET_POLICY.hotTurnWindow;
41
+ export const TOOL_OLD_AGE = DEFAULT_BUDGET_POLICY.toolOldAge;
56
42
  function msgTokens(m) {
57
43
  // 与请求预估复用同一实现,避免角色结构开销、多模态和 tool_calls 在两个预算路径中漂移。
58
44
  return messageTokens(m);
@@ -84,8 +70,12 @@ export function evaluateBudget(history, window, step = 0, correction = 1, active
84
70
  // 校正后的 token 数:raw * correction,最小 1(raw > 0 时)。
85
71
  const adj = (raw) => (raw > 0 ? Math.max(1, Math.round(raw * correction)) : 0);
86
72
  const sysMsg = history[0];
73
+ const systemCosts = {
74
+ prompt: sysMsg ? msgTokens(sysMsg) : 0,
75
+ toolSchemas: estimateToolSchemaTokens(activeTools),
76
+ };
87
77
  // 工具 schema 与 system prompt 同属请求固定开销;必须计入总量才能可靠触发压缩。
88
- layers.system.actual = adj((sysMsg ? msgTokens(sysMsg) : 0) + estimateToolSchemaTokens(activeTools));
78
+ layers.system.actual = adj(systemCosts.prompt + systemCosts.toolSchemas);
89
79
  // Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
90
80
  // 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
91
81
  // 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
@@ -130,61 +120,46 @@ export function evaluateBudget(history, window, step = 0, correction = 1, active
130
120
  // 按 overRatio 降序
131
121
  triggers.sort((a, b) => layers[b].overRatio - layers[a].overRatio);
132
122
  const total = BUDGET_LAYERS.reduce((s, k) => s + (k === 'reserve' ? 0 : layers[k].actual), 0);
133
- // 安全裕量:用 0.82 而非 0.85,预留 3% 给 correction 波动与新消息增量。
134
- const totalOver = total >= 0.82 * window;
123
+ const totalOver = total >= DEFAULT_BUDGET_POLICY.totalTriggerRatio * window;
135
124
  return {
136
125
  step,
137
126
  total,
138
127
  window,
139
128
  layers,
129
+ systemCosts,
140
130
  triggers,
141
131
  totalOver,
142
132
  hotBoundary: hotStart,
143
133
  correction,
144
134
  };
145
135
  }
146
- /** 根据 BudgetReport 生成调度动作(从轻到重,直至总占用回落到阈值以下)。
147
- * 规则:
148
- * - system warn(不压,配置问题不是内容问题)
149
- * - toolOld L1(中截超大)→ L2(same-path 已有 relevance)→ L3(age stub,新增)
150
- * - toolRecent 超 → cap(只降低单条上限,不 stub)
151
- * - history 超 或 totalOver → compact_history(调 maybeCompact / compactHistory)
152
- * - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算)
153
- *
154
- * 安全裕量:headroom 按 0.80 * window - total * 1.05 计算(预留 5% 应对 correction 误差
155
- * 与新消息增量),避免估算偏差导致被 API 硬截断。 */
136
+ /** 根据 BudgetReport 生成可执行动作。
137
+ * push-time cap、relevance、lifecycle 与 age-aware sweep 已在评估前完成,
138
+ * 因此这里不再生成无法执行的 Cold/Hot tool action。
139
+ * History 或总量超预算时才考虑昂贵的 LLM 摘要。 */
156
140
  export function scheduleActions(report) {
157
141
  const actions = [];
158
142
  const { layers, totalOver, total } = report;
159
- // 收紧:0.80 阈值(原 0.85)+ total * 1.05 放大(估算不确定性缓冲)
160
- const headroom = 0.80 * report.window - total * 1.05;
161
- // system → warn,不是 schedule 目标
143
+ const policy = DEFAULT_BUDGET_POLICY;
144
+ const headroom = policy.schedulerTargetRatio * report.window
145
+ - total * policy.estimateSafetyFactor;
162
146
  if (layers.system.overBudget) {
147
+ const { prompt, toolSchemas } = report.systemCosts;
148
+ const { actual, budget } = layers.system;
149
+ const excess = actual - budget;
150
+ const percent = ((actual / Math.max(budget, 1)) * 100).toFixed(0);
163
151
  actions.push({
164
152
  kind: 'warn',
165
153
  layer: 'system',
166
- reason: `System prompt 超预算(${layers.system.actual} > ${layers.system.budget}),请检查配置/MOCODE.md`,
154
+ reason: `固定请求开销 ${actual}/${budget} tokens (+${excess}, ${percent}%);`
155
+ + `系统提示 ${prompt} + 工具 schema ${toolSchemas},校正 ×${report.correction.toFixed(2)}。`
156
+ + '系统提示偏高时检查 MOCODE.md;工具 schema 偏高时减少可用工具;CONTEXT_WINDOW_TOKENS 应匹配模型真实窗口。',
167
157
  });
168
158
  }
169
- // 渐进:toolOld(轻→重)
170
- if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.1) {
171
- actions.push({ kind: 'shrink_cold_tools', level: 1 });
172
- }
173
- if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.3) {
174
- actions.push({ kind: 'shrink_cold_tools', level: 2 });
175
- }
176
- if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.6) {
177
- actions.push({ kind: 'shrink_cold_tools', level: 3 });
178
- }
179
- // Hot 区只 cap
180
- if (layers.toolRecent.overBudget && layers.toolRecent.overRatio > 0.15) {
181
- actions.push({ kind: 'cap_hot_tools', aggressive: layers.toolRecent.overRatio > 0.5 });
182
- }
183
- // History / total 超 → 摘要(最贵);headroom < -1500 真正触发(原 -2000,裕量收紧后同步调低),让 cold tools 先动
184
- if ((layers.history.overBudget || totalOver) && headroom < -1500) {
159
+ if ((layers.history.overBudget || totalOver)
160
+ && headroom < -policy.compactHeadroomTokens) {
185
161
  actions.push({ kind: 'compact_history' });
186
162
  }
187
- // 排序(同 kind 已在上面排好):warn → cold L1→L2→L3 → cap_hot → compact_history
188
163
  return actions;
189
164
  }
190
165
  /** 拍平成人类可读(供 /context 命令与 check-budget 脚本用)。 */