@zhushanwen/pi-smart-context 0.1.0 → 0.1.2
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 +24 -6
- package/src/__tests__/startup-config-declaration.test.ts +34 -0
- package/src/compact-handler.ts +24 -9
- package/src/llm.ts +4 -4
- package/src/pure.ts +22 -8
- package/src/reminder.ts +7 -2
- package/src/tool.ts +9 -4
package/package.json
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-smart-context",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
7
|
-
"role": "universal"
|
|
7
|
+
"role": "universal",
|
|
8
|
+
"startupConfig": [
|
|
9
|
+
{
|
|
10
|
+
"path": "config/smart-context-ext-config.json",
|
|
11
|
+
"content": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"compactModel": {
|
|
14
|
+
"type": "ref",
|
|
15
|
+
"ref": ""
|
|
16
|
+
},
|
|
17
|
+
"reminderThresholds": [
|
|
18
|
+
200000,
|
|
19
|
+
400000,
|
|
20
|
+
600000
|
|
21
|
+
],
|
|
22
|
+
"excludedModels": []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
]
|
|
8
26
|
},
|
|
9
27
|
"pi": {
|
|
10
28
|
"extensions": [
|
|
@@ -18,8 +36,8 @@
|
|
|
18
36
|
"pi-package"
|
|
19
37
|
],
|
|
20
38
|
"dependencies": {
|
|
21
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
22
|
-
"@zhushanwen/pi-llm-shared": "0.4.
|
|
39
|
+
"@zhushanwen/pi-extension-logger": "0.3.1",
|
|
40
|
+
"@zhushanwen/pi-llm-shared": "0.4.2"
|
|
23
41
|
},
|
|
24
42
|
"devDependencies": {
|
|
25
43
|
"@vitest/coverage-v8": "^4.1.9",
|
|
@@ -32,8 +50,8 @@
|
|
|
32
50
|
"vitest.config.ts"
|
|
33
51
|
],
|
|
34
52
|
"peerDependencies": {
|
|
35
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
36
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
53
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
54
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
37
55
|
"typebox": "*"
|
|
38
56
|
},
|
|
39
57
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startupConfig 声明守护测试:断言 package.json `xyz-agent.startupConfig` 声明的
|
|
3
|
+
* content 与代码 DEFAULT_SMART_CONTEXT_CONFIG 常量深相等,防止两侧任一改动未同步(漂移)。
|
|
4
|
+
* 机制:runtime 启动序列统一 ensure(extension-startup-config.ts),声明即首建内容。
|
|
5
|
+
*
|
|
6
|
+
* 运行:cd extensions/universal/smart-context && npx vitest run src/__tests__/startup-config-declaration.test.ts
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_SMART_CONTEXT_CONFIG } from "../pure.js";
|
|
15
|
+
|
|
16
|
+
/** package.json `xyz-agent.startupConfig` 声明条目的最小形状(守护断言用)。 */
|
|
17
|
+
interface StartupConfigEntry {
|
|
18
|
+
path: string;
|
|
19
|
+
content: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pkg = JSON.parse(
|
|
23
|
+
readFileSync(join(fileURLToPath(import.meta.url), "../../../package.json"), "utf-8"),
|
|
24
|
+
) as { "xyz-agent"?: { startupConfig?: StartupConfigEntry[] } };
|
|
25
|
+
|
|
26
|
+
describe("startupConfig 声明守护", () => {
|
|
27
|
+
it("声明 content 与代码 DEFAULT_SMART_CONTEXT_CONFIG 深相等", () => {
|
|
28
|
+
const entry = pkg["xyz-agent"]?.startupConfig?.find(
|
|
29
|
+
(e: StartupConfigEntry) => e.path === "config/smart-context-ext-config.json",
|
|
30
|
+
);
|
|
31
|
+
expect(entry).toBeDefined();
|
|
32
|
+
expect(entry?.content).toEqual(DEFAULT_SMART_CONTEXT_CONFIG);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/compact-handler.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { buildSessionContext, compact as nativeCompact, convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import type { CompactionResult } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { createLogger } from "@zhushanwen/pi-extension-logger";
|
|
16
16
|
import { resolveModel } from "@zhushanwen/pi-llm-shared";
|
|
@@ -95,17 +95,30 @@ export interface SmartContextDetails {
|
|
|
95
95
|
mode: "same-model" | "cross-model";
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** sessionManager 上会话文件字段/方法的宽松形状(D13-4,消费侧可选链兜底)。 */
|
|
99
|
+
interface SessionManagerSessionFileLike {
|
|
100
|
+
getSessionFile?: () => string | undefined;
|
|
101
|
+
sessionFile?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* sessionManager 会话文件访问形状的运行时守卫(D13-4):
|
|
106
|
+
* 只收窄到对象(字段存在性/类型由消费侧可选链 + ?? 兜底,与原语义一致)。
|
|
107
|
+
*/
|
|
108
|
+
function isSessionManagerSessionFileLike(sm: unknown): sm is SessionManagerSessionFileLike {
|
|
109
|
+
return typeof sm === "object" && sm !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
98
112
|
/**
|
|
99
113
|
* session 文件路径(transcript 回查指针用,D13-4):从 sessionManager 推导。
|
|
100
114
|
* getSessionFile 若不可得则返回空串(指针省略,不失败)。
|
|
101
115
|
*/
|
|
102
116
|
function getSessionFilePath(ctx: ExtensionContext): string {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
};
|
|
117
|
+
// 经 unknown 再守卫:sessionFile 是 sessionManager 的类型未暴露字段(宽于其声明)
|
|
118
|
+
const sm: unknown = ctx.sessionManager;
|
|
119
|
+
const accessor = isSessionManagerSessionFileLike(sm) ? sm : undefined;
|
|
107
120
|
try {
|
|
108
|
-
return
|
|
121
|
+
return accessor?.getSessionFile?.() ?? accessor?.sessionFile ?? "";
|
|
109
122
|
} catch {
|
|
110
123
|
return "";
|
|
111
124
|
}
|
|
@@ -165,7 +178,7 @@ async function generateSameMode(
|
|
|
165
178
|
}
|
|
166
179
|
// AgentMessage[](含 bash/custom 等扩展消息)→ 标准 Message[](与主会话请求同源转换,
|
|
167
180
|
// convertToLlm 是 pi host 默认实现——同样的输入产生同样的输出,前缀缓存对齐的前提)
|
|
168
|
-
const fullMessages = convertToLlm(buildSessionContext(event.branchEntries as
|
|
181
|
+
const fullMessages = convertToLlm(buildSessionContext(event.branchEntries as SessionEntry[]).messages);
|
|
169
182
|
const instructionMessage = {
|
|
170
183
|
role: "user" as const,
|
|
171
184
|
content: [{ type: "text" as const, text: buildSameModelInstruction(event.customInstructions) }],
|
|
@@ -237,9 +250,11 @@ async function generateCrossMode(
|
|
|
237
250
|
Object.entries(auth.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
238
251
|
)
|
|
239
252
|
: undefined;
|
|
253
|
+
// preparation:BeforeCompactLikeEvent 的宽松形状是 nativeCompact 入参(CompactionPreparation,
|
|
254
|
+
// 含 settings 等未消费字段)的结构子集——运行时是 pi 原生事件对象,此处单层收窄断言
|
|
240
255
|
const result = await nativeCompact(
|
|
241
|
-
event.preparation as
|
|
242
|
-
model
|
|
256
|
+
event.preparation as Parameters<typeof nativeCompact>[0],
|
|
257
|
+
model,
|
|
243
258
|
auth.apiKey,
|
|
244
259
|
headers,
|
|
245
260
|
event.customInstructions,
|
package/src/llm.ts
CHANGED
|
@@ -79,8 +79,8 @@ export interface SameModelCallOptions {
|
|
|
79
79
|
sessionId?: string;
|
|
80
80
|
/** 工具投影与 LLM 调用的依赖注入(单测 mock 点)。 */
|
|
81
81
|
deps?: {
|
|
82
|
-
getApiKeyAndHeaders?: (model:
|
|
83
|
-
call?: (model:
|
|
82
|
+
getApiKeyAndHeaders?: (model: Model<never> | Model<string>) => Promise<{ ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> } | { ok: false; error: string }>;
|
|
83
|
+
call?: (model: Model<never> | Model<string>, context: LlmContext, options: SimpleStreamOptions) => Promise<SimpleResponseLike>;
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -102,8 +102,8 @@ export async function callSameModelCompaction(
|
|
|
102
102
|
opts: SameModelCallOptions,
|
|
103
103
|
): Promise<SameModelCallResult> {
|
|
104
104
|
const deps = opts.deps ?? {};
|
|
105
|
-
const getAuth = deps.getApiKeyAndHeaders ?? ((m:
|
|
106
|
-
const call = deps.call ?? ((m:
|
|
105
|
+
const getAuth = deps.getApiKeyAndHeaders ?? ((m: Model<never> | Model<string>) => ctx.modelRegistry.getApiKeyAndHeaders(m));
|
|
106
|
+
const call = deps.call ?? ((m: Model<never> | Model<string>, c: LlmContext, o: SimpleStreamOptions) => completeSimple(m, c, o));
|
|
107
107
|
try {
|
|
108
108
|
if (!opts.model) {
|
|
109
109
|
return { ok: false, error: "no current model" };
|
package/src/pure.ts
CHANGED
|
@@ -18,7 +18,14 @@ export const CHARS_PER_TOKEN_ESTIMATE = 4;
|
|
|
18
18
|
const MAX_THRESHOLD_TIERS = 3;
|
|
19
19
|
|
|
20
20
|
/** 3 档提醒阈值默认值(token 绝对数):200K / 400K / 600K。 */
|
|
21
|
-
const
|
|
21
|
+
const DEFAULT_REMINDER_THRESHOLD_TIER_1_TOKENS = 200_000;
|
|
22
|
+
const DEFAULT_REMINDER_THRESHOLD_TIER_2_TOKENS = 400_000;
|
|
23
|
+
const DEFAULT_REMINDER_THRESHOLD_TIER_3_TOKENS = 600_000;
|
|
24
|
+
const DEFAULT_REMINDER_THRESHOLDS: readonly number[] = [
|
|
25
|
+
DEFAULT_REMINDER_THRESHOLD_TIER_1_TOKENS,
|
|
26
|
+
DEFAULT_REMINDER_THRESHOLD_TIER_2_TOKENS,
|
|
27
|
+
DEFAULT_REMINDER_THRESHOLD_TIER_3_TOKENS,
|
|
28
|
+
];
|
|
22
29
|
|
|
23
30
|
/** smart-context 磁盘配置(<agentDir>/config/smart-context-ext-config.json)。 */
|
|
24
31
|
export interface SmartContextConfig {
|
|
@@ -88,7 +95,7 @@ export function loadSmartContextConfig(): SmartContextConfig {
|
|
|
88
95
|
|
|
89
96
|
// ──────────────────────── 门控判定(D5 矩阵) ────────────────────────
|
|
90
97
|
|
|
91
|
-
// 当前模型 ID 拼接口径单点在 llm-shared
|
|
98
|
+
// 当前模型 ID 拼接口径单点在 llm-shared
|
|
92
99
|
export { getCurrentModelId } from "@zhushanwen/pi-llm-shared";
|
|
93
100
|
|
|
94
101
|
/**
|
|
@@ -263,6 +270,11 @@ export function countCompactions(entries: ReadonlyArray<EntryLike>): number {
|
|
|
263
270
|
return entries.filter((e) => e.type === "compaction").length;
|
|
264
271
|
}
|
|
265
272
|
|
|
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
|
+
|
|
266
278
|
/**
|
|
267
279
|
* 保留段已 Read 的文件集合(D13-11 去重:重注入跳过保留段已有的 Read 结果)。
|
|
268
280
|
* 保留段 = branchEntries 中 firstKeptEntryId 之后的 message entries;从其 toolCall 参数提取 path。
|
|
@@ -271,16 +283,18 @@ export function collectKeptReadFiles(branchEntries: ReadonlyArray<unknown>, firs
|
|
|
271
283
|
const kept = new Set<string>();
|
|
272
284
|
let found = false;
|
|
273
285
|
for (const entry of branchEntries) {
|
|
274
|
-
|
|
286
|
+
if (!isRecord(entry)) continue;
|
|
275
287
|
if (!found) {
|
|
276
|
-
if (
|
|
288
|
+
if (entry.id === firstKeptEntryId) found = true;
|
|
277
289
|
continue;
|
|
278
290
|
}
|
|
279
|
-
const msg =
|
|
291
|
+
const msg = isRecord(entry.message) ? entry.message : undefined;
|
|
280
292
|
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
281
|
-
for (const block of msg.content
|
|
282
|
-
if (
|
|
283
|
-
block.
|
|
293
|
+
for (const block of msg.content) {
|
|
294
|
+
if (
|
|
295
|
+
isRecord(block) && block.type === "toolCall" && block.name === "read" &&
|
|
296
|
+
isRecord(block.arguments) && typeof block.arguments.path === "string"
|
|
297
|
+
) {
|
|
284
298
|
kept.add(block.arguments.path);
|
|
285
299
|
}
|
|
286
300
|
}
|
package/src/reminder.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
formatK,
|
|
12
12
|
} from "./pure.js";
|
|
13
13
|
|
|
14
|
+
/** 百分比换算基数(tokens/contextWindow 比例 → 百分数显示)。 */
|
|
15
|
+
const PERCENT_SCALE = 100;
|
|
16
|
+
/** pi 内建自动压缩触发线的 reserveTokens 默认值(window − 16K)。 */
|
|
17
|
+
const PI_DEFAULT_RESERVE_TOKENS = 16_384;
|
|
18
|
+
|
|
14
19
|
/**
|
|
15
20
|
* 阈值提醒消息(D3/D4):越档信息 + 用量数据 + 工具名 + 三条件自查 + 可忽略出口。
|
|
16
21
|
* 多档同时越过合并为一条(D3 去重规则)。
|
|
@@ -21,7 +26,7 @@ export function buildThresholdReminder(
|
|
|
21
26
|
contextWindow: number,
|
|
22
27
|
compactionCount: number,
|
|
23
28
|
): string {
|
|
24
|
-
const percent = contextWindow > 0 ? ((tokens / contextWindow) *
|
|
29
|
+
const percent = contextWindow > 0 ? ((tokens / contextWindow) * PERCENT_SCALE).toFixed(1) : "?";
|
|
25
30
|
const tiers = crossedThresholds
|
|
26
31
|
.map((t, index) => `${formatK(t)}(第 ${index + 1} 档)`)
|
|
27
32
|
.join("、");
|
|
@@ -67,7 +72,7 @@ export function buildDownshiftNotice(
|
|
|
67
72
|
return null;
|
|
68
73
|
}
|
|
69
74
|
if (newWindow >= previousWindow) return null;
|
|
70
|
-
const triggerLine = newWindow -
|
|
75
|
+
const triggerLine = newWindow - PI_DEFAULT_RESERVE_TOKENS; // pi 内建触发线(window − reserveTokens 默认值)
|
|
71
76
|
if (tokens < triggerLine) return null;
|
|
72
77
|
return `[smart-context] 当前上下文 ${formatK(tokens)} tokens,已接近新模型窗口上限(${formatK(newWindow)})。建议尽快压缩(调用 compact_context 或 /compact),否则内建自动压缩将在触线时强制执行。`;
|
|
73
78
|
}
|
package/src/tool.ts
CHANGED
|
@@ -65,11 +65,16 @@ const TOOL_DESCRIPTION =
|
|
|
65
65
|
"2) 后续工作不再依赖将被压缩的早期细节;3) 上下文已超过提醒阈值(你会收到 [smart-context 提示])。" +
|
|
66
66
|
"若任一条件不满足,不要调用。";
|
|
67
67
|
|
|
68
|
+
/** unknown 的对象收窄(Record 视图;字段消费再经 typeof 收窄)。 */
|
|
69
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null;
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
/** CompactionResult → 宽松形状的 guard 转换(onComplete 回调参数消费,避免 cast)。 */
|
|
69
74
|
function toCompactionResultLike(result: unknown): CompactionResultLike {
|
|
70
|
-
const r
|
|
71
|
-
const usage = (r.usage
|
|
72
|
-
const details = (r.details
|
|
75
|
+
const r: Record<string, unknown> = isRecord(result) ? result : {};
|
|
76
|
+
const usage = isRecord(r.usage) ? r.usage : null;
|
|
77
|
+
const details = isRecord(r.details) ? r.details : null;
|
|
73
78
|
const num = (v: unknown) => (typeof v === "number" ? v : undefined);
|
|
74
79
|
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
75
80
|
return {
|
|
@@ -82,7 +87,7 @@ function toCompactionResultLike(result: unknown): CompactionResultLike {
|
|
|
82
87
|
cacheRead: num(usage.cacheRead),
|
|
83
88
|
}
|
|
84
89
|
: undefined,
|
|
85
|
-
details: details
|
|
90
|
+
details: details
|
|
86
91
|
? {
|
|
87
92
|
engine: str(details.engine),
|
|
88
93
|
mode: str(details.mode),
|