@zhushanwen/pi-smart-context 0.4.4 → 0.5.0
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.
- package/README.md +1 -1
- package/package.json +6 -5
- package/skills/smart-context-ext-config/SKILL.md +6 -2
- package/src/__tests__/compact-handler.test.ts +1178 -6
- package/src/__tests__/index-compaction-signal.test.ts +207 -0
- package/src/__tests__/index-generation.test.ts +34 -39
- package/src/__tests__/index-manual-compact-command.test.ts +262 -0
- package/src/__tests__/index-reminder-delivery.test.ts +1 -1
- package/src/__tests__/notices.test.ts +194 -0
- package/src/__tests__/pure.test.ts +20 -2
- package/src/__tests__/summary.test.ts +11 -8
- package/src/__tests__/tool.test.ts +88 -79
- package/src/compact-handler.ts +947 -28
- package/src/index.ts +236 -17
- package/src/notices.ts +189 -3
- package/src/pure.ts +17 -1
- package/src/reminder.ts +4 -3
- package/src/tool.ts +85 -125
package/src/index.ts
CHANGED
|
@@ -3,33 +3,67 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - session_start:session 级闭包状态重建(规范 Session 隔离:熔断计数不跨 session)
|
|
5
5
|
* + fired 档位从 session entries 重建(D15:reload/进程重启同 session 不重复提醒)
|
|
6
|
+
* + 会话替换类启动取消进行中后台压缩任务(D3 第一层,new/resume/fork)
|
|
6
7
|
* - subagent 进程(R6):不注册工具、不提醒(宁缺勿污)
|
|
7
8
|
* - session_before_compact:双模式接管(compact-handler)
|
|
8
|
-
* - session_compact:重置提醒 fired(D3)
|
|
9
|
-
*
|
|
9
|
+
* - session_compact:重置提醒 fired(D3,与后台落盘成功回调共用 resetFiredThresholds)
|
|
10
|
+
* + 后台任务被抢先取消(D3 第一层——后台路径自身落盘不发该事件,收到即代表其他压缩
|
|
11
|
+
* 抢先落盘)
|
|
12
|
+
* - session_shutdown:后台任务取消(D3 第一层)
|
|
13
|
+
* - session_tree:仅观测告警(D3 rejected:会话内回退不破坏压缩正确性,不取消)
|
|
14
|
+
* - agent_settled:后台压缩自动触发(D4:触发线/互斥/门控/独立熔断判定内聚 manager)
|
|
15
|
+
* + 越档检查 + marker 持久化(D15)+ nextTurn 投递一次性提醒(D3/D4①)
|
|
16
|
+
* - registerCompactContextTool:注入 background 契约(U5——工具路径与自动触发共享
|
|
17
|
+
* 互斥容器;工具纯触发化后无异步回调,isCtxStale 过渡字段退役)
|
|
18
|
+
* - 后台压缩落盘段 stale ctx 防线(C-pi-17):manager 的 isCtxStale 注入位由本文件的
|
|
19
|
+
* 稳定间接层供给,经续段钩子转交 persistBackgroundCompaction——落盘段跨 await 持有
|
|
20
|
+
* ctx,session 替换窗口内放弃落盘回报 dropped(会话态失效类放弃,不递增熔断计数)
|
|
21
|
+
* - 后台任务终局:完成/失败通知经 nextTurn 投递(U5/D6——validated 完成、失败不静默,
|
|
22
|
+
* 熔断达到阈值并入该条通知;cancelled 事件取消不发);通知投递失败由 compact-handler
|
|
23
|
+
* 的 settle 内吞留痕,不改写压缩成败事实
|
|
24
|
+
* - 后台压缩落盘信号(C1 分界行实时化):persistBackgroundCompaction 成功的事实点
|
|
25
|
+
* (onPersisted 回调)经 sendCompactionPersistSignal 发 custom message(display:false、
|
|
26
|
+
* {triggerTurn:false} 无 deliverAs,空闲时立即 append)——taiji runtime 分诊命中后取
|
|
27
|
+
* compaction entry 合成对话流分界行帧,分界行不再只能重开可见
|
|
10
28
|
* - model_select:跨界通知 + downshift 提醒(D5/D4①)
|
|
11
29
|
*
|
|
12
30
|
* 通知投递路径(D4①,delivery-ownership-kernel):四类通知全部经 notices.ts 的
|
|
13
31
|
* sendSmartContextNotice 走 nextTurn(随下一次 prompt 注入、不自起 run)——通知 run 不再
|
|
14
|
-
*
|
|
32
|
+
* 抢占用户消息投递跑道。手动触发失败回执(start-failed/busy)走即时投递(见 notices.ts
|
|
33
|
+
* sendSmartContextImmediateNotice)。后台压缩全程不掐 turn(C2 后手动压缩不经 pi 同步
|
|
34
|
+
* compact 链,进行中回合不受影响),无「被压缩掐断的 turn」需续跑兜底。
|
|
15
35
|
*/
|
|
16
36
|
|
|
17
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
38
|
import { guardStaleCtx, isSubagentProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
19
39
|
import { setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
20
40
|
|
|
21
41
|
import {
|
|
42
|
+
createBackgroundCompactionManager,
|
|
43
|
+
createBackgroundCompactionState,
|
|
22
44
|
createBeforeCompactHandler,
|
|
23
45
|
createTakeoverState,
|
|
24
46
|
debugLog,
|
|
47
|
+
persistBackgroundCompaction,
|
|
48
|
+
type AfterGenerationHook,
|
|
49
|
+
type BackgroundCompactionState,
|
|
25
50
|
type TakeoverState,
|
|
26
51
|
} from "./compact-handler.js";
|
|
27
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
sendSmartContextImmediateNotice,
|
|
54
|
+
sendSmartContextNotice,
|
|
55
|
+
buildCompactBusyNotice,
|
|
56
|
+
buildCompactCompleteNotice,
|
|
57
|
+
buildCompactFailureNotice,
|
|
58
|
+
buildCompactStartFailureNotice,
|
|
59
|
+
sendCompactionPersistSignal,
|
|
60
|
+
} from "./notices.js";
|
|
28
61
|
import { buildDownshiftNotice, buildSwitchNotice, buildThresholdReminder } from "./reminder.js";
|
|
29
62
|
import { registerCompactContextTool } from "./tool.js";
|
|
30
63
|
import {
|
|
31
64
|
countCompactions,
|
|
32
65
|
deriveFiredThresholds,
|
|
66
|
+
estimateTextTokens,
|
|
33
67
|
findCrossedThresholds,
|
|
34
68
|
FIRED_ENTRY_CUSTOM_TYPE,
|
|
35
69
|
getCurrentModelId,
|
|
@@ -37,10 +71,13 @@ import {
|
|
|
37
71
|
loadSmartContextConfig,
|
|
38
72
|
type EntryLike,
|
|
39
73
|
} from "./pure.js";
|
|
74
|
+
import type { BackgroundTaskSettlement } from "./compact-handler.js";
|
|
40
75
|
|
|
41
76
|
/** session 级闭包状态(规范:模块级仅工厂函数,状态在 session_start 重建)。 */
|
|
42
77
|
interface SessionState {
|
|
43
78
|
takeover: TakeoverState;
|
|
79
|
+
/** 后台压缩任务状态(U2:进行中互斥 + 独立熔断计数)。 */
|
|
80
|
+
background: BackgroundCompactionState;
|
|
44
81
|
/** 已提醒档位(token 值为键;session_compact 清空,D3)。 */
|
|
45
82
|
firedThresholds: Set<number>;
|
|
46
83
|
/** fired 是否已从 session entries seed 过(D15:每次状态重建恰好 seed 一次)。 */
|
|
@@ -49,7 +86,12 @@ interface SessionState {
|
|
|
49
86
|
|
|
50
87
|
/** session 级状态唯一构造点(初始态与 session_start 重建同源,新增字段不落两处)。 */
|
|
51
88
|
function createSessionState(): SessionState {
|
|
52
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
takeover: createTakeoverState(),
|
|
91
|
+
background: createBackgroundCompactionState(),
|
|
92
|
+
firedThresholds: new Set(),
|
|
93
|
+
firedSeeded: false,
|
|
94
|
+
};
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
// G1 代际检测(crash-resilience D1,同 scheduler/src/index.ts 的模块级代数计数器范式):
|
|
@@ -82,8 +124,9 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
82
124
|
let state: SessionState = createSessionState();
|
|
83
125
|
|
|
84
126
|
// G1 代际比对闭包(isCtxStale):session_start 装配本代比对(读实时模块级代数),
|
|
85
|
-
//
|
|
86
|
-
// pi 错误文案。首个 session_start 前无
|
|
127
|
+
// 供下方事件回调的 guardStaleCtx 前置检查使用(工具侧消费已随 U4 纯触发化退役——
|
|
128
|
+
// 见工具注册点注释)——stale 分诊不依赖 pi 错误文案。首个 session_start 前无
|
|
129
|
+
// session,恒 false 为安全默认。
|
|
87
130
|
let isCtxStale: () => boolean = () => false;
|
|
88
131
|
|
|
89
132
|
/**
|
|
@@ -124,7 +167,110 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
124
167
|
}
|
|
125
168
|
};
|
|
126
169
|
|
|
127
|
-
|
|
170
|
+
// ── 后台压缩任务管理(U2:触发判定/互斥/独立熔断/事件取消;U3:续段落盘 + 膨胀段共享读取)──
|
|
171
|
+
// getState 读当前代容器(state.background),manager 自身跨代存活无需重建。
|
|
172
|
+
// 落盘续段(U3/D3 第二层/D2/D6):三项兜底校验 + appendCompaction 直调,落盘成功回调
|
|
173
|
+
// 清 firedThresholds(D6 档位重置补偿,与 session_compact 监听共用 resetFiredThresholds),
|
|
174
|
+
// 收缩校验失败回调写共享膨胀段集合(与同步路径同段不重试同源)。续段跨 await 持有 ctx,
|
|
175
|
+
// 经下方 isCurrentCtxStale 注入位接 C-pi-17 stale 防线(落盘段内 guardStaleCtx 分诊)。
|
|
176
|
+
const resetFiredThresholds = (): void => {
|
|
177
|
+
state.firedThresholds.clear();
|
|
178
|
+
};
|
|
179
|
+
const backgroundAfterGeneration: AfterGenerationHook = (result, snapshot, ctx, isCtxStale) =>
|
|
180
|
+
persistBackgroundCompaction(
|
|
181
|
+
ctx,
|
|
182
|
+
result,
|
|
183
|
+
snapshot,
|
|
184
|
+
{
|
|
185
|
+
onPersisted: (entryId) => {
|
|
186
|
+
resetFiredThresholds();
|
|
187
|
+
debugLog(`fired thresholds reset after background compaction (sessionId=${snapshot.sessionId})`);
|
|
188
|
+
// C1 分界行实时化:落盘信号挂在「落盘成功」事实点(本回调在 appendCompaction
|
|
189
|
+
// 成功后的同一同步帧内、guardStaleCtx 之中,非 stale 由外层保证),不在终局
|
|
190
|
+
// 回调点(settle 已跨 await 且不带 entryId)。发送失败由 sendCompactionPersistSignal
|
|
191
|
+
// 内吞留痕(上抛会把已落盘的压缩改记失败,见该函数注释)。
|
|
192
|
+
sendCompactionPersistSignal(pi, entryId);
|
|
193
|
+
},
|
|
194
|
+
onInflated: (firstKeptEntryId) => {
|
|
195
|
+
state.takeover.inflatedSegments.add(firstKeptEntryId);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
isCtxStale,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// ── 后台压缩终局通知(U5:D6 补偿的呈现半边)──
|
|
202
|
+
// 每个终局点恰一条通知(cancelled 事件取消不发——D3 取消非失败):validated → 完成
|
|
203
|
+
// 通知(压缩前后 token 数与模式);inflated/dropped/empty/error → 失败通知不静默,
|
|
204
|
+
// 熔断达到阈值时文案附加「自动触发已暂停」。压缩后 token 口径:estimatedTokensAfter
|
|
205
|
+
// (pi 原生可填)优先,缺省按「压缩前 − 被压段 + 摘要」估算(chars/4 口径与收缩校验
|
|
206
|
+
// 同源)。异步收尾时 session 可能已替换——guardStaleCtx 分诊,stale 静默降级。本回调
|
|
207
|
+
// 抛错(非 stale 的上抛语义)由 compact-handler 的 settle 内吞留痕:通知失败不得把已
|
|
208
|
+
// validated 的压缩改记失败并无谓递增熔断计数。
|
|
209
|
+
const onBackgroundSettled = (settlement: BackgroundTaskSettlement): void => {
|
|
210
|
+
if (settlement.outcome === "validated") {
|
|
211
|
+
const result = settlement.result;
|
|
212
|
+
if (!result) {
|
|
213
|
+
debugLog(`compact complete settlement missing result (sessionId=${settlement.sessionId})`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const shadowed = settlement.shadowedTokens ?? 0;
|
|
217
|
+
const tokensAfter =
|
|
218
|
+
result.estimatedTokensAfter ?? Math.max(0, result.tokensBefore - shadowed + estimateTextTokens(result.summary));
|
|
219
|
+
const text = buildCompactCompleteNotice({
|
|
220
|
+
mode: settlement.mode,
|
|
221
|
+
tokensBefore: result.tokensBefore,
|
|
222
|
+
tokensAfter,
|
|
223
|
+
});
|
|
224
|
+
debugLog(`compact complete notice queued (sessionId=${settlement.sessionId} tokens=${result.tokensBefore}->${tokensAfter})`);
|
|
225
|
+
guardStaleCtx(() => {
|
|
226
|
+
sendSmartContextNotice(pi, text, "compact-complete");
|
|
227
|
+
}, {
|
|
228
|
+
isCtxStale,
|
|
229
|
+
label: "smart-context:compact-complete-notice",
|
|
230
|
+
onStale: (error) => debugLog(`compact complete notice skipped (stale ctx): ${toErrorMessage(error)}`),
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const text = buildCompactFailureNotice({
|
|
235
|
+
outcome: settlement.outcome,
|
|
236
|
+
droppedReason: settlement.droppedReason,
|
|
237
|
+
error: settlement.error,
|
|
238
|
+
failureStreak: settlement.failureStreak,
|
|
239
|
+
circuitBreakerOpen: settlement.circuitBreakerOpen,
|
|
240
|
+
});
|
|
241
|
+
debugLog(`compact failure notice queued (sessionId=${settlement.sessionId} outcome=${settlement.outcome})`);
|
|
242
|
+
guardStaleCtx(() => {
|
|
243
|
+
sendSmartContextNotice(pi, text, "compact-failed");
|
|
244
|
+
}, {
|
|
245
|
+
isCtxStale,
|
|
246
|
+
label: "smart-context:compact-failed-notice",
|
|
247
|
+
onStale: (error) => debugLog(`compact failure notice skipped (stale ctx): ${toErrorMessage(error)}`),
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// 代际检查的稳定间接层(C-pi-17 注入位):isCtxStale 在 session_start 被重赋值(本代
|
|
252
|
+
// 比对),而 manager 在 factory 体内创建、早于首个 session_start——直接传引用会把初始
|
|
253
|
+
// 的 () => false 冻结成永不 stale,故经箭头读实时绑定(与事件回调内 isCtxStale 的 live
|
|
254
|
+
// 读取同语义)。
|
|
255
|
+
const isCurrentCtxStale = (): boolean => isCtxStale();
|
|
256
|
+
|
|
257
|
+
const background = createBackgroundCompactionManager(
|
|
258
|
+
pi,
|
|
259
|
+
() => state.background,
|
|
260
|
+
loadSmartContextConfig,
|
|
261
|
+
backgroundAfterGeneration,
|
|
262
|
+
() => state.takeover.inflatedSegments,
|
|
263
|
+
onBackgroundSettled,
|
|
264
|
+
isCurrentCtxStale,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
pi.on("session_start", (event, ctx) => {
|
|
268
|
+
// D3 第一层(SI4):new/resume/fork = 会话替换,进行中后台任务立即取消放弃结果
|
|
269
|
+
// (必须在重建 state 前 abort——否则旧任务失控跑完白花生成费用);startup/reload
|
|
270
|
+
// 不取消(startup 时任务不存在;reload 使扩展工厂重跑、内存态自清)
|
|
271
|
+
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
272
|
+
background.cancelActive(`session_start(${event.reason})`);
|
|
273
|
+
}
|
|
128
274
|
// 先递增模块级代数再装配:自此同模块环境内所有前代闭包的 isCtxStale 返回 true
|
|
129
275
|
sessionGeneration += 1;
|
|
130
276
|
const myGeneration = sessionGeneration;
|
|
@@ -133,6 +279,19 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
133
279
|
seedFiredThresholds(ctx);
|
|
134
280
|
});
|
|
135
281
|
|
|
282
|
+
// session_shutdown(D3 第一层):扩展运行时因退出/reload/会话替换被拆除前到达——有任务即取消
|
|
283
|
+
pi.on("session_shutdown", (event) => {
|
|
284
|
+
background.cancelActive(`session_shutdown(${event.reason})`);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// session_tree(D3 rejected 论证):会话内回退不产生语义错位(回退不取消,取消只浪费
|
|
288
|
+
// 已花生成费用)——仅观测告警
|
|
289
|
+
pi.on("session_tree", () => {
|
|
290
|
+
if (state.background.active) {
|
|
291
|
+
debugLog("session_tree during background compaction: keeping task (rollback does not invalidate snapshot)");
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
136
295
|
// ── 压缩生成接管(D1/D12)──
|
|
137
296
|
const beforeCompact = createBeforeCompactHandler(
|
|
138
297
|
pi,
|
|
@@ -141,20 +300,80 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
141
300
|
);
|
|
142
301
|
pi.on("session_before_compact", beforeCompact);
|
|
143
302
|
|
|
144
|
-
// ── 压缩完成:重置提醒档位(D3
|
|
303
|
+
// ── 压缩完成:重置提醒档位(D3)+ 后台任务被抢先取消(D3 第一层)──
|
|
304
|
+
// 后台路径自身落盘不发 session_compact(UD6:appendCompaction 纯追加无事件),
|
|
305
|
+
// 收到该事件必是其他压缩(同步接管/手动/原生兜底)抢先落盘;档位清空与后台落盘成功
|
|
306
|
+
// 回调共用 resetFiredThresholds(D6 补偿共用化)
|
|
145
307
|
pi.on("session_compact", (_event, _ctx) => {
|
|
146
|
-
|
|
308
|
+
background.cancelActive("session_compact");
|
|
309
|
+
resetFiredThresholds();
|
|
147
310
|
});
|
|
148
311
|
|
|
149
312
|
// ── 工具注册(常驻,不可用态由 execute 运行时校验拒绝,D5)──
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
registerCompactContextTool(pi, {
|
|
313
|
+
// background 注入(U5 接线残项落地):isActive/startForTool 与 agent_settled 自动触发
|
|
314
|
+
// 共享同一 session 级互斥容器(RL1),工具路径跳过触发线与独立熔断(SI5)。
|
|
315
|
+
// 工具已无异步回调(U4 纯触发化),旧 isCtxStale 过渡字段随注入一并退役——代际
|
|
316
|
+
// 分诊仅剩事件回调内的 guardStaleCtx 消费(闭包内读变量绑定,天然 live)。
|
|
317
|
+
registerCompactContextTool(pi, {
|
|
318
|
+
background: { isActive: background.isActive, start: background.startForTool },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── 手动触发入口(C2 /compact 后台化):GUI 手动 /compact 的扩展侧命令 ──
|
|
322
|
+
// taiji runtime 收到 session.compactBackground RPC 后经 client.prompt fire-and-forget
|
|
323
|
+
// 发 `/__taiji_compact__ <encoded>`(先例 __taiji_bg_reconcile__,session-service)。
|
|
324
|
+
// `__` 双下划线前缀 = 内部命令(前端 internal-command-filter 过滤不显示);命令同步
|
|
325
|
+
// 执行、不进模型、无 turn(pi agent-session _tryExecuteExtensionCommand)。
|
|
326
|
+
// args 编码契约(与 runtime 发送侧成对):runtime 侧 encodeURIComponent(customInstructions)
|
|
327
|
+
// 后拼在命令名后(percent 编码无空格/换行,args = 首空格后原样余串可逆还原;空串 =
|
|
328
|
+
// 未携带,pi 切分语义 PS-63 实锚 agent-session.js:1604-1608)。
|
|
329
|
+
// 回执路径三分:启动成功不发通知(GUI 提示由 runtime reply → renderer toast 承载,
|
|
330
|
+
// 不双投);false = 后台任务进行中,幂等回执经即时通知通道投递(口径与 compact_context
|
|
331
|
+
// 工具 busy 回执一致);抛错 = 非互斥失败(门控拒绝/探测失败/无可压内容),失败文案经
|
|
332
|
+
// 即时通知通道投递后不再重抛——重抛只进 pi emitError 日志、用户不可见,通知才是用户
|
|
333
|
+
// 可达的失败呈现(发送失败由投递函数内吞留痕,与其他通知同语义)。两条回执走即时
|
|
334
|
+
// 形态(sendSmartContextImmediateNotice):承载的是用户刚敲的 /compact 的即时失败
|
|
335
|
+
// 事实,nextTurn 的 pending 队列会把它推迟到用户下一条消息后才可见——失败提示与
|
|
336
|
+
// 触发动作脱钩,且 renderer 受理 toast(「后台压缩已启动」)已先行,延迟到达 = 假成功
|
|
337
|
+
// 观感;即时形态空闲即达,与受理 toast 形成「受理≠成功、失败独立可见」的完整回执链。
|
|
338
|
+
pi.registerCommand("__taiji_compact__", {
|
|
339
|
+
description:
|
|
340
|
+
"Internal: start a background compaction on demand (taiji runtime manual /compact trigger; args = encodeURIComponent(customInstructions), empty = none)",
|
|
341
|
+
handler: async (args: string, _ctx: ExtensionCommandContext) => {
|
|
342
|
+
let customInstructions: string | undefined;
|
|
343
|
+
if (args.trim() !== "") {
|
|
344
|
+
try {
|
|
345
|
+
customInstructions = decodeURIComponent(args.trim());
|
|
346
|
+
} catch (error) {
|
|
347
|
+
debugLog(`__taiji_compact__ args decode failed: ${toErrorMessage(error)}`);
|
|
348
|
+
sendSmartContextImmediateNotice(
|
|
349
|
+
pi,
|
|
350
|
+
buildCompactStartFailureNotice(`压缩指引参数解码失败(预期百分号编码):${toErrorMessage(error)}`),
|
|
351
|
+
"compact-start-failed",
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
let started = false;
|
|
357
|
+
try {
|
|
358
|
+
started = background.startForTool(_ctx, customInstructions, "manual");
|
|
359
|
+
} catch (error) {
|
|
360
|
+
debugLog(`__taiji_compact__ start failed: ${toErrorMessage(error)}`);
|
|
361
|
+
sendSmartContextImmediateNotice(pi, buildCompactStartFailureNotice(toErrorMessage(error)), "compact-start-failed");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!started) {
|
|
365
|
+
debugLog("__taiji_compact__ busy: background compaction already running (idempotent receipt)");
|
|
366
|
+
sendSmartContextImmediateNotice(pi, buildCompactBusyNotice(), "compact-busy");
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
});
|
|
155
370
|
|
|
156
371
|
// ── 阈值提醒(D3/D4):agent_settled 越档检查 + marker 持久化 + nextTurn 一次性投递 ──
|
|
157
372
|
pi.on("agent_settled", (_event, ctx) => {
|
|
373
|
+
// 后台压缩自动触发(D4):门控/互斥/独立熔断/触发线/准备数据组装判定全部内聚
|
|
374
|
+
// maybeStart,与下方提醒链路相互独立(触发即后台执行,不阻塞本回调)
|
|
375
|
+
background.maybeStart(ctx);
|
|
376
|
+
|
|
158
377
|
const config = loadSmartContextConfig();
|
|
159
378
|
const modelId = getCurrentModelId(ctx.model);
|
|
160
379
|
if (!isGatingActive(config, modelId)) return;
|
|
@@ -163,7 +382,7 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
163
382
|
seedFiredThresholds(ctx);
|
|
164
383
|
|
|
165
384
|
const usage = ctx.getContextUsage();
|
|
166
|
-
if (!usage) return; // R7
|
|
385
|
+
if (!usage) return; // pi 观察级 R7(旧编号,见 compact-handler.ts 头注消歧):tokens 可能 null(压缩后首响应前)——findCrossedThresholds 容错
|
|
167
386
|
const crossed = findCrossedThresholds(config.reminderThresholds, usage.tokens, state.firedThresholds);
|
|
168
387
|
if (crossed.length === 0) return;
|
|
169
388
|
|
package/src/notices.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 通知注入唯一出口(D4①:四类通知 nextTurn
|
|
2
|
+
* 通知注入唯一出口(D4①:四类通知 nextTurn 化;C1:后台压缩落盘信号即时 append 化;
|
|
3
|
+
* C2 复审:手动触发失败回执即时可见化)。
|
|
3
4
|
*
|
|
4
5
|
* 四类通知(压缩结果 onComplete / onError、阈值提醒、model_select 跨界与降档两条)从
|
|
5
6
|
* `sendUserMessage(deliverAs:'steer'|'followUp')` 改为 `sendMessage(deliverAs:'nextTurn',
|
|
@@ -8,6 +9,17 @@
|
|
|
8
9
|
* 消息投递跑道」——故事 C 根因:压缩完成后通知 run 与用户消息同时 prompt(),后到者被 pi 以
|
|
9
10
|
* `Agent is already processing` 拒绝。
|
|
10
11
|
*
|
|
12
|
+
* 落盘信号(C1 分界行实时化)是第五条投递,走另一形态:`sendMessage({triggerTurn:false})`
|
|
13
|
+
* 无 deliverAs——空闲时 pi 立即 append 并 emit message_start/end(runtime 即时收到
|
|
14
|
+
* customStart 帧合成对话流分界行),streaming 窗口内挂回合尾(秒级延迟,接受)。
|
|
15
|
+
*
|
|
16
|
+
* 手动触发失败回执(compact-start-failed / compact-busy,C2 `__taiji_compact__` 命令路径)
|
|
17
|
+
* 走同一即时形态但可见(sendSmartContextImmediateNotice):nextTurn 的 pending 队列在用户
|
|
18
|
+
* 下一条消息前不可见,而这两条回执承载的是用户刚敲的 /compact 的即时失败事实——延迟到
|
|
19
|
+
* 下一条消息才出现 = 失败提示与触发动作脱钩(用户已看到「后台压缩已启动」的受理 toast,
|
|
20
|
+
* 假成功观感)。即时形态空闲即达(pi sendCustomMessage 空闲分支直接 append+emit),
|
|
21
|
+
* streaming 中挂回合尾(与 C1 信号同窗接受)。
|
|
22
|
+
*
|
|
11
23
|
* 语义定性(设计 D4「C-ext-19 合规定性」四类两分):
|
|
12
24
|
* - 阈值提醒 / model_select 两条 = **提醒语义**(与 scheduler 2026-09-15 逐条处理完毕先例同类:
|
|
13
25
|
* 提醒不承载必须必达的结果);
|
|
@@ -20,14 +32,25 @@
|
|
|
20
32
|
* 注入——pi 内建 overflow/threshold 自动压缩兜底在场,无人在场时上下文可能持续增长。
|
|
21
33
|
*/
|
|
22
34
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import {
|
|
36
|
+
COMPACTION_ENTRY_APPENDED_SOURCE,
|
|
37
|
+
SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
38
|
+
} from "@zhushanwen/extension-protocol";
|
|
23
39
|
|
|
24
|
-
|
|
25
|
-
|
|
40
|
+
import { debugLog } from "./compact-handler.js";
|
|
41
|
+
import type { DroppedReason } from "./compact-handler.js";
|
|
42
|
+
import { formatK } from "./pure.js";
|
|
43
|
+
|
|
44
|
+
// customType 单源在 extension-protocol(runtime 分诊同源消费),此处 re-export 保持既有
|
|
45
|
+
// import 路径(本扩展历史消费方)不变
|
|
46
|
+
export { SMART_CONTEXT_NOTICE_CUSTOM_TYPE };
|
|
26
47
|
|
|
27
48
|
/** 通知来源(进 details.source,排障定位用)。 */
|
|
28
49
|
export type SmartContextNoticeSource =
|
|
29
50
|
| "compact-complete"
|
|
30
51
|
| "compact-failed"
|
|
52
|
+
| "compact-busy"
|
|
53
|
+
| "compact-start-failed"
|
|
31
54
|
| "threshold-reminder"
|
|
32
55
|
| "model-switch"
|
|
33
56
|
| "model-downshift";
|
|
@@ -57,3 +80,166 @@ export function sendSmartContextNotice(
|
|
|
57
80
|
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
58
81
|
);
|
|
59
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 注入一条即时可见的 smart-context 通知(失败回执路径,不自起 run)。
|
|
86
|
+
*
|
|
87
|
+
* 与 sendSmartContextNotice 的差别只在投递选项:无 deliverAs + `{triggerTurn:false}`——
|
|
88
|
+
* pi sendCustomMessage 该形态空闲时直接 `_appendCustomMessage`(立即 append + emit,
|
|
89
|
+
* 不进 `_pendingNextTurnMessages`),streaming 窗口内挂回合尾(`_pendingCustomMessages`
|
|
90
|
+
* 回合尾 flush)。消费方是「用户刚敲的命令立刻失败」类回执(compact-start-failed /
|
|
91
|
+
* compact-busy):即时事实用即时通道,nextTurn 的「常驻至下一条用户消息」对它是延迟
|
|
92
|
+
* 而非必达(消息内容与触发动作脱钩)。字段载荷(customType/display/details.source)
|
|
93
|
+
* 与 nextTurn 形态完全一致——runtime 分诊不区分投递路径。
|
|
94
|
+
*/
|
|
95
|
+
export function sendSmartContextImmediateNotice(
|
|
96
|
+
pi: Pick<ExtensionAPI, "sendMessage">,
|
|
97
|
+
text: string,
|
|
98
|
+
source: SmartContextNoticeSource,
|
|
99
|
+
): void {
|
|
100
|
+
pi.sendMessage(
|
|
101
|
+
{
|
|
102
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
103
|
+
content: text,
|
|
104
|
+
display: true,
|
|
105
|
+
details: { source },
|
|
106
|
+
},
|
|
107
|
+
{ triggerTurn: false },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 完成通知数据(U5:D6 完成通知的组装输入;token 数为压缩前后口径)。 */
|
|
112
|
+
export interface CompactCompleteNoticeInput {
|
|
113
|
+
/** 本次压缩的模式(与生成入口判定同源)。 */
|
|
114
|
+
mode: "same-model" | "cross-model";
|
|
115
|
+
/** 压缩前上下文 token(触发快照口径)。 */
|
|
116
|
+
tokensBefore: number;
|
|
117
|
+
/** 压缩后上下文 token(estimatedTokensAfter 或「保留段 + 摘要」估算,接线层定口径)。 */
|
|
118
|
+
tokensAfter: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 后台压缩完成通知文案(D6:落盘成功后投递,含压缩前后 token 数与模式)。
|
|
123
|
+
* 「下一条消息起生效」语义登记 GL3/AS3:投影按请求现算,无需用户操作。
|
|
124
|
+
*/
|
|
125
|
+
export function buildCompactCompleteNotice(input: CompactCompleteNoticeInput): string {
|
|
126
|
+
return (
|
|
127
|
+
`[smart-context] 后台压缩完成(${input.mode} 模式):上下文 ` +
|
|
128
|
+
`${formatK(input.tokensBefore)} → ${formatK(input.tokensAfter)} tokens。` +
|
|
129
|
+
`下一条消息起将使用压缩后的上下文。`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 失败通知数据(U5:D6 失败通知的组装输入;条件 = 校验放弃/探测失败/生成空结果/异常/熔断)。 */
|
|
134
|
+
export interface CompactFailureNoticeInput {
|
|
135
|
+
/** 失败终局(dropped 涵盖落盘兜底校验放弃与 appendCompaction 探测失败两类)。 */
|
|
136
|
+
outcome: "inflated" | "dropped" | "empty" | "error";
|
|
137
|
+
/**
|
|
138
|
+
* outcome=dropped 时的细分原因(compact-handler 透传):按实际终局分文案——
|
|
139
|
+
* preempted / session-mismatch 下上下文已被另一轮压缩或新会话改变,不得声称「上下文保持原样」。
|
|
140
|
+
*/
|
|
141
|
+
droppedReason?: DroppedReason;
|
|
142
|
+
/** outcome=error 时的错误消息。 */
|
|
143
|
+
error?: string;
|
|
144
|
+
/** 失败类终局的独立熔断计数(文案展示用)。 */
|
|
145
|
+
failureStreak?: number;
|
|
146
|
+
/** 递增后计数达到熔断阈值:自动触发已暂停(工具路径不受影响,SI5)。 */
|
|
147
|
+
circuitBreakerOpen?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 失败原因行(按终局给短语与恢复动作)。「上下文保持原样」不是各终局共同的不变量——
|
|
152
|
+
* 只对确认未触碰上下文的终局声明(inflated / empty / error / persist-unavailable);
|
|
153
|
+
* preempted / session-mismatch 下上下文已被另一轮压缩或新会话改变,文案如实描述发生了什么。
|
|
154
|
+
*/
|
|
155
|
+
function compactFailureReasonLine(input: CompactFailureNoticeInput): string {
|
|
156
|
+
switch (input.outcome) {
|
|
157
|
+
case "inflated":
|
|
158
|
+
return "后台压缩已放弃:生成的摘要未缩小上下文(无净收益),结果未落盘,上下文保持原样。";
|
|
159
|
+
case "dropped":
|
|
160
|
+
switch (input.droppedReason) {
|
|
161
|
+
case "preempted":
|
|
162
|
+
return "后台压缩已放弃:另一轮压缩已先行落盘,上下文已使用该较新的压缩结果,本次结果未再写入。";
|
|
163
|
+
case "session-mismatch":
|
|
164
|
+
return "后台压缩已放弃:生成期间会话已切换,本次结果未落盘;当前会话的上下文不受本次压缩影响。";
|
|
165
|
+
case "persist-unavailable":
|
|
166
|
+
return "后台压缩已放弃:会话存储的压缩写入接口不可用,结果未落盘,上下文保持原样。";
|
|
167
|
+
default:
|
|
168
|
+
return "后台压缩已放弃:生成期间会话状态已变化,结果未落盘。";
|
|
169
|
+
}
|
|
170
|
+
case "empty":
|
|
171
|
+
return "后台压缩失败:摘要生成未返回结果,上下文保持原样。";
|
|
172
|
+
case "error":
|
|
173
|
+
return `后台压缩失败:${input.error ?? "未知错误"},上下文保持原样。`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 后台压缩进行中幂等回执文案(C2 手动触发入口:`__taiji_compact__` 命令在后台任务
|
|
179
|
+
* 进行中再触发时的用户回执)。口径与 compact_context 工具的 busy 回执逐字一致(同一
|
|
180
|
+
* 互斥事实,两入口共用一份事实描述)。
|
|
181
|
+
*/
|
|
182
|
+
export function buildCompactBusyNotice(): string {
|
|
183
|
+
return "后台压缩进行中,本次请求已并入该任务,无需重复触发。压缩在后台进行,你可以继续任何工作;完成后会收到一条结果消息。";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 后台压缩启动失败文案(C2 手动触发入口:`__taiji_compact__` 命令路径的非互斥失败
|
|
188
|
+
* ——门控拒绝 / appendCompaction 探测失败 / 无可压内容 / 参数解码失败)。reason 透传
|
|
189
|
+
* startForTool 的抛错文案(自身已含原因与恢复动作,此处只加前缀定位来源)。
|
|
190
|
+
*/
|
|
191
|
+
export function buildCompactStartFailureNotice(reason: string): string {
|
|
192
|
+
return `[smart-context] 后台压缩未启动:${reason}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 后台压缩失败通知文案(D6:失败不静默)。熔断达到阈值时附加一行说明(同一次失败
|
|
197
|
+
* 只投一条通知,熔断语义并入该条——不重复投递)。
|
|
198
|
+
*/
|
|
199
|
+
export function buildCompactFailureNotice(input: CompactFailureNoticeInput): string {
|
|
200
|
+
const lines = [`[smart-context] ${compactFailureReasonLine(input)}`];
|
|
201
|
+
if (input.circuitBreakerOpen) {
|
|
202
|
+
lines.push(
|
|
203
|
+
`已连续失败 ${input.failureStreak ?? "?"} 次,本会话自动触发压缩已暂停(compact_context 工具仍可手动调用)。`,
|
|
204
|
+
);
|
|
205
|
+
} else if (input.outcome === "empty" || input.outcome === "error") {
|
|
206
|
+
lines.push("可稍后重试(调用 compact_context),或忽略本轮继续工作。");
|
|
207
|
+
}
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 后台压缩落盘信号(C1 分界行实时化):落盘成功这个事实点的即时通知。消费方是 taiji
|
|
213
|
+
* runtime(分诊 customType + details.source 命中后取 compaction entry 合成对话流分界行帧),
|
|
214
|
+
* 不是用户——content 空串 + display:false(对话流不渲染,details 是信号唯一载荷)。
|
|
215
|
+
* LLM 上下文路径(实况链):该 custom_message entry 进 pi 的上下文投影,convertToLlm 将
|
|
216
|
+
* role:"custom" 无条件转 user 消息(不读 display)——content 空串即映射为空文本 text block,
|
|
217
|
+
* provider transform 层过滤空 text block(不耗 token、不报错),对模型净效果为零。这是
|
|
218
|
+
* 空串 content 的结构性无害,不是「不进上下文」的协议保证——content 若非空即对模型可见。
|
|
219
|
+
* 投递形态 `{triggerTurn:false}` 无 deliverAs:空闲时 pi 立即 append 并 emit
|
|
220
|
+
* message_start/end(agent-session sendCustomMessage 末段分支),runtime 即时收到 customStart
|
|
221
|
+
* 帧;streaming 窗口内挂回合尾(秒级延迟,接受并登记)。
|
|
222
|
+
*
|
|
223
|
+
* 失败语义(与通知投递失败同款,dmg-r1-2):同步抛错只 debugLog 留痕、不上抛——调用点
|
|
224
|
+
* (BackgroundPersistCallbacks.onPersisted)在 persistBackgroundCompaction 的 appendCompaction
|
|
225
|
+
* try 块内,上抛会把已落盘的压缩改记失败并无谓递增熔断计数(成败事实被信号失败篡改)。
|
|
226
|
+
* 信号丢失的最坏结果 = 分界行退回「重开会话才可见」的原状,压缩本身不受影响。
|
|
227
|
+
*/
|
|
228
|
+
export function sendCompactionPersistSignal(
|
|
229
|
+
pi: Pick<ExtensionAPI, "sendMessage">,
|
|
230
|
+
compactionId: string | undefined,
|
|
231
|
+
): void {
|
|
232
|
+
try {
|
|
233
|
+
pi.sendMessage(
|
|
234
|
+
{
|
|
235
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
236
|
+
content: "",
|
|
237
|
+
display: false,
|
|
238
|
+
details: { source: COMPACTION_ENTRY_APPENDED_SOURCE, compactionId },
|
|
239
|
+
},
|
|
240
|
+
{ triggerTurn: false },
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
debugLog(`compaction persist signal delivery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/pure.ts
CHANGED
|
@@ -28,6 +28,9 @@ const DEFAULT_REMINDER_THRESHOLDS: readonly number[] = [
|
|
|
28
28
|
DEFAULT_REMINDER_THRESHOLD_TIER_3_TOKENS,
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
/** 后台压缩触发线默认值(窗口 90%,用户裁决 2026-10-31;设计 D4)。 */
|
|
32
|
+
export const DEFAULT_AUTO_COMPACT_THRESHOLD_RATIO = 0.9;
|
|
33
|
+
|
|
31
34
|
/** smart-context 磁盘配置(<agentDir>/config/smart-context-ext-config.json)。 */
|
|
32
35
|
export interface SmartContextConfig {
|
|
33
36
|
enabled: boolean;
|
|
@@ -37,6 +40,11 @@ export interface SmartContextConfig {
|
|
|
37
40
|
reminderThresholds: number[];
|
|
38
41
|
/** 排除模型列表:完整 provider/modelId 精准等值匹配(D5)。 */
|
|
39
42
|
excludedModels: string[];
|
|
43
|
+
/**
|
|
44
|
+
* 后台压缩触发线:相对当前模型窗口的用量百分比(tokens 越过 ratio×窗口时触发,
|
|
45
|
+
* 设计 D4)。与 reminderThresholds 三档提醒是两个独立行为各自配置。
|
|
46
|
+
*/
|
|
47
|
+
autoCompactThresholdRatio: number;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
export const DEFAULT_SMART_CONTEXT_CONFIG: SmartContextConfig = {
|
|
@@ -44,6 +52,7 @@ export const DEFAULT_SMART_CONTEXT_CONFIG: SmartContextConfig = {
|
|
|
44
52
|
compactModel: { type: "ref", ref: "" },
|
|
45
53
|
reminderThresholds: [...DEFAULT_REMINDER_THRESHOLDS],
|
|
46
54
|
excludedModels: [],
|
|
55
|
+
autoCompactThresholdRatio: DEFAULT_AUTO_COMPACT_THRESHOLD_RATIO,
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
/**
|
|
@@ -51,6 +60,7 @@ export const DEFAULT_SMART_CONTEXT_CONFIG: SmartContextConfig = {
|
|
|
51
60
|
* - reminderThresholds:过滤非正数 → 升序 → 截 3 档;空数组回退默认
|
|
52
61
|
* - excludedModels:过滤非字符串与不含 "/" 的条目(精准匹配要求完整 provider/modelId)→ 去重
|
|
53
62
|
* - compactModel:ref 非字符串按空串
|
|
63
|
+
* - autoCompactThresholdRatio:非有限数回默认 0.9;有限数钳制到 [0, 1]
|
|
54
64
|
*/
|
|
55
65
|
export function normalizeSmartContextConfig(raw: unknown): SmartContextConfig {
|
|
56
66
|
const base = DEFAULT_SMART_CONTEXT_CONFIG;
|
|
@@ -78,7 +88,13 @@ export function normalizeSmartContextConfig(raw: unknown): SmartContextConfig {
|
|
|
78
88
|
),
|
|
79
89
|
];
|
|
80
90
|
|
|
81
|
-
|
|
91
|
+
const rawRatio = r.autoCompactThresholdRatio;
|
|
92
|
+
const autoCompactThresholdRatio =
|
|
93
|
+
typeof rawRatio === "number" && Number.isFinite(rawRatio)
|
|
94
|
+
? Math.min(1, Math.max(0, rawRatio))
|
|
95
|
+
: base.autoCompactThresholdRatio;
|
|
96
|
+
|
|
97
|
+
return { enabled, compactModel, reminderThresholds, excludedModels, autoCompactThresholdRatio };
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/**
|
package/src/reminder.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* 避免 agent 见提醒就压缩。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { DEFAULT_COMPACTION_SETTINGS } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
10
12
|
import {
|
|
11
13
|
DEGRADATION_HINT_MIN_COMPACTIONS,
|
|
12
14
|
formatK,
|
|
@@ -14,8 +16,6 @@ import {
|
|
|
14
16
|
|
|
15
17
|
/** 百分比换算基数(tokens/contextWindow 比例 → 百分数显示)。 */
|
|
16
18
|
const PERCENT_SCALE = 100;
|
|
17
|
-
/** pi 内建自动压缩触发线的 reserveTokens 默认值(window − 16K)。 */
|
|
18
|
-
const PI_DEFAULT_RESERVE_TOKENS = 16_384;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* 阈值提醒消息(D3/D4/D4'):越档信息 + 工具名 + 三条件自查 + 可忽略出口。
|
|
@@ -72,7 +72,8 @@ export function buildDownshiftNotice(
|
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
if (newWindow >= previousWindow) return null;
|
|
75
|
-
|
|
75
|
+
// pi 内建自动压缩触发线(window − reserveTokens 默认值,权威值经 pi 包导出直读)
|
|
76
|
+
const triggerLine = newWindow - DEFAULT_COMPACTION_SETTINGS.reserveTokens;
|
|
76
77
|
if (tokens < triggerLine) return null;
|
|
77
78
|
return `[smart-context] 当前上下文 ${formatK(tokens)} tokens,已接近新模型窗口上限(${formatK(newWindow)})。建议尽快压缩(调用 compact_context 或 /compact),否则内建自动压缩将在触线时强制执行。`;
|
|
78
79
|
}
|