@zhushanwen/pi-smart-context 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/package.json +4 -3
- package/src/__tests__/compact-handler.test.ts +25 -8
- package/src/__tests__/index-generation.test.ts +105 -0
- package/src/__tests__/pure.test.ts +38 -5
- package/src/compact-handler.ts +17 -39
- package/src/index.ts +57 -36
- package/src/llm.ts +6 -12
- package/src/pure.ts +10 -35
- package/src/tool.ts +45 -24
package/README.md
CHANGED
|
@@ -13,10 +13,20 @@
|
|
|
13
13
|
- **健壮性**:摘要收缩校验、max-tokens 截断 fail-closed、接管失败 3 次熔断、transcript 回查指针、压缩后最近文件内容重注入(≤5 文件/50K)、多轮压缩降智提示
|
|
14
14
|
- **subagent 进程**自动静默(`PI_SUBAGENT_ROOT_SESSION_ID` 标记)
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## 行为门控
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
| 状态 | 工具 execute | 阈值提醒 | 压缩生成接管 |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| enabled,模型未排除 | 放行(低于最低档阈值时拒绝并返回用量建议) | 生效 | 生效(按双模式判定) |
|
|
21
|
+
| enabled,模型命中排除列表 | 拒绝(返回原因) | 跳过 | 跳过(回落 pi 原生生成) |
|
|
22
|
+
| enabled=false | 拒绝("可在设置页开启") | 跳过 | 跳过(回落 pi 原生) |
|
|
23
|
+
| compactModel 未配置/无效 | 放行(same-model 不依赖该配置) | 生效 | same-model 生效;cross-model 回退当前模型,压缩不失败 |
|
|
24
|
+
| 模型切换跨界(model_select) | 常驻不变 | 注入一条可用性变化通知(仅跨界时) | 按新模型即时重判 |
|
|
25
|
+
|
|
26
|
+
行为正确性由 execute / handler 现场校验兜底,每次事件回调重新读配置(热加载,改完下一 turn 生效,无需重启)。
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
> 设计层 why 与业界调研记录见原设计文档(已随 2026-09 docs 清理退役,git 历史可查 `docs/extensions/smart-context/design.md`)。
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
## 配置
|
|
31
|
+
|
|
32
|
+
`<agentDir>/config/smart-context-ext-config.json`(读时热加载)。schema 与示例见 `skills/smart-context-ext-config/SKILL.md`。xyz-agent 桌面端在设置页(系统 → 智能上下文压缩)可视化配置。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-smart-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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.4.0",
|
|
40
|
+
"@zhushanwen/pi-extension-logger": "0.6.0",
|
|
41
|
+
"@zhushanwen/pi-llm-shared": "0.8.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,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
|
-
|
|
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
|
|
|
@@ -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,13 +5,13 @@ import {
|
|
|
5
5
|
buildReinjectSection,
|
|
6
6
|
checkToolThresholdGuard,
|
|
7
7
|
computeFileListsLike,
|
|
8
|
+
estimateShadowedTokens,
|
|
8
9
|
estimateTextTokens,
|
|
9
10
|
findCrossedThresholds,
|
|
10
11
|
formatFileOperationsLike,
|
|
11
12
|
formatK,
|
|
12
13
|
getCurrentModelId,
|
|
13
14
|
isGatingActive,
|
|
14
|
-
isSubagentProcess,
|
|
15
15
|
isSummaryInflated,
|
|
16
16
|
normalizeSmartContextConfig,
|
|
17
17
|
pickMode,
|
|
@@ -136,9 +136,42 @@ describe("摘要后处理纯函数(D11/D13)", () => {
|
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
describe("
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
describe("estimateShadowedTokens 口径契约(D2:与 pi estimateTokens 同源)", () => {
|
|
140
|
+
// 合法 AssistantMessage 形状的 fixture(block 数组 content,禁止 as 硬塞 string content——
|
|
141
|
+
// estimateTokens 对 string content 计 0,口径用例会静默失去判别力;设计 §8.3)。
|
|
142
|
+
// 不 import 名义类型:const 泛型保留 block 字面量形状,由 estimateShadowedTokens
|
|
143
|
+
// 参数类型(Parameters<typeof estimateTokens>[0])在调用点结构校验。
|
|
144
|
+
function makeAssistantMessage<const C extends readonly unknown[]>(content: C) {
|
|
145
|
+
return {
|
|
146
|
+
role: "assistant" as const,
|
|
147
|
+
content,
|
|
148
|
+
api: "openai-completions",
|
|
149
|
+
provider: "zai",
|
|
150
|
+
model: "glm",
|
|
151
|
+
usage: {
|
|
152
|
+
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
|
|
153
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
154
|
+
},
|
|
155
|
+
stopReason: "stop" as const,
|
|
156
|
+
timestamp: 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it("仅含 toolCall 块(arguments 大 JSON)→ > 0(旧 chars/4 口径漏计为 0)", () => {
|
|
161
|
+
const tokens = estimateShadowedTokens([
|
|
162
|
+
makeAssistantMessage([{ type: "toolCall", id: "c1", name: "edit", arguments: { content: "x".repeat(2_400) } }]),
|
|
163
|
+
]);
|
|
164
|
+
expect(tokens).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("仅含 thinking 块 → > 0(旧 chars/4 口径漏计为 0)", () => {
|
|
168
|
+
const tokens = estimateShadowedTokens([
|
|
169
|
+
makeAssistantMessage([{ type: "thinking", thinking: "t".repeat(1_600) }]),
|
|
170
|
+
]);
|
|
171
|
+
expect(tokens).toBeGreaterThan(0);
|
|
143
172
|
});
|
|
144
173
|
});
|
|
174
|
+
|
|
175
|
+
// [HISTORICAL] subagent 识别(R6)用例已随本地实现删除移除——现行谓词收敛于
|
|
176
|
+
// ext-guards isSubagentProcess(ext-simplify-17 D4),三态与「宁缺勿污」钉值见
|
|
177
|
+
// extensions/shared/ext-guards/src/__tests__/predicates.test.ts。
|
package/src/compact-handler.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { buildSessionContext, compact as nativeCompact, convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import type { CompactionResult,
|
|
15
|
+
import type { CompactionResult, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
17
18
|
import { createLogger } from "@zhushanwen/pi-extension-logger";
|
|
18
19
|
import { resolveModel } from "@zhushanwen/pi-llm-shared";
|
|
19
20
|
import { readFileSync } from "node:fs";
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
estimateTextTokens,
|
|
33
34
|
formatFileOperationsLike,
|
|
34
35
|
getCurrentModelId,
|
|
36
|
+
isGatingActive,
|
|
35
37
|
isSummaryInflated,
|
|
36
38
|
pickMode,
|
|
37
39
|
pickReinjectFiles,
|
|
@@ -39,31 +41,6 @@ import {
|
|
|
39
41
|
type SmartContextConfig,
|
|
40
42
|
} from "./pure.js";
|
|
41
43
|
|
|
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
44
|
/** session 级接管状态(session_start 重建闭包,规范 Session 隔离)。 */
|
|
68
45
|
export interface TakeoverState {
|
|
69
46
|
/** 接管连续失败计数(D13-3 熔断:≥3 停止接管)。 */
|
|
@@ -173,7 +150,7 @@ function assembleSummary(
|
|
|
173
150
|
async function generateSameMode(
|
|
174
151
|
pi: ExtensionAPI,
|
|
175
152
|
ctx: ExtensionContext,
|
|
176
|
-
event:
|
|
153
|
+
event: SessionBeforeCompactEvent,
|
|
177
154
|
): Promise<CompactionResult | null> {
|
|
178
155
|
const model = ctx.model;
|
|
179
156
|
if (!model) {
|
|
@@ -182,7 +159,7 @@ async function generateSameMode(
|
|
|
182
159
|
}
|
|
183
160
|
// AgentMessage[](含 bash/custom 等扩展消息)→ 标准 Message[](与主会话请求同源转换,
|
|
184
161
|
// convertToLlm 是 pi host 默认实现——同样的输入产生同样的输出,前缀缓存对齐的前提)
|
|
185
|
-
const fullMessages = convertToLlm(buildSessionContext(event.branchEntries
|
|
162
|
+
const fullMessages = convertToLlm(buildSessionContext(event.branchEntries).messages);
|
|
186
163
|
const instructionMessage = {
|
|
187
164
|
role: "user" as const,
|
|
188
165
|
content: [{ type: "text" as const, text: buildSameModelInstruction(event.customInstructions) }],
|
|
@@ -238,7 +215,7 @@ async function generateSameMode(
|
|
|
238
215
|
*/
|
|
239
216
|
async function generateCrossMode(
|
|
240
217
|
ctx: ExtensionContext,
|
|
241
|
-
event:
|
|
218
|
+
event: SessionBeforeCompactEvent,
|
|
242
219
|
config: SmartContextConfig,
|
|
243
220
|
): Promise<CompactionResult | null> {
|
|
244
221
|
const model = resolveModel(ctx, config.compactModel);
|
|
@@ -258,10 +235,8 @@ async function generateCrossMode(
|
|
|
258
235
|
Object.entries(auth.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
259
236
|
)
|
|
260
237
|
: undefined;
|
|
261
|
-
// preparation:BeforeCompactLikeEvent 的宽松形状是 nativeCompact 入参(CompactionPreparation,
|
|
262
|
-
// 含 settings 等未消费字段)的结构子集——运行时是 pi 原生事件对象,此处单层收窄断言
|
|
263
238
|
const result = await nativeCompact(
|
|
264
|
-
event.preparation
|
|
239
|
+
event.preparation,
|
|
265
240
|
model,
|
|
266
241
|
auth.apiKey,
|
|
267
242
|
headers,
|
|
@@ -287,20 +262,21 @@ async function generateCrossMode(
|
|
|
287
262
|
* session_before_compact handler 工厂。
|
|
288
263
|
*
|
|
289
264
|
* state 为 session 级闭包(由 src/index.ts 在 session_start 重建后传入)。
|
|
265
|
+
* 返回类型省略标注、由函数体对象字面量推断({} / {compaction})——SDK SessionBeforeCompactResult
|
|
266
|
+
* 不在包根导出(仅深层 dist 路径可见),显式标注该名不可 import;推断形状与 SDK 逐字段同形,
|
|
267
|
+
* 注册点 pi.on() 的 ExtensionHandler 泛型按结构兼容校验。
|
|
290
268
|
*/
|
|
291
269
|
export function createBeforeCompactHandler(
|
|
292
270
|
pi: ExtensionAPI,
|
|
293
271
|
getState: () => TakeoverState,
|
|
294
272
|
loadConfigFn: () => SmartContextConfig,
|
|
295
|
-
)
|
|
296
|
-
return async (event, ctx) => {
|
|
273
|
+
) {
|
|
274
|
+
return async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
297
275
|
const config = loadConfigFn();
|
|
298
276
|
const currentModelId = getCurrentModelId(ctx.model);
|
|
299
277
|
|
|
300
278
|
// D5 门控:禁用/排除 → 空返回(pi 原生生成)
|
|
301
|
-
if (config
|
|
302
|
-
return {};
|
|
303
|
-
}
|
|
279
|
+
if (!isGatingActive(config, currentModelId)) return {};
|
|
304
280
|
const state = getState();
|
|
305
281
|
|
|
306
282
|
// D13-3 熔断:连续失败 ≥3 → 本 session 停止接管
|
|
@@ -338,11 +314,13 @@ export function createBeforeCompactHandler(
|
|
|
338
314
|
}
|
|
339
315
|
|
|
340
316
|
state.failStreak = 0;
|
|
341
|
-
debugLog(
|
|
317
|
+
debugLog(
|
|
318
|
+
`takeover ok: mode=${mode} reason=${event.reason} summaryTokens=${summaryTokens} shadowedTokens=${shadowedTokens}`,
|
|
319
|
+
);
|
|
342
320
|
return { compaction: result };
|
|
343
321
|
} catch (error) {
|
|
344
322
|
state.failStreak += 1;
|
|
345
|
-
warnLog("takeover error, falling back to native", { error:
|
|
323
|
+
warnLog("takeover error, falling back to native", { error: toErrorMessage(error) });
|
|
346
324
|
return {}; // D7 回退
|
|
347
325
|
}
|
|
348
326
|
};
|
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, isSubagentProcess, 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";
|
|
@@ -27,34 +27,10 @@ import {
|
|
|
27
27
|
findCrossedThresholds,
|
|
28
28
|
getCurrentModelId,
|
|
29
29
|
isGatingActive,
|
|
30
|
-
isSubagentProcess,
|
|
31
30
|
loadSmartContextConfig,
|
|
32
31
|
type EntryLike,
|
|
33
32
|
} from "./pure.js";
|
|
34
33
|
|
|
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
34
|
/** session 级闭包状态(规范:模块级仅工厂函数,状态在 session_start 重建)。 */
|
|
59
35
|
interface SessionState {
|
|
60
36
|
takeover: TakeoverState;
|
|
@@ -67,6 +43,15 @@ function createSessionState(): SessionState {
|
|
|
67
43
|
return { takeover: createTakeoverState(), firedThresholds: new Set() };
|
|
68
44
|
}
|
|
69
45
|
|
|
46
|
+
// G1 代际检测(crash-resilience D1,同 scheduler/src/index.ts 的模块级代数计数器范式):
|
|
47
|
+
// 必须声明在模块级而非 factory 体内——pi 每次 session 替换(newSession/fork/switchSession)
|
|
48
|
+
// 都重跑 extension factory 函数体(extensionCache 只缓存 factory 函数对象),闭包级声明
|
|
49
|
+
// 每次重跑即重置,各代的 isCtxStale 恒 false。模块级声明下 extensionCache 命中期间共享
|
|
50
|
+
// 同一模块绑定,计数器跨 factory 重跑保留递增:新代 session_start 递增后,旧代闭包捕获
|
|
51
|
+
// 的代数从此小于模块值 → isCtxStale 生效。残余盲区(显式 reload 触发 jiti 重新 import
|
|
52
|
+
// 产生全新模块环境)由 guardStaleCtx 的 STALE_CTX_MARKER 文案兜底分诊覆盖(PS-30 门禁)。
|
|
53
|
+
let sessionGeneration = 0;
|
|
54
|
+
|
|
70
55
|
/**
|
|
71
56
|
* pi-smart-context extension 工厂函数。
|
|
72
57
|
*
|
|
@@ -77,7 +62,8 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
77
62
|
// 日志通道注入(extension-logger 两阶段初始化:工厂拿 pi → setPiHandle)
|
|
78
63
|
setPiHandle(pi);
|
|
79
64
|
|
|
80
|
-
// R6:subagent
|
|
65
|
+
// R6:subagent 子进程不注册工具、不提醒(宁缺勿污;XYZ_AGENT_SUBAGENT 标记——
|
|
66
|
+
// ext-guards isSubagentProcess,ext-simplify-17 D4 重锚)
|
|
81
67
|
if (isSubagentProcess()) {
|
|
82
68
|
debugLog("subagent process detected, staying inert");
|
|
83
69
|
return;
|
|
@@ -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,18 @@
|
|
|
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";
|
|
17
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
24
18
|
|
|
25
19
|
/**
|
|
26
20
|
* ToolInfo → pi-ai Tool 投影:三字段直取。parameters 是 typebox schema(主会话同源对象,
|
|
27
21
|
* 序列化后与主请求一致——缓存对齐的关键是不改造、原样透传)。
|
|
28
22
|
*/
|
|
29
|
-
export function projectTools(toolInfos: readonly
|
|
23
|
+
export function projectTools(toolInfos: readonly ToolInfo[]): LlmTool[] {
|
|
30
24
|
return toolInfos.map((t) => ({
|
|
31
25
|
name: t.name,
|
|
32
26
|
description: t.description,
|
|
33
|
-
parameters: t.parameters
|
|
27
|
+
parameters: t.parameters,
|
|
34
28
|
}));
|
|
35
29
|
}
|
|
36
30
|
|
|
@@ -134,6 +128,6 @@ export async function callSameModelCompaction(
|
|
|
134
128
|
const text = resp.content.map(blockText).join("\n").trim();
|
|
135
129
|
return { ok: true, text, usage: resp.usage, stopReason: resp.stopReason };
|
|
136
130
|
} catch (error) {
|
|
137
|
-
return { ok: false, error:
|
|
131
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
138
132
|
}
|
|
139
133
|
}
|
package/src/pure.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
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";
|
|
9
|
+
import { isRecord } from "@zhushanwen/pi-ext-guards";
|
|
8
10
|
import type { ModelSelector } from "@zhushanwen/pi-llm-shared";
|
|
9
|
-
import { loadConfig } from "@zhushanwen/pi-llm-shared";
|
|
11
|
+
import { loadConfig, normalizeModelSelector } from "@zhushanwen/pi-llm-shared";
|
|
10
12
|
|
|
11
13
|
// ──────────────────────── 配置 schema(D8) ────────────────────────
|
|
12
14
|
|
|
@@ -58,13 +60,8 @@ export function normalizeSmartContextConfig(raw: unknown): SmartContextConfig {
|
|
|
58
60
|
|
|
59
61
|
const enabled = typeof r.enabled === "boolean" ? r.enabled : base.enabled;
|
|
60
62
|
|
|
61
|
-
const rawModel = typeof r.compactModel === "object" && r.compactModel !== null
|
|
62
|
-
? (r.compactModel as Record<string, unknown>)
|
|
63
|
-
: null;
|
|
64
63
|
const compactModel: ModelSelector =
|
|
65
|
-
|
|
66
|
-
? { type: "ref", ref: rawModel.ref }
|
|
67
|
-
: { type: "ref", ref: "" };
|
|
64
|
+
normalizeModelSelector(r.compactModel) ?? { type: "ref", ref: "" };
|
|
68
65
|
|
|
69
66
|
const rawThresholds = Array.isArray(r.reminderThresholds)
|
|
70
67
|
? r.reminderThresholds
|
|
@@ -248,16 +245,6 @@ export function buildReinjectSection(contents: ReadonlyArray<{ path: string; con
|
|
|
248
245
|
return `\n\n<recently-read-files>\n${parts.join("\n\n")}\n</recently-read-files>`;
|
|
249
246
|
}
|
|
250
247
|
|
|
251
|
-
// ──────────────────────── subagent 识别(R6) ────────────────────────
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* subagent 子进程检测(D9/R6):subagent-core session-runner 无条件注入 PI_SUBAGENT_ROOT_SESSION_ID。
|
|
255
|
-
* 命中 → 本进程不注册工具、不提醒(宁缺勿污)。
|
|
256
|
-
*/
|
|
257
|
-
export function isSubagentProcess(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
258
|
-
return env.PI_SUBAGENT_ROOT_SESSION_ID !== undefined;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
248
|
// ──────────────────────── session entries 统计(D13 纯函数) ────────────────────────
|
|
262
249
|
|
|
263
250
|
/** sessionManager entries 的宽松形状(降智计数)。 */
|
|
@@ -270,11 +257,6 @@ export function countCompactions(entries: ReadonlyArray<EntryLike>): number {
|
|
|
270
257
|
return entries.filter((e) => e.type === "compaction").length;
|
|
271
258
|
}
|
|
272
259
|
|
|
273
|
-
/** unknown 的对象收窄(Record 视图;字段消费再经 typeof / Array.isArray 收窄)。 */
|
|
274
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
275
|
-
return typeof value === "object" && value !== null;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
260
|
/**
|
|
279
261
|
* 保留段已 Read 的文件集合(D13-11 去重:重注入跳过保留段已有的 Read 结果)。
|
|
280
262
|
* 保留段 = branchEntries 中 firstKeptEntryId 之后的 message entries;从其 toolCall 参数提取 path。
|
|
@@ -304,22 +286,15 @@ export function collectKeptReadFiles(branchEntries: ReadonlyArray<unknown>, firs
|
|
|
304
286
|
|
|
305
287
|
/**
|
|
306
288
|
* 被压段 token 估算(收缩校验分母:仅 messagesToSummarize;turnPrefixMessages 是保留段前缀,
|
|
307
|
-
*
|
|
308
|
-
*
|
|
289
|
+
* 不属于被压段,不计入)。与 pi 自身压缩用量核算同源:逐条 estimateTokens 求和
|
|
290
|
+
* (toolCall arguments 与 thinking 块计入分母),消息类型经其签名反推、随 pi 升级自动跟随。
|
|
309
291
|
*/
|
|
310
292
|
export function estimateShadowedTokens(
|
|
311
|
-
messagesToSummarize: ReadonlyArray<
|
|
293
|
+
messagesToSummarize: ReadonlyArray<Parameters<typeof estimateTokens>[0]>,
|
|
312
294
|
): number {
|
|
313
|
-
let
|
|
295
|
+
let tokens = 0;
|
|
314
296
|
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
|
-
}
|
|
297
|
+
tokens += estimateTokens(m);
|
|
323
298
|
}
|
|
324
|
-
return
|
|
299
|
+
return tokens;
|
|
325
300
|
}
|
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, isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
13
14
|
import { Type } from "typebox";
|
|
14
15
|
|
|
15
16
|
import { debugLog } from "./compact-handler.js";
|
|
@@ -65,11 +66,6 @@ const TOOL_DESCRIPTION =
|
|
|
65
66
|
"2) 后续工作不再依赖将被压缩的早期细节;3) 上下文已超过提醒阈值(你会收到 [smart-context 提示])。" +
|
|
66
67
|
"若任一条件不满足,不要调用。";
|
|
67
68
|
|
|
68
|
-
/** unknown 的对象收窄(Record 视图;字段消费再经 typeof 收窄)。 */
|
|
69
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
-
return typeof value === "object" && value !== null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
69
|
/** CompactionResult → 宽松形状的 guard 转换(onComplete 回调参数消费,避免 cast)。 */
|
|
74
70
|
function toCompactionResultLike(result: unknown): CompactionResultLike {
|
|
75
71
|
const r: Record<string, unknown> = isRecord(result) ? result : {};
|
|
@@ -109,6 +105,13 @@ export function registerCompactContextTool(
|
|
|
109
105
|
gatingProbe?: (ctx: ExtensionContext) => { active: boolean; modelId: string };
|
|
110
106
|
usageProbe?: (ctx: ExtensionContext) => { tokens: number | null; contextWindow: number };
|
|
111
107
|
getEntries?: (ctx: ExtensionContext) => ReadonlyArray<EntryLike>;
|
|
108
|
+
/**
|
|
109
|
+
* G1 代际检测(crash-resilience D1):compact 是 fire-and-forget——onComplete/onError
|
|
110
|
+
* 回调在压缩 LLM 调用期间异步触发,用户此间切换/重载 session 后回调持有的 pi 已
|
|
111
|
+
* stale(E1 实锤崩溃点)。index.ts 注入模块级代数比对闭包,前置检查 + 错误分诊
|
|
112
|
+
* 双保险;缺省时守卫退化为纯文案兜底分诊(PS-30 门禁守文案)。
|
|
113
|
+
*/
|
|
114
|
+
isCtxStale?: () => boolean;
|
|
112
115
|
},
|
|
113
116
|
): void {
|
|
114
117
|
const probeGating =
|
|
@@ -165,29 +168,47 @@ export function registerCompactContextTool(
|
|
|
165
168
|
typeof params.custom_instructions === "string" && params.custom_instructions.trim() !== ""
|
|
166
169
|
? params.custom_instructions
|
|
167
170
|
: undefined,
|
|
171
|
+
// E1 实锤崩溃点(9/3 pi-crash log):两个回调由 compact 的内部 Promise 链异步
|
|
172
|
+
// 调用,不在 pi runner emit() 的 try/catch 内——session 替换窗口(GUI 切
|
|
173
|
+
// session/新建/重载高频触发)下 pi.sendUserMessage 命中 stale ctx 同步抛错即
|
|
174
|
+
// 杀死 pi 进程。守卫 stale 静默降级(结果不投递,用户可重试 /compact),非
|
|
175
|
+
// stale 错误原样上抛(守卫不吞真实 bug)。
|
|
168
176
|
onComplete: (r: unknown) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
177
|
+
guardStaleCtx(() => {
|
|
178
|
+
const result = toCompactionResultLike(r);
|
|
179
|
+
const resultMode = result.details?.mode ?? "native-fallback";
|
|
180
|
+
const fellBack = resultMode === "native-fallback";
|
|
181
|
+
if (fellBack) debugLog("compact_context: takeover fell back to native generation");
|
|
182
|
+
const cacheRead = result.usage?.cacheRead;
|
|
183
|
+
const cost = result.usage
|
|
184
|
+
? `${formatK(result.usage.input ?? 0)} input${cacheRead ? `(其中缓存命中 ${formatK(cacheRead)})` : ""} + ${formatK(result.usage.output ?? 0)} output`
|
|
185
|
+
: "未知";
|
|
186
|
+
const showHint = compactionCount + 1 >= DEGRADATION_HINT_MIN_COMPACTIONS;
|
|
187
|
+
const lines = [
|
|
188
|
+
`[smart-context] 压缩完成。模式:${resultMode}${fellBack ? "(压缩模型不可用,已回退当前模型——请检查配置:xyz-agent 设置页或 smart-context-ext-config skill)" : ""}。`,
|
|
189
|
+
`压缩前 ${formatK(result.tokensBefore ?? 0)} tokens → 压缩后约 ${formatK(result.estimatedTokensAfter ?? 0)} tokens;摘要生成成本:${cost}。`,
|
|
190
|
+
showHint ? buildDegradationHintLine() : "",
|
|
191
|
+
].filter((l) => l !== "");
|
|
192
|
+
pi.sendUserMessage(lines.join("\n"), { deliverAs: "steer" });
|
|
193
|
+
}, {
|
|
194
|
+
isCtxStale: deps?.isCtxStale,
|
|
195
|
+
label: "smart-context:compact-onComplete",
|
|
196
|
+
onStale: (error) => debugLog(`compact result delivery skipped (stale ctx): ${toErrorMessage(error)}`),
|
|
197
|
+
});
|
|
184
198
|
},
|
|
185
199
|
onError: (err: Error) => {
|
|
200
|
+
// 压缩本身的失败信息先落日志(守卫体外——stale 降级时该观测保留)
|
|
186
201
|
debugLog(`compact_context error: ${err.message}`);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
guardStaleCtx(() => {
|
|
203
|
+
pi.sendUserMessage(
|
|
204
|
+
`[smart-context] 压缩失败:${err.message}。上下文未变化,可稍后重试(若反复失败,检查 smart-context 配置或使用 /compact)。`,
|
|
205
|
+
{ deliverAs: "steer" },
|
|
206
|
+
);
|
|
207
|
+
}, {
|
|
208
|
+
isCtxStale: deps?.isCtxStale,
|
|
209
|
+
label: "smart-context:compact-onError",
|
|
210
|
+
onStale: (error) => debugLog(`compact failure notice delivery skipped (stale ctx): ${toErrorMessage(error)}`),
|
|
211
|
+
});
|
|
191
212
|
},
|
|
192
213
|
});
|
|
193
214
|
|