@zhushanwen/pi-smart-context 0.3.3 → 0.3.5
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 +2 -1
- package/package.json +3 -3
- package/skills/smart-context-ext-config/SKILL.md +1 -1
- package/src/__tests__/index-reminder-delivery.test.ts +86 -8
- package/src/__tests__/sdk-contract.test.ts +21 -1
- package/src/__tests__/tool.test.ts +25 -21
- package/src/index.ts +14 -23
- package/src/notices.ts +59 -0
- package/src/pure.ts +0 -12
- package/src/reminder.ts +2 -2
- package/src/tool.ts +15 -19
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
- **双模式生成接管**(`session_before_compact`,覆盖工具 / `/compact` / 内建 auto 三条路径):
|
|
9
9
|
- same-model:完整原始上下文 + 会话原 system prompt + tools + 末尾追加压缩指令——前缀缓存全命中,成本≈增量指令+输出,且模型看全量上下文(质量上限最高)
|
|
10
10
|
- cross-model:调用 pi 原生 `compact()` 仅换模型与凭证——split-turn / fileOps / previousSummary 原生组装
|
|
11
|
-
- **3 档阈值提醒**(默认 400K/500K/600K):`agent_settled`
|
|
11
|
+
- **3 档阈值提醒**(默认 400K/500K/600K):`agent_settled` 越档检查,每档一次、多档合并、压缩后重置,nextTurn 投递(随下一次 prompt 注入,不自起 run);已提醒档位经 `pi.appendEntry` 落在 session entries(`smart-context:fired`),reload / 进程重启后从 entries 重建,同一档不会重复提醒;措辞是数据投递不是指令
|
|
12
12
|
- **排除模型**(精准 `provider/modelId` 匹配):工具拒绝 + 不提醒 + 回落 pi 原生生成;切换跨界时注入一条可用性通知,downshift(切小窗模型将触线)时建议先压缩
|
|
13
|
+
- **通知车道**:压缩结果 / 阈值提醒 / 模型切换四类通知统一走 nextTurn(custom role 随下次 prompt 注入,不自起 run)——工具压缩掐断的 turn 由 taiji runtime 在 `compaction_end` 判定后经投递内核追加续跑投递(详情见仓库设计文档 delivery-ownership-kernel)
|
|
13
14
|
- **健壮性**:摘要收缩校验、max-tokens 截断 fail-closed、接管失败 3 次熔断(本 session 内停止接管)、transcript 回查指针、压缩后最近文件内容重注入(≤5 文件/50K)、多轮压缩降智提示(累计压缩 ≥2 次后附加)
|
|
14
15
|
- **subagent 进程**自动静默(`TAIJI_AGENT_SUBAGENT` 标记)
|
|
15
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-smart-context",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"pi-package"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
39
|
+
"@zhushanwen/pi-ext-guards": "0.4.2",
|
|
40
40
|
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
41
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
41
|
+
"@zhushanwen/pi-llm-shared": "0.11.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@vitest/coverage-v8": "^4.1.9",
|
|
@@ -31,7 +31,7 @@ description: "配置 @zhushanwen/pi-smart-context(智能上下文压缩:agen
|
|
|
31
31
|
```json
|
|
32
32
|
{
|
|
33
33
|
"enabled": true,
|
|
34
|
-
"compactModel": { "type": "ref", "ref": "xiaomi-token-plan-cn/mimo-v2.
|
|
34
|
+
"compactModel": { "type": "ref", "ref": "xiaomi-token-plan-cn/mimo-v2.6-flash" },
|
|
35
35
|
"reminderThresholds": [400000, 500000, 600000],
|
|
36
36
|
"excludedModels": ["deepseek/deepseek-chat"]
|
|
37
37
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/__tests__/index-reminder-delivery.test.ts
|
|
2
2
|
//
|
|
3
|
-
// D4
|
|
3
|
+
// D4① + D15 装配回归:越档提醒的「nextTurn 投递 + 档位持久化」两条语义在 index.ts 装配点成立。
|
|
4
4
|
//
|
|
5
5
|
// 现场(2026-09-19 会话 01a0b8b3):taiji skill-reload 发 `/__taiji_reload__` → pi ctx.reload()
|
|
6
6
|
// 重跑 extension factory(jiti moduleCache:false 重新 import)→ firedThresholds 归零,且 reload
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// sendUserMessage(followUp) 恒触发一轮(pi 语义),用户收工后仍被唤醒烧掉一整轮全量上下文。
|
|
9
9
|
//
|
|
10
10
|
// 本文件锁两件事:
|
|
11
|
-
// ① 投递形态 =
|
|
11
|
+
// ① 投递形态 = notices.ts 的 sendSmartContextNotice(customType "smart-context" + display:true,
|
|
12
|
+
// {triggerTurn:false, deliverAs:"nextTurn"}——随下一次 prompt 注入,不自起 run),且不再用
|
|
13
|
+
// sendUserMessage;
|
|
12
14
|
// ② fired 档位写入 session entries(appendEntry marker),工厂重跑(reload 等价拓扑)后
|
|
13
15
|
// 从 entries 重建 → 同档不重复、跨档照常提醒。
|
|
14
16
|
|
|
@@ -37,12 +39,15 @@ const TEST_CONFIG = {
|
|
|
37
39
|
reminderThresholds: [200_000, 400_000, 600_000],
|
|
38
40
|
excludedModels: [] as string[],
|
|
39
41
|
};
|
|
42
|
+
/** 当前生效配置(事件回调热读——用例按需改写,beforeEach 复位)。 */
|
|
43
|
+
let activeConfig = structuredClone(TEST_CONFIG);
|
|
40
44
|
vi.mock("../pure.js", async (importOriginal) => ({
|
|
41
45
|
...(await importOriginal<typeof import("../pure.js")>()),
|
|
42
|
-
loadSmartContextConfig: () => structuredClone(
|
|
46
|
+
loadSmartContextConfig: () => structuredClone(activeConfig),
|
|
43
47
|
}));
|
|
44
48
|
|
|
45
|
-
import { FIRED_ENTRY_CUSTOM_TYPE
|
|
49
|
+
import { FIRED_ENTRY_CUSTOM_TYPE } from "../pure.js";
|
|
50
|
+
import { SMART_CONTEXT_NOTICE_CUSTOM_TYPE } from "../notices.js";
|
|
46
51
|
import smartContextExtension from "../index.js";
|
|
47
52
|
|
|
48
53
|
interface MockPi {
|
|
@@ -93,8 +98,8 @@ function fireSessionStart(pi: MockPi, ctx: ExtensionContext): void {
|
|
|
93
98
|
pi.events.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
describe("D4
|
|
97
|
-
it("命中档位 → sendMessage(customType
|
|
101
|
+
describe("D4①: 越档提醒 nextTurn 投递(不自起 run,随下次 prompt 注入)", () => {
|
|
102
|
+
it("命中档位 → sendMessage(customType smart-context + display:true + source) + {triggerTurn:false, deliverAs:'nextTurn'};不再 sendUserMessage", () => {
|
|
98
103
|
const mock = createMockPi();
|
|
99
104
|
smartContextExtension(mock.pi);
|
|
100
105
|
fireSessionStart(mock, makeCtx(0));
|
|
@@ -103,9 +108,13 @@ describe("D4': 越档提醒静默注入(不触发 turn、不进对话流)",
|
|
|
103
108
|
expect(mock.sendUserMessage).not.toHaveBeenCalled();
|
|
104
109
|
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
105
110
|
const [message, options] = mock.sendMessage.mock.calls[0]!;
|
|
106
|
-
expect(message).toMatchObject({
|
|
111
|
+
expect(message).toMatchObject({
|
|
112
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
113
|
+
display: true,
|
|
114
|
+
details: { source: "threshold-reminder" },
|
|
115
|
+
});
|
|
107
116
|
expect(String(message.content)).toContain("[smart-context]");
|
|
108
|
-
expect(options).toEqual({ triggerTurn: false });
|
|
117
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
109
118
|
});
|
|
110
119
|
|
|
111
120
|
it("命中档位 → 先写 fired marker(session entries 持久化)", () => {
|
|
@@ -201,6 +210,75 @@ describe("D15: 档位持久化跨 factory 重跑(reload 等价拓扑)", () =
|
|
|
201
210
|
});
|
|
202
211
|
});
|
|
203
212
|
|
|
213
|
+
describe("D5/D4①: 模型切换通知 nextTurn 投递(跨界 switch + downshift 两形态)", () => {
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
activeConfig = structuredClone(TEST_CONFIG);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/** 触发 model_select。 */
|
|
219
|
+
const fireModelSelect = (
|
|
220
|
+
mock: MockPi,
|
|
221
|
+
model: { provider: string; id: string; contextWindow?: number },
|
|
222
|
+
previousModel: { provider: string; id: string; contextWindow?: number },
|
|
223
|
+
tokens: number,
|
|
224
|
+
): void => {
|
|
225
|
+
mock.events.get("model_select")!({ type: "model_select", model, previousModel }, makeCtx(tokens));
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
it("跨越排除边界(切到 excludedModels 内模型)→ switch notice『不可用』经 nextTurn 投递", () => {
|
|
229
|
+
activeConfig.excludedModels = ["prov/small"];
|
|
230
|
+
const mock = createMockPi();
|
|
231
|
+
smartContextExtension(mock.pi);
|
|
232
|
+
fireModelSelect(mock, { provider: "prov", id: "small" }, { provider: "prov", id: "big" }, 0);
|
|
233
|
+
|
|
234
|
+
expect(mock.sendUserMessage).not.toHaveBeenCalled();
|
|
235
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
236
|
+
const [message, options] = mock.sendMessage.mock.calls[0]!;
|
|
237
|
+
expect(message).toMatchObject({
|
|
238
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
239
|
+
display: true,
|
|
240
|
+
details: { source: "model-switch" },
|
|
241
|
+
});
|
|
242
|
+
expect(String(message.content)).toContain("暂时不可用");
|
|
243
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("同边界内切换(双方都未排除)且未触线 → 静默不投递", () => {
|
|
247
|
+
const mock = createMockPi();
|
|
248
|
+
smartContextExtension(mock.pi);
|
|
249
|
+
fireModelSelect(
|
|
250
|
+
mock,
|
|
251
|
+
{ provider: "prov", id: "a", contextWindow: 1_000_000 },
|
|
252
|
+
{ provider: "prov", id: "b", contextWindow: 1_000_000 },
|
|
253
|
+
100,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(mock.sendMessage).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("切到更小窗口且 tokens 将触线 → downshift notice 经 nextTurn 投递", () => {
|
|
260
|
+
const mock = createMockPi();
|
|
261
|
+
smartContextExtension(mock.pi);
|
|
262
|
+
// 旧窗口 1M → 新窗口 200K;触线 = 200K − pi 内建 reserve 16_384 = 183_616,tokens 190K 已过线
|
|
263
|
+
fireModelSelect(
|
|
264
|
+
mock,
|
|
265
|
+
{ provider: "prov", id: "small", contextWindow: 200_000 },
|
|
266
|
+
{ provider: "prov", id: "big", contextWindow: 1_000_000 },
|
|
267
|
+
190_000,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(mock.sendMessage).toHaveBeenCalledTimes(1);
|
|
271
|
+
const [message, options] = mock.sendMessage.mock.calls[0]!;
|
|
272
|
+
expect(message).toMatchObject({
|
|
273
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
274
|
+
display: true,
|
|
275
|
+
details: { source: "model-downshift" },
|
|
276
|
+
});
|
|
277
|
+
expect(String(message.content)).toContain("接近新模型窗口上限");
|
|
278
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
204
282
|
describe("D15: entries 读取异常降级(不阻断提醒链路)", () => {
|
|
205
283
|
beforeEach(() => {
|
|
206
284
|
loggerMock.debug.mockClear();
|
|
@@ -3,9 +3,10 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
/**
|
|
4
4
|
* SDK 契约测试(规范:凡调用 pi.on / pi.registerTool / 读 ctx.* 的代码必须有契约测试覆盖)。
|
|
5
5
|
* 兜底 compact-handler.ts / llm.ts 中跨 SDK 泛型边界的 `as never` 断言——
|
|
6
|
-
* 断言的运行时形状在这里实测(node_modules 实装 @earendil-works/pi-coding-agent@0.84.
|
|
6
|
+
* 断言的运行时形状在这里实测(node_modules 实装 @earendil-works/pi-coding-agent@0.84.4)。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import {
|
|
10
11
|
buildSessionContext,
|
|
11
12
|
compact,
|
|
@@ -44,6 +45,25 @@ describe("pi SDK 契约(compact 接管路径依赖的导出形状)", () => {
|
|
|
44
45
|
expect(typeof completeSimple).toBe("function");
|
|
45
46
|
});
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* D4① 通知注入通道契约:notices.ts 的 sendSmartContextNotice 形态必须与 SDK
|
|
50
|
+
* ExtensionAPI.sendMessage 参数类型完全对齐(customType/content/display/details +
|
|
51
|
+
* {triggerTurn, deliverAs:'nextTurn'})——SDK 形状漂移时本用例编译期红。
|
|
52
|
+
*/
|
|
53
|
+
it("sendMessage(nextTurn) 通知形态符合 SDK 参数契约", () => {
|
|
54
|
+
type SendMessageParams = Parameters<ExtensionAPI["sendMessage"]>;
|
|
55
|
+
const message: SendMessageParams[0] = {
|
|
56
|
+
customType: "smart-context",
|
|
57
|
+
content: "[smart-context] 压缩完成。",
|
|
58
|
+
display: true,
|
|
59
|
+
details: { source: "compact-complete" },
|
|
60
|
+
};
|
|
61
|
+
const options: SendMessageParams[1] = { triggerTurn: false, deliverAs: "nextTurn" };
|
|
62
|
+
expect(message.customType).toBe("smart-context");
|
|
63
|
+
expect(options?.deliverAs).toBe("nextTurn");
|
|
64
|
+
expect(options?.triggerTurn).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
47
67
|
it("compact() 的 preparation 参数运行时消费字段(messagesToSummarize/previousSummary/fileOps)在函数体内可达——静态签名核对", async () => {
|
|
48
68
|
// 不真正调网:用立即 abort 的 signal 让 compact 提前拒绝,验证参数形状被接受不抛 TypeError
|
|
49
69
|
const controller = new AbortController();
|
|
@@ -9,7 +9,7 @@ vi.mock("../pure.js", async (importOriginal) => {
|
|
|
9
9
|
};
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
import { DEFAULT_SMART_CONTEXT_CONFIG,
|
|
12
|
+
import { DEFAULT_SMART_CONTEXT_CONFIG, loadSmartContextConfig } from "../pure.js";
|
|
13
13
|
import { registerCompactContextTool } from "../tool.js";
|
|
14
14
|
import { countCompactions } from "../pure.js";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -26,12 +26,13 @@ type CompactOpts = {
|
|
|
26
26
|
onError: (e: Error) => void;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function makePi(): { pi: ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn> }; tools: RegisteredTool[] } {
|
|
29
|
+
function makePi(): { pi: ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn>; sendUserMessage: ReturnType<typeof vi.fn> }; tools: RegisteredTool[] } {
|
|
30
30
|
const tools: RegisteredTool[] = [];
|
|
31
31
|
const pi = {
|
|
32
32
|
registerTool: (t: RegisteredTool) => tools.push(t),
|
|
33
33
|
sendMessage: vi.fn(),
|
|
34
|
-
|
|
34
|
+
sendUserMessage: vi.fn(),
|
|
35
|
+
} as unknown as ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn>; sendUserMessage: ReturnType<typeof vi.fn> };
|
|
35
36
|
return { pi, tools };
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -94,20 +95,24 @@ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入
|
|
|
94
95
|
expect(result.details).toMatchObject({ mode: "same-model", launched: true, fellBack: false, compactionCount: 1 });
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
it("onComplete 兑现后
|
|
98
|
+
it("onComplete 兑现后 nextTurn 注入结果(含模式/前后 tokens/成本;降智提示按次数)", async () => {
|
|
98
99
|
const { pi, tools } = makePi();
|
|
99
100
|
registerCompactContextTool(pi, { getEntries: () => [{ type: "compaction" }, { type: "compaction" }] });
|
|
100
101
|
await tools[0].execute("t1", {}, undefined, undefined, makeCtx());
|
|
101
102
|
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
102
|
-
const [message, options] = pi.sendMessage.mock.calls[0]
|
|
103
|
-
|
|
104
|
-
expect(
|
|
105
|
-
expect(
|
|
106
|
-
expect(
|
|
107
|
-
|
|
108
|
-
expect(
|
|
109
|
-
|
|
110
|
-
expect(
|
|
103
|
+
const [message, options] = pi.sendMessage.mock.calls[0];
|
|
104
|
+
// D4①:nextTurn 化——custom role 消息 + 不自起 run 的投递车道
|
|
105
|
+
expect(message.customType).toBe("smart-context");
|
|
106
|
+
expect(message.details).toEqual({ source: "compact-complete" });
|
|
107
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
108
|
+
const text = message.content as string;
|
|
109
|
+
expect(text).toContain("压缩完成");
|
|
110
|
+
expect(text).toContain("same-model");
|
|
111
|
+
expect(text).toContain("500K");
|
|
112
|
+
expect(text).toContain("24K");
|
|
113
|
+
expect(text).toContain("compacted multiple times");
|
|
114
|
+
// 旧车道(自起 run 的 user 消息)已退役:不再调用 sendUserMessage(mock 挂 spy,被调用即断言红)
|
|
115
|
+
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
|
111
116
|
});
|
|
112
117
|
|
|
113
118
|
it("onComplete 无 engine 标记 → 注入消息含回退说明与修复指引(D7)", async () => {
|
|
@@ -116,20 +121,19 @@ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入
|
|
|
116
121
|
const ctx = makeCtx((options) => options.onComplete({ tokensBefore: 1, estimatedTokensAfter: 1 }));
|
|
117
122
|
await tools[0].execute("t1", {}, undefined, undefined, ctx);
|
|
118
123
|
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
119
|
-
expect(
|
|
124
|
+
expect(pi.sendMessage.mock.calls[0][0].content).toContain("回退");
|
|
120
125
|
});
|
|
121
126
|
|
|
122
|
-
it("onError → 注入失败消息带重试指引(display:true 失败必须用户可见 +
|
|
127
|
+
it("onError → 注入失败消息带重试指引(display:true 失败必须用户可见 + nextTurn 车道不自起 run)", async () => {
|
|
123
128
|
const { pi, tools } = makePi();
|
|
124
129
|
registerCompactContextTool(pi);
|
|
125
130
|
const ctx = makeCtx((options) => options.onError(new Error("Nothing to compact")));
|
|
126
131
|
await tools[0].execute("t1", {}, undefined, undefined, ctx);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
expect(message).
|
|
130
|
-
expect(
|
|
131
|
-
expect(
|
|
132
|
-
expect(options).toEqual({ triggerTurn: true });
|
|
132
|
+
const [message, options] = pi.sendMessage.mock.calls[0];
|
|
133
|
+
expect(message.content).toContain("压缩失败");
|
|
134
|
+
expect(message.content).toContain("Nothing to compact");
|
|
135
|
+
expect(message.details).toEqual({ source: "compact-failed" });
|
|
136
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
133
137
|
});
|
|
134
138
|
|
|
135
139
|
it("custom_instructions 透传给 ctx.compact", async () => {
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* - subagent 进程(R6):不注册工具、不提醒(宁缺勿污)
|
|
7
7
|
* - session_before_compact:双模式接管(compact-handler)
|
|
8
8
|
* - session_compact:重置提醒 fired(D3)
|
|
9
|
-
* - agent_settled:越档检查 + marker
|
|
10
|
-
* - model_select:跨界通知 + downshift 提醒(D5
|
|
9
|
+
* - agent_settled:越档检查 + marker 持久化(D15)+ nextTurn 投递一次性提醒(D3/D4①)
|
|
10
|
+
* - model_select:跨界通知 + downshift 提醒(D5/D4①)
|
|
11
|
+
*
|
|
12
|
+
* 通知注入车道(D4①,delivery-ownership-kernel):四类通知全部经 notices.ts 的
|
|
13
|
+
* sendSmartContextNotice 走 nextTurn(随下一次 prompt 注入、不自起 run)——通知 run 不再
|
|
14
|
+
* 抢占用户消息投递跑道;被压缩掐断的 turn 由 runtime 续跑投递兜底(D4②)。
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
debugLog,
|
|
21
25
|
type TakeoverState,
|
|
22
26
|
} from "./compact-handler.js";
|
|
27
|
+
import { sendSmartContextNotice } from "./notices.js";
|
|
23
28
|
import { buildDownshiftNotice, buildSwitchNotice, buildThresholdReminder } from "./reminder.js";
|
|
24
29
|
import { registerCompactContextTool } from "./tool.js";
|
|
25
30
|
import {
|
|
@@ -30,9 +35,6 @@ import {
|
|
|
30
35
|
getCurrentModelId,
|
|
31
36
|
isGatingActive,
|
|
32
37
|
loadSmartContextConfig,
|
|
33
|
-
SWITCH_NOTICE_CUSTOM_TYPE,
|
|
34
|
-
DOWNSHIFT_NOTICE_CUSTOM_TYPE,
|
|
35
|
-
THRESHOLD_REMINDER_CUSTOM_TYPE,
|
|
36
38
|
type EntryLike,
|
|
37
39
|
} from "./pure.js";
|
|
38
40
|
|
|
@@ -151,7 +153,7 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
151
153
|
// 守卫的前置代际检查恒不生效。wrapper 每次调用读闭包当前绑定。
|
|
152
154
|
registerCompactContextTool(pi, { isCtxStale: () => isCtxStale() });
|
|
153
155
|
|
|
154
|
-
// ── 阈值提醒(D3/D4
|
|
156
|
+
// ── 阈值提醒(D3/D4):agent_settled 越档检查 + marker 持久化 + nextTurn 一次性投递 ──
|
|
155
157
|
pi.on("agent_settled", (_event, ctx) => {
|
|
156
158
|
const config = loadSmartContextConfig();
|
|
157
159
|
const modelId = getCurrentModelId(ctx.model);
|
|
@@ -178,18 +180,13 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
178
180
|
label: "smart-context:fired-marker",
|
|
179
181
|
onStale: (error) => debugLog(`fired marker skipped (stale ctx): ${toErrorMessage(error)}`),
|
|
180
182
|
});
|
|
181
|
-
// D4
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// 用户继续对话时,模型在下一轮自然看到本提醒并自行决定是否 compact。
|
|
185
|
-
// 防循环:crossed 全部已标记 fired,marker 也不会产生新 turn。
|
|
183
|
+
// D4①:nextTurn(随下一次 prompt 作为 custom role 上下文注入,不自起 run)——原 followUp
|
|
184
|
+
// 会在 agent 空闲后自起一个提醒 run,与用户消息同抢 prompt 跑道(故事 C 的三重放大器之一)。
|
|
185
|
+
// 防循环:crossed 全部已标记 fired,marker 与提醒触发的 settled 不会重复发。
|
|
186
186
|
// 事件回调内直接调用捕获的 pi——session 替换窗口可能 stale(crash-resilience D1
|
|
187
187
|
// 普查接入点),守卫 stale 静默降级(不杀 pi 进程),非 stale 错误原样上抛。
|
|
188
188
|
guardStaleCtx(() => {
|
|
189
|
-
pi
|
|
190
|
-
{ customType: THRESHOLD_REMINDER_CUSTOM_TYPE, content: message, display: false },
|
|
191
|
-
{ triggerTurn: false },
|
|
192
|
-
);
|
|
189
|
+
sendSmartContextNotice(pi, message, "threshold-reminder");
|
|
193
190
|
}, {
|
|
194
191
|
isCtxStale,
|
|
195
192
|
label: "smart-context:threshold-reminder",
|
|
@@ -216,10 +213,7 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
216
213
|
// 调整(compact 工具 execute 有运行时门控校验兜底),不为通知烧一整轮全量上下文。
|
|
217
214
|
// session 替换窗口可能 stale(D1 普查接入点)——守卫 stale 静默降级
|
|
218
215
|
guardStaleCtx(() => {
|
|
219
|
-
pi
|
|
220
|
-
{ customType: SWITCH_NOTICE_CUSTOM_TYPE, content: notice, display: false },
|
|
221
|
-
{ triggerTurn: false },
|
|
222
|
-
);
|
|
216
|
+
sendSmartContextNotice(pi, notice, "model-switch");
|
|
223
217
|
}, {
|
|
224
218
|
isCtxStale,
|
|
225
219
|
label: "smart-context:switch-notice",
|
|
@@ -240,10 +234,7 @@ export default function smartContextExtension(pi: ExtensionAPI): void {
|
|
|
240
234
|
// "建议"非紧急(同 S1 裁决):triggerTurn:false 不唤醒轮次,用户继续对话时
|
|
241
235
|
// LLM 自行决策是否先压缩。session 替换窗口可能 stale(D1 普查接入点)——守卫 stale 静默降级
|
|
242
236
|
guardStaleCtx(() => {
|
|
243
|
-
pi
|
|
244
|
-
{ customType: DOWNSHIFT_NOTICE_CUSTOM_TYPE, content: downshift, display: false },
|
|
245
|
-
{ triggerTurn: false },
|
|
246
|
-
);
|
|
237
|
+
sendSmartContextNotice(pi, downshift, "model-downshift");
|
|
247
238
|
}, {
|
|
248
239
|
isCtxStale,
|
|
249
240
|
label: "smart-context:downshift-notice",
|
package/src/notices.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通知注入唯一出口(D4①:四类通知 nextTurn 化)。
|
|
3
|
+
*
|
|
4
|
+
* 四类通知(压缩结果 onComplete / onError、阈值提醒、model_select 跨界与降档两条)从
|
|
5
|
+
* `sendUserMessage(deliverAs:'steer'|'followUp')` 改为 `sendMessage(deliverAs:'nextTurn',
|
|
6
|
+
* triggerTurn:false)`——通知作为 custom role 上下文随下一次 prompt 注入(pi 0.84.4 实证,登记
|
|
7
|
+
* docs/pi-semantics.json#PS-06:`_pendingNextTurnMessages` 常驻至被消费、不自起 run),结构上消除「通知自起 run 抢占用户
|
|
8
|
+
* 消息投递跑道」——故事 C 根因:压缩完成后通知 run 与用户消息同时 prompt(),后到者被 pi 以
|
|
9
|
+
* `Agent is already processing` 拒绝。
|
|
10
|
+
*
|
|
11
|
+
* 语义定性(设计 D4「C-ext-19 合规定性」四类两分):
|
|
12
|
+
* - 阈值提醒 / model_select 两条 = **提醒语义**(与 scheduler 2026-09-15 销账先例同类:
|
|
13
|
+
* 提醒不承载必须必达的结果);
|
|
14
|
+
* - 压缩结果 onComplete / onError = **结果语义**,本改造是**存量域内改判而非新建
|
|
15
|
+
* at-most-once 通道**:原 steer 通道即 at-most-once 且自起 run,nextTurn 化后必达性
|
|
16
|
+
* (常驻至被消费)与运行面(不自起 run)同时持平或改善;账本化正向要求的完整迁移维持
|
|
17
|
+
* pi-boundary-reliability 附录 B 挂账不变(设计 §1.3 Out)。
|
|
18
|
+
*
|
|
19
|
+
* 已接受代价(设计 D4 四要素):阈值提醒不再于 agent 空闲时立即生效,改为随用户下一条消息
|
|
20
|
+
* 注入——pi 内建 overflow/threshold 自动压缩兜底在场,无人在场时上下文可能持续增长。
|
|
21
|
+
*/
|
|
22
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
/** 通知的 customType(taiji 侧按此识别 system 消息来源;pi TUI display 恒 true)。 */
|
|
25
|
+
export const SMART_CONTEXT_NOTICE_CUSTOM_TYPE = "smart-context";
|
|
26
|
+
|
|
27
|
+
/** 通知来源(进 details.source,排障定位用)。 */
|
|
28
|
+
export type SmartContextNoticeSource =
|
|
29
|
+
| "compact-complete"
|
|
30
|
+
| "compact-failed"
|
|
31
|
+
| "threshold-reminder"
|
|
32
|
+
| "model-switch"
|
|
33
|
+
| "model-downshift";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 注入一条 smart-context 通知(nextTurn 车道,不自起 run)。
|
|
37
|
+
*
|
|
38
|
+
* `deliverAs:'nextTurn'` 是投递车道(pi 实装分支序:nextTurn 分支先命中,`triggerTurn`
|
|
39
|
+
* 不再参与判定);`triggerTurn:false` 是意图的显式声明(双写防未来实现分支序漂移)。
|
|
40
|
+
* `display:true` 使通知在对话流可见——分车道语义(D4①):compact-result 两条(complete/
|
|
41
|
+
* failed)改造前即 display:true 系统消息,可见性保持;阈值提醒/模型跨界/downshift 三条
|
|
42
|
+
* 改造前 display:false 静默(只进 LLM 上下文,不进对话流),随 nextTurn 车道统一转
|
|
43
|
+
* display:true 可见。
|
|
44
|
+
*/
|
|
45
|
+
export function sendSmartContextNotice(
|
|
46
|
+
pi: Pick<ExtensionAPI, "sendMessage">,
|
|
47
|
+
text: string,
|
|
48
|
+
source: SmartContextNoticeSource,
|
|
49
|
+
): void {
|
|
50
|
+
pi.sendMessage(
|
|
51
|
+
{
|
|
52
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
53
|
+
content: text,
|
|
54
|
+
display: true,
|
|
55
|
+
details: { source },
|
|
56
|
+
},
|
|
57
|
+
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/pure.ts
CHANGED
|
@@ -272,18 +272,6 @@ export function countCompactions(entries: ReadonlyArray<EntryLike>): number {
|
|
|
272
272
|
*/
|
|
273
273
|
export const FIRED_ENTRY_CUSTOM_TYPE = "smart-context:fired";
|
|
274
274
|
|
|
275
|
-
/** 越档提醒注入的 custom message 类型(静默通道:只进 LLM 上下文,不进对话流)。 */
|
|
276
|
-
export const THRESHOLD_REMINDER_CUSTOM_TYPE = "smart-context:threshold-reminder";
|
|
277
|
-
|
|
278
|
-
/** 模型跨界通知注入的 custom message 类型(静默通道:不触发 turn,LLM 下轮自然可见)。 */
|
|
279
|
-
export const SWITCH_NOTICE_CUSTOM_TYPE = "smart-context:switch-notice";
|
|
280
|
-
|
|
281
|
-
/** downshift 建议压缩注入的 custom message 类型(静默通道:不触发 turn,用户继续对话时 LLM 自行决策)。 */
|
|
282
|
-
export const DOWNSHIFT_NOTICE_CUSTOM_TYPE = "smart-context:downshift-notice";
|
|
283
|
-
|
|
284
|
-
/** compact 完成/失败注入的 custom message 类型(用户可见系统消息 + 唤醒一轮消费压缩结果)。 */
|
|
285
|
-
export const COMPACT_RESULT_CUSTOM_TYPE = "smart-context:compact-result";
|
|
286
|
-
|
|
287
275
|
/** fired marker 的载荷形状(写入方 index.ts 与本文件推导函数共用的唯一定义点)。 */
|
|
288
276
|
export interface FiredMarkerData {
|
|
289
277
|
/** 本次标记已提醒的档位(token 值)。 */
|
package/src/reminder.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 提醒与通知文案构造(D3/D4/D5/D13-12)。
|
|
3
3
|
*
|
|
4
|
-
* 全部为纯函数:文案 + 越档判定。注入由 src/index.ts / src/tool.ts
|
|
5
|
-
*
|
|
4
|
+
* 全部为纯函数:文案 + 越档判定。注入由 src/index.ts / src/tool.ts 经 notices.ts 的
|
|
5
|
+
* sendSmartContextNotice(nextTurn 车道,D4①)执行。
|
|
6
6
|
* 措辞原则(目标 3):提醒是数据投递不是指令——给三条件自查清单 + 明确的"可忽略"出口,
|
|
7
7
|
* 避免 agent 见提醒就压缩。
|
|
8
8
|
*/
|
package/src/tool.ts
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
* (R2 实测 2026-08-22:tool execute 内 await ctx.compact() 不可行——AgentSession.compact
|
|
6
6
|
* 开头的 abort() 会中止当前 agent 循环,挂起的 Promise 永不兑现、session 无 toolResult。
|
|
7
7
|
* 故走 §3.2 降级态:execute 立即返回"压缩已启动",onComplete/onError 后经
|
|
8
|
-
*
|
|
8
|
+
* sendSmartContextNotice 注入结果消息(nextTurn 车道,D4①——随下一次 prompt 注入,
|
|
9
|
+
* 不自起 run;此刻无进行中回合,compact 内部 abort 为 no-op)。
|
|
9
10
|
* 压缩生成由 session_before_compact 接管 handler 完成(工具只触发,不生成)。
|
|
11
|
+
*
|
|
12
|
+
* 掐断的 turn 由 runtime 兜底续跑(设计 D4②):本工具触发的 manual compact 会 abort 进行中
|
|
13
|
+
* 的 agent turn(pi F5:manual compact 明文不续跑),runtime 在 compaction_end 判定
|
|
14
|
+
* 「manual + 非 runtime 发起 + compaction_start 前 turn 被掐断」后经投递内核追加续跑投递。
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -14,11 +19,11 @@ import { guardStaleCtx, isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guar
|
|
|
14
19
|
import { Type } from "typebox";
|
|
15
20
|
|
|
16
21
|
import { debugLog } from "./compact-handler.js";
|
|
22
|
+
import { sendSmartContextNotice } from "./notices.js";
|
|
17
23
|
import { buildDegradationHintLine } from "./reminder.js";
|
|
18
24
|
import {
|
|
19
25
|
DEGRADATION_HINT_MIN_COMPACTIONS,
|
|
20
26
|
checkToolThresholdGuard,
|
|
21
|
-
COMPACT_RESULT_CUSTOM_TYPE,
|
|
22
27
|
countCompactions,
|
|
23
28
|
formatK,
|
|
24
29
|
getCurrentModelId,
|
|
@@ -161,9 +166,8 @@ export function registerCompactContextTool(
|
|
|
161
166
|
const compactionCount = countCompactions(getEntries(ctx));
|
|
162
167
|
|
|
163
168
|
// R2 降级态:fire-and-forget。工具立即返回"已启动";压缩完成后(此时无进行中
|
|
164
|
-
// 回合,compact 内部的 abort 为 no-op)经
|
|
165
|
-
// (
|
|
166
|
-
// 结果消息";若此刻仍在 streaming,pi 缺省走 steer 队列,下一 LLM 调用前投递)
|
|
169
|
+
// 回合,compact 内部的 abort 为 no-op)经 sendSmartContextNotice 注入结果
|
|
170
|
+
// (nextTurn:随下一次 prompt 作为 custom role 上下文注入,不自起 run——D4①)
|
|
167
171
|
const mode = pickMode(config, gating.modelId);
|
|
168
172
|
ctx.compact({
|
|
169
173
|
customInstructions:
|
|
@@ -172,7 +176,7 @@ export function registerCompactContextTool(
|
|
|
172
176
|
: undefined,
|
|
173
177
|
// E1 实锤崩溃点(9/3 pi-crash log):两个回调由 compact 的内部 Promise 链异步
|
|
174
178
|
// 调用,不在 pi runner emit() 的 try/catch 内——session 替换窗口(GUI 切
|
|
175
|
-
// session/新建/重载高频触发)下
|
|
179
|
+
// session/新建/重载高频触发)下 sendMessage 命中 stale ctx 同步抛错即
|
|
176
180
|
// 杀死 pi 进程(pi 0.84.4 实装:dist/core/extensions/loader.js 的 assertActive
|
|
177
181
|
// 214-219 wrapper → 142-146 staleMessage throw,在 runtime 调用前同步 throw)。
|
|
178
182
|
// 守卫 stale 静默降级(结果不投递,用户可重试 /compact),非
|
|
@@ -193,12 +197,7 @@ export function registerCompactContextTool(
|
|
|
193
197
|
`压缩前 ${formatK(result.tokensBefore ?? 0)} tokens → 压缩后约 ${formatK(result.estimatedTokensAfter ?? 0)} tokens;摘要生成成本:${cost}。`,
|
|
194
198
|
showHint ? buildDegradationHintLine() : "",
|
|
195
199
|
].filter((l) => l !== "");
|
|
196
|
-
|
|
197
|
-
// base-tool-enhance 完成通知先例(失败必须用户可见,不压 display:false)
|
|
198
|
-
pi.sendMessage(
|
|
199
|
-
{ customType: COMPACT_RESULT_CUSTOM_TYPE, content: lines.join("\n"), display: true },
|
|
200
|
-
{ triggerTurn: true },
|
|
201
|
-
);
|
|
200
|
+
sendSmartContextNotice(pi, lines.join("\n"), "compact-complete");
|
|
202
201
|
}, {
|
|
203
202
|
isCtxStale: deps?.isCtxStale,
|
|
204
203
|
label: "smart-context:compact-onComplete",
|
|
@@ -209,13 +208,10 @@ export function registerCompactContextTool(
|
|
|
209
208
|
// 压缩本身的失败信息先落日志(守卫体外——stale 降级时该观测保留)
|
|
210
209
|
debugLog(`compact_context error: ${err.message}`);
|
|
211
210
|
guardStaleCtx(() => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
display: true,
|
|
217
|
-
},
|
|
218
|
-
{ triggerTurn: true },
|
|
211
|
+
sendSmartContextNotice(
|
|
212
|
+
pi,
|
|
213
|
+
`[smart-context] 压缩失败:${err.message}。上下文未变化,可稍后重试(若反复失败,检查 smart-context 配置或使用 /compact)。`,
|
|
214
|
+
"compact-failed",
|
|
219
215
|
);
|
|
220
216
|
}, {
|
|
221
217
|
isCtxStale: deps?.isCtxStale,
|