@zhushanwen/pi-smart-context 0.4.3 → 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/compact-handler.ts
CHANGED
|
@@ -1,20 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* session_before_compact 接管 handler
|
|
2
|
+
* session_before_compact 接管 handler(上一代接管设计核心:门控 / 双模式生成 / 收缩校验 / 熔断)+ 后台压缩任务管理(D2/D3/D4,U2)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 编号消歧:本文件旧段(接管 handler 与其状态、组装与生成的注释)引用的
|
|
5
|
+
* D1(entry 标记)/D2/D7/D11/D12/D13-x/R4/R7/§8.3 指上一代 smart-context 接管设计
|
|
6
|
+
* 与 pi 观察级清单(文档已不在可达域,按注释内联语义理解);裸 D2-D6/SI/UD/RL/AS
|
|
7
|
+
* 指本设计(async-compaction)。两套编号同名不同义,凡与上下文对不上的按旧号处理。
|
|
8
|
+
*
|
|
9
|
+
* 同步接管路径(session_before_compact):三条压缩路径(agent 工具 / 用户 /compact / 内建 auto)
|
|
10
|
+
* 统一经过这里:
|
|
11
|
+
* - 门控未放行 / 熔断 / 生成失败 → 返回空(pi 原生生成兜底,接管设计 D2/D7)
|
|
6
12
|
* - same-model 模式:完整上下文 + 会话原 system prompt + tools + 末尾追加压缩指令(kv-cache 前缀命中)
|
|
7
13
|
* - cross-model 模式:直接调用包导出的 compact(preparation, 压缩Model, ...)(原生组装零复刻,R4 结论)
|
|
8
14
|
*
|
|
9
|
-
*
|
|
15
|
+
* 后台压缩路径(agent_settled 自动触发,U2):不经过 AgentSession(D2),本文件自管——
|
|
16
|
+
* - 触发判定(D4):agent_settled + tokens 越过 ratio×窗口 + 无进行中任务 + 门控前置 + 独立熔断未开
|
|
17
|
+
* - 准备数据组装:pi prepareCompaction 不在包根导出清单(实测 undefined),按 D2 用包根原子函数
|
|
18
|
+
* 组装等价数据(buildContextEntries → findCutPoint → sessionEntryToContextMessages);
|
|
19
|
+
* settings 取 DEFAULT_COMPACTION_SETTINGS(ExtensionContext 无设置读取通道,AS10)。
|
|
20
|
+
* 与 pi 原生的差异点 = AS4 已登记项:findCutPoint 原始条目视角在含 context_edit 会话中
|
|
21
|
+
* 可能与 pi 投影视角(findProjectedCutPoint,不导出)有一格偏移;tokensBefore 用
|
|
22
|
+
* ctx.getContextUsage() 口径。均为已接受代价,不当缺陷修。
|
|
23
|
+
* - 状态机(终态形态):session 级「空闲 ⇄ 进行中」单态互斥,生成→校验→落盘是进行中内
|
|
24
|
+
* 连续序列(无「已生成未落盘」独立态);任务自持 AbortController。
|
|
25
|
+
* - 事件取消(D3 第一层,SI4):session_start(new/resume/fork) / session_shutdown /
|
|
26
|
+
* session_compact 到达即取消任务放弃结果;session_tree 仅观测告警(回退不取消);
|
|
27
|
+
* session_start(startup/reload) 不取消(reload 使扩展工厂重跑、内存态自清)。
|
|
28
|
+
* - 落盘兜底校验 + appendCompaction 直调 + 档位重置补偿(U3:D3 第二层 / D2 / D6):
|
|
29
|
+
* validateBackgroundPersist 三项校验(sessionId 一致 / 无被抢先 compaction / 收缩校验)
|
|
30
|
+
* 任一失败放弃落盘;persistBackgroundCompaction 直调 sessionManager.appendCompaction
|
|
31
|
+
* (local 形状声明 + 存在性探测,fromHook=true 与同步接管一致);落盘成功经回调清
|
|
32
|
+
* firedThresholds(D6,与 session_compact 监听共用)。落盘段跨 await 持有 ctx,整体包
|
|
33
|
+
* guardStaleCtx(C-pi-17:stale 放弃落盘回报 dropped、不递增熔断计数)。
|
|
34
|
+
* - 独立熔断(SI5):后台失败独立计数,连续 ≥3(沿用 TAKEOVER_FAILURE_LIMIT)停止自动触发;
|
|
35
|
+
* 与同步路径 failStreak 隔离、膨胀段记录(inflatedSegments)共享读取;被抢先取消不计入。
|
|
36
|
+
* - 工具路径启动(U5 接线残项):startForTool = compact_context 显式请求(互斥 + 门控前置,
|
|
37
|
+
* 跳过触发线与独立熔断——SI5 两者仅约束自动触发;非互斥失败抛错不谎报幂等)。
|
|
38
|
+
* - 终局通知(U5/D6 呈现半边):runBackgroundTask 各终局回调 onSettled 恰一次
|
|
39
|
+
* (cancelled 不回调),接线层(index.ts)组装完成/失败文案经 nextTurn 投递;通知投递
|
|
40
|
+
* 失败只留痕(settle 内吞),不改写压缩成败事实与熔断计数。
|
|
41
|
+
*
|
|
42
|
+
* 输出 CompactionResult.details 携带 {engine:"smart-context", mode, model} 标记(接管设计 D1:entry 标记)。
|
|
10
43
|
* model = `${model.provider}/${model.id}`(执行压缩的实际模型)——用量页 compaction 归属数据源
|
|
11
|
-
* (
|
|
44
|
+
* (pi-semantics.json PS-28:appendCompaction 对 details 逐字透传落盘,归属字段必然随 entry 落盘)。
|
|
12
45
|
*/
|
|
13
46
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
47
|
+
import {
|
|
48
|
+
buildContextEntries,
|
|
49
|
+
buildSessionContext,
|
|
50
|
+
compact as nativeCompact,
|
|
51
|
+
convertToLlm,
|
|
52
|
+
DEFAULT_COMPACTION_SETTINGS,
|
|
53
|
+
estimateTokens,
|
|
54
|
+
findCutPoint,
|
|
55
|
+
sessionEntryToContextMessages,
|
|
56
|
+
} from "@earendil-works/pi-coding-agent";
|
|
57
|
+
import type {
|
|
58
|
+
CompactionEntry,
|
|
59
|
+
CompactionResult,
|
|
60
|
+
FileOperations,
|
|
61
|
+
SessionBeforeCompactEvent,
|
|
62
|
+
SessionBeforeCompactResult,
|
|
63
|
+
SessionEntry,
|
|
64
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
65
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
66
|
+
import { guardStaleCtx, isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
18
67
|
import { createLogger } from "@zhushanwen/pi-extension-logger";
|
|
19
68
|
import { resolveModel } from "@zhushanwen/pi-llm-shared";
|
|
20
69
|
import { readFileSync } from "node:fs";
|
|
@@ -55,6 +104,855 @@ export function createTakeoverState(): TakeoverState {
|
|
|
55
104
|
return { failStreak: 0, inflatedSegments: new Set() };
|
|
56
105
|
}
|
|
57
106
|
|
|
107
|
+
// ──────────────────────── 后台压缩任务管理(U2:D2/D3/D4) ────────────────────────
|
|
108
|
+
|
|
109
|
+
/** 触发时刻快照(SI3):供 U3 落盘兜底校验对账(生成窗口内快照可能失效)。 */
|
|
110
|
+
export interface BackgroundTaskSnapshot {
|
|
111
|
+
/** 触发时刻 sessionId(D3 第二层校验①:落盘时须与触发时刻一致)。 */
|
|
112
|
+
sessionId: string;
|
|
113
|
+
/** 触发快照末端条目 id(观测/遗留字段:生产逻辑零消费——校验②由 compactionIds 集合判定承担,见其注释;测试快照断言在用)。 */
|
|
114
|
+
lastEntryId: string;
|
|
115
|
+
/** 触发时刻上下文用量(观测/遗留字段:生产逻辑零消费——启动日志与触发线判定均取 launchTask 入参 usageTokens;语义以 preparation.tokensBefore 为准)。 */
|
|
116
|
+
tokens: number;
|
|
117
|
+
/**
|
|
118
|
+
* 触发时刻 branch 上全部 compaction entry id(D3 第二层校验②的已知集合):
|
|
119
|
+
* 校验②落地为「当前 branch 上出现未知 id 的 compaction = 被抢先」——锚点条目可定位时
|
|
120
|
+
* 与「快照末端之后区间扫描」等价(锚点之前的公共前缀条目 id 全部已知);锚点被
|
|
121
|
+
* navigateTree 回退丢弃时(V4:回退不取消、结果应正常落盘)区间无定义,集合判定
|
|
122
|
+
* 是它的时序无关自然延伸(runlog U3 决策 A1)。
|
|
123
|
+
*/
|
|
124
|
+
compactionIds: string[];
|
|
125
|
+
/**
|
|
126
|
+
* 触发时刻预计算的被压段 token(收缩校验③分母):被压范围来自触发快照,分母在触发
|
|
127
|
+
* 时刻定死,续段无需 preparation 穿透(runlog U3 决策 A2,AfterGenerationHook 契约不动)。
|
|
128
|
+
*/
|
|
129
|
+
shadowedTokens: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 进行中的后台任务(状态机「进行中」态载体;null = 空闲)。
|
|
134
|
+
* 生成→校验→落盘是本态内连续序列(终态形态:无「已生成未落盘」独立态——取消语义、
|
|
135
|
+
* 互斥判定、落盘时机三处机制均不因「生成已完成」产生行为差异)。
|
|
136
|
+
*/
|
|
137
|
+
export interface ActiveBackgroundTask {
|
|
138
|
+
/** 任务自持 AbortController(取消通道,不与 pi 锁/信号共享)。 */
|
|
139
|
+
abort: AbortController;
|
|
140
|
+
/** 触发时刻快照(U3 落盘兜底校验输入)。 */
|
|
141
|
+
snapshot: BackgroundTaskSnapshot;
|
|
142
|
+
/**
|
|
143
|
+
* 任务收尾 promise(try/finally 保证 settle)。正常路径无人 await(fire-and-forget),
|
|
144
|
+
* 暴露它是为了测试可确定性收尾(单测判据②:await 该信号等价同步语义)与调试观测。
|
|
145
|
+
*/
|
|
146
|
+
done: Promise<void>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** session 级后台压缩状态(session_start 重建闭包,与 TakeoverState 同生命周期口径)。 */
|
|
150
|
+
export interface BackgroundCompactionState {
|
|
151
|
+
/** 进行中任务(null = 空闲)。互斥判定以本字段单态判定(RL1)。 */
|
|
152
|
+
active: ActiveBackgroundTask | null;
|
|
153
|
+
/**
|
|
154
|
+
* 后台失败独立熔断计数(SI5):连续 ≥TAKEOVER_FAILURE_LIMIT 停止自动触发。
|
|
155
|
+
* 与同步路径 failStreak 隔离(后台失败无 pi 原生兜底,失败语义不同);
|
|
156
|
+
* 被抢先取消不计入(取消非失败);递增点三类 = 生成空结果 / 收缩校验失败(U3 回报)/
|
|
157
|
+
* 异常;session 级生命周期(session_start 重建归零)。
|
|
158
|
+
*/
|
|
159
|
+
failureStreak: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createBackgroundCompactionState(): BackgroundCompactionState {
|
|
163
|
+
return { active: null, failureStreak: 0 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 后台触发线判定(D4):tokens 越过 ratio×本模型窗口(越过含等于,与 findCrossedThresholds
|
|
168
|
+
* 同口径)。tokens 为 null(压缩后首响应前,pi 观察级 R7——旧编号,见文件头注消歧)由调用方先短路,
|
|
169
|
+
* 本函数只收有限数。
|
|
170
|
+
*/
|
|
171
|
+
export function isAutoCompactTriggered(tokens: number, contextWindow: number, ratio: number): boolean {
|
|
172
|
+
if (!Number.isFinite(tokens) || !Number.isFinite(contextWindow) || contextWindow <= 0) return false;
|
|
173
|
+
return tokens >= contextWindow * ratio;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* dropped 终局的细分原因(通知文案分派用):dropped 本身只说明「本次结果未落盘」,不说明
|
|
178
|
+
* 上下文是否被动过——preempted / session-mismatch 下上下文已被另一轮压缩或新会话改变,
|
|
179
|
+
* 通知不得再声称「上下文保持原样」。
|
|
180
|
+
*/
|
|
181
|
+
export type DroppedReason =
|
|
182
|
+
/** 会话已切换(兜底校验① 或 stale ctx 代际防线命中):本次结果未落盘,原会话不受影响。 */
|
|
183
|
+
| "session-mismatch"
|
|
184
|
+
/** 被其他压缩抢先:另一轮压缩已落盘,上下文已使用较新的压缩结果。 */
|
|
185
|
+
| "preempted"
|
|
186
|
+
/** appendCompaction 写入接口不可用(RL3 探测失败):结果未落盘,上下文保持原样。 */
|
|
187
|
+
| "persist-unavailable";
|
|
188
|
+
|
|
189
|
+
/** 生成完成后续段的处置回报(U3 收缩校验+落盘的裁决结果,驱动独立熔断计数)。 */
|
|
190
|
+
export type ContinuationOutcome =
|
|
191
|
+
/** 校验通过且已落盘 → 成功清零。 */
|
|
192
|
+
| "validated"
|
|
193
|
+
/** 收缩校验失败(摘要膨胀)放弃落盘 → 熔断递增(递增点之二)。 */
|
|
194
|
+
| "inflated"
|
|
195
|
+
/**
|
|
196
|
+
* 落盘兜底校验失败 / 写入接口不可用 / stale ctx 前置命中 → 会话态失效类放弃,非生成
|
|
197
|
+
* 质量问题,计数不动。携带细分原因(droppedReason)供通知文案如实分派。
|
|
198
|
+
*/
|
|
199
|
+
| { outcome: "dropped"; reason: DroppedReason };
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 生成完成后续段钩子(U3 接线点:收缩校验 + appendCompaction 落盘 + 补偿)。
|
|
203
|
+
* U2 阶段不传(缺省仅记日志)——生成与续段的边界即本契约:续段处置经返回值回流驱动熔断计数。
|
|
204
|
+
*
|
|
205
|
+
* isCtxStale 由 createBackgroundCompactionManager 注入(C-pi-17 接入位):续段跨 await
|
|
206
|
+
* 持有 ctx,session 替换窗口内裸调会命中 pi 的 assertActive——钩子实装(落盘段)据此前置
|
|
207
|
+
* 分诊,缺省(不注入)恒视为非 stale,与 guardStaleCtx 的缺省语义一致。
|
|
208
|
+
*/
|
|
209
|
+
export type AfterGenerationHook = (
|
|
210
|
+
result: CompactionResult,
|
|
211
|
+
snapshot: BackgroundTaskSnapshot,
|
|
212
|
+
ctx: ExtensionContext,
|
|
213
|
+
isCtxStale?: () => boolean,
|
|
214
|
+
) => Promise<ContinuationOutcome>;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 后台任务终局描述(U5 通知接线点:D6 补偿的呈现半边)。
|
|
218
|
+
* runBackgroundTask 在每个终局点回调 onSettled 恰一次(cancelled 事件取消除外——
|
|
219
|
+
* D3:取消非失败,且 session_compact 抢先场景用户刚完成一次手动压缩,再弹通知是噪音)。
|
|
220
|
+
* 接线层(index.ts)据此组装完成/失败文案经 nextTurn 投递;每次任务至多一条通知(不重复)。
|
|
221
|
+
*/
|
|
222
|
+
export interface BackgroundTaskSettlement {
|
|
223
|
+
/** 任务触发时刻的 sessionId(stale 分诊与排障)。 */
|
|
224
|
+
sessionId: string;
|
|
225
|
+
/** 任务终局(语义同 ContinuationOutcome + 生成层结局)。 */
|
|
226
|
+
outcome:
|
|
227
|
+
/** 校验通过且已落盘 → 完成通知。 */
|
|
228
|
+
| "validated"
|
|
229
|
+
/** 收缩校验失败放弃落盘 → 失败通知(递增点②)。 */
|
|
230
|
+
| "inflated"
|
|
231
|
+
/** 落盘兜底校验失败或 appendCompaction 探测失败,放弃落盘 → 失败通知(计数不动)。 */
|
|
232
|
+
| "dropped"
|
|
233
|
+
/** 生成空结果 → 失败通知(递增点①)。 */
|
|
234
|
+
| "empty"
|
|
235
|
+
/** 生成异常 → 失败通知(递增点③)。 */
|
|
236
|
+
| "error";
|
|
237
|
+
/**
|
|
238
|
+
* outcome=dropped 时携带:放弃原因(通知文案分派——preempted / session-mismatch 下
|
|
239
|
+
* 上下文已被另一轮压缩或新会话改变,通知不得声称「上下文保持原样」)。
|
|
240
|
+
*/
|
|
241
|
+
droppedReason?: DroppedReason;
|
|
242
|
+
/** 本次任务的模式(完成通知展示「压缩前后 token 数与模式」,D6)。 */
|
|
243
|
+
mode: "same-model" | "cross-model";
|
|
244
|
+
/** validated/inflated 时携带:生成的压缩结果(tokensBefore/summary/estimatedTokensAfter 通知数据源)。 */
|
|
245
|
+
result?: CompactionResult;
|
|
246
|
+
/** validated/inflated 时携带:触发快照的被压段 token(压缩后 token 估算输入)。 */
|
|
247
|
+
shadowedTokens?: number;
|
|
248
|
+
/** error 时携带:错误消息。 */
|
|
249
|
+
error?: string;
|
|
250
|
+
/** 失败类终局(empty/inflated/error):递增后的独立熔断计数。 */
|
|
251
|
+
failureStreak?: number;
|
|
252
|
+
/** 失败类终局:递增后计数达到熔断阈值(自动触发将暂停,工具路径不受影响,SI5)。 */
|
|
253
|
+
circuitBreakerOpen?: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ──────────────────────── 落盘兜底校验 + appendCompaction 直调(U3:D3 第二层 / D2 / D6) ────────────────────────
|
|
257
|
+
|
|
258
|
+
/** 落盘兜底校验失败原因(可辨识:驱动 ContinuationOutcome 与日志)。 */
|
|
259
|
+
export type PersistCheckFailure =
|
|
260
|
+
/** 校验①:sessionId 与触发时刻不一致(会话已替换的兜底对账)。 */
|
|
261
|
+
| "session-mismatch"
|
|
262
|
+
/** 校验②:快照已知集合之外出现 compaction entry(被抢先压缩)。 */
|
|
263
|
+
| "preempted"
|
|
264
|
+
/** 校验③:摘要膨胀(isSummaryInflated,压缩无净收益)。 */
|
|
265
|
+
| "summary-inflated";
|
|
266
|
+
|
|
267
|
+
/** 校验结果(纯数据:ok=false 时携带失败原因,放弃必留日志由调用方落地)。 */
|
|
268
|
+
export type PersistCheckVerdict = { ok: true } | { ok: false; reason: PersistCheckFailure };
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 落盘兜底校验器(D3 第二层,覆盖事件取消与落盘之间的竞态窗口):纯函数、可穷举单测(RL2)。
|
|
272
|
+
* 三项任一失败放弃落盘、等下轮触发:
|
|
273
|
+
* ①sessionId 与触发时刻一致(防御性——taiji 每 session 独立进程下无自然触发场景,护栏 pi 裸跑形态);
|
|
274
|
+
* ②当前 branch 上无快照已知集合之外的 compaction entry(被抢先的时序无关判定,runlog U3 决策 A1——
|
|
275
|
+
* 锚点可定位时等价于「快照末端之后区间扫描」,锚点被回退丢弃时集合判定仍可裁决);
|
|
276
|
+
* ③收缩校验 isSummaryInflated(SI6:摘要 token ≥ 被压段 token 即放弃,分母取触发快照预计算值)。
|
|
277
|
+
*/
|
|
278
|
+
export function validateBackgroundPersist(
|
|
279
|
+
snapshot: BackgroundTaskSnapshot,
|
|
280
|
+
entriesNow: ReadonlyArray<SessionEntry>,
|
|
281
|
+
sessionIdNow: string,
|
|
282
|
+
result: CompactionResult,
|
|
283
|
+
): PersistCheckVerdict {
|
|
284
|
+
// ①会话已替换(session_start/shutdown 事件层取消未达的竞态窗口兜底)
|
|
285
|
+
if (sessionIdNow !== snapshot.sessionId) {
|
|
286
|
+
return { ok: false, reason: "session-mismatch" };
|
|
287
|
+
}
|
|
288
|
+
// ②被抢先压缩:未知 id 的 compaction 必为触发后新增(触发时刻 branch 上的已知集合之外)
|
|
289
|
+
for (const entry of entriesNow) {
|
|
290
|
+
if (entry.type === "compaction" && !snapshot.compactionIds.includes(entry.id)) {
|
|
291
|
+
return { ok: false, reason: "preempted" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// ③收缩校验:摘要不小于被压段 → 落盘使上下文不降反升,放弃(膨胀段记录由调用方经回调共享)
|
|
295
|
+
const summaryTokens = estimateTextTokens(result.summary);
|
|
296
|
+
if (isSummaryInflated(summaryTokens, snapshot.shadowedTokens)) {
|
|
297
|
+
return { ok: false, reason: "summary-inflated" };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* SessionManager.appendCompaction 的局部形状声明(AS1:ExtensionContext.sessionManager 类型上是
|
|
304
|
+
* ReadonlySessionManager——Pick 收窄不含 appendCompaction,运行时是完整 SessionManager 实例)。
|
|
305
|
+
* 参数序与类型按 pi dist 实读核对:session-manager.d.ts:271
|
|
306
|
+
* `appendCompaction<T>(summary, firstKeptEntryId, tokensBefore, details?, fromHook?, usage?): string`,
|
|
307
|
+
* usage 与 CompactionResult["usage"] 同为 pi-ai Usage(任务书 U3 checkpoint 兑现)。
|
|
308
|
+
*/
|
|
309
|
+
interface AppendCompactionLike {
|
|
310
|
+
appendCompaction?: (
|
|
311
|
+
summary: string,
|
|
312
|
+
firstKeptEntryId: string | null,
|
|
313
|
+
tokensBefore: number,
|
|
314
|
+
details?: unknown,
|
|
315
|
+
fromHook?: boolean,
|
|
316
|
+
usage?: CompactionResult["usage"],
|
|
317
|
+
) => string | undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** sessionManager 的运行时收窄检查(RL3:只收窄到对象,方法存在性单独探测)。 */
|
|
321
|
+
function asAppendCompactionHost(sm: unknown): AppendCompactionLike | undefined {
|
|
322
|
+
return typeof sm === "object" && sm !== null ? (sm as AppendCompactionLike) : undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** persistBackgroundCompaction 的补偿回调(D6):由接线层(index.ts)注入 session 级状态操作。 */
|
|
326
|
+
export interface BackgroundPersistCallbacks {
|
|
327
|
+
/**
|
|
328
|
+
* 落盘成功补偿:清空已提醒档位(与 session_compact 事件监听共用同一段逻辑,D6)。
|
|
329
|
+
* C1 起附带 entryId(appendCompaction 返回的 compaction entry id,pi 形状声明保守允许
|
|
330
|
+
* undefined)——接线层用它组装落盘信号(分界行实时化),调用点仍在落盘事实点的同一
|
|
331
|
+
* 同步帧内(guardStaleCtx 内、appendCompaction 成功后),非 stale 由外层保证。
|
|
332
|
+
*/
|
|
333
|
+
onPersisted?: (entryId: string | undefined) => void;
|
|
334
|
+
/** 收缩校验失败的膨胀段记录(D3:与同步路径 inflatedSegments 共享读取,同段不再触发防反复烧钱)。 */
|
|
335
|
+
onInflated?: (firstKeptEntryId: string) => void;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 落盘续段(U3 主体:存在性探测 → 三项兜底校验 → appendCompaction 直调 → 补偿回调)。
|
|
340
|
+
*
|
|
341
|
+
* 探测(RL3):`typeof === "function"` 存在性探测,缺失放弃落盘、记日志、不抛错(观察级依赖
|
|
342
|
+
* UD1/UD8 的运行时对账;maybeStart 另有前置探测省生成费用,两处语义不同见其注释)。
|
|
343
|
+
* 落盘(D2/AX2):直调 sessionManager.appendCompaction,参数口径与 pi 原生压缩流程一致
|
|
344
|
+
* (agent-session.js:2199),fromHook=true 与同步接管路径一致;挂载点(leaf)由 pi 依落盘
|
|
345
|
+
* 时刻 branch 决定、不干预(AS6:压缩期间新条目天然衔接进保留段);usage/details 随 entry
|
|
346
|
+
* 写入(D6 用量归因消费侧照读)。不伪造 pi 事件(UD6:appendCompaction 纯追加无事件)。
|
|
347
|
+
* 落盘异常不在此吞——debugLog 标注阶段后原样上抛,由 runBackgroundTask 异常递增点③收口。
|
|
348
|
+
*
|
|
349
|
+
* stale ctx 防线(C-pi-17):落盘段跨 await 持有 ctx(生成窗口内 session 可能已被替换),
|
|
350
|
+
* sessionManager 读取与 appendCompaction 直调整体包 guardStaleCtx——stale 时 fn 完全不
|
|
351
|
+
* 执行、回报 "dropped"(会话态失效类放弃,不递增熔断计数;失败通知由接线层同一检查静默
|
|
352
|
+
* 降级,stale 会话收不到通知是设计内语义)。非 stale 错误按 ext-guards 语义原样上抛,
|
|
353
|
+
* 检查不吞真实 bug(落盘异常仍走递增点③)。
|
|
354
|
+
*/
|
|
355
|
+
export async function persistBackgroundCompaction(
|
|
356
|
+
ctx: ExtensionContext,
|
|
357
|
+
result: CompactionResult,
|
|
358
|
+
snapshot: BackgroundTaskSnapshot,
|
|
359
|
+
callbacks?: BackgroundPersistCallbacks,
|
|
360
|
+
isCtxStale?: () => boolean,
|
|
361
|
+
): Promise<ContinuationOutcome> {
|
|
362
|
+
const outcome = await guardStaleCtx(
|
|
363
|
+
// 显式返回类型:dropped 的对象字面量不经泛型推断宽化(outcome/reason 保持字面量类型)
|
|
364
|
+
async (): Promise<ContinuationOutcome> => {
|
|
365
|
+
const host = asAppendCompactionHost(ctx.sessionManager);
|
|
366
|
+
// RL3 存在性探测:方法缺失 = pi 观察级依赖失效,放弃落盘不抛错(绝不静默)
|
|
367
|
+
if (!host || typeof host.appendCompaction !== "function") {
|
|
368
|
+
warnLog("background compaction persist skipped: sessionManager.appendCompaction unavailable");
|
|
369
|
+
return { outcome: "dropped", reason: "persist-unavailable" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// D3 第二层:三项兜底校验(entriesNow 与 appendCompaction 之间无 await——单线程事件循环
|
|
373
|
+
// 同步帧内连续执行,校验与落盘之间构造性无竞态)
|
|
374
|
+
const sessionIdNow = ctx.sessionManager.getSessionId();
|
|
375
|
+
const entriesNow = ctx.sessionManager.getEntries();
|
|
376
|
+
const verdict = validateBackgroundPersist(snapshot, entriesNow, sessionIdNow, result);
|
|
377
|
+
if (!verdict.ok) {
|
|
378
|
+
if (verdict.reason === "summary-inflated") {
|
|
379
|
+
callbacks?.onInflated?.(result.firstKeptEntryId);
|
|
380
|
+
warnLog("background compaction persist rejected: summary inflated", {
|
|
381
|
+
summaryTokens: estimateTextTokens(result.summary),
|
|
382
|
+
shadowedTokens: snapshot.shadowedTokens,
|
|
383
|
+
});
|
|
384
|
+
return "inflated";
|
|
385
|
+
}
|
|
386
|
+
debugLog(`background compaction persist rejected: ${verdict.reason} (sessionId=${snapshot.sessionId})`);
|
|
387
|
+
// 此处 reason 已 narrow 为 session-mismatch | preempted(summary-inflated 上面已分支)
|
|
388
|
+
return { outcome: "dropped", reason: verdict.reason };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const entryId = host.appendCompaction(
|
|
393
|
+
result.summary,
|
|
394
|
+
result.firstKeptEntryId,
|
|
395
|
+
result.tokensBefore,
|
|
396
|
+
result.details,
|
|
397
|
+
true, // fromHook:与同步接管路径落盘口径一致(pi 同参位传 fromExtension=true)
|
|
398
|
+
result.usage,
|
|
399
|
+
);
|
|
400
|
+
callbacks?.onPersisted?.(entryId);
|
|
401
|
+
debugLog(
|
|
402
|
+
`background compaction persisted: entry=${entryId ?? "?"} firstKept=${result.firstKeptEntryId} ` +
|
|
403
|
+
`sessionId=${snapshot.sessionId}`,
|
|
404
|
+
);
|
|
405
|
+
return "validated";
|
|
406
|
+
} catch (error) {
|
|
407
|
+
debugLog(`background compaction persist error: ${toErrorMessage(error)}`);
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
isCtxStale,
|
|
413
|
+
label: "smart-context:background-compaction-persist",
|
|
414
|
+
onStale: () =>
|
|
415
|
+
debugLog(`background compaction persist skipped (stale ctx, sessionId=${snapshot.sessionId})`),
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
// 前置检查命中时 guardStaleCtx 返回 undefined(fn 未执行、无 Promise 可言)→ 按 dropped 回报。
|
|
419
|
+
// 原因归 session-mismatch:stale ctx 防线命中即会话已替换(C-pi-17),与兜底校验①同类事实。
|
|
420
|
+
return outcome ?? { outcome: "dropped", reason: "session-mismatch" };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 从单条消息提取文件操作(pi extractFileOpsFromMessage 的等价组装——该函数不在包根导出,
|
|
425
|
+
* 语义按 pi compaction/utils.js 实读复刻:toolResult 侧收 nestedCalls、assistant 侧收
|
|
426
|
+
* toolCall blocks,按工具名 read/write/edit 归档 path 参数)。按 role 分派到两侧提取,
|
|
427
|
+
* 分派外的形态不计(collectToolResultFileOps / collectAssistantToolCallOps / addFileOp)。
|
|
428
|
+
*/
|
|
429
|
+
function extractFileOpsFromAgentMessage(message: Parameters<typeof estimateTokens>[0], fileOps: FileOperations): void {
|
|
430
|
+
if (message.role === "toolResult") {
|
|
431
|
+
collectToolResultFileOps(message, fileOps);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (message.role !== "assistant") return;
|
|
435
|
+
collectAssistantToolCallOps(message, fileOps);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** toolResult 侧提取:codemode 脚本发起的调用记录在脚本结果上(pi 同款语义,nestedCalls)。 */
|
|
439
|
+
function collectToolResultFileOps(message: Parameters<typeof estimateTokens>[0], fileOps: FileOperations): void {
|
|
440
|
+
const rec = isRecord(message) ? message : undefined;
|
|
441
|
+
const nested = isRecord(rec?.nestedCalls) ? rec.nestedCalls : undefined;
|
|
442
|
+
const calls = Array.isArray(nested?.calls) ? nested.calls : [];
|
|
443
|
+
for (const call of calls) {
|
|
444
|
+
const c = isRecord(call) ? call : undefined;
|
|
445
|
+
addFileOp(fileOps, c?.name, c?.arguments);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** assistant 侧提取:content 内 toolCall blocks(按工具名归档 path 参数)。
|
|
450
|
+
* 入参 = AgentMessage 联合的 assistant 变体(role 分派处窄化后传入,content 才含
|
|
451
|
+
* toolCall block 联合成员)。 */
|
|
452
|
+
function collectAssistantToolCallOps(
|
|
453
|
+
message: Extract<Parameters<typeof estimateTokens>[0], { role: "assistant" }>,
|
|
454
|
+
fileOps: FileOperations,
|
|
455
|
+
): void {
|
|
456
|
+
const content = isRecord(message) ? message.content : undefined;
|
|
457
|
+
if (!Array.isArray(content)) return;
|
|
458
|
+
for (const block of content) {
|
|
459
|
+
const b = isRecord(block) ? block : undefined;
|
|
460
|
+
if (b?.type !== "toolCall") continue;
|
|
461
|
+
addFileOp(fileOps, b.name, b.arguments);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 工具名 → fileOps 分桶:read/written/edited 归档 path 参数(无 path 参数或其余工具名不计)。 */
|
|
466
|
+
function addFileOp(fileOps: FileOperations, toolName: unknown, args: unknown): void {
|
|
467
|
+
const path = isRecord(args) && typeof args.path === "string" ? args.path : undefined;
|
|
468
|
+
if (!path) return;
|
|
469
|
+
if (toolName === "read") fileOps.read.add(path);
|
|
470
|
+
else if (toolName === "write") fileOps.written.add(path);
|
|
471
|
+
else if (toolName === "edit") fileOps.edited.add(path);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 组装被压范围 fileOps(pi extractFileOperations 等价——不在包根导出):上一份 compaction
|
|
476
|
+
* entry(pi 原生生成的才带 details 文件清单)的累积 + 被压消息逐条提取。
|
|
477
|
+
*/
|
|
478
|
+
function collectFileOps(
|
|
479
|
+
messages: ReadonlyArray<Parameters<typeof estimateTokens>[0]>,
|
|
480
|
+
prevCompaction: CompactionEntry | undefined,
|
|
481
|
+
): FileOperations {
|
|
482
|
+
const fileOps: FileOperations = { read: new Set(), written: new Set(), edited: new Set() };
|
|
483
|
+
if (prevCompaction && !prevCompaction.fromHook) {
|
|
484
|
+
const details = isRecord(prevCompaction.details) ? prevCompaction.details : undefined;
|
|
485
|
+
if (Array.isArray(details?.readFiles)) {
|
|
486
|
+
for (const f of details.readFiles) if (typeof f === "string") fileOps.read.add(f);
|
|
487
|
+
}
|
|
488
|
+
if (Array.isArray(details?.modifiedFiles)) {
|
|
489
|
+
for (const f of details.modifiedFiles) if (typeof f === "string") fileOps.edited.add(f);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const msg of messages) extractFileOpsFromAgentMessage(msg, fileOps);
|
|
493
|
+
return fileOps;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 后台压缩准备数据组装(D2,prepareCompaction 不在包根导出的等价组装,~50 行):
|
|
498
|
+
* buildContextEntries 取当前活动条目列表 → findCutPoint 算切点(DEFAULT_COMPACTION_SETTINGS)
|
|
499
|
+
* → sessionEntryToContextMessages 组装被压消息与上一份摘要。
|
|
500
|
+
*
|
|
501
|
+
* 与 pi 原生 prepareCompaction 的差异 = AS4 已登记项(不当缺陷修):
|
|
502
|
+
* - 切点用 findCutPoint(原始条目视角):含 context_edit 的会话可能与 pi 投影视角
|
|
503
|
+
* (findProjectedCutPoint,包根不导出)有一格偏移,无 context_edit 时等价;
|
|
504
|
+
* - tokensBefore 用 ctx.getContextUsage() 口径(读不到时 0 兜底——仅日志/展示语义,
|
|
505
|
+
* 投影切分正确性不依赖它)。
|
|
506
|
+
* system role 消息剔除与 pi 原生一致(compaction.js:50-53 同款过滤,见组装处注释),
|
|
507
|
+
* 不属于差异项。
|
|
508
|
+
* 返回 undefined = 无可压(条目不足 / 刚压缩完 / 全量在保留预算内 / 被压段仅 system 消息)。
|
|
509
|
+
* 组装流程拆为 loadPreparationContext(条目装载 + 无可压前置判定)/
|
|
510
|
+
* sliceCompressionEntries + toNonSystemMessages(被压段组装)/
|
|
511
|
+
* collectCompressedFileOps(fileOps 汇总),本函数只做编排(圈复杂度门禁阈值 15)。
|
|
512
|
+
*/
|
|
513
|
+
export function buildBackgroundPreparation(
|
|
514
|
+
ctx: ExtensionContext,
|
|
515
|
+
): SessionBeforeCompactEvent["preparation"] | undefined {
|
|
516
|
+
const loaded = loadPreparationContext(ctx);
|
|
517
|
+
if (!loaded) return undefined;
|
|
518
|
+
const { contextEntries, previousSummary, boundaryStart } = loaded;
|
|
519
|
+
|
|
520
|
+
const cutPoint = findCutPoint(contextEntries, boundaryStart, contextEntries.length, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens);
|
|
521
|
+
const firstKeptEntry = contextEntries[cutPoint.firstKeptEntryIndex];
|
|
522
|
+
if (!firstKeptEntry?.id) return undefined;
|
|
523
|
+
|
|
524
|
+
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
|
525
|
+
const { shadowedEntries, turnPrefixEntries } = sliceCompressionEntries(contextEntries, boundaryStart, cutPoint, historyEnd);
|
|
526
|
+
// system role 消息剔除(pi getMessagesFromProjectedEntryForCompaction 同款,compaction.js
|
|
527
|
+
// :50-53——system message 是 prompt 状态非对话内容,compaction entry 的 systemMessage 承载
|
|
528
|
+
// 其回放;不剔除会使摘要 LLM 输入混入 system 消息、与 pi 原生组装(已过滤)不同分布)
|
|
529
|
+
const messagesToSummarize = toNonSystemMessages(shadowedEntries);
|
|
530
|
+
const turnPrefixMessages = toNonSystemMessages(turnPrefixEntries);
|
|
531
|
+
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) return undefined;
|
|
532
|
+
|
|
533
|
+
const head = contextEntries[0];
|
|
534
|
+
const prevCompaction = head?.type === "compaction" ? head : undefined;
|
|
535
|
+
const fileOps = collectCompressedFileOps(messagesToSummarize, turnPrefixMessages, prevCompaction, cutPoint.isSplitTurn);
|
|
536
|
+
|
|
537
|
+
const tokensBefore = ctx.getContextUsage()?.tokens ?? 0;
|
|
538
|
+
return {
|
|
539
|
+
firstKeptEntryId: firstKeptEntry.id,
|
|
540
|
+
messagesToSummarize,
|
|
541
|
+
turnPrefixMessages,
|
|
542
|
+
isSplitTurn: cutPoint.isSplitTurn,
|
|
543
|
+
tokensBefore,
|
|
544
|
+
previousSummary,
|
|
545
|
+
fileOps,
|
|
546
|
+
// ExtensionContext 无 pi 设置读取通道(AS10):用包根常量对齐基线,与用户自定义
|
|
547
|
+
// pi 压缩设置的漂移为已接受代价(四要素登记见设计 AS10)
|
|
548
|
+
settings: { ...DEFAULT_COMPACTION_SETTINGS },
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** [buildBackgroundPreparation 拆分] 条目装载 + 无可压前置判定:getEntries 读失败 /
|
|
553
|
+
* 零活动条目 / 末条是 compaction(刚压缩完,appendCompaction 挂 leaf,prepareCompaction
|
|
554
|
+
* 同款前置)→ undefined。同时定 previousSummary 与 boundaryStart——最新 compaction 是
|
|
555
|
+
* 活动列表首条(buildContextEntries 切分语义);更早 compaction 的摘要已被它收编,
|
|
556
|
+
* previousSummary 只取最新一份(prepareCompaction 同款边界)。 */
|
|
557
|
+
function loadPreparationContext(ctx: ExtensionContext) {
|
|
558
|
+
let entries: readonly SessionEntry[];
|
|
559
|
+
try {
|
|
560
|
+
entries = ctx.sessionManager.getEntries();
|
|
561
|
+
} catch (error) {
|
|
562
|
+
debugLog(`background preparation: entries read failed: ${toErrorMessage(error)}`);
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
const contextEntries = buildContextEntries([...entries]);
|
|
566
|
+
if (contextEntries.length === 0) return undefined;
|
|
567
|
+
if (contextEntries[contextEntries.length - 1].type === "compaction") return undefined;
|
|
568
|
+
let previousSummary: string | undefined;
|
|
569
|
+
let boundaryStart = 0;
|
|
570
|
+
const head = contextEntries[0];
|
|
571
|
+
if (head?.type === "compaction") {
|
|
572
|
+
previousSummary = head.summary;
|
|
573
|
+
boundaryStart = 1;
|
|
574
|
+
}
|
|
575
|
+
return { contextEntries, previousSummary, boundaryStart };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** [buildBackgroundPreparation 拆分] 被压段切片:shadowedEntries = [boundaryStart, historyEnd)
|
|
579
|
+
* 内老 compaction 过滤(其投影贡献为空消息,AS6——仅最新 compaction 贡献摘要,原始视角
|
|
580
|
+
* 组装须显式对齐,否则上一份摘要消息会混入被压段重复提交);turnPrefixEntries =
|
|
581
|
+
* split-turn 时 [turnStartIndex, firstKeptEntryIndex) 段(同款 compaction 过滤,
|
|
582
|
+
* 非 split-turn 为空)。 */
|
|
583
|
+
function sliceCompressionEntries(
|
|
584
|
+
contextEntries: SessionEntry[],
|
|
585
|
+
boundaryStart: number,
|
|
586
|
+
cutPoint: ReturnType<typeof findCutPoint>,
|
|
587
|
+
historyEnd: number,
|
|
588
|
+
) {
|
|
589
|
+
const shadowedEntries = contextEntries.slice(boundaryStart, historyEnd).filter((e) => e.type !== "compaction");
|
|
590
|
+
const turnPrefixEntries = cutPoint.isSplitTurn
|
|
591
|
+
? contextEntries.slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex).filter((e) => e.type !== "compaction")
|
|
592
|
+
: [];
|
|
593
|
+
return { shadowedEntries, turnPrefixEntries };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** [buildBackgroundPreparation 拆分] system role 消息剔除(pi getMessagesFromProjectedEntryForCompaction
|
|
597
|
+
* 同款,compaction.js:50-53——system message 是 prompt 状态非对话内容,compaction entry 的
|
|
598
|
+
* systemMessage 承载其回放;不剔除会使摘要 LLM 输入混入 system 消息、与 pi 原生组装(已过滤)不同分布)。 */
|
|
599
|
+
function toNonSystemMessages(entries: SessionEntry[]) {
|
|
600
|
+
return entries.flatMap(sessionEntryToContextMessages).filter((message) => message.role !== "system");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** [buildBackgroundPreparation 拆分] 被压范围 fileOps:被压消息逐条提取(collectFileOps,
|
|
604
|
+
* pi extractFileOperations 等价)+ split-turn 时 turnPrefix 也在压缩范围
|
|
605
|
+
* (prepareCompaction 同款提取,前缀 fileOps 并回主集合)。 */
|
|
606
|
+
function collectCompressedFileOps(
|
|
607
|
+
messagesToSummarize: ReadonlyArray<Parameters<typeof estimateTokens>[0]>,
|
|
608
|
+
turnPrefixMessages: ReadonlyArray<Parameters<typeof estimateTokens>[0]>,
|
|
609
|
+
prevCompaction: CompactionEntry | undefined,
|
|
610
|
+
isSplitTurn: boolean,
|
|
611
|
+
): FileOperations {
|
|
612
|
+
const fileOps = collectFileOps(messagesToSummarize, prevCompaction);
|
|
613
|
+
if (!isSplitTurn) return fileOps;
|
|
614
|
+
const prefixOps = collectFileOps(turnPrefixMessages, undefined);
|
|
615
|
+
for (const key of ["read", "written", "edited"] as const) {
|
|
616
|
+
for (const f of prefixOps[key]) fileOps[key].add(f);
|
|
617
|
+
}
|
|
618
|
+
return fileOps;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 后台压缩任务管理器(U2/U3/U5):触发判定 + 互斥 + 独立熔断 + 事件取消 + 膨胀段共享读取
|
|
623
|
+
* + 工具路径启动 + 终局通知回调。
|
|
624
|
+
*
|
|
625
|
+
* state 为 session 级闭包(index.ts 在 session_start 重建后经 getState 读当前代容器——
|
|
626
|
+
* 旧代任务的回调写旧闭包对象,对新状态天然无害)。afterGeneration 是 U3 续段接线点;
|
|
627
|
+
* onSettled 是 U5 通知接线点(每个终局恰一次,cancelled 除外);getInflatedSegments 提供
|
|
628
|
+
* 膨胀段共享读取(D3:与同步路径 inflatedSegments 同一集合,同段不再触发防反复烧钱),
|
|
629
|
+
* 缺省视为空集。isCtxStale 是 C-pi-17 接入位:由接线层注入代际检查,manager 原样转交
|
|
630
|
+
* 续段钩子(落盘段跨 await 持有 ctx,见 AfterGenerationHook 注释)——注入方不传即该防线
|
|
631
|
+
* 关闭(guardStaleCtx 缺省语义:仅错误文案兜底分诊)。
|
|
632
|
+
*
|
|
633
|
+
* 两个启动入口共享同一「进行中」单态容器(RL1):maybeStart = agent_settled 自动触发
|
|
634
|
+
* (互斥 + 门控 + 探测 + 熔断 + 触发线 + 膨胀段全判定);startForTool = compact_context
|
|
635
|
+
* 工具显式请求(互斥 + 门控前置,跳过触发线与独立熔断——SI5:两者仅约束自动触发入口)。
|
|
636
|
+
*/
|
|
637
|
+
export function createBackgroundCompactionManager(
|
|
638
|
+
pi: ExtensionAPI,
|
|
639
|
+
getState: () => BackgroundCompactionState,
|
|
640
|
+
loadConfigFn: () => SmartContextConfig,
|
|
641
|
+
afterGeneration?: AfterGenerationHook,
|
|
642
|
+
getInflatedSegments?: () => ReadonlySet<string>,
|
|
643
|
+
onSettled?: (settlement: BackgroundTaskSettlement) => void,
|
|
644
|
+
isCtxStale?: () => boolean,
|
|
645
|
+
) {
|
|
646
|
+
/**
|
|
647
|
+
* 事件取消(D3 第一层):有进行中任务则 abort 放弃结果并回空闲。
|
|
648
|
+
* 被取消不计入熔断(取消非失败——被抢先时结果本就不应落盘,D3 审讯结论)。
|
|
649
|
+
*/
|
|
650
|
+
const cancelActive = (reason: string): void => {
|
|
651
|
+
const state = getState();
|
|
652
|
+
const task = state.active;
|
|
653
|
+
if (!task) return;
|
|
654
|
+
state.active = null;
|
|
655
|
+
task.abort.abort();
|
|
656
|
+
debugLog(`background compaction cancelled: ${reason} (sessionId=${task.snapshot.sessionId})`);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/** 互斥查询(RL1):单态判定,工具路径契约 BackgroundCompactionControls.isActive 的实装。 */
|
|
660
|
+
const isActive = (): boolean => getState().active !== null;
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* appendCompaction 前置存在性探测(U3):缺失 = pi 观察级依赖失效(UD1/UD8)的恒定
|
|
664
|
+
* 状态,拦截在生成前省一次全量生成费用;落盘侧另有存在性探测兜底(RL3,语义不同——
|
|
665
|
+
* 此处「不启动任务」、彼处「放弃结果」,运行中 pi 包不变故两者判定恒一致)。
|
|
666
|
+
*/
|
|
667
|
+
const appendCompactionAvailable = (ctx: ExtensionContext): boolean => {
|
|
668
|
+
const sm: unknown = ctx.sessionManager;
|
|
669
|
+
return Boolean(sm && typeof (sm as AppendCompactionLike).appendCompaction === "function");
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 共用启动段(快照 → AbortController → runBackgroundTask):互斥兜底由本函数自检
|
|
674
|
+
* (两入口已前置判定,此处兜 isActive 与 start 之间的同帧竞态)。state.active 赋值与
|
|
675
|
+
* runBackgroundTask 调用同处一个同步帧:run 的续段都在微任务,不会在赋值前看到空
|
|
676
|
+
* active(单线程事件循环顺序保证);task 引用先落地再回填 done,run 的 finally 凭
|
|
677
|
+
* 引用判「是否仍是自己」,构造性排除误清后继任务的竞态。
|
|
678
|
+
*/
|
|
679
|
+
const launchTask = (
|
|
680
|
+
ctx: ExtensionContext,
|
|
681
|
+
config: SmartContextConfig,
|
|
682
|
+
modelId: string,
|
|
683
|
+
preparation: SessionBeforeCompactEvent["preparation"],
|
|
684
|
+
usageTokens: number,
|
|
685
|
+
customInstructions: string | undefined,
|
|
686
|
+
source: "auto" | "tool" | "manual",
|
|
687
|
+
): boolean => {
|
|
688
|
+
const state = getState();
|
|
689
|
+
if (state.active) return false;
|
|
690
|
+
|
|
691
|
+
const entriesTail = ctx.sessionManager.getEntries();
|
|
692
|
+
const snapshot: BackgroundTaskSnapshot = {
|
|
693
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
694
|
+
lastEntryId: entriesTail[entriesTail.length - 1]?.id ?? "",
|
|
695
|
+
tokens: usageTokens,
|
|
696
|
+
compactionIds: entriesTail.filter((e) => e.type === "compaction").map((e) => e.id),
|
|
697
|
+
shadowedTokens: estimateShadowedTokens(preparation.messagesToSummarize),
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// 任务自持 AbortController;异步序列 fire-and-forget(正常路径无人 await)
|
|
701
|
+
const abort = new AbortController();
|
|
702
|
+
const input: GenerationInput = {
|
|
703
|
+
preparation,
|
|
704
|
+
// 触发时刻快照(SI3):branch 条目流随 preparation 组装自同一 entries 视图
|
|
705
|
+
branchEntries: [...entriesTail],
|
|
706
|
+
customInstructions,
|
|
707
|
+
signal: abort.signal,
|
|
708
|
+
};
|
|
709
|
+
const mode = pickMode(config, modelId);
|
|
710
|
+
const task: ActiveBackgroundTask = { abort, snapshot, done: Promise.resolve() };
|
|
711
|
+
state.active = task;
|
|
712
|
+
// .catch 兜底(dmg-r1-2):settle 已内吞通知投递失败,此处防其余抛漏(finally 段 /
|
|
713
|
+
// 未预见路径)形成 unhandled rejection——done 的定位是「测试可确定性收尾 + 调试观测」,
|
|
714
|
+
// 不是「把失败透传给无人 await 的正常路径」;留痕后任务终局事实已由 settle 落定。
|
|
715
|
+
task.done = runBackgroundTask(
|
|
716
|
+
pi,
|
|
717
|
+
ctx,
|
|
718
|
+
config,
|
|
719
|
+
mode,
|
|
720
|
+
input,
|
|
721
|
+
abort,
|
|
722
|
+
state,
|
|
723
|
+
snapshot,
|
|
724
|
+
afterGeneration,
|
|
725
|
+
onSettled,
|
|
726
|
+
task,
|
|
727
|
+
isCtxStale,
|
|
728
|
+
).catch((error: unknown) => {
|
|
729
|
+
warnLog("background compaction task rejected", { error: toErrorMessage(error) });
|
|
730
|
+
});
|
|
731
|
+
debugLog(
|
|
732
|
+
`background compaction started(${source}): mode=${mode} tokens=${usageTokens} ` +
|
|
733
|
+
`firstKept=${preparation.firstKeptEntryId}`,
|
|
734
|
+
);
|
|
735
|
+
return true;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/** 触发判定 + 启动(D4 自动入口)。返回是否启动(供日志;判定全部前置短路)。 */
|
|
739
|
+
const maybeStart = (ctx: ExtensionContext): boolean => {
|
|
740
|
+
const state = getState();
|
|
741
|
+
|
|
742
|
+
// 互斥(RL1):任务进行中不再触发(生成→校验→落盘连续序列占用同一「进行中」态)
|
|
743
|
+
if (state.active) return false;
|
|
744
|
+
|
|
745
|
+
const config = loadConfigFn();
|
|
746
|
+
const modelId = getCurrentModelId(ctx.model);
|
|
747
|
+
// 门控前置(D4):enabled 与 excludedModels,与同步接管同款判定
|
|
748
|
+
if (!isGatingActive(config, modelId)) return false;
|
|
749
|
+
|
|
750
|
+
if (!appendCompactionAvailable(ctx)) {
|
|
751
|
+
debugLog("background compaction skipped: sessionManager.appendCompaction unavailable");
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// 独立熔断(SI5):连续失败 ≥3 停止自动触发(只影响本入口,工具/手动路径不受影响)
|
|
756
|
+
if (state.failureStreak >= TAKEOVER_FAILURE_LIMIT) {
|
|
757
|
+
debugLog("background compaction circuit breaker open, skipping auto trigger");
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 触发线(D4):tokens 越过 ratio×窗口;读不到用量(压缩后首响应前)跳过本轮
|
|
762
|
+
const usage = ctx.getContextUsage();
|
|
763
|
+
if (!usage || usage.tokens === null) return false;
|
|
764
|
+
if (!isAutoCompactTriggered(usage.tokens, usage.contextWindow, config.autoCompactThresholdRatio)) return false;
|
|
765
|
+
|
|
766
|
+
// 准备数据组装(D2):无可压(刚压缩完/预算内)→ 不触发
|
|
767
|
+
const preparation = buildBackgroundPreparation(ctx);
|
|
768
|
+
if (!preparation) return false;
|
|
769
|
+
|
|
770
|
+
// 膨胀段共享读取(D3/SI6):该段收缩校验曾失败 → 同段不再触发防反复烧钱
|
|
771
|
+
if (getInflatedSegments?.().has(preparation.firstKeptEntryId)) {
|
|
772
|
+
debugLog(`background compaction skipped: segment previously inflated (firstKept=${preparation.firstKeptEntryId})`);
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return launchTask(ctx, config, modelId, preparation, usage.tokens, undefined, "auto");
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* 工具/手动路径启动(U5 接线残项 + C2 手动触发入口):显式请求的后台任务启动,即
|
|
781
|
+
* tool.ts BackgroundCompactionControls 契约的 start 实装与 `__taiji_compact__`
|
|
782
|
+
* 命令(GUI 手动 /compact,C2 /compact 后台化)共用入口。
|
|
783
|
+
*
|
|
784
|
+
* 与自动入口的判定差异(§3.1 入口矩阵①):
|
|
785
|
+
* - 保留:互斥(撞上返回 false——工具按幂等处理,U4 契约唯一 false 语义);tool 来源
|
|
786
|
+
* 保留 isGatingActive 门控前置(工具 execute 已现场校验过,此处防御双检——配置热改
|
|
787
|
+
* 窗口兜底);
|
|
788
|
+
* - 跳过:manual 来源门控(手动显式命令 = 用户意志,与旧 pi 原生手动压缩行为对齐——
|
|
789
|
+
* 旧链不受 smart-context 开关影响;enabled/excludedModels 只拦 agent 自主决策的
|
|
790
|
+
* auto/tool 路径);触发线(D4 ratio×窗口——显式请求即用户/agent 决策,不受自动
|
|
791
|
+
* 触发线约束)与独立熔断(SI5:熔断仅停自动触发);
|
|
792
|
+
* - 不做膨胀段拦截:膨胀段(D3/SI6)防的是「同段反复自动触发反复烧钱」,工具显式
|
|
793
|
+
* 请求是知情决策(可带 custom_instructions 改善摘要),不拦——落盘侧收缩校验仍会
|
|
794
|
+
* 放弃膨胀结果并经通知回报。
|
|
795
|
+
*
|
|
796
|
+
* 失败语义:非互斥原因不可启动(门控拒绝 / appendCompaction 探测失败 / 无可压内容)
|
|
797
|
+
* 抛错而非返回 false——false 在工具侧按「后台压缩进行中」幂等回执处理,非互斥失败
|
|
798
|
+
* 返回 false 会撒谎回执;抛错经工具 execute 上浮为失败回执(诚实、可操作),命令路径
|
|
799
|
+
* 由命令 handler catch 后经通知通道投递失败文案。
|
|
800
|
+
*
|
|
801
|
+
* source 标记(观测口径):tool = compact_context 工具;manual = GUI 手动 /compact
|
|
802
|
+
* 经 runtime `__taiji_compact__` 命令触发。除 manual 跳门控外不影响判定与通知。
|
|
803
|
+
*/
|
|
804
|
+
const startForTool = (
|
|
805
|
+
ctx: ExtensionContext,
|
|
806
|
+
customInstructions?: string,
|
|
807
|
+
source: "tool" | "manual" = "tool",
|
|
808
|
+
): boolean => {
|
|
809
|
+
if (isActive()) return false;
|
|
810
|
+
|
|
811
|
+
const config = loadConfigFn();
|
|
812
|
+
const modelId = getCurrentModelId(ctx.model);
|
|
813
|
+
if (source !== "manual" && !isGatingActive(config, modelId)) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`compact_context 后台压缩启动被门控拒绝:当前模型 ${modelId} 不在放行范围(enabled/excludedModels)。` +
|
|
816
|
+
`可在 taiji 设置页或 smart-context-ext-config skill 中调整后重试。`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (!appendCompactionAvailable(ctx)) {
|
|
820
|
+
throw new Error(
|
|
821
|
+
"compact_context 后台压缩启动失败:sessionManager.appendCompaction 不可用(pi 会话接口缺失,观察级依赖失效)。" +
|
|
822
|
+
"请升级或回退 pi 版本后重试。",
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const preparation = buildBackgroundPreparation(ctx);
|
|
827
|
+
if (!preparation) {
|
|
828
|
+
throw new Error(
|
|
829
|
+
"compact_context 后台压缩未启动:当前上下文没有可压缩内容(刚压缩过,或全部上下文在保留预算内)。",
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return launchTask(ctx, config, modelId, preparation, ctx.getContextUsage()?.tokens ?? 0, customInstructions, source);
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
return { maybeStart, cancelActive, isActive, startForTool };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* 后台任务执行序列(「进行中」态内的连续序列):生成 → 续段钩子(U3)。
|
|
841
|
+
* 结局四类:cancelled(放弃结果,计数不动)/ empty(生成空结果,递增①)/ error(异常,递增③)/
|
|
842
|
+
* 生成成功 → afterGeneration 处置回报(validated 清零 / inflated 递增② / dropped 不动)。
|
|
843
|
+
* 任一结局回空闲(try/finally)。旧代闭包场景:写入的是捕获的旧 state 对象,无害。
|
|
844
|
+
* 终局通知(U5/D6):onSettled 在每个终局点回调恰一次(cancelled 不回调——取消非失败,
|
|
845
|
+
* 不在 D6 失败通知清单;afterGeneration 未接线的开发中间态也不回调,不撒谎成功)。
|
|
846
|
+
*/
|
|
847
|
+
async function runBackgroundTask(
|
|
848
|
+
pi: ExtensionAPI,
|
|
849
|
+
ctx: ExtensionContext,
|
|
850
|
+
config: SmartContextConfig,
|
|
851
|
+
mode: "same-model" | "cross-model",
|
|
852
|
+
input: GenerationInput,
|
|
853
|
+
abort: AbortController,
|
|
854
|
+
state: BackgroundCompactionState,
|
|
855
|
+
snapshot: BackgroundTaskSnapshot,
|
|
856
|
+
afterGeneration?: AfterGenerationHook,
|
|
857
|
+
onSettled?: (settlement: BackgroundTaskSettlement) => void,
|
|
858
|
+
task?: ActiveBackgroundTask,
|
|
859
|
+
isCtxStale?: () => boolean,
|
|
860
|
+
): Promise<void> {
|
|
861
|
+
/**
|
|
862
|
+
* 终局回调适配:sessionId/mode 由本闭包统一补齐,接线层只消费终局语义与数据载荷。
|
|
863
|
+
*
|
|
864
|
+
* 通知失败内吞留痕(dmg-r1-2):接线层 onBackgroundSettled 的 guardStaleCtx 对非 stale
|
|
865
|
+
* 错误按 ext-guards 语义原样上抛——若任其穿出,已 validated 的压缩会被下方 catch 改记
|
|
866
|
+
* 失败并无谓递增熔断计数(成败事实被通知投递失败篡改),catch 分支再 settle 一次还会
|
|
867
|
+
* 二次投递。故通知失败只 warnLog,不改写 failureStreak、不走 error 终局。
|
|
868
|
+
*/
|
|
869
|
+
const settle = (settlement: Omit<BackgroundTaskSettlement, "sessionId" | "mode">): void => {
|
|
870
|
+
try {
|
|
871
|
+
onSettled?.({ ...settlement, sessionId: snapshot.sessionId, mode });
|
|
872
|
+
} catch (error) {
|
|
873
|
+
warnLog("background compaction settlement notification failed", {
|
|
874
|
+
outcome: settlement.outcome,
|
|
875
|
+
error: toErrorMessage(error),
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
try {
|
|
880
|
+
const result = mode === "same-model"
|
|
881
|
+
? await generateSameMode(pi, ctx, input)
|
|
882
|
+
: await generateCrossMode(ctx, config, input);
|
|
883
|
+
|
|
884
|
+
// 被抢先取消:放弃结果(取消非失败,SI5 递增点不含取消;不发终局通知)
|
|
885
|
+
if (abort.signal.aborted) {
|
|
886
|
+
debugLog(`background compaction discarded result after cancellation (sessionId=${snapshot.sessionId})`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
// 递增点①:生成空结果
|
|
890
|
+
if (!result) {
|
|
891
|
+
state.failureStreak += 1;
|
|
892
|
+
warnLog("background compaction produced no result", { failureStreak: state.failureStreak });
|
|
893
|
+
settle({
|
|
894
|
+
outcome: "empty",
|
|
895
|
+
failureStreak: state.failureStreak,
|
|
896
|
+
circuitBreakerOpen: state.failureStreak >= TAKEOVER_FAILURE_LIMIT,
|
|
897
|
+
});
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// 生成成功 → 续段(U3 接线:收缩校验 + 落盘 + 补偿)。U2 缺省仅日志。
|
|
902
|
+
// isCtxStale 由 manager 注入位转交(C-pi-17):落盘段据此前置分诊 session 替换窗口。
|
|
903
|
+
if (!afterGeneration) {
|
|
904
|
+
debugLog(
|
|
905
|
+
`background compaction generated (continuation not wired yet): firstKept=${result.firstKeptEntryId}`,
|
|
906
|
+
);
|
|
907
|
+
state.failureStreak = 0;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const outcome = await afterGeneration(result, snapshot, ctx, isCtxStale);
|
|
911
|
+
if (abort.signal.aborted) {
|
|
912
|
+
debugLog(`background compaction continuation discarded after cancellation (sessionId=${snapshot.sessionId})`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (outcome === "validated") {
|
|
916
|
+
state.failureStreak = 0;
|
|
917
|
+
settle({ outcome, result, shadowedTokens: snapshot.shadowedTokens });
|
|
918
|
+
} else if (outcome === "inflated") {
|
|
919
|
+
state.failureStreak += 1;
|
|
920
|
+
warnLog("background compaction summary inflated, rejected by continuation", { failureStreak: state.failureStreak });
|
|
921
|
+
settle({
|
|
922
|
+
outcome,
|
|
923
|
+
result,
|
|
924
|
+
shadowedTokens: snapshot.shadowedTokens,
|
|
925
|
+
failureStreak: state.failureStreak,
|
|
926
|
+
circuitBreakerOpen: state.failureStreak >= TAKEOVER_FAILURE_LIMIT,
|
|
927
|
+
});
|
|
928
|
+
} else {
|
|
929
|
+
debugLog(
|
|
930
|
+
`background compaction dropped by continuation (reason=${outcome.reason}, sessionId=${snapshot.sessionId})`,
|
|
931
|
+
);
|
|
932
|
+
settle({ outcome: "dropped", droppedReason: outcome.reason });
|
|
933
|
+
}
|
|
934
|
+
} catch (error) {
|
|
935
|
+
// 取消引发的异常(abort 竞态)不算失败
|
|
936
|
+
if (abort.signal.aborted) {
|
|
937
|
+
debugLog(`background compaction errored after cancellation (sessionId=${snapshot.sessionId})`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
// 递增点③:异常
|
|
941
|
+
state.failureStreak += 1;
|
|
942
|
+
warnLog("background compaction error", { error: toErrorMessage(error), failureStreak: state.failureStreak });
|
|
943
|
+
settle({
|
|
944
|
+
outcome: "error",
|
|
945
|
+
error: toErrorMessage(error),
|
|
946
|
+
failureStreak: state.failureStreak,
|
|
947
|
+
circuitBreakerOpen: state.failureStreak >= TAKEOVER_FAILURE_LIMIT,
|
|
948
|
+
});
|
|
949
|
+
} finally {
|
|
950
|
+
// 回空闲:凭引用判「active 仍是本任务」才清(跨代写旧闭包对象无害;同代不误清后继)
|
|
951
|
+
if (task && state.active === task) state.active = null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
|
|
58
956
|
/** 统一 logger(@zhushanwen/pi-extension-logger:debug 文件日志 / warn 走 appendEntry 持久化)。 */
|
|
59
957
|
const logger = createLogger("smart-context");
|
|
60
958
|
|
|
@@ -72,7 +970,7 @@ function warnLog(message: string, data?: unknown): void {
|
|
|
72
970
|
export interface SmartContextDetails {
|
|
73
971
|
engine: "smart-context";
|
|
74
972
|
mode: "same-model" | "cross-model";
|
|
75
|
-
/** 执行压缩的模型 `${provider}/${modelId}
|
|
973
|
+
/** 执行压缩的模型 `${provider}/${modelId}`(用量页 compaction 归属数据源,pi-semantics PS-28:details 逐字透传落盘)。 */
|
|
76
974
|
model: string;
|
|
77
975
|
}
|
|
78
976
|
|
|
@@ -143,6 +1041,19 @@ function assembleSummary(
|
|
|
143
1041
|
return summary;
|
|
144
1042
|
}
|
|
145
1043
|
|
|
1044
|
+
/**
|
|
1045
|
+
* 压缩生成的显式输入(U1 解耦):从 SessionBeforeCompactEvent 摘出生成逻辑实际消费的字段。
|
|
1046
|
+
* 后台压缩路径(D2)没有事件对象,组装等价字段直接传入——生成逻辑与事件载体解耦,
|
|
1047
|
+
* same-model / cross-model 两模式共用此形状(各取所需字段)。
|
|
1048
|
+
* preparation 类型经索引派生:CompactionPreparation 不在 SDK 包根导出清单。
|
|
1049
|
+
*/
|
|
1050
|
+
interface GenerationInput {
|
|
1051
|
+
preparation: SessionBeforeCompactEvent["preparation"];
|
|
1052
|
+
branchEntries: SessionBeforeCompactEvent["branchEntries"];
|
|
1053
|
+
customInstructions: SessionBeforeCompactEvent["customInstructions"];
|
|
1054
|
+
signal: SessionBeforeCompactEvent["signal"];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
146
1057
|
/**
|
|
147
1058
|
* same-model 生成(D12):完整上下文 + 会话原 system prompt + tools + 追加压缩指令。
|
|
148
1059
|
* 返回 null = 失败(调用方走 D7 回退)。
|
|
@@ -150,19 +1061,20 @@ function assembleSummary(
|
|
|
150
1061
|
async function generateSameMode(
|
|
151
1062
|
pi: ExtensionAPI,
|
|
152
1063
|
ctx: ExtensionContext,
|
|
153
|
-
|
|
1064
|
+
input: GenerationInput,
|
|
154
1065
|
): Promise<CompactionResult | null> {
|
|
155
1066
|
const model = ctx.model;
|
|
156
1067
|
if (!model) {
|
|
157
1068
|
debugLog("same-mode: no current model");
|
|
158
1069
|
return null;
|
|
159
1070
|
}
|
|
1071
|
+
const { preparation, branchEntries, customInstructions, signal } = input;
|
|
160
1072
|
// AgentMessage[](含 bash/custom 等扩展消息)→ 标准 Message[](与主会话请求同源转换,
|
|
161
1073
|
// convertToLlm 是 pi host 默认实现——同样的输入产生同样的输出,前缀缓存对齐的前提)
|
|
162
|
-
const fullMessages = convertToLlm(buildSessionContext(
|
|
1074
|
+
const fullMessages = convertToLlm(buildSessionContext(branchEntries).messages);
|
|
163
1075
|
const instructionMessage = {
|
|
164
1076
|
role: "user" as const,
|
|
165
|
-
content: [{ type: "text" as const, text: buildSameModelInstruction(
|
|
1077
|
+
content: [{ type: "text" as const, text: buildSameModelInstruction(customInstructions) }],
|
|
166
1078
|
timestamp: Date.now(),
|
|
167
1079
|
};
|
|
168
1080
|
const tools = projectTools(pi.getAllTools());
|
|
@@ -171,7 +1083,7 @@ async function generateSameMode(
|
|
|
171
1083
|
systemPrompt: ctx.getSystemPrompt() ?? "",
|
|
172
1084
|
messages: [...fullMessages, instructionMessage],
|
|
173
1085
|
tools,
|
|
174
|
-
signal
|
|
1086
|
+
signal,
|
|
175
1087
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
176
1088
|
});
|
|
177
1089
|
if (!result.ok) {
|
|
@@ -190,15 +1102,15 @@ async function generateSameMode(
|
|
|
190
1102
|
const sessionFile = getSessionFilePath(ctx);
|
|
191
1103
|
const summary = assembleSummary(
|
|
192
1104
|
result.text,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1105
|
+
preparation.fileOps,
|
|
1106
|
+
branchEntries,
|
|
1107
|
+
preparation.firstKeptEntryId,
|
|
196
1108
|
sessionFile,
|
|
197
1109
|
);
|
|
198
1110
|
return {
|
|
199
1111
|
summary,
|
|
200
|
-
firstKeptEntryId:
|
|
201
|
-
tokensBefore:
|
|
1112
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
1113
|
+
tokensBefore: preparation.tokensBefore,
|
|
202
1114
|
usage: result.usage as CompactionResult["usage"],
|
|
203
1115
|
details: {
|
|
204
1116
|
engine: "smart-context",
|
|
@@ -215,8 +1127,8 @@ async function generateSameMode(
|
|
|
215
1127
|
*/
|
|
216
1128
|
async function generateCrossMode(
|
|
217
1129
|
ctx: ExtensionContext,
|
|
218
|
-
event: SessionBeforeCompactEvent,
|
|
219
1130
|
config: SmartContextConfig,
|
|
1131
|
+
input: GenerationInput,
|
|
220
1132
|
): Promise<CompactionResult | null> {
|
|
221
1133
|
const model = resolveModel(ctx, config.compactModel);
|
|
222
1134
|
if (!model) {
|
|
@@ -236,12 +1148,12 @@ async function generateCrossMode(
|
|
|
236
1148
|
)
|
|
237
1149
|
: undefined;
|
|
238
1150
|
const result = await nativeCompact(
|
|
239
|
-
|
|
1151
|
+
input.preparation,
|
|
240
1152
|
model,
|
|
241
1153
|
auth.apiKey,
|
|
242
1154
|
headers,
|
|
243
|
-
|
|
244
|
-
|
|
1155
|
+
input.customInstructions,
|
|
1156
|
+
input.signal,
|
|
245
1157
|
undefined,
|
|
246
1158
|
undefined,
|
|
247
1159
|
auth.env,
|
|
@@ -262,16 +1174,16 @@ async function generateCrossMode(
|
|
|
262
1174
|
* session_before_compact handler 工厂。
|
|
263
1175
|
*
|
|
264
1176
|
* state 为 session 级闭包(由 src/index.ts 在 session_start 重建后传入)。
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
1177
|
+
* 返回类型显式标注 SessionBeforeCompactResult({}/ {compaction})——该名在 SDK
|
|
1178
|
+
* 包根以 export type 提供(dist/index.d.ts:8 导出清单),import type 可用(tsc
|
|
1179
|
+
* --strict 单文件探针实编译通过)。
|
|
268
1180
|
*/
|
|
269
1181
|
export function createBeforeCompactHandler(
|
|
270
1182
|
pi: ExtensionAPI,
|
|
271
1183
|
getState: () => TakeoverState,
|
|
272
1184
|
loadConfigFn: () => SmartContextConfig,
|
|
273
1185
|
) {
|
|
274
|
-
return async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
1186
|
+
return async (event: SessionBeforeCompactEvent, ctx: ExtensionContext): Promise<SessionBeforeCompactResult> => {
|
|
275
1187
|
const config = loadConfigFn();
|
|
276
1188
|
const currentModelId = getCurrentModelId(ctx.model);
|
|
277
1189
|
|
|
@@ -294,9 +1206,16 @@ export function createBeforeCompactHandler(
|
|
|
294
1206
|
const mode = pickMode(config, currentModelId);
|
|
295
1207
|
|
|
296
1208
|
try {
|
|
1209
|
+
// 薄包装(U1 解耦):事件对象摘出生成逻辑消费的字段,两模式共用同一输入形状
|
|
1210
|
+
const input: GenerationInput = {
|
|
1211
|
+
preparation: event.preparation,
|
|
1212
|
+
branchEntries: event.branchEntries,
|
|
1213
|
+
customInstructions: event.customInstructions,
|
|
1214
|
+
signal: event.signal,
|
|
1215
|
+
};
|
|
297
1216
|
const result = mode === "same-model"
|
|
298
|
-
? await generateSameMode(pi, ctx,
|
|
299
|
-
: await generateCrossMode(ctx,
|
|
1217
|
+
? await generateSameMode(pi, ctx, input)
|
|
1218
|
+
: await generateCrossMode(ctx, config, input);
|
|
300
1219
|
|
|
301
1220
|
if (!result) {
|
|
302
1221
|
state.failStreak += 1;
|