@zhushanwen/pi-smart-context 0.1.4 → 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 +4 -3
- package/src/__tests__/compact-handler.test.ts +59 -12
- package/src/__tests__/index-generation.test.ts +105 -0
- package/src/__tests__/pure.test.ts +37 -0
- package/src/compact-handler.ts +31 -41
- package/src/index.ts +55 -34
- package/src/llm.ts +4 -11
- package/src/pure.ts +7 -13
- package/src/tool.ts +45 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-smart-context",
|
|
3
|
-
"version": "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-
|
|
40
|
-
"@zhushanwen/pi-
|
|
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
|
-
|
|
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,41 +18,59 @@ 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
|
-
|
|
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
|
-
{
|
|
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
|
|
|
46
|
-
function makeCtx(): ExtensionContext {
|
|
63
|
+
function makeCtx(crossModel?: { provider: string; id: string } | null): ExtensionContext {
|
|
47
64
|
return {
|
|
48
65
|
model: { provider: "zai", id: "glm" },
|
|
49
66
|
getSystemPrompt: () => "sys",
|
|
50
67
|
sessionManager: { getSessionId: () => "s1", getSessionFile: () => "/tmp/s1.jsonl" },
|
|
51
68
|
modelRegistry: {
|
|
52
69
|
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k" }),
|
|
53
|
-
|
|
70
|
+
// resolveRef 精确匹配:仅 (provider,id) 同时命中时返回模型(配合 hasConfiguredAuth 放行)
|
|
71
|
+
find: (provider: string, id: string) =>
|
|
72
|
+
crossModel && provider === crossModel.provider && id === crossModel.id ? crossModel : null,
|
|
73
|
+
hasConfiguredAuth: () => crossModel != null,
|
|
54
74
|
},
|
|
55
75
|
} as unknown as ExtensionContext;
|
|
56
76
|
}
|
|
@@ -84,11 +104,12 @@ describe("session_before_compact 接管 handler", () => {
|
|
|
84
104
|
expect(mockedCall).not.toHaveBeenCalled();
|
|
85
105
|
});
|
|
86
106
|
|
|
87
|
-
it("same-model 成功 → compaction 带 engine/mode 标记 + preamble + fileOps 清单 + transcript 指针", async () => {
|
|
107
|
+
it("same-model 成功 → compaction 带 engine/mode/model 标记 + preamble + fileOps 清单 + transcript 指针", async () => {
|
|
88
108
|
mockedCall.mockResolvedValue({ ok: true, text: "summary body", usage: { input: 1, output: 2 } });
|
|
89
109
|
const { handler } = makeHandler(normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "" } }));
|
|
90
110
|
const decision = await handler(makeEvent(), makeCtx());
|
|
91
|
-
|
|
111
|
+
// details.model = `${ctx.model.provider}/${ctx.model.id}`(§3.3 ①,用量归属数据源)
|
|
112
|
+
expect(decision.compaction?.details).toEqual({ engine: "smart-context", mode: "same-model", model: "zai/glm" });
|
|
92
113
|
expect(decision.compaction?.summary).toContain("summary body");
|
|
93
114
|
// D13-9 preamble 在最前;D11-2 fileOps;D13-4 transcript
|
|
94
115
|
expect(decision.compaction?.summary.startsWith("This is an automatically generated checkpoint")).toBe(true);
|
|
@@ -115,10 +136,36 @@ describe("session_before_compact 接管 handler", () => {
|
|
|
115
136
|
expect(mockedNative).not.toHaveBeenCalled();
|
|
116
137
|
});
|
|
117
138
|
|
|
139
|
+
it("cross-model 成功 → details.model=compactModel 解析结果,native details 字段保留", async () => {
|
|
140
|
+
mockedNative.mockResolvedValue({
|
|
141
|
+
summary: "native checkpoint",
|
|
142
|
+
firstKeptEntryId: "kept-1",
|
|
143
|
+
tokensBefore: 500_000,
|
|
144
|
+
usage: {
|
|
145
|
+
input: 5, output: 6, cacheRead: 0, cacheWrite: 0, totalTokens: 11,
|
|
146
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
147
|
+
},
|
|
148
|
+
details: { nativeMarker: "keep" },
|
|
149
|
+
});
|
|
150
|
+
const { handler } = makeHandler(
|
|
151
|
+
normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "xiaomi/mimo" } }),
|
|
152
|
+
);
|
|
153
|
+
const decision = await handler(makeEvent(), makeCtx({ provider: "xiaomi", id: "mimo" }));
|
|
154
|
+
// details.model = `${resolveModel(...).provider}/${id}`(§3.3 ①);native details 字段原样保留(spread 增量,不改变其他字段)
|
|
155
|
+
expect(decision.compaction?.details).toEqual({
|
|
156
|
+
engine: "smart-context",
|
|
157
|
+
mode: "cross-model",
|
|
158
|
+
model: "xiaomi/mimo",
|
|
159
|
+
nativeMarker: "keep",
|
|
160
|
+
});
|
|
161
|
+
// nativeCompact 收到的 model 就是解析结果(details.model 与实际执行模型同源)
|
|
162
|
+
expect(mockedNative.mock.calls[0][1]).toEqual({ provider: "xiaomi", id: "mimo" });
|
|
163
|
+
});
|
|
164
|
+
|
|
118
165
|
it("收缩校验失败(摘要 ≥ 被压段)→ 拒绝 + 同段记录不重试(D13-1)", async () => {
|
|
119
166
|
mockedCall.mockResolvedValue({ ok: true, text: "z".repeat(10_000) });
|
|
120
167
|
const { handler, state } = makeHandler(normalizeSmartContextConfig({}));
|
|
121
|
-
const event = makeEvent({ messagesToSummarize: [{ role: "user", content: "x".repeat(400) }] });
|
|
168
|
+
const event = makeEvent({ messagesToSummarize: [{ role: "user", content: "x".repeat(400), timestamp: 0 }] });
|
|
122
169
|
await expect(handler(event, makeCtx())).resolves.toEqual({});
|
|
123
170
|
expect(state.inflatedSegments.has("kept-1")).toBe(true);
|
|
124
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);
|
package/src/compact-handler.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* - same-model 模式:完整上下文 + 会话原 system prompt + tools + 末尾追加压缩指令(kv-cache 前缀命中)
|
|
7
7
|
* - cross-model 模式:直接调用包导出的 compact(preparation, 压缩Model, ...)(原生组装零复刻,R4 结论)
|
|
8
8
|
*
|
|
9
|
-
* 输出 CompactionResult.details 携带 {engine:"smart-context", mode} 标记(D1 entry 标记)。
|
|
9
|
+
* 输出 CompactionResult.details 携带 {engine:"smart-context", mode, model} 标记(D1 entry 标记)。
|
|
10
|
+
* model = `${model.provider}/${model.id}`(执行压缩的实际模型)——用量页 compaction 归属数据源
|
|
11
|
+
* (usage-page-fixes §3.3 ①);pi appendCompaction 对 details 逐字透传落盘(pi-semantics PS-28)。
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { buildSessionContext, compact as nativeCompact, convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import type { CompactionResult,
|
|
15
|
+
import type { CompactionResult, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
14
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
import { createLogger } from "@zhushanwen/pi-extension-logger";
|
|
16
18
|
import { resolveModel } from "@zhushanwen/pi-llm-shared";
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
estimateTextTokens,
|
|
31
33
|
formatFileOperationsLike,
|
|
32
34
|
getCurrentModelId,
|
|
35
|
+
isGatingActive,
|
|
33
36
|
isSummaryInflated,
|
|
34
37
|
pickMode,
|
|
35
38
|
pickReinjectFiles,
|
|
@@ -37,31 +40,6 @@ import {
|
|
|
37
40
|
type SmartContextConfig,
|
|
38
41
|
} from "./pure.js";
|
|
39
42
|
|
|
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
43
|
/** session 级接管状态(session_start 重建闭包,规范 Session 隔离)。 */
|
|
66
44
|
export interface TakeoverState {
|
|
67
45
|
/** 接管连续失败计数(D13-3 熔断:≥3 停止接管)。 */
|
|
@@ -93,6 +71,8 @@ function warnLog(message: string, data?: unknown): void {
|
|
|
93
71
|
export interface SmartContextDetails {
|
|
94
72
|
engine: "smart-context";
|
|
95
73
|
mode: "same-model" | "cross-model";
|
|
74
|
+
/** 执行压缩的模型 `${provider}/${modelId}`(用量归属数据源,usage-page-fixes §3.3 ①)。 */
|
|
75
|
+
model: string;
|
|
96
76
|
}
|
|
97
77
|
|
|
98
78
|
/** sessionManager 上会话文件字段/方法的宽松形状(D13-4,消费侧可选链兜底)。 */
|
|
@@ -169,7 +149,7 @@ function assembleSummary(
|
|
|
169
149
|
async function generateSameMode(
|
|
170
150
|
pi: ExtensionAPI,
|
|
171
151
|
ctx: ExtensionContext,
|
|
172
|
-
event:
|
|
152
|
+
event: SessionBeforeCompactEvent,
|
|
173
153
|
): Promise<CompactionResult | null> {
|
|
174
154
|
const model = ctx.model;
|
|
175
155
|
if (!model) {
|
|
@@ -178,7 +158,7 @@ async function generateSameMode(
|
|
|
178
158
|
}
|
|
179
159
|
// AgentMessage[](含 bash/custom 等扩展消息)→ 标准 Message[](与主会话请求同源转换,
|
|
180
160
|
// convertToLlm 是 pi host 默认实现——同样的输入产生同样的输出,前缀缓存对齐的前提)
|
|
181
|
-
const fullMessages = convertToLlm(buildSessionContext(event.branchEntries
|
|
161
|
+
const fullMessages = convertToLlm(buildSessionContext(event.branchEntries).messages);
|
|
182
162
|
const instructionMessage = {
|
|
183
163
|
role: "user" as const,
|
|
184
164
|
content: [{ type: "text" as const, text: buildSameModelInstruction(event.customInstructions) }],
|
|
@@ -219,7 +199,11 @@ async function generateSameMode(
|
|
|
219
199
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
220
200
|
tokensBefore: event.preparation.tokensBefore,
|
|
221
201
|
usage: result.usage as CompactionResult["usage"],
|
|
222
|
-
details: {
|
|
202
|
+
details: {
|
|
203
|
+
engine: "smart-context",
|
|
204
|
+
mode: "same-model",
|
|
205
|
+
model: `${model.provider}/${model.id}`,
|
|
206
|
+
} satisfies SmartContextDetails,
|
|
223
207
|
};
|
|
224
208
|
}
|
|
225
209
|
|
|
@@ -230,7 +214,7 @@ async function generateSameMode(
|
|
|
230
214
|
*/
|
|
231
215
|
async function generateCrossMode(
|
|
232
216
|
ctx: ExtensionContext,
|
|
233
|
-
event:
|
|
217
|
+
event: SessionBeforeCompactEvent,
|
|
234
218
|
config: SmartContextConfig,
|
|
235
219
|
): Promise<CompactionResult | null> {
|
|
236
220
|
const model = resolveModel(ctx, config.compactModel);
|
|
@@ -250,10 +234,8 @@ async function generateCrossMode(
|
|
|
250
234
|
Object.entries(auth.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
251
235
|
)
|
|
252
236
|
: undefined;
|
|
253
|
-
// preparation:BeforeCompactLikeEvent 的宽松形状是 nativeCompact 入参(CompactionPreparation,
|
|
254
|
-
// 含 settings 等未消费字段)的结构子集——运行时是 pi 原生事件对象,此处单层收窄断言
|
|
255
237
|
const result = await nativeCompact(
|
|
256
|
-
event.preparation
|
|
238
|
+
event.preparation,
|
|
257
239
|
model,
|
|
258
240
|
auth.apiKey,
|
|
259
241
|
headers,
|
|
@@ -266,7 +248,12 @@ async function generateCrossMode(
|
|
|
266
248
|
// D13-2:原生 compact 内部对截断的处理沿用原生语义;此处补 engine 标记
|
|
267
249
|
return {
|
|
268
250
|
...result,
|
|
269
|
-
details: {
|
|
251
|
+
details: {
|
|
252
|
+
...(result.details as object | undefined),
|
|
253
|
+
engine: "smart-context",
|
|
254
|
+
mode: "cross-model",
|
|
255
|
+
model: `${model.provider}/${model.id}`,
|
|
256
|
+
} as SmartContextDetails,
|
|
270
257
|
};
|
|
271
258
|
}
|
|
272
259
|
|
|
@@ -274,20 +261,21 @@ async function generateCrossMode(
|
|
|
274
261
|
* session_before_compact handler 工厂。
|
|
275
262
|
*
|
|
276
263
|
* state 为 session 级闭包(由 src/index.ts 在 session_start 重建后传入)。
|
|
264
|
+
* 返回类型省略标注、由函数体对象字面量推断({} / {compaction})——SDK SessionBeforeCompactResult
|
|
265
|
+
* 不在包根导出(仅深层 dist 路径可见),显式标注该名不可 import;推断形状与 SDK 逐字段同形,
|
|
266
|
+
* 注册点 pi.on() 的 ExtensionHandler 泛型按结构兼容校验。
|
|
277
267
|
*/
|
|
278
268
|
export function createBeforeCompactHandler(
|
|
279
269
|
pi: ExtensionAPI,
|
|
280
270
|
getState: () => TakeoverState,
|
|
281
271
|
loadConfigFn: () => SmartContextConfig,
|
|
282
|
-
)
|
|
283
|
-
return async (event, ctx) => {
|
|
272
|
+
) {
|
|
273
|
+
return async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
284
274
|
const config = loadConfigFn();
|
|
285
275
|
const currentModelId = getCurrentModelId(ctx.model);
|
|
286
276
|
|
|
287
277
|
// D5 门控:禁用/排除 → 空返回(pi 原生生成)
|
|
288
|
-
if (config
|
|
289
|
-
return {};
|
|
290
|
-
}
|
|
278
|
+
if (!isGatingActive(config, currentModelId)) return {};
|
|
291
279
|
const state = getState();
|
|
292
280
|
|
|
293
281
|
// D13-3 熔断:连续失败 ≥3 → 本 session 停止接管
|
|
@@ -325,7 +313,9 @@ export function createBeforeCompactHandler(
|
|
|
325
313
|
}
|
|
326
314
|
|
|
327
315
|
state.failStreak = 0;
|
|
328
|
-
debugLog(
|
|
316
|
+
debugLog(
|
|
317
|
+
`takeover ok: mode=${mode} reason=${event.reason} summaryTokens=${summaryTokens} shadowedTokens=${shadowedTokens}`,
|
|
318
|
+
);
|
|
329
319
|
return { compaction: result };
|
|
330
320
|
} catch (error) {
|
|
331
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",
|
|
100
|
-
beforeCompact(event, ctx));
|
|
94
|
+
pi.on("session_before_compact", beforeCompact);
|
|
101
95
|
|
|
102
96
|
// ── 压缩完成:重置提醒档位(D3)──
|
|
103
|
-
pi.on("session_compact", (_event
|
|
97
|
+
pi.on("session_compact", (_event, _ctx) => {
|
|
104
98
|
state.firedThresholds.clear();
|
|
105
99
|
});
|
|
106
100
|
|
|
107
101
|
// ── 工具注册(常驻,不可用态由 execute 运行时校验拒绝,D5)──
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
308
|
-
*
|
|
308
|
+
* 不属于被压段,不计入)。与 pi 自身压缩用量核算同源:逐条 estimateTokens 求和
|
|
309
|
+
* (toolCall arguments 与 thinking 块计入分母),消息类型经其签名反推、随 pi 升级自动跟随。
|
|
309
310
|
*/
|
|
310
311
|
export function estimateShadowedTokens(
|
|
311
|
-
messagesToSummarize: ReadonlyArray<
|
|
312
|
+
messagesToSummarize: ReadonlyArray<Parameters<typeof estimateTokens>[0]>,
|
|
312
313
|
): number {
|
|
313
|
-
let
|
|
314
|
+
let tokens = 0;
|
|
314
315
|
for (const m of messagesToSummarize) {
|
|
315
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|