@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 CHANGED
@@ -1,10 +1,28 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-smart-context",
3
- "version": "0.1.0",
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.2.2",
22
- "@zhushanwen/pi-llm-shared": "0.4.0"
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.1",
36
- "@earendil-works/pi-ai": "^0.84.1",
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
+ });
@@ -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
- const sm = ctx.sessionManager as unknown as {
104
- getSessionFile?: () => string | undefined;
105
- sessionFile?: string;
106
- };
117
+ // unknown 再守卫:sessionFile sessionManager 的类型未暴露字段(宽于其声明)
118
+ const sm: unknown = ctx.sessionManager;
119
+ const accessor = isSessionManagerSessionFileLike(sm) ? sm : undefined;
107
120
  try {
108
- return sm.getSessionFile?.() ?? sm.sessionFile ?? "";
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 never).messages);
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 never,
242
- model as never,
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: unknown) => Promise<{ ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> } | { ok: false; error: string }>;
83
- call?: (model: unknown, context: LlmContext, options: SimpleStreamOptions) => Promise<SimpleResponseLike>;
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: unknown) => ctx.modelRegistry.getApiKeyAndHeaders(m as never));
106
- const call = deps.call ?? ((m: unknown, c: LlmContext, o: SimpleStreamOptions) => completeSimple(m as never, c, o));
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 DEFAULT_REMINDER_THRESHOLDS: readonly number[] = [200_000, 400_000, 600_000];
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(model-switch 同源消费)
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
- const e = entry as { id?: string; message?: { role?: string; content?: unknown } };
286
+ if (!isRecord(entry)) continue;
275
287
  if (!found) {
276
- if (e.id === firstKeptEntryId) found = true;
288
+ if (entry.id === firstKeptEntryId) found = true;
277
289
  continue;
278
290
  }
279
- const msg = e.message;
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 as ReadonlyArray<{ type?: string; name?: string; arguments?: { path?: unknown } }>) {
282
- if (block.type === "toolCall" && block.name === "read" &&
283
- block.arguments && typeof block.arguments.path === "string") {
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) * 100).toFixed(1) : "?";
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 - 16_384; // pi 内建触发线(window − reserveTokens 默认值)
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 = result as { tokensBefore?: unknown; estimatedTokensAfter?: unknown; usage?: unknown; details?: unknown };
71
- const usage = (r.usage ?? null) as { input?: unknown; output?: unknown; cacheRead?: unknown } | null;
72
- const details = (r.details ?? null) as { engine?: unknown; mode?: unknown } | null;
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 && typeof details === "object"
90
+ details: details
86
91
  ? {
87
92
  engine: str(details.engine),
88
93
  mode: str(details.mode),