@zhushanwen/pi-smart-context 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-smart-context",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -36,8 +36,9 @@
36
36
  "pi-package"
37
37
  ],
38
38
  "dependencies": {
39
- "@zhushanwen/pi-extension-logger": "0.4.1",
40
- "@zhushanwen/pi-llm-shared": "0.6.0"
39
+ "@zhushanwen/pi-ext-guards": "0.3.0",
40
+ "@zhushanwen/pi-extension-logger": "0.6.0",
41
+ "@zhushanwen/pi-llm-shared": "0.7.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@vitest/coverage-v8": "^4.1.9",
@@ -1,7 +1,9 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- // mock pi SDK:接管路径的单测不触网(same-mode 输入链 + cross-mode 原生组装都替换)
4
- vi.mock("@earendil-works/pi-coding-agent", () => ({
3
+ // mock pi SDK:接管路径的单测不触网(same-mode 输入链 + cross-mode 原生组装都替换)。
4
+ // importOriginal 保留真实模块(含 pure.js 依赖的 estimateTokens),其余覆盖项在后
5
+ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => ({
6
+ ...(await importOriginal<typeof import("@earendil-works/pi-coding-agent")>()),
5
7
  buildSessionContext: (entries: unknown[]) => ({ messages: [{ role: "user", content: "history" }] }),
6
8
  convertToLlm: (messages: unknown[]) => messages,
7
9
  compact: vi.fn(),
@@ -16,30 +18,45 @@ import { callSameModelCompaction } from "../llm.js";
16
18
  import {
17
19
  createBeforeCompactHandler,
18
20
  createTakeoverState,
19
- type BeforeCompactLikeEvent,
20
21
  } from "../compact-handler.js";
21
22
  import { normalizeSmartContextConfig, type SmartContextConfig } from "../pure.js";
22
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+ import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
23
24
 
24
- function makeEvent(overrides?: Partial<BeforeCompactLikeEvent["preparation"]>): BeforeCompactLikeEvent {
25
+ // assistant 消息形态锁定为单 text 块(设计 §8.3):必填标量字段补齐 + content 不得追加
26
+ // thinking/toolCall 块——追加会抬高 shadowedTokens,使「新旧口径数值等价」声明静默失效
27
+ function makeEvent(overrides?: Partial<SessionBeforeCompactEvent["preparation"]>): SessionBeforeCompactEvent {
25
28
  return {
26
29
  type: "session_before_compact",
27
30
  preparation: {
28
31
  firstKeptEntryId: "kept-1",
29
32
  messagesToSummarize: [
30
- { role: "user", content: "x".repeat(4_000) },
31
- { role: "assistant", content: "y".repeat(4_000) },
33
+ { role: "user", content: "x".repeat(4_000), timestamp: 0 },
34
+ {
35
+ role: "assistant",
36
+ content: [{ type: "text", text: "y".repeat(4_000) }],
37
+ api: "openai-completions",
38
+ provider: "zai",
39
+ model: "glm",
40
+ usage: {
41
+ input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
42
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
43
+ },
44
+ stopReason: "stop",
45
+ timestamp: 0,
46
+ },
32
47
  ],
33
48
  turnPrefixMessages: [],
34
49
  isSplitTurn: false,
35
50
  tokensBefore: 500_000,
36
51
  fileOps: { read: new Set(["/a.ts"]), written: new Set(), edited: new Set() },
52
+ settings: { enabled: true, reserveTokens: 16_384, keepRecentTokens: 20_000 },
37
53
  ...overrides,
38
54
  },
39
55
  branchEntries: [],
40
56
  customInstructions: undefined,
41
57
  reason: "manual",
42
58
  willRetry: false,
59
+ signal: new AbortController().signal,
43
60
  };
44
61
  }
45
62
 
@@ -148,7 +165,7 @@ describe("session_before_compact 接管 handler", () => {
148
165
  it("收缩校验失败(摘要 ≥ 被压段)→ 拒绝 + 同段记录不重试(D13-1)", async () => {
149
166
  mockedCall.mockResolvedValue({ ok: true, text: "z".repeat(10_000) });
150
167
  const { handler, state } = makeHandler(normalizeSmartContextConfig({}));
151
- const event = makeEvent({ messagesToSummarize: [{ role: "user", content: "x".repeat(400) }] });
168
+ const event = makeEvent({ messagesToSummarize: [{ role: "user", content: "x".repeat(400), timestamp: 0 }] });
152
169
  await expect(handler(event, makeCtx())).resolves.toEqual({});
153
170
  expect(state.inflatedSegments.has("kept-1")).toBe(true);
154
171
  // 同段第二次直接跳过(不再调 LLM)
@@ -0,0 +1,105 @@
1
+ // src/__tests__/index-generation.test.ts
2
+ //
3
+ // G1 装配级测试(crash-resilience D1,参照 scheduler __tests__/index-generation.test.ts 形态):
4
+ // index.ts 装配点向 registerCompactContextTool 注入的 deps.isCtxStale 必须是 live 绑定
5
+ // wrapper(() => isCtxStale())——registerCompactContextTool 在 factory 体同步执行,简写
6
+ // 属性 { isCtxStale } 会把该行时刻的初始 () => false 快照进 deps 对象,session_start
7
+ // handler 内的重新赋值不回写已构造对象 → compact onComplete/onError(E1 实锤崩溃点)
8
+ // 守卫的前置代际检查恒不生效,stale 分诊 100% 退化为 PS-30 文案兜底。
9
+ //
10
+ // 可区分快照 vs live 的唯一拓扑是 factory 重跑(生产主路径:pi 每次 session 替换
11
+ // newSession/fork/switchSession 都重跑 factory 函数体,loader.ts extensionCache 只缓存
12
+ // factory 函数对象):同闭包内 fire session_start 后本闭包恒是当前代(isCtxStale 读到
13
+ // false,与快照实现值不可区分);只有第一代 deps 在第二代 session_start 递增模块级
14
+ // sessionGeneration 后必须翻 true——快照实现在此恒 false,正是生产失效路径。
15
+
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import { beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // Mock 共享 logger(compact-handler 顶层 createLogger;同 scheduler index-generation 形态)
20
+ const { loggerMock } = vi.hoisted(() => ({
21
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
+ }));
23
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
24
+ getLogger: () => loggerMock,
25
+ createLogger: () => loggerMock,
26
+ setPiHandle: vi.fn(),
27
+ }));
28
+
29
+ // 装配捕获:mock tool.js,逐次登记 index.ts 传入的 deps(同 scheduler 捕获 runtime 构造第三参)
30
+ const { registerCompactContextToolCalls } = vi.hoisted(() => ({
31
+ registerCompactContextToolCalls: [] as Array<{ deps?: { isCtxStale?: () => boolean } }>,
32
+ }));
33
+ vi.mock("../tool.js", () => ({
34
+ registerCompactContextTool: (_pi: unknown, deps?: { isCtxStale?: () => boolean }) => {
35
+ registerCompactContextToolCalls.push({ deps });
36
+ },
37
+ }));
38
+
39
+ import smartContextExtension from "../index.js";
40
+
41
+ /** 最小 fake pi:装配路径只消费 on(事件接线);registerTool/sendUserMessage 兜底。 */
42
+ function createMockPi(): {
43
+ pi: ExtensionAPI
44
+ events: Map<string, (...args: unknown[]) => void>
45
+ } {
46
+ const events = new Map<string, (...args: unknown[]) => void>();
47
+ const pi = {
48
+ registerTool: vi.fn(),
49
+ on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
50
+ sendUserMessage: vi.fn(),
51
+ } as unknown as ExtensionAPI;
52
+ return { pi, events };
53
+ }
54
+
55
+ /** 触发 session_start(handler 只更新模块级代数与闭包状态,ctx 不被读取)。 */
56
+ function fireSessionStart(events: Map<string, (...args: unknown[]) => void>): void {
57
+ const sessionStart = events.get("session_start");
58
+ expect(sessionStart).toBeDefined();
59
+ sessionStart!({ type: "session_start" }, {} as ExtensionContext);
60
+ }
61
+
62
+ describe("G1: compact 工具 deps.isCtxStale live 绑定(crash-resilience D1 装配回归)", () => {
63
+ beforeEach(() => {
64
+ registerCompactContextToolCalls.length = 0;
65
+ });
66
+
67
+ it("factory 同步构造 deps:注入的是函数,首个 session_start 前恒 false(安全默认),fire 后本闭包为当前代", () => {
68
+ const { pi, events } = createMockPi();
69
+ smartContextExtension(pi);
70
+ expect(registerCompactContextToolCalls).toHaveLength(1);
71
+ const isCtxStale = registerCompactContextToolCalls[0]!.deps?.isCtxStale;
72
+ expect(isCtxStale).toBeTypeOf("function");
73
+
74
+ // 首个 session_start 前:无 session,恒 false(安全默认)
75
+ expect(isCtxStale!()).toBe(false);
76
+
77
+ // fire 后本闭包被装配为当前代 → false;同闭包重复 fire(rpc-mode bindExtensions
78
+ // 重调)后仍为当前代。注意:单闭包拓扑内 live wrapper 与快照实现的返回值同为
79
+ // false、不可区分——快照 vs live 的回归断言在下方 factory 重跑用例。
80
+ fireSessionStart(events);
81
+ expect(isCtxStale!()).toBe(false);
82
+ fireSessionStart(events);
83
+ expect(isCtxStale!()).toBe(false);
84
+ });
85
+
86
+ it("factory 重跑:第二次 factory + session_start 后,第一代 deps.isCtxStale 翻 true、第二代 false", () => {
87
+ // 第一代:独立 factory 执行 + session_start 装配
88
+ const first = createMockPi();
89
+ smartContextExtension(first.pi);
90
+ fireSessionStart(first.events);
91
+ const firstIsCtxStale = registerCompactContextToolCalls[0]!.deps!.isCtxStale!;
92
+ expect(firstIsCtxStale()).toBe(false); // 第一代是当前代
93
+
94
+ // 第二代:再次独立执行 factory(模拟 newSession/fork/switchSession 的 factory 重跑,
95
+ // 新闭包;模块级 sessionGeneration 跨 factory 重跑保留)+ session_start
96
+ const second = createMockPi();
97
+ smartContextExtension(second.pi);
98
+ fireSessionStart(second.events);
99
+
100
+ // 核心回归断言:第一代 deps 读到第一代闭包重新赋值后的 isCtxStale,感知换代翻
101
+ // true(快照实现 = 简写属性 { isCtxStale },在此恒 false——本用例的回归失败点)
102
+ expect(firstIsCtxStale()).toBe(true);
103
+ expect(registerCompactContextToolCalls[1]!.deps!.isCtxStale!()).toBe(false); // 第二代是当前代
104
+ });
105
+ });
@@ -5,6 +5,7 @@ import {
5
5
  buildReinjectSection,
6
6
  checkToolThresholdGuard,
7
7
  computeFileListsLike,
8
+ estimateShadowedTokens,
8
9
  estimateTextTokens,
9
10
  findCrossedThresholds,
10
11
  formatFileOperationsLike,
@@ -136,6 +137,42 @@ describe("摘要后处理纯函数(D11/D13)", () => {
136
137
  });
137
138
  });
138
139
 
140
+ describe("estimateShadowedTokens 口径契约(D2:与 pi estimateTokens 同源)", () => {
141
+ // 合法 AssistantMessage 形状的 fixture(block 数组 content,禁止 as 硬塞 string content——
142
+ // estimateTokens 对 string content 计 0,口径用例会静默失去判别力;设计 §8.3)。
143
+ // 不 import 名义类型:const 泛型保留 block 字面量形状,由 estimateShadowedTokens
144
+ // 参数类型(Parameters<typeof estimateTokens>[0])在调用点结构校验。
145
+ function makeAssistantMessage<const C extends readonly unknown[]>(content: C) {
146
+ return {
147
+ role: "assistant" as const,
148
+ content,
149
+ api: "openai-completions",
150
+ provider: "zai",
151
+ model: "glm",
152
+ usage: {
153
+ input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
154
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
155
+ },
156
+ stopReason: "stop" as const,
157
+ timestamp: 0,
158
+ };
159
+ }
160
+
161
+ it("仅含 toolCall 块(arguments 大 JSON)→ > 0(旧 chars/4 口径漏计为 0)", () => {
162
+ const tokens = estimateShadowedTokens([
163
+ makeAssistantMessage([{ type: "toolCall", id: "c1", name: "edit", arguments: { content: "x".repeat(2_400) } }]),
164
+ ]);
165
+ expect(tokens).toBeGreaterThan(0);
166
+ });
167
+
168
+ it("仅含 thinking 块 → > 0(旧 chars/4 口径漏计为 0)", () => {
169
+ const tokens = estimateShadowedTokens([
170
+ makeAssistantMessage([{ type: "thinking", thinking: "t".repeat(1_600) }]),
171
+ ]);
172
+ expect(tokens).toBeGreaterThan(0);
173
+ });
174
+ });
175
+
139
176
  describe("subagent 识别(R6)", () => {
140
177
  it("PI_SUBAGENT_ROOT_SESSION_ID 存在即 subagent", () => {
141
178
  expect(isSubagentProcess({ PI_SUBAGENT_ROOT_SESSION_ID: "s1" })).toBe(true);
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { buildSessionContext, compact as nativeCompact, convertToLlm } from "@earendil-works/pi-coding-agent";
15
- import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-agent";
15
+ import type { CompactionResult, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import { createLogger } from "@zhushanwen/pi-extension-logger";
18
18
  import { resolveModel } from "@zhushanwen/pi-llm-shared";
@@ -32,6 +32,7 @@ import {
32
32
  estimateTextTokens,
33
33
  formatFileOperationsLike,
34
34
  getCurrentModelId,
35
+ isGatingActive,
35
36
  isSummaryInflated,
36
37
  pickMode,
37
38
  pickReinjectFiles,
@@ -39,31 +40,6 @@ import {
39
40
  type SmartContextConfig,
40
41
  } from "./pure.js";
41
42
 
42
- /** session_before_compact 事件的宽松形状(消费字段收窄,不依赖 pi 事件类型导出)。 */
43
- export interface BeforeCompactLikeEvent {
44
- type: "session_before_compact";
45
- preparation: {
46
- firstKeptEntryId: string;
47
- messagesToSummarize: ReadonlyArray<{ role: string; content?: unknown }>;
48
- turnPrefixMessages: ReadonlyArray<{ role: string }>;
49
- isSplitTurn: boolean;
50
- tokensBefore: number;
51
- previousSummary?: string;
52
- fileOps: FileOpsLike;
53
- };
54
- branchEntries: ReadonlyArray<unknown>;
55
- customInstructions?: string;
56
- reason: "manual" | "threshold" | "overflow";
57
- willRetry: boolean;
58
- signal?: AbortSignal;
59
- }
60
-
61
- /** handler 返回(SessionBeforeCompactResult 子集)。 */
62
- export interface BeforeCompactDecision {
63
- cancel?: boolean;
64
- compaction?: CompactionResult;
65
- }
66
-
67
43
  /** session 级接管状态(session_start 重建闭包,规范 Session 隔离)。 */
68
44
  export interface TakeoverState {
69
45
  /** 接管连续失败计数(D13-3 熔断:≥3 停止接管)。 */
@@ -173,7 +149,7 @@ function assembleSummary(
173
149
  async function generateSameMode(
174
150
  pi: ExtensionAPI,
175
151
  ctx: ExtensionContext,
176
- event: BeforeCompactLikeEvent,
152
+ event: SessionBeforeCompactEvent,
177
153
  ): Promise<CompactionResult | null> {
178
154
  const model = ctx.model;
179
155
  if (!model) {
@@ -182,7 +158,7 @@ async function generateSameMode(
182
158
  }
183
159
  // AgentMessage[](含 bash/custom 等扩展消息)→ 标准 Message[](与主会话请求同源转换,
184
160
  // convertToLlm 是 pi host 默认实现——同样的输入产生同样的输出,前缀缓存对齐的前提)
185
- const fullMessages = convertToLlm(buildSessionContext(event.branchEntries as SessionEntry[]).messages);
161
+ const fullMessages = convertToLlm(buildSessionContext(event.branchEntries).messages);
186
162
  const instructionMessage = {
187
163
  role: "user" as const,
188
164
  content: [{ type: "text" as const, text: buildSameModelInstruction(event.customInstructions) }],
@@ -238,7 +214,7 @@ async function generateSameMode(
238
214
  */
239
215
  async function generateCrossMode(
240
216
  ctx: ExtensionContext,
241
- event: BeforeCompactLikeEvent,
217
+ event: SessionBeforeCompactEvent,
242
218
  config: SmartContextConfig,
243
219
  ): Promise<CompactionResult | null> {
244
220
  const model = resolveModel(ctx, config.compactModel);
@@ -258,10 +234,8 @@ async function generateCrossMode(
258
234
  Object.entries(auth.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
259
235
  )
260
236
  : undefined;
261
- // preparation:BeforeCompactLikeEvent 的宽松形状是 nativeCompact 入参(CompactionPreparation,
262
- // 含 settings 等未消费字段)的结构子集——运行时是 pi 原生事件对象,此处单层收窄断言
263
237
  const result = await nativeCompact(
264
- event.preparation as Parameters<typeof nativeCompact>[0],
238
+ event.preparation,
265
239
  model,
266
240
  auth.apiKey,
267
241
  headers,
@@ -287,20 +261,21 @@ async function generateCrossMode(
287
261
  * session_before_compact handler 工厂。
288
262
  *
289
263
  * state 为 session 级闭包(由 src/index.ts 在 session_start 重建后传入)。
264
+ * 返回类型省略标注、由函数体对象字面量推断({} / {compaction})——SDK SessionBeforeCompactResult
265
+ * 不在包根导出(仅深层 dist 路径可见),显式标注该名不可 import;推断形状与 SDK 逐字段同形,
266
+ * 注册点 pi.on() 的 ExtensionHandler 泛型按结构兼容校验。
290
267
  */
291
268
  export function createBeforeCompactHandler(
292
269
  pi: ExtensionAPI,
293
270
  getState: () => TakeoverState,
294
271
  loadConfigFn: () => SmartContextConfig,
295
- ): (event: BeforeCompactLikeEvent, ctx: ExtensionContext) => Promise<BeforeCompactDecision> {
296
- return async (event, ctx) => {
272
+ ) {
273
+ return async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
297
274
  const config = loadConfigFn();
298
275
  const currentModelId = getCurrentModelId(ctx.model);
299
276
 
300
277
  // D5 门控:禁用/排除 → 空返回(pi 原生生成)
301
- if (config.enabled !== true || currentModelId === "" || config.excludedModels.includes(currentModelId)) {
302
- return {};
303
- }
278
+ if (!isGatingActive(config, currentModelId)) return {};
304
279
  const state = getState();
305
280
 
306
281
  // D13-3 熔断:连续失败 ≥3 → 本 session 停止接管
@@ -338,7 +313,9 @@ export function createBeforeCompactHandler(
338
313
  }
339
314
 
340
315
  state.failStreak = 0;
341
- debugLog(`takeover ok: mode=${mode} reason=${event.reason} summaryTokens=${summaryTokens}`);
316
+ debugLog(
317
+ `takeover ok: mode=${mode} reason=${event.reason} summaryTokens=${summaryTokens} shadowedTokens=${shadowedTokens}`,
318
+ );
342
319
  return { compaction: result };
343
320
  } catch (error) {
344
321
  state.failStreak += 1;
package/src/index.ts CHANGED
@@ -11,13 +11,13 @@
11
11
  */
12
12
 
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { guardStaleCtx, toErrorMessage } from "@zhushanwen/pi-ext-guards";
14
15
  import { setPiHandle } from "@zhushanwen/pi-extension-logger";
15
16
 
16
17
  import {
17
18
  createBeforeCompactHandler,
18
19
  createTakeoverState,
19
20
  debugLog,
20
- type BeforeCompactLikeEvent,
21
21
  type TakeoverState,
22
22
  } from "./compact-handler.js";
23
23
  import { buildDownshiftNotice, buildSwitchNotice, buildThresholdReminder } from "./reminder.js";
@@ -32,29 +32,6 @@ import {
32
32
  type EntryLike,
33
33
  } from "./pure.js";
34
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
35
  /** session 级闭包状态(规范:模块级仅工厂函数,状态在 session_start 重建)。 */
59
36
  interface SessionState {
60
37
  takeover: TakeoverState;
@@ -67,6 +44,15 @@ function createSessionState(): SessionState {
67
44
  return { takeover: createTakeoverState(), firedThresholds: new Set() };
68
45
  }
69
46
 
47
+ // G1 代际检测(crash-resilience D1,同 scheduler/src/index.ts 的模块级代数计数器范式):
48
+ // 必须声明在模块级而非 factory 体内——pi 每次 session 替换(newSession/fork/switchSession)
49
+ // 都重跑 extension factory 函数体(extensionCache 只缓存 factory 函数对象),闭包级声明
50
+ // 每次重跑即重置,各代的 isCtxStale 恒 false。模块级声明下 extensionCache 命中期间共享
51
+ // 同一模块绑定,计数器跨 factory 重跑保留递增:新代 session_start 递增后,旧代闭包捕获
52
+ // 的代数从此小于模块值 → isCtxStale 生效。残余盲区(显式 reload 触发 jiti 重新 import
53
+ // 产生全新模块环境)由 guardStaleCtx 的 STALE_CTX_MARKER 文案兜底分诊覆盖(PS-30 门禁)。
54
+ let sessionGeneration = 0;
55
+
70
56
  /**
71
57
  * pi-smart-context extension 工厂函数。
72
58
  *
@@ -86,7 +72,16 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
86
72
  // session 级状态(session_start 重建闭包;模块级引用仅指向当前 session 的容器)
87
73
  let state: SessionState = createSessionState();
88
74
 
75
+ // G1 代际比对闭包(isCtxStale):session_start 装配本代比对(读实时模块级代数),
76
+ // 供下方事件回调与 compact 工具回调的 guardStaleCtx 前置检查使用——stale 分诊不依赖
77
+ // pi 错误文案。首个 session_start 前无 session,恒 false 为安全默认。
78
+ let isCtxStale: () => boolean = () => false;
79
+
89
80
  pi.on("session_start", (_event: unknown, _ctx: ExtensionContext) => {
81
+ // 先递增模块级代数再装配:自此同模块环境内所有前代闭包的 isCtxStale 返回 true
82
+ sessionGeneration += 1;
83
+ const myGeneration = sessionGeneration;
84
+ isCtxStale = () => sessionGeneration !== myGeneration;
90
85
  state = createSessionState();
91
86
  });
92
87
 
@@ -96,19 +91,22 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
96
91
  () => state.takeover,
97
92
  loadSmartContextConfig,
98
93
  );
99
- pi.on("session_before_compact", (event: BeforeCompactLikeEvent, ctx: ExtensionContext) =>
100
- beforeCompact(event, ctx));
94
+ pi.on("session_before_compact", beforeCompact);
101
95
 
102
96
  // ── 压缩完成:重置提醒档位(D3)──
103
- pi.on("session_compact", (_event: SessionCompactLikeEvent, _ctx: ExtensionContext) => {
97
+ pi.on("session_compact", (_event, _ctx) => {
104
98
  state.firedThresholds.clear();
105
99
  });
106
100
 
107
101
  // ── 工具注册(常驻,不可用态由 execute 运行时校验拒绝,D5)──
108
- registerCompactContextTool(pi);
102
+ // isCtxStale 必须传 live 绑定 wrapper 而非简写属性:本行在 factory 体同步执行,简写
103
+ // { isCtxStale } 会把此刻的初始 () => false 快照进 deps 对象,上方 session_start
104
+ // handler 的重新赋值不回写已构造对象 → compact onComplete/onError(E1 实锤崩溃点)
105
+ // 守卫的前置代际检查恒不生效。wrapper 每次调用读闭包当前绑定。
106
+ registerCompactContextTool(pi, { isCtxStale: () => isCtxStale() });
109
107
 
110
108
  // ── 阈值提醒(D3/D4):agent_settled 越档检查 + followUp 一次性投递 ──
111
- pi.on("agent_settled", (_event: AgentSettledLikeEvent, ctx: ExtensionContext) => {
109
+ pi.on("agent_settled", (_event, ctx) => {
112
110
  const config = loadSmartContextConfig();
113
111
  const modelId = getCurrentModelId(ctx.model);
114
112
  if (!isGatingActive(config, modelId)) return;
@@ -123,12 +121,21 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
123
121
  const message = buildThresholdReminder(crossed, usage.tokens ?? 0, usage.contextWindow, compactionCount);
124
122
  debugLog(`reminder fired: tiers=${crossed.join(",")} tokens=${usage.tokens}`);
125
123
  // D4:followUp(agent 空闲后投递并触发一个 turn,可立即决定压缩);
126
- // 防循环:crossed 全部已标记 fired,提醒触发的 settled 不会重复发
127
- pi.sendUserMessage(message, { deliverAs: "followUp" });
124
+ // 防循环:crossed 全部已标记 fired,提醒触发的 settled 不会重复发。
125
+ // 事件回调内直接调用捕获的 pi——session 替换窗口可能 stale(crash-resilience D1
126
+ // 普查接入点),守卫 stale 静默降级(不杀 pi 进程),非 stale 错误原样上抛。
127
+ guardStaleCtx(() => {
128
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
129
+ }, {
130
+ isCtxStale,
131
+ label: "smart-context:threshold-reminder",
132
+ onStale: (error) => debugLog(`threshold reminder delivery skipped (stale ctx): ${toErrorMessage(error)}`),
133
+ });
128
134
  });
129
135
 
130
136
  // ── 模型切换:跨界通知 + downshift 提醒(D5,仅跨界时注入一次)──
131
- pi.on("model_select", (event: ModelSelectLikeEvent, ctx: ExtensionContext) => {
137
+ // event 类型由 on() 重载上下文推导为 SDK ModelSelectEvent(不在包根导出,省略标注)
138
+ pi.on("model_select", (event, ctx) => {
132
139
  const config = loadSmartContextConfig();
133
140
  const modelId = getCurrentModelId(event.model);
134
141
  const previousModelId = getCurrentModelId(event.previousModel);
@@ -141,7 +148,14 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
141
148
  if (config.enabled && nowExcluded !== wasExcluded) {
142
149
  const notice = buildSwitchNotice(nowExcluded ? "unavailable" : "available", modelId);
143
150
  debugLog(`switch notice: ${nowExcluded ? "unavailable" : "available"} (${modelId})`);
144
- pi.sendUserMessage(notice, { deliverAs: "steer" });
151
+ // session 替换窗口可能 stale(D1 普查接入点)——守卫 stale 静默降级
152
+ guardStaleCtx(() => {
153
+ pi.sendUserMessage(notice, { deliverAs: "steer" });
154
+ }, {
155
+ isCtxStale,
156
+ label: "smart-context:switch-notice",
157
+ onStale: (error) => debugLog(`switch notice delivery skipped (stale ctx): ${toErrorMessage(error)}`),
158
+ });
145
159
  return;
146
160
  }
147
161
 
@@ -154,7 +168,14 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
154
168
  );
155
169
  if (downshift && isGatingActive(config, modelId)) {
156
170
  debugLog("downshift notice fired");
157
- pi.sendUserMessage(downshift, { deliverAs: "steer" });
171
+ // session 替换窗口可能 stale(D1 普查接入点)——守卫 stale 静默降级
172
+ guardStaleCtx(() => {
173
+ pi.sendUserMessage(downshift, { deliverAs: "steer" });
174
+ }, {
175
+ isCtxStale,
176
+ label: "smart-context:downshift-notice",
177
+ onStale: (error) => debugLog(`downshift notice delivery skipped (stale ctx): ${toErrorMessage(error)}`),
178
+ });
158
179
  }
159
180
  });
160
181
  }
package/src/llm.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * same-model 模式的 LLM 调用(D12 + D13-5 cache-key 一致性)。
3
3
  *
4
- * 不走 llm-shared callLLM:其 tools:[] 硬编码会破坏前缀缓存对齐(call.ts:113)。
4
+ * 不走 llm-shared callLLM:其 tools:[] 硬编码会破坏前缀缓存对齐(llm-shared call.ts)。
5
5
  * 此处直接用 completeSimple + getApiKeyAndHeaders,并把 tools schema 与主会话对齐
6
6
  * (deepseek-harness summarizer 同款做法:system + tools + messages 全部复用做缓存对齐)。
7
7
  *
@@ -13,24 +13,17 @@
13
13
  import { completeSimple } from "@earendil-works/pi-ai/compat";
14
14
  import type { Context as LlmContext, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
15
15
  import type { Tool as LlmTool, Message, Model } from "@earendil-works/pi-ai";
16
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
-
18
- /** ToolInfo 的宽松形状(Pick<ToolDefinition, "name"|"description"|"parameters"> 即可投影为 pi-ai Tool)。 */
19
- interface ToolInfoLike {
20
- name: string;
21
- description: string;
22
- parameters: unknown;
23
- }
16
+ import type { ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
24
17
 
25
18
  /**
26
19
  * ToolInfo → pi-ai Tool 投影:三字段直取。parameters 是 typebox schema(主会话同源对象,
27
20
  * 序列化后与主请求一致——缓存对齐的关键是不改造、原样透传)。
28
21
  */
29
- export function projectTools(toolInfos: readonly ToolInfoLike[]): LlmTool[] {
22
+ export function projectTools(toolInfos: readonly ToolInfo[]): LlmTool[] {
30
23
  return toolInfos.map((t) => ({
31
24
  name: t.name,
32
25
  description: t.description,
33
- parameters: t.parameters as LlmTool["parameters"],
26
+ parameters: t.parameters,
34
27
  }));
35
28
  }
36
29
 
package/src/pure.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * 设计文档:docs/extensions/smart-context/design.md(D5 门控矩阵 / D6 阈值保护 / D8 配置 schema)。
6
6
  */
7
7
 
8
+ import { estimateTokens } from "@earendil-works/pi-coding-agent";
8
9
  import type { ModelSelector } from "@zhushanwen/pi-llm-shared";
9
10
  import { loadConfig } from "@zhushanwen/pi-llm-shared";
10
11
 
@@ -304,22 +305,15 @@ export function collectKeptReadFiles(branchEntries: ReadonlyArray<unknown>, firs
304
305
 
305
306
  /**
306
307
  * 被压段 token 估算(收缩校验分母:仅 messagesToSummarize;turnPrefixMessages 是保留段前缀,
307
- * 不属于被压段,不计入)。pi 的 estimateTokens 按 message 内容估算;此处 chars/4 的保守替代:
308
- * serialize 后长度 / 4(与 pi 同口径量级,用于"摘要 >= 原文"的粗判已足)。
308
+ * 不属于被压段,不计入)。与 pi 自身压缩用量核算同源:逐条 estimateTokens 求和
309
+ * (toolCall arguments 与 thinking 块计入分母),消息类型经其签名反推、随 pi 升级自动跟随。
309
310
  */
310
311
  export function estimateShadowedTokens(
311
- messagesToSummarize: ReadonlyArray<{ role: string; content?: unknown }>,
312
+ messagesToSummarize: ReadonlyArray<Parameters<typeof estimateTokens>[0]>,
312
313
  ): number {
313
- let chars = 0;
314
+ let tokens = 0;
314
315
  for (const m of messagesToSummarize) {
315
- const content = m.content;
316
- if (typeof content === "string") {
317
- chars += content.length;
318
- } else if (Array.isArray(content)) {
319
- for (const b of content as ReadonlyArray<{ type?: string; text?: string }>) {
320
- if (typeof b.text === "string") chars += b.text.length;
321
- }
322
- }
316
+ tokens += estimateTokens(m);
323
317
  }
324
- return Math.ceil(chars / CHARS_PER_TOKEN_ESTIMATE);
318
+ return tokens;
325
319
  }
package/src/tool.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { guardStaleCtx, toErrorMessage } from "@zhushanwen/pi-ext-guards";
13
14
  import { Type } from "typebox";
14
15
 
15
16
  import { debugLog } from "./compact-handler.js";
@@ -109,6 +110,13 @@ export function registerCompactContextTool(
109
110
  gatingProbe?: (ctx: ExtensionContext) => { active: boolean; modelId: string };
110
111
  usageProbe?: (ctx: ExtensionContext) => { tokens: number | null; contextWindow: number };
111
112
  getEntries?: (ctx: ExtensionContext) => ReadonlyArray<EntryLike>;
113
+ /**
114
+ * G1 代际检测(crash-resilience D1):compact 是 fire-and-forget——onComplete/onError
115
+ * 回调在压缩 LLM 调用期间异步触发,用户此间切换/重载 session 后回调持有的 pi 已
116
+ * stale(E1 实锤崩溃点)。index.ts 注入模块级代数比对闭包,前置检查 + 错误分诊
117
+ * 双保险;缺省时守卫退化为纯文案兜底分诊(PS-30 门禁守文案)。
118
+ */
119
+ isCtxStale?: () => boolean;
112
120
  },
113
121
  ): void {
114
122
  const probeGating =
@@ -165,29 +173,47 @@ export function registerCompactContextTool(
165
173
  typeof params.custom_instructions === "string" && params.custom_instructions.trim() !== ""
166
174
  ? params.custom_instructions
167
175
  : undefined,
176
+ // E1 实锤崩溃点(9/3 pi-crash log):两个回调由 compact 的内部 Promise 链异步
177
+ // 调用,不在 pi runner emit() 的 try/catch 内——session 替换窗口(GUI 切
178
+ // session/新建/重载高频触发)下 pi.sendUserMessage 命中 stale ctx 同步抛错即
179
+ // 杀死 pi 进程。守卫 stale 静默降级(结果不投递,用户可重试 /compact),非
180
+ // stale 错误原样上抛(守卫不吞真实 bug)。
168
181
  onComplete: (r: unknown) => {
169
- const result = toCompactionResultLike(r);
170
- const resultMode = result.details?.mode ?? "native-fallback";
171
- const fellBack = resultMode === "native-fallback";
172
- if (fellBack) debugLog("compact_context: takeover fell back to native generation");
173
- const cacheRead = result.usage?.cacheRead;
174
- const cost = result.usage
175
- ? `${formatK(result.usage.input ?? 0)} input${cacheRead ? `(其中缓存命中 ${formatK(cacheRead)})` : ""} + ${formatK(result.usage.output ?? 0)} output`
176
- : "未知";
177
- const showHint = compactionCount + 1 >= DEGRADATION_HINT_MIN_COMPACTIONS;
178
- const lines = [
179
- `[smart-context] 压缩完成。模式:${resultMode}${fellBack ? "(压缩模型不可用,已回退当前模型——请检查配置:xyz-agent 设置页或 smart-context-ext-config skill)" : ""}。`,
180
- `压缩前 ${formatK(result.tokensBefore ?? 0)} tokens → 压缩后约 ${formatK(result.estimatedTokensAfter ?? 0)} tokens;摘要生成成本:${cost}。`,
181
- showHint ? buildDegradationHintLine() : "",
182
- ].filter((l) => l !== "");
183
- pi.sendUserMessage(lines.join("\n"), { deliverAs: "steer" });
182
+ guardStaleCtx(() => {
183
+ const result = toCompactionResultLike(r);
184
+ const resultMode = result.details?.mode ?? "native-fallback";
185
+ const fellBack = resultMode === "native-fallback";
186
+ if (fellBack) debugLog("compact_context: takeover fell back to native generation");
187
+ const cacheRead = result.usage?.cacheRead;
188
+ const cost = result.usage
189
+ ? `${formatK(result.usage.input ?? 0)} input${cacheRead ? `(其中缓存命中 ${formatK(cacheRead)})` : ""} + ${formatK(result.usage.output ?? 0)} output`
190
+ : "未知";
191
+ const showHint = compactionCount + 1 >= DEGRADATION_HINT_MIN_COMPACTIONS;
192
+ const lines = [
193
+ `[smart-context] 压缩完成。模式:${resultMode}${fellBack ? "(压缩模型不可用,已回退当前模型——请检查配置:xyz-agent 设置页或 smart-context-ext-config skill)" : ""}。`,
194
+ `压缩前 ${formatK(result.tokensBefore ?? 0)} tokens → 压缩后约 ${formatK(result.estimatedTokensAfter ?? 0)} tokens;摘要生成成本:${cost}。`,
195
+ showHint ? buildDegradationHintLine() : "",
196
+ ].filter((l) => l !== "");
197
+ pi.sendUserMessage(lines.join("\n"), { deliverAs: "steer" });
198
+ }, {
199
+ isCtxStale: deps?.isCtxStale,
200
+ label: "smart-context:compact-onComplete",
201
+ onStale: (error) => debugLog(`compact result delivery skipped (stale ctx): ${toErrorMessage(error)}`),
202
+ });
184
203
  },
185
204
  onError: (err: Error) => {
205
+ // 压缩本身的失败信息先落日志(守卫体外——stale 降级时该观测保留)
186
206
  debugLog(`compact_context error: ${err.message}`);
187
- pi.sendUserMessage(
188
- `[smart-context] 压缩失败:${err.message}。上下文未变化,可稍后重试(若反复失败,检查 smart-context 配置或使用 /compact)。`,
189
- { deliverAs: "steer" },
190
- );
207
+ guardStaleCtx(() => {
208
+ pi.sendUserMessage(
209
+ `[smart-context] 压缩失败:${err.message}。上下文未变化,可稍后重试(若反复失败,检查 smart-context 配置或使用 /compact)。`,
210
+ { deliverAs: "steer" },
211
+ );
212
+ }, {
213
+ isCtxStale: deps?.isCtxStale,
214
+ label: "smart-context:compact-onError",
215
+ onStale: (error) => debugLog(`compact failure notice delivery skipped (stale ctx): ${toErrorMessage(error)}`),
216
+ });
191
217
  },
192
218
  });
193
219