@zhushanwen/pi-smart-context 0.1.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.
@@ -0,0 +1,144 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // mock 配置加载(工具测试不落盘)
4
+ vi.mock("../pure.js", async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import("../pure.js")>();
6
+ return {
7
+ ...actual,
8
+ loadSmartContextConfig: vi.fn(),
9
+ };
10
+ });
11
+
12
+ import { DEFAULT_SMART_CONTEXT_CONFIG, loadSmartContextConfig } from "../pure.js";
13
+ import { registerCompactContextTool } from "../tool.js";
14
+ import { countCompactions } from "../pure.js";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+
17
+ interface RegisteredTool {
18
+ name: string;
19
+ parameters: { type?: string };
20
+ execute: (id: string, params: Record<string, unknown>, signal: undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
21
+ }
22
+
23
+ type CompactOpts = {
24
+ customInstructions?: string;
25
+ onComplete: (r: unknown) => void;
26
+ onError: (e: Error) => void;
27
+ };
28
+
29
+ function makePi(): { pi: ExtensionAPI & { sendUserMessage: ReturnType<typeof vi.fn> }; tools: RegisteredTool[] } {
30
+ const tools: RegisteredTool[] = [];
31
+ const pi = {
32
+ registerTool: (t: RegisteredTool) => tools.push(t),
33
+ sendUserMessage: vi.fn(),
34
+ } as unknown as ExtensionAPI & { sendUserMessage: ReturnType<typeof vi.fn> };
35
+ return { pi, tools };
36
+ }
37
+
38
+ function makeCtx(compactImpl?: (options: CompactOpts) => void): ExtensionContext {
39
+ return {
40
+ model: { provider: "zai", id: "glm" },
41
+ getContextUsage: () => ({ tokens: 250_000, contextWindow: 1_000_000 }),
42
+ sessionManager: { getEntries: () => [] },
43
+ compact: compactImpl ?? ((options: CompactOpts) => {
44
+ options.onComplete({
45
+ tokensBefore: 500_000,
46
+ estimatedTokensAfter: 24_000,
47
+ usage: { input: 432_000, output: 1_800, cacheRead: 0 },
48
+ details: { engine: "smart-context", mode: "same-model" },
49
+ });
50
+ }),
51
+ } as unknown as ExtensionContext;
52
+ }
53
+
54
+ const mockedLoad = vi.mocked(loadSmartContextConfig);
55
+
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ mockedLoad.mockReturnValue(DEFAULT_SMART_CONTEXT_CONFIG);
59
+ });
60
+
61
+ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入)", () => {
62
+ it("parameters 顶层为 Type.Object(OpenAI 兼容红线)", () => {
63
+ const { pi, tools } = makePi();
64
+ registerCompactContextTool(pi);
65
+ expect(tools[0].parameters.type).toBe("object");
66
+ expect(tools[0].name).toBe("compact_context");
67
+ });
68
+
69
+ it("门控拒绝(排除命中)→ throw 带原因与恢复指引(D5)", async () => {
70
+ mockedLoad.mockReturnValue({ ...DEFAULT_SMART_CONTEXT_CONFIG, excludedModels: ["zai/glm"] });
71
+ const { pi, tools } = makePi();
72
+ registerCompactContextTool(pi, { gatingProbe: () => ({ active: false, modelId: "zai/glm" }) });
73
+ await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/已配置为排除/);
74
+ });
75
+
76
+ it("阈值保护:低于最低档 throw 带用量数据(D6)", async () => {
77
+ const { pi, tools } = makePi();
78
+ registerCompactContextTool(pi, { usageProbe: () => ({ tokens: 38_000, contextWindow: 1_000_000 }) });
79
+ await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/38K/);
80
+ });
81
+
82
+ it("阈值保护:tokens null → 用量未知拒绝(D6 null 分支)", async () => {
83
+ const { pi, tools } = makePi();
84
+ registerCompactContextTool(pi, { usageProbe: () => ({ tokens: null, contextWindow: 1_000_000 }) });
85
+ await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/用量未知/);
86
+ });
87
+
88
+ it("execute 立即返回「已启动」(不 await 压缩完成,R2 契约)+ details 结构化", async () => {
89
+ const { pi, tools } = makePi();
90
+ registerCompactContextTool(pi, { getEntries: () => [{ type: "message" }] });
91
+ const result = await tools[0].execute("t1", {}, undefined, undefined, makeCtx());
92
+ expect(result.content[0].text).toContain("压缩已启动");
93
+ expect(result.content[0].text).toContain("same-model");
94
+ expect(result.details).toMatchObject({ mode: "same-model", launched: true, fellBack: false, compactionCount: 1 });
95
+ });
96
+
97
+ it("onComplete 兑现后 sendUserMessage 注入结果(含模式/前后 tokens/成本;降智提示按次数)", async () => {
98
+ const { pi, tools } = makePi();
99
+ registerCompactContextTool(pi, { getEntries: () => [{ type: "compaction" }, { type: "compaction" }] });
100
+ await tools[0].execute("t1", {}, undefined, undefined, makeCtx());
101
+ expect(pi.sendUserMessage).toHaveBeenCalledTimes(1);
102
+ const [message, options] = pi.sendUserMessage.mock.calls[0];
103
+ expect(message).toContain("压缩完成");
104
+ expect(message).toContain("same-model");
105
+ expect(message).toContain("500K");
106
+ expect(message).toContain("24K");
107
+ expect(message).toContain("compacted multiple times");
108
+ expect(options).toEqual({ deliverAs: "steer" });
109
+ });
110
+
111
+ it("onComplete 无 engine 标记 → 注入消息含回退说明与修复指引(D7)", async () => {
112
+ const { pi, tools } = makePi();
113
+ registerCompactContextTool(pi);
114
+ const ctx = makeCtx((options) => options.onComplete({ tokensBefore: 1, estimatedTokensAfter: 1 }));
115
+ await tools[0].execute("t1", {}, undefined, undefined, ctx);
116
+ expect(pi.sendUserMessage).toHaveBeenCalledTimes(1);
117
+ expect(pi.sendUserMessage.mock.calls[0][0]).toContain("回退");
118
+ });
119
+
120
+ it("onError → 注入失败消息带重试指引", async () => {
121
+ const { pi, tools } = makePi();
122
+ registerCompactContextTool(pi);
123
+ const ctx = makeCtx((options) => options.onError(new Error("Nothing to compact")));
124
+ await tools[0].execute("t1", {}, undefined, undefined, ctx);
125
+ expect(pi.sendUserMessage.mock.calls[0][0]).toContain("压缩失败");
126
+ expect(pi.sendUserMessage.mock.calls[0][0]).toContain("Nothing to compact");
127
+ });
128
+
129
+ it("custom_instructions 透传给 ctx.compact", async () => {
130
+ let received: string | undefined;
131
+ const { pi, tools } = makePi();
132
+ registerCompactContextTool(pi);
133
+ const ctx = makeCtx((options) => {
134
+ received = options.customInstructions;
135
+ options.onComplete({ details: { engine: "smart-context", mode: "cross-model" } });
136
+ });
137
+ await tools[0].execute("t1", { custom_instructions: "保留验证结果" }, undefined, undefined, ctx);
138
+ expect(received).toBe("保留验证结果");
139
+ });
140
+
141
+ it("countCompactions 只数 compaction entries", () => {
142
+ expect(countCompactions([{ type: "message" }, { type: "compaction" }, { type: "compaction" }])).toBe(2);
143
+ });
144
+ });
@@ -0,0 +1,321 @@
1
+ /**
2
+ * session_before_compact 接管 handler(D1/D11/D12/D13 核心)。
3
+ *
4
+ * 三条压缩路径(agent 工具 / 用户 /compact / 内建 auto)统一经过这里:
5
+ * - 门控未放行 / 熔断 / 生成失败 → 返回空(pi 原生生成兜底,D2/D7)
6
+ * - same-model 模式:完整上下文 + 会话原 system prompt + tools + 末尾追加压缩指令(kv-cache 前缀命中)
7
+ * - cross-model 模式:直接调用包导出的 compact(preparation, 压缩Model, ...)(原生组装零复刻,R4 结论)
8
+ *
9
+ * 输出 CompactionResult.details 携带 {engine:"smart-context", mode} 标记(D1 entry 标记)。
10
+ */
11
+
12
+ import { buildSessionContext, compact as nativeCompact, convertToLlm } from "@earendil-works/pi-coding-agent";
13
+ import type { CompactionResult } from "@earendil-works/pi-coding-agent";
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { createLogger } from "@zhushanwen/pi-extension-logger";
16
+ import { resolveModel } from "@zhushanwen/pi-llm-shared";
17
+ import { readFileSync } from "node:fs";
18
+
19
+ import { callSameModelCompaction, projectTools } from "./llm.js";
20
+ import {
21
+ buildSameModelInstruction,
22
+ buildTranscriptPointer,
23
+ CHECKPOINT_PREAMBLE,
24
+ } from "./prompts.js";
25
+ import {
26
+ buildReinjectSection,
27
+ collectKeptReadFiles,
28
+ computeFileListsLike,
29
+ estimateShadowedTokens,
30
+ estimateTextTokens,
31
+ formatFileOperationsLike,
32
+ getCurrentModelId,
33
+ isSummaryInflated,
34
+ pickMode,
35
+ pickReinjectFiles,
36
+ type FileOpsLike,
37
+ type SmartContextConfig,
38
+ } from "./pure.js";
39
+
40
+ /** session_before_compact 事件的宽松形状(消费字段收窄,不依赖 pi 事件类型导出)。 */
41
+ export interface BeforeCompactLikeEvent {
42
+ type: "session_before_compact";
43
+ preparation: {
44
+ firstKeptEntryId: string;
45
+ messagesToSummarize: ReadonlyArray<{ role: string; content?: unknown }>;
46
+ turnPrefixMessages: ReadonlyArray<{ role: string }>;
47
+ isSplitTurn: boolean;
48
+ tokensBefore: number;
49
+ previousSummary?: string;
50
+ fileOps: FileOpsLike;
51
+ };
52
+ branchEntries: ReadonlyArray<unknown>;
53
+ customInstructions?: string;
54
+ reason: "manual" | "threshold" | "overflow";
55
+ willRetry: boolean;
56
+ signal?: AbortSignal;
57
+ }
58
+
59
+ /** handler 返回(SessionBeforeCompactResult 子集)。 */
60
+ export interface BeforeCompactDecision {
61
+ cancel?: boolean;
62
+ compaction?: CompactionResult;
63
+ }
64
+
65
+ /** session 级接管状态(session_start 重建闭包,规范 Session 隔离)。 */
66
+ export interface TakeoverState {
67
+ /** 接管连续失败计数(D13-3 熔断:≥3 停止接管)。 */
68
+ failStreak: number;
69
+ /** 收缩校验失败已记录的段(firstKeptEntryId 集合,D13-1 同段不重试)。 */
70
+ inflatedSegments: Set<string>;
71
+ }
72
+
73
+ const TAKEOVER_FAILURE_LIMIT = 3;
74
+
75
+ export function createTakeoverState(): TakeoverState {
76
+ return { failStreak: 0, inflatedSegments: new Set() };
77
+ }
78
+
79
+ /** 统一 logger(@zhushanwen/pi-extension-logger:debug 文件日志 / warn 走 appendEntry 持久化)。 */
80
+ const logger = createLogger("smart-context");
81
+
82
+ /** 开发调试日志(XYZ_AGENT_DEBUG=1 时写文件,默认 no-op;logging-conventions 统一通道)。 */
83
+ export function debugLog(message: string): void {
84
+ logger.debug(message);
85
+ }
86
+
87
+ /** 内部降级/失败(事后排查价值,appendEntry 持久化不进 LLM 上下文)。 */
88
+ function warnLog(message: string, data?: unknown): void {
89
+ logger.warn(message, data);
90
+ }
91
+
92
+ /** 压缩引擎标记(D1 entry 标记,details 字段直接落 compaction entry)。 */
93
+ export interface SmartContextDetails {
94
+ engine: "smart-context";
95
+ mode: "same-model" | "cross-model";
96
+ }
97
+
98
+ /**
99
+ * session 文件路径(transcript 回查指针用,D13-4):从 sessionManager 推导。
100
+ * getSessionFile 若不可得则返回空串(指针省略,不失败)。
101
+ */
102
+ function getSessionFilePath(ctx: ExtensionContext): string {
103
+ const sm = ctx.sessionManager as unknown as {
104
+ getSessionFile?: () => string | undefined;
105
+ sessionFile?: string;
106
+ };
107
+ try {
108
+ return sm.getSessionFile?.() ?? sm.sessionFile ?? "";
109
+ } catch {
110
+ return "";
111
+ }
112
+ }
113
+
114
+ /** 读文件做重注入(D13-11):读失败/空内容返回空串(逐文件降级)。 */
115
+ function readFileForReinject(path: string): string {
116
+ try {
117
+ return readFileSync(path, "utf8");
118
+ } catch {
119
+ return "";
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 组装 summary 后处理(两模式共用,D13-1/4/9/11 + D11-2):
125
+ * preamble + 模型摘要 + fileOps 清单 + 文件重注入节 + transcript 指针。
126
+ */
127
+ function assembleSummary(
128
+ summaryText: string,
129
+ fileOps: FileOpsLike,
130
+ branchEntries: ReadonlyArray<unknown>,
131
+ firstKeptEntryId: string,
132
+ sessionFilePath: string,
133
+ ): string {
134
+ const { readFiles, modifiedFiles } = computeFileListsLike(fileOps);
135
+ let summary = `${CHECKPOINT_PREAMBLE}\n\n${summaryText}`;
136
+ summary += formatFileOperationsLike(readFiles, modifiedFiles);
137
+
138
+ // D13-11 文件重注入:只读文件取最近 ≤5 个(保留段已有的跳过)
139
+ const keptReads = collectKeptReadFiles(branchEntries, firstKeptEntryId);
140
+ const candidates = pickReinjectFiles(readFiles, keptReads);
141
+ if (candidates.length > 0) {
142
+ const contents = candidates.map((p) => ({ path: p, content: readFileForReinject(p) }));
143
+ summary += buildReinjectSection(contents);
144
+ }
145
+
146
+ if (sessionFilePath !== "") {
147
+ summary += buildTranscriptPointer(sessionFilePath);
148
+ }
149
+ return summary;
150
+ }
151
+
152
+ /**
153
+ * same-model 生成(D12):完整上下文 + 会话原 system prompt + tools + 追加压缩指令。
154
+ * 返回 null = 失败(调用方走 D7 回退)。
155
+ */
156
+ async function generateSameMode(
157
+ pi: ExtensionAPI,
158
+ ctx: ExtensionContext,
159
+ event: BeforeCompactLikeEvent,
160
+ ): Promise<CompactionResult | null> {
161
+ const model = ctx.model;
162
+ if (!model) {
163
+ debugLog("same-mode: no current model");
164
+ return null;
165
+ }
166
+ // AgentMessage[](含 bash/custom 等扩展消息)→ 标准 Message[](与主会话请求同源转换,
167
+ // convertToLlm 是 pi host 默认实现——同样的输入产生同样的输出,前缀缓存对齐的前提)
168
+ const fullMessages = convertToLlm(buildSessionContext(event.branchEntries as never).messages);
169
+ const instructionMessage = {
170
+ role: "user" as const,
171
+ content: [{ type: "text" as const, text: buildSameModelInstruction(event.customInstructions) }],
172
+ timestamp: Date.now(),
173
+ };
174
+ const tools = projectTools(pi.getAllTools());
175
+ const result = await callSameModelCompaction(ctx, {
176
+ model,
177
+ systemPrompt: ctx.getSystemPrompt() ?? "",
178
+ messages: [...fullMessages, instructionMessage],
179
+ tools,
180
+ signal: event.signal,
181
+ sessionId: ctx.sessionManager.getSessionId(),
182
+ });
183
+ if (!result.ok) {
184
+ warnLog("same-mode call failed", { error: result.error });
185
+ return null;
186
+ }
187
+ // D13-2 max-tokens 截断 fail-closed:不完整 checkpoint 不采用
188
+ if (result.stopReason === "length") {
189
+ debugLog("same-mode: summary truncated by max-tokens, rejecting");
190
+ return null;
191
+ }
192
+ if (result.text === "") {
193
+ debugLog("same-mode: empty summary text");
194
+ return null;
195
+ }
196
+ const sessionFile = getSessionFilePath(ctx);
197
+ const summary = assembleSummary(
198
+ result.text,
199
+ event.preparation.fileOps,
200
+ event.branchEntries,
201
+ event.preparation.firstKeptEntryId,
202
+ sessionFile,
203
+ );
204
+ return {
205
+ summary,
206
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
207
+ tokensBefore: event.preparation.tokensBefore,
208
+ usage: result.usage as CompactionResult["usage"],
209
+ details: { engine: "smart-context", mode: "same-model" } satisfies SmartContextDetails,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * cross-model 生成(D12):调用包导出的原生 compact(),仅替换 model + auth。
215
+ * split-turn 双段合并 / fileOps 追加 / previousSummary 透传全部原生(R4 静态结论,零复刻)。
216
+ * 返回 null = 失败(调用方走 D7 回退)。
217
+ */
218
+ async function generateCrossMode(
219
+ ctx: ExtensionContext,
220
+ event: BeforeCompactLikeEvent,
221
+ config: SmartContextConfig,
222
+ ): Promise<CompactionResult | null> {
223
+ const model = resolveModel(ctx, config.compactModel);
224
+ if (!model) {
225
+ debugLog("cross-mode: compact model not available, falling back");
226
+ return null;
227
+ }
228
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
229
+ if (!auth.ok) {
230
+ warnLog("cross-mode auth failed", { error: auth.error });
231
+ return null;
232
+ }
233
+ // ProviderHeaders 值可为 null;nativeCompact 的 headers 参数是 Record<string, string>——
234
+ // 过滤 null 值(运行时清洗而非 cast)
235
+ const headers = auth.headers
236
+ ? Object.fromEntries(
237
+ Object.entries(auth.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
238
+ )
239
+ : undefined;
240
+ const result = await nativeCompact(
241
+ event.preparation as never,
242
+ model as never,
243
+ auth.apiKey,
244
+ headers,
245
+ event.customInstructions,
246
+ event.signal,
247
+ undefined,
248
+ undefined,
249
+ auth.env,
250
+ );
251
+ // D13-2:原生 compact 内部对截断的处理沿用原生语义;此处补 engine 标记
252
+ return {
253
+ ...result,
254
+ details: { ...(result.details as object | undefined), engine: "smart-context", mode: "cross-model" } as SmartContextDetails,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * session_before_compact handler 工厂。
260
+ *
261
+ * state 为 session 级闭包(由 src/index.ts 在 session_start 重建后传入)。
262
+ */
263
+ export function createBeforeCompactHandler(
264
+ pi: ExtensionAPI,
265
+ getState: () => TakeoverState,
266
+ loadConfigFn: () => SmartContextConfig,
267
+ ): (event: BeforeCompactLikeEvent, ctx: ExtensionContext) => Promise<BeforeCompactDecision> {
268
+ return async (event, ctx) => {
269
+ const config = loadConfigFn();
270
+ const currentModelId = getCurrentModelId(ctx.model);
271
+
272
+ // D5 门控:禁用/排除 → 空返回(pi 原生生成)
273
+ if (config.enabled !== true || currentModelId === "" || config.excludedModels.includes(currentModelId)) {
274
+ return {};
275
+ }
276
+ const state = getState();
277
+
278
+ // D13-3 熔断:连续失败 ≥3 → 本 session 停止接管
279
+ if (state.failStreak >= TAKEOVER_FAILURE_LIMIT) {
280
+ debugLog("takeover circuit breaker open, falling back to native");
281
+ return {};
282
+ }
283
+ // D13-1 收缩校验失败段不重试
284
+ if (state.inflatedSegments.has(event.preparation.firstKeptEntryId)) {
285
+ debugLog("segment previously inflated, skipping takeover");
286
+ return {};
287
+ }
288
+
289
+ // D12 模式判定(现场热判,切模型/改配置后下次压缩即生效)
290
+ const mode = pickMode(config, currentModelId);
291
+
292
+ try {
293
+ const result = mode === "same-model"
294
+ ? await generateSameMode(pi, ctx, event)
295
+ : await generateCrossMode(ctx, event, config);
296
+
297
+ if (!result) {
298
+ state.failStreak += 1;
299
+ return {}; // D7 回退:pi 原生生成兜底
300
+ }
301
+
302
+ // D13-1 收缩校验:摘要 ≥ 被压段 → 拒绝落盘 + 记录该段
303
+ const summaryTokens = estimateTextTokens(result.summary);
304
+ const shadowedTokens = estimateShadowedTokens(event.preparation.messagesToSummarize);
305
+ if (isSummaryInflated(summaryTokens, shadowedTokens)) {
306
+ state.inflatedSegments.add(event.preparation.firstKeptEntryId);
307
+ state.failStreak += 1;
308
+ warnLog("summary inflated, rejecting takeover", { summaryTokens, shadowedTokens });
309
+ return {};
310
+ }
311
+
312
+ state.failStreak = 0;
313
+ debugLog(`takeover ok: mode=${mode} reason=${event.reason} summaryTokens=${summaryTokens}`);
314
+ return { compaction: result };
315
+ } catch (error) {
316
+ state.failStreak += 1;
317
+ warnLog("takeover error, falling back to native", { error: error instanceof Error ? error.message : String(error) });
318
+ return {}; // D7 回退
319
+ }
320
+ };
321
+ }
package/src/index.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @zhushanwen/pi-smart-context 入口:事件接线 + 门控。
3
+ *
4
+ * 设计文档:docs/extensions/smart-context/design.md
5
+ * - session_start:session 级闭包状态重建(规范 Session 隔离:fired 档位/熔断计数不跨 session)
6
+ * - subagent 进程(R6):不注册工具、不提醒(宁缺勿污)
7
+ * - session_before_compact:双模式接管(compact-handler)
8
+ * - session_compact:重置提醒 fired(D3)
9
+ * - agent_settled:越档检查 + followUp 投递一次性提醒(D3/D4)
10
+ * - model_select:跨界通知 + downshift 提醒(D5)
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { setPiHandle } from "@zhushanwen/pi-extension-logger";
15
+
16
+ import {
17
+ createBeforeCompactHandler,
18
+ createTakeoverState,
19
+ debugLog,
20
+ type BeforeCompactLikeEvent,
21
+ type TakeoverState,
22
+ } from "./compact-handler.js";
23
+ import { buildDownshiftNotice, buildSwitchNotice, buildThresholdReminder } from "./reminder.js";
24
+ import { registerCompactContextTool } from "./tool.js";
25
+ import {
26
+ countCompactions,
27
+ findCrossedThresholds,
28
+ getCurrentModelId,
29
+ isGatingActive,
30
+ isSubagentProcess,
31
+ loadSmartContextConfig,
32
+ type EntryLike,
33
+ } from "./pure.js";
34
+
35
+ /** agent_settled 事件形状(无 payload)。 */
36
+ interface AgentSettledLikeEvent {
37
+ type: "agent_settled";
38
+ }
39
+
40
+ /** model_select 事件形状。 */
41
+ interface ModelSelectLikeEvent {
42
+ type: "model_select";
43
+ model: { provider?: string; id?: string; contextWindow?: number } | undefined;
44
+ previousModel: { provider?: string; id?: string; contextWindow?: number } | undefined;
45
+ source: string;
46
+ }
47
+
48
+ /** session_compact 事件形状(compactionEntry 只消费 type;interface 无隐式 index signature,
49
+ * 禁用 `& Record<string, unknown>` 交叉目标——会破坏 on() 重载的参数逆变匹配)。 */
50
+ interface SessionCompactLikeEvent {
51
+ type: "session_compact";
52
+ compactionEntry: { type: string };
53
+ fromExtension: boolean;
54
+ reason: "manual" | "threshold" | "overflow";
55
+ willRetry: boolean;
56
+ }
57
+
58
+ /** session 级闭包状态(规范:模块级仅工厂函数,状态在 session_start 重建)。 */
59
+ interface SessionState {
60
+ takeover: TakeoverState;
61
+ /** 已提醒档位(token 值为键;session_compact 清空,D3)。 */
62
+ firedThresholds: Set<number>;
63
+ }
64
+
65
+ /** session 级状态唯一构造点(初始态与 session_start 重建同源,新增字段不落两处)。 */
66
+ function createSessionState(): SessionState {
67
+ return { takeover: createTakeoverState(), firedThresholds: new Set() };
68
+ }
69
+
70
+ /**
71
+ * pi-smart-context extension 工厂函数。
72
+ *
73
+ * agent 自决上下文压缩:compact_context 工具 + 双模式接管生成(same-model kv-cache /
74
+ * cross-model 廉价模型)+ 3 档阈值提醒 + 排除模型门控与切换通知。
75
+ */
76
+ export default function smartContextExtension(pi: ExtensionAPI): void {
77
+ // 日志通道注入(extension-logger 两阶段初始化:工厂拿 pi → setPiHandle)
78
+ setPiHandle(pi);
79
+
80
+ // R6:subagent 子进程不注册工具、不提醒(PI_SUBAGENT_ROOT_SESSION_ID 标记)
81
+ if (isSubagentProcess()) {
82
+ debugLog("subagent process detected, staying inert");
83
+ return;
84
+ }
85
+
86
+ // session 级状态(session_start 重建闭包;模块级引用仅指向当前 session 的容器)
87
+ let state: SessionState = createSessionState();
88
+
89
+ pi.on("session_start", (_event: unknown, _ctx: ExtensionContext) => {
90
+ state = createSessionState();
91
+ });
92
+
93
+ // ── 压缩生成接管(D1/D12)──
94
+ const beforeCompact = createBeforeCompactHandler(
95
+ pi,
96
+ () => state.takeover,
97
+ loadSmartContextConfig,
98
+ );
99
+ pi.on("session_before_compact", (event: BeforeCompactLikeEvent, ctx: ExtensionContext) =>
100
+ beforeCompact(event, ctx));
101
+
102
+ // ── 压缩完成:重置提醒档位(D3)──
103
+ pi.on("session_compact", (_event: SessionCompactLikeEvent, _ctx: ExtensionContext) => {
104
+ state.firedThresholds.clear();
105
+ });
106
+
107
+ // ── 工具注册(常驻,不可用态由 execute 运行时校验拒绝,D5)──
108
+ registerCompactContextTool(pi);
109
+
110
+ // ── 阈值提醒(D3/D4):agent_settled 越档检查 + followUp 一次性投递 ──
111
+ pi.on("agent_settled", (_event: AgentSettledLikeEvent, ctx: ExtensionContext) => {
112
+ const config = loadSmartContextConfig();
113
+ const modelId = getCurrentModelId(ctx.model);
114
+ if (!isGatingActive(config, modelId)) return;
115
+
116
+ const usage = ctx.getContextUsage();
117
+ if (!usage) return; // R7:tokens 可能 null(压缩后首响应前)——findCrossedThresholds 容错
118
+ const crossed = findCrossedThresholds(config.reminderThresholds, usage.tokens, state.firedThresholds);
119
+ if (crossed.length === 0) return;
120
+
121
+ for (const t of crossed) state.firedThresholds.add(t);
122
+ const compactionCount = countCompactions(ctx.sessionManager.getEntries() as ReadonlyArray<EntryLike>);
123
+ const message = buildThresholdReminder(crossed, usage.tokens ?? 0, usage.contextWindow, compactionCount);
124
+ debugLog(`reminder fired: tiers=${crossed.join(",")} tokens=${usage.tokens}`);
125
+ // D4:followUp(agent 空闲后投递并触发一个 turn,可立即决定压缩);
126
+ // 防循环:crossed 全部已标记 fired,提醒触发的 settled 不会重复发
127
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
128
+ });
129
+
130
+ // ── 模型切换:跨界通知 + downshift 提醒(D5,仅跨界时注入一次)──
131
+ pi.on("model_select", (event: ModelSelectLikeEvent, ctx: ExtensionContext) => {
132
+ const config = loadSmartContextConfig();
133
+ const modelId = getCurrentModelId(event.model);
134
+ const previousModelId = getCurrentModelId(event.previousModel);
135
+ if (modelId === "" || modelId === previousModelId) return;
136
+
137
+ const nowExcluded = config.excludedModels.includes(modelId);
138
+ const wasExcluded = config.excludedModels.includes(previousModelId);
139
+
140
+ // 跨越排除边界:注入一条可用性变化通知(同边界内切换静默)
141
+ if (config.enabled && nowExcluded !== wasExcluded) {
142
+ const notice = buildSwitchNotice(nowExcluded ? "unavailable" : "available", modelId);
143
+ debugLog(`switch notice: ${nowExcluded ? "unavailable" : "available"} (${modelId})`);
144
+ pi.sendUserMessage(notice, { deliverAs: "steer" });
145
+ return;
146
+ }
147
+
148
+ // downshift 检测:切到更小窗口模型且将触线 → 建议先压缩(不阻止切换)
149
+ const usage = ctx.getContextUsage();
150
+ const downshift = buildDownshiftNotice(
151
+ usage?.tokens ?? null,
152
+ event.previousModel?.contextWindow,
153
+ event.model?.contextWindow,
154
+ );
155
+ if (downshift && isGatingActive(config, modelId)) {
156
+ debugLog("downshift notice fired");
157
+ pi.sendUserMessage(downshift, { deliverAs: "steer" });
158
+ }
159
+ });
160
+ }