@zhushanwen/pi-smart-context 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/index.ts +1 -0
- package/package.json +54 -0
- package/skills/smart-context-ext-config/SKILL.md +56 -0
- package/src/__tests__/compact-handler.test.ts +141 -0
- package/src/__tests__/pure.test.ts +144 -0
- package/src/__tests__/reminder.test.ts +83 -0
- package/src/__tests__/sdk-contract.test.ts +66 -0
- package/src/__tests__/tool.test.ts +144 -0
- package/src/compact-handler.ts +321 -0
- package/src/index.ts +160 -0
- package/src/llm.ts +139 -0
- package/src/prompts.ts +70 -0
- package/src/pure.ts +311 -0
- package/src/reminder.ts +73 -0
- package/src/tool.ts +206 -0
- package/vitest.config.ts +7 -0
package/src/llm.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* same-model 模式的 LLM 调用(D12 + D13-5 cache-key 一致性)。
|
|
3
|
+
*
|
|
4
|
+
* 不走 llm-shared callLLM:其 tools:[] 硬编码会破坏前缀缓存对齐(call.ts:113)。
|
|
5
|
+
* 此处直接用 completeSimple + getApiKeyAndHeaders,并把 tools schema 与主会话对齐
|
|
6
|
+
* (deepseek-harness summarizer 同款做法:system + tools + messages 全部复用做缓存对齐)。
|
|
7
|
+
*
|
|
8
|
+
* cache-key 一致性约束(D13-5):除末尾追加的压缩指令 user message 外,
|
|
9
|
+
* systemPrompt / tools / messages 前缀与主会话完全一致;不设 maxTokens / reasoning 覆盖
|
|
10
|
+
* (Claude Code 教训:单独设置 maxOutputTokens/thinking 会造成 cache-key mismatch 前缀全 miss)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
14
|
+
import type { Context as LlmContext, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* ToolInfo → pi-ai Tool 投影:三字段直取。parameters 是 typebox schema(主会话同源对象,
|
|
27
|
+
* 序列化后与主请求一致——缓存对齐的关键是不改造、原样透传)。
|
|
28
|
+
*/
|
|
29
|
+
export function projectTools(toolInfos: readonly ToolInfoLike[]): LlmTool[] {
|
|
30
|
+
return toolInfos.map((t) => ({
|
|
31
|
+
name: t.name,
|
|
32
|
+
description: t.description,
|
|
33
|
+
parameters: t.parameters as LlmTool["parameters"],
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** completeSimple 返回的宽松形状(消费 stopReason / usage / content text)。 */
|
|
38
|
+
interface SimpleResponseLike {
|
|
39
|
+
stopReason?: string;
|
|
40
|
+
usage?: {
|
|
41
|
+
input?: number;
|
|
42
|
+
output?: number;
|
|
43
|
+
cacheRead?: number;
|
|
44
|
+
cacheWrite?: number;
|
|
45
|
+
totalTokens?: number;
|
|
46
|
+
};
|
|
47
|
+
content: ReadonlyArray<{ type: string; text?: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* same-model 调用结果(判别联合):成功携带摘要文本与 usage;失败仅携带 error
|
|
52
|
+
* (调用方走 D7 回退),stopReason 两态均可携带(length 截断 fail-closed / 排查用)。
|
|
53
|
+
*/
|
|
54
|
+
export type SameModelCallResult =
|
|
55
|
+
| {
|
|
56
|
+
ok: true;
|
|
57
|
+
/** 摘要文本(content 内全部 text block 拼接,D13-10 只取 text)。 */
|
|
58
|
+
text: string;
|
|
59
|
+
/** provider usage(cacheRead 供 R8 探针验证缓存命中)。 */
|
|
60
|
+
usage?: SimpleResponseLike["usage"];
|
|
61
|
+
/** stopReason(length = max-tokens 截断,D13-2 fail-closed 判据)。 */
|
|
62
|
+
stopReason?: string;
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
ok: false;
|
|
66
|
+
error: string;
|
|
67
|
+
stopReason?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export interface SameModelCallOptions {
|
|
71
|
+
model: Model<never> | Model<string> | undefined;
|
|
72
|
+
/** 会话原 system prompt(ctx.getSystemPrompt()——缓存对齐必要条件)。 */
|
|
73
|
+
systemPrompt: string;
|
|
74
|
+
/** 完整上下文 messages + 末尾已追加的压缩指令 message。 */
|
|
75
|
+
messages: Message[];
|
|
76
|
+
/** 主会话工具投影(缓存对齐)。 */
|
|
77
|
+
tools: LlmTool[];
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
sessionId?: string;
|
|
80
|
+
/** 工具投影与 LLM 调用的依赖注入(单测 mock 点)。 */
|
|
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>;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* content block 的 text 提取(in-guard:联合中 ThinkingContent/ToolCall 无 text 字段)。
|
|
89
|
+
* 与 llm-shared extractText 刻意不同:不过滤 block type、join 用 "\n"——摘要需保留
|
|
90
|
+
* 多段换行结构(extractText 过滤 type==="text" 且 join 空格,语义是单段纯文本)。
|
|
91
|
+
*/
|
|
92
|
+
function blockText(block: { type: string; text?: unknown }): string {
|
|
93
|
+
return typeof block.text === "string" ? block.text : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 发起 same-model 压缩调用。失败归一为 {ok:false, error}(调用方走 D7 回退),不抛错。
|
|
98
|
+
* max-tokens 截断(stopReason === "length")返回 ok:true + stopReason 由调用方 fail-closed。
|
|
99
|
+
*/
|
|
100
|
+
export async function callSameModelCompaction(
|
|
101
|
+
ctx: ExtensionContext,
|
|
102
|
+
opts: SameModelCallOptions,
|
|
103
|
+
): Promise<SameModelCallResult> {
|
|
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));
|
|
107
|
+
try {
|
|
108
|
+
if (!opts.model) {
|
|
109
|
+
return { ok: false, error: "no current model" };
|
|
110
|
+
}
|
|
111
|
+
const auth = await getAuth(opts.model);
|
|
112
|
+
if (!auth.ok) {
|
|
113
|
+
return { ok: false, error: auth.error };
|
|
114
|
+
}
|
|
115
|
+
const context: LlmContext = {
|
|
116
|
+
systemPrompt: opts.systemPrompt,
|
|
117
|
+
messages: opts.messages,
|
|
118
|
+
tools: opts.tools,
|
|
119
|
+
};
|
|
120
|
+
// cache-key 一致性(D13-5):不设 maxTokens / reasoning / timeoutMs 覆盖——
|
|
121
|
+
// 任何 cache-key 参数差异都使前缀缓存整体失效
|
|
122
|
+
const options: SimpleStreamOptions = {
|
|
123
|
+
apiKey: auth.apiKey,
|
|
124
|
+
headers: auth.headers,
|
|
125
|
+
env: auth.env,
|
|
126
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
127
|
+
...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
|
|
128
|
+
};
|
|
129
|
+
const resp = await call(opts.model, context, options);
|
|
130
|
+
if (resp.stopReason === "error" || resp.stopReason === "aborted") {
|
|
131
|
+
const errorText = resp.content.map(blockText).join(" ").trim();
|
|
132
|
+
return { ok: false, error: errorText || `stopReason=${resp.stopReason}`, stopReason: resp.stopReason };
|
|
133
|
+
}
|
|
134
|
+
const text = resp.content.map(blockText).join("\n").trim();
|
|
135
|
+
return { ok: true, text, usage: resp.usage, stopReason: resp.stopReason };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 压缩 prompt 工程(D13-6/7/8/9/12 + D12 same-model 压缩指令)。
|
|
3
|
+
*
|
|
4
|
+
* 全部为纯常量/纯函数。措辞吸收自 Claude Code / Codex / deepseek-harness(附录 B 吸收记录),
|
|
5
|
+
* 实施期按摘要质量迭代(§5.3-3)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 落回包裹语(D13-9):消除压缩后"确认收到摘要"的浪费回合。 */
|
|
9
|
+
export const CHECKPOINT_PREAMBLE =
|
|
10
|
+
"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.";
|
|
11
|
+
|
|
12
|
+
/** same-model 压缩指令的结构化模板(D13-7:pi 原生 6 节 + Files and Code + Errors and Fixes)。 */
|
|
13
|
+
const CHECKPOINT_TEMPLATE = `## Goal
|
|
14
|
+
- [the user's original and evolving goals; quote verbatim where the exact wording matters]
|
|
15
|
+
|
|
16
|
+
## Constraints & Preferences
|
|
17
|
+
- [constraints, conventions, and user preferences in play]
|
|
18
|
+
|
|
19
|
+
## Progress
|
|
20
|
+
- Done: [completed work with verification status]
|
|
21
|
+
- In Progress: [what was underway at this checkpoint]
|
|
22
|
+
- Blocked: [blocked items and why]
|
|
23
|
+
|
|
24
|
+
## Key Decisions
|
|
25
|
+
- [decisions made and their rationale]
|
|
26
|
+
|
|
27
|
+
## Files and Code
|
|
28
|
+
- [exact path: why it matters, key changes or snippets]
|
|
29
|
+
|
|
30
|
+
## Errors and Fixes
|
|
31
|
+
- [error: how it was resolved, plus any related user feedback/corrections]
|
|
32
|
+
|
|
33
|
+
## Next Steps
|
|
34
|
+
- [the single next action, directly in line with the most recent request, or "(none)"]
|
|
35
|
+
|
|
36
|
+
## Critical Context
|
|
37
|
+
- [data, examples, or references needed to continue]`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* same-model 模式的压缩指令 user message(追加在完整上下文末尾,D12)。
|
|
41
|
+
*
|
|
42
|
+
* - 首尾 TEXT ONLY 双保险(D13-6,防模型在压缩调用里乱调工具)
|
|
43
|
+
* - 先验 checkpoint 合并规则(D13-8,输入里已有上次摘要时:不逐字复制、保留仍真、丢弃过时)
|
|
44
|
+
* - custom_instructions(agent 的重点关注)非空时追加
|
|
45
|
+
*/
|
|
46
|
+
export function buildSameModelInstruction(customInstructions?: string): string {
|
|
47
|
+
const prior = `If the conversation above already contains a checkpoint summary from an earlier compaction, do NOT copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated checkpoint under the same structure.`;
|
|
48
|
+
const focus = customInstructions?.trim()
|
|
49
|
+
? `\n\nAdditional focus from the calling agent (weight these higher):\n${customInstructions.trim()}`
|
|
50
|
+
: "";
|
|
51
|
+
return [
|
|
52
|
+
`CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. This is a compaction request.`,
|
|
53
|
+
``,
|
|
54
|
+
`You are now acting as a compaction engine for this AI coding session. Condense the conversation ABOVE into a structured checkpoint that lets the next model turn resume the work with no loss of essential context.`,
|
|
55
|
+
``,
|
|
56
|
+
prior,
|
|
57
|
+
``,
|
|
58
|
+
`Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section. Preserve exact file paths, commands, error strings, identifiers, numeric values, and function signatures.`,
|
|
59
|
+
``,
|
|
60
|
+
CHECKPOINT_TEMPLATE,
|
|
61
|
+
focus,
|
|
62
|
+
``,
|
|
63
|
+
`REMINDER: Respond with TEXT ONLY. Do NOT call any tools. Output only the checkpoint text.`,
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** transcript 回查指针(D13-4):附在 summary 末尾。 */
|
|
68
|
+
export function buildTranscriptPointer(sessionFilePath: string): string {
|
|
69
|
+
return `\n\n<transcript-ref>Need details from before this compaction? Read the full session transcript at: ${sessionFilePath}</transcript-ref>`;
|
|
70
|
+
}
|
package/src/pure.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯函数层:配置 schema / 加载 / 门控判定 / 阈值检查 / 摘要后处理。
|
|
3
|
+
*
|
|
4
|
+
* 无副作用(fs 读取经 llm-shared loadConfig 的缓存封装),全部可单测。
|
|
5
|
+
* 设计文档:docs/extensions/smart-context/design.md(D5 门控矩阵 / D6 阈值保护 / D8 配置 schema)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ModelSelector } from "@zhushanwen/pi-llm-shared";
|
|
9
|
+
import { loadConfig } from "@zhushanwen/pi-llm-shared";
|
|
10
|
+
|
|
11
|
+
// ──────────────────────── 配置 schema(D8) ────────────────────────
|
|
12
|
+
|
|
13
|
+
/** 单 K 的 token 数(显示格式化用)。 */
|
|
14
|
+
const TOKENS_PER_K = 1_000;
|
|
15
|
+
/** chars/4 的 token 估算口径(对齐 pi estimateTokens 启发式)。 */
|
|
16
|
+
export const CHARS_PER_TOKEN_ESTIMATE = 4;
|
|
17
|
+
/** 提醒阈值最大档数(3 档)。 */
|
|
18
|
+
const MAX_THRESHOLD_TIERS = 3;
|
|
19
|
+
|
|
20
|
+
/** 3 档提醒阈值默认值(token 绝对数):200K / 400K / 600K。 */
|
|
21
|
+
const DEFAULT_REMINDER_THRESHOLDS: readonly number[] = [200_000, 400_000, 600_000];
|
|
22
|
+
|
|
23
|
+
/** smart-context 磁盘配置(<agentDir>/config/smart-context-ext-config.json)。 */
|
|
24
|
+
export interface SmartContextConfig {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
/** 压缩模型 ref;与当前会话模型一致 → same-model 模式(D12)。 */
|
|
27
|
+
compactModel: ModelSelector;
|
|
28
|
+
/** 3 档提醒阈值(token 绝对数,升序)。 */
|
|
29
|
+
reminderThresholds: number[];
|
|
30
|
+
/** 排除模型列表:完整 provider/modelId 精准等值匹配(D5)。 */
|
|
31
|
+
excludedModels: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_SMART_CONTEXT_CONFIG: SmartContextConfig = {
|
|
35
|
+
enabled: true,
|
|
36
|
+
compactModel: { type: "ref", ref: "" },
|
|
37
|
+
reminderThresholds: [...DEFAULT_REMINDER_THRESHOLDS],
|
|
38
|
+
excludedModels: [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 配置 normalize:字段缺失/非法回退默认值(向后兼容,规范要求 deserializeState 同款纪律)。
|
|
43
|
+
* - reminderThresholds:过滤非正数 → 升序 → 截 3 档;空数组回退默认
|
|
44
|
+
* - excludedModels:过滤非字符串与不含 "/" 的条目(精准匹配要求完整 provider/modelId)→ 去重
|
|
45
|
+
* - compactModel:ref 非字符串按空串
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeSmartContextConfig(raw: unknown): SmartContextConfig {
|
|
48
|
+
const base = DEFAULT_SMART_CONTEXT_CONFIG;
|
|
49
|
+
if (typeof raw !== "object" || raw === null) return { ...base, reminderThresholds: [...base.reminderThresholds] };
|
|
50
|
+
const r = raw as Record<string, unknown>;
|
|
51
|
+
|
|
52
|
+
const enabled = typeof r.enabled === "boolean" ? r.enabled : base.enabled;
|
|
53
|
+
|
|
54
|
+
const rawModel = typeof r.compactModel === "object" && r.compactModel !== null
|
|
55
|
+
? (r.compactModel as Record<string, unknown>)
|
|
56
|
+
: null;
|
|
57
|
+
const compactModel: ModelSelector =
|
|
58
|
+
rawModel?.type === "ref" && typeof rawModel.ref === "string"
|
|
59
|
+
? { type: "ref", ref: rawModel.ref }
|
|
60
|
+
: { type: "ref", ref: "" };
|
|
61
|
+
|
|
62
|
+
const rawThresholds = Array.isArray(r.reminderThresholds)
|
|
63
|
+
? r.reminderThresholds
|
|
64
|
+
: [];
|
|
65
|
+
const thresholds = rawThresholds
|
|
66
|
+
.filter((t): t is number => typeof t === "number" && Number.isFinite(t) && t > 0)
|
|
67
|
+
.sort((a, b) => a - b)
|
|
68
|
+
.slice(0, MAX_THRESHOLD_TIERS);
|
|
69
|
+
const reminderThresholds = thresholds.length > 0 ? thresholds : [...base.reminderThresholds];
|
|
70
|
+
|
|
71
|
+
const rawExcluded = Array.isArray(r.excludedModels) ? r.excludedModels : [];
|
|
72
|
+
const excludedModels = [
|
|
73
|
+
...new Set(
|
|
74
|
+
rawExcluded.filter((m): m is string => typeof m === "string" && m.includes("/")),
|
|
75
|
+
),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
return { enabled, compactModel, reminderThresholds, excludedModels };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 读取配置(llm-shared loadConfig:mtime+size 读时刷新,热重载契约禁止上层缓存)。
|
|
83
|
+
* 文件不存在/损坏 → 默认值。
|
|
84
|
+
*/
|
|
85
|
+
export function loadSmartContextConfig(): SmartContextConfig {
|
|
86
|
+
return loadConfig("smart-context", DEFAULT_SMART_CONTEXT_CONFIG, normalizeSmartContextConfig);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ──────────────────────── 门控判定(D5 矩阵) ────────────────────────
|
|
90
|
+
|
|
91
|
+
// 当前模型 ID 拼接口径单点在 llm-shared(model-switch 同源消费)
|
|
92
|
+
export { getCurrentModelId } from "@zhushanwen/pi-llm-shared";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 门控是否放行(D5 矩阵第一列):enabled 且当前模型未精准命中排除列表。
|
|
96
|
+
* 每次事件回调现场调用(热读配置),不缓存。
|
|
97
|
+
*/
|
|
98
|
+
export function isGatingActive(config: SmartContextConfig, currentModelId: string): boolean {
|
|
99
|
+
if (!config.enabled) return false;
|
|
100
|
+
if (currentModelId === "") return false;
|
|
101
|
+
return !config.excludedModels.includes(currentModelId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 模式判定(D12):compactModel.ref 等于当前模型(或未配置 = 跟随当前模型)→ "same-model";
|
|
106
|
+
* 否则 "cross-model"。
|
|
107
|
+
*/
|
|
108
|
+
export function pickMode(config: SmartContextConfig, currentModelId: string): "same-model" | "cross-model" {
|
|
109
|
+
const ref = config.compactModel.ref;
|
|
110
|
+
if (ref === "" || ref === currentModelId) return "same-model";
|
|
111
|
+
return "cross-model";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ──────────────────────── 阈值检查(D3/D6) ────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 越档检查:返回本次应提醒的档位(已 fired 的排除;多档合并由调用方组装成一条消息)。
|
|
118
|
+
* tokens 为 null(压缩后首响应前,R7)→ 空数组(跳过本轮检查)。
|
|
119
|
+
*/
|
|
120
|
+
export function findCrossedThresholds(
|
|
121
|
+
thresholds: readonly number[],
|
|
122
|
+
tokens: number | null | undefined,
|
|
123
|
+
fired: ReadonlySet<number>,
|
|
124
|
+
): number[] {
|
|
125
|
+
if (typeof tokens !== "number" || !Number.isFinite(tokens)) return [];
|
|
126
|
+
return thresholds.filter((t) => tokens >= t && !fired.has(t));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 工具最低阈值保护(D6):返回 null = 放行;返回字符串 = 拒绝原因(含当前用量数据)。
|
|
131
|
+
* tokens 为 null → 拒绝("用量未知",null 窗口期紧随压缩完成,不应再压)。
|
|
132
|
+
*/
|
|
133
|
+
export function checkToolThresholdGuard(
|
|
134
|
+
thresholds: readonly number[],
|
|
135
|
+
tokens: number | null | undefined,
|
|
136
|
+
): string | null {
|
|
137
|
+
const min = Math.min(...thresholds);
|
|
138
|
+
if (typeof tokens !== "number" || !Number.isFinite(tokens)) {
|
|
139
|
+
return `当前上下文用量未知(可能刚完成一次压缩),暂不执行压缩。若确有必要,请稍后重试。`;
|
|
140
|
+
}
|
|
141
|
+
if (tokens < min) {
|
|
142
|
+
return `当前上下文 ${formatK(tokens)} tokens,未达第 1 档提醒阈值 ${formatK(min)},无需压缩。继续你的工作即可。`;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ──────────────────────── 摘要后处理(D13 纯函数部分) ────────────────────────
|
|
148
|
+
|
|
149
|
+
/** token 数格式化为 K 显示(200000 → "200K";非整数 K 保留一位小数)。 */
|
|
150
|
+
export function formatK(tokens: number): string {
|
|
151
|
+
const k = tokens / TOKENS_PER_K;
|
|
152
|
+
return Number.isInteger(k) ? `${k}K` : `${k.toFixed(1)}K`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 收缩校验(D13-1):摘要 token 数 ≥ 被压段 token 数 → 不合格(返回 true)。
|
|
157
|
+
* 估算口径与 pi 一致(chars/4)。
|
|
158
|
+
*/
|
|
159
|
+
export function isSummaryInflated(summaryTokens: number, shadowedTokens: number): boolean {
|
|
160
|
+
return summaryTokens >= shadowedTokens;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** chars/4 估算 token(对齐 pi estimateTokens 口径的文本版)。 */
|
|
164
|
+
export function estimateTextTokens(text: string): number {
|
|
165
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 降智提示阈值(D13-12):累计 compaction 次数 ≥2 时提示开新会话。 */
|
|
169
|
+
export const DEGRADATION_HINT_MIN_COMPACTIONS = 2;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* fileOps 形状(对齐 pi createFileOps:{read/written/edited: Set<string>})的宽松结构。
|
|
173
|
+
* preparation.fileOps 直接传入。
|
|
174
|
+
*/
|
|
175
|
+
export interface FileOpsLike {
|
|
176
|
+
read: Iterable<string>;
|
|
177
|
+
written: Iterable<string>;
|
|
178
|
+
edited: Iterable<string>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 计算文件清单(对齐 pi computeFileLists 语义:只读 = read − modified;modified = edited ∪ written)。
|
|
183
|
+
* 返回排序后的两个列表。
|
|
184
|
+
*/
|
|
185
|
+
export function computeFileListsLike(fileOps: FileOpsLike): { readFiles: string[]; modifiedFiles: string[] } {
|
|
186
|
+
const modified = new Set<string>([...fileOps.edited, ...fileOps.written]);
|
|
187
|
+
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
|
188
|
+
const modifiedFiles = [...modified].sort();
|
|
189
|
+
return { readFiles, modifiedFiles };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 文件清单追加(D11-2,对齐 pi formatFileOperations 输出格式:XML tags)。
|
|
194
|
+
*/
|
|
195
|
+
export function formatFileOperationsLike(readFiles: readonly string[], modifiedFiles: readonly string[]): string {
|
|
196
|
+
const sections: string[] = [];
|
|
197
|
+
if (readFiles.length > 0) {
|
|
198
|
+
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
|
199
|
+
}
|
|
200
|
+
if (modifiedFiles.length > 0) {
|
|
201
|
+
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
|
202
|
+
}
|
|
203
|
+
if (sections.length === 0) return "";
|
|
204
|
+
return `\n\n${sections.join("\n\n")}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 文件重注入预算(D13-11):≤5 文件 / 每文件 5K 字符 / 总 50K 字符。 */
|
|
208
|
+
const REINJECT_MAX_FILES = 5;
|
|
209
|
+
const REINJECT_PER_FILE_CHARS = 5_000;
|
|
210
|
+
const REINJECT_TOTAL_CHARS = 50_000;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 文件重注入选择(D13-11):从只读文件列表取最近 ≤5 个(Set 保序 = 插入序 ≈ 时间序,
|
|
214
|
+
* 取尾部即最近),返回选中的路径列表(预算裁剪由调用方读文件时执行)。
|
|
215
|
+
* keptReadFiles:保留段已出现过的 Read 结果(跳过,避免重复占上下文)。
|
|
216
|
+
*/
|
|
217
|
+
export function pickReinjectFiles(readFiles: readonly string[], keptReadFiles: ReadonlySet<string>): string[] {
|
|
218
|
+
return readFiles
|
|
219
|
+
.filter((f) => !keptReadFiles.has(f))
|
|
220
|
+
.slice(-REINJECT_MAX_FILES);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 组装「Recently read files」节(D13-11):每文件头 5K 字符 + 截断标记,总 50K 截停。
|
|
225
|
+
* 文件内容缺失(读失败/空)逐文件跳过。
|
|
226
|
+
*/
|
|
227
|
+
export function buildReinjectSection(contents: ReadonlyArray<{ path: string; content: string }>): string {
|
|
228
|
+
let total = 0;
|
|
229
|
+
const parts: string[] = [];
|
|
230
|
+
for (const { path, content } of contents) {
|
|
231
|
+
if (content === "") continue;
|
|
232
|
+
const budgetFile = Math.min(REINJECT_PER_FILE_CHARS, REINJECT_TOTAL_CHARS - total);
|
|
233
|
+
if (budgetFile <= 0) break;
|
|
234
|
+
const text = content.length <= budgetFile
|
|
235
|
+
? content
|
|
236
|
+
: `${content.slice(0, budgetFile)}\n[... truncated]`;
|
|
237
|
+
total += text.length;
|
|
238
|
+
parts.push(`### ${path}\n\`\`\`\n${text}\n\`\`\``);
|
|
239
|
+
}
|
|
240
|
+
if (parts.length === 0) return "";
|
|
241
|
+
return `\n\n<recently-read-files>\n${parts.join("\n\n")}\n</recently-read-files>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ──────────────────────── subagent 识别(R6) ────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* subagent 子进程检测(D9/R6):subagent-workflow 无条件注入 PI_SUBAGENT_ROOT_SESSION_ID。
|
|
248
|
+
* 命中 → 本进程不注册工具、不提醒(宁缺勿污)。
|
|
249
|
+
*/
|
|
250
|
+
export function isSubagentProcess(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
251
|
+
return env.PI_SUBAGENT_ROOT_SESSION_ID !== undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ──────────────────────── session entries 统计(D13 纯函数) ────────────────────────
|
|
255
|
+
|
|
256
|
+
/** sessionManager entries 的宽松形状(降智计数)。 */
|
|
257
|
+
export interface EntryLike {
|
|
258
|
+
type: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 累计 compaction 次数(D13-12 判据)。 */
|
|
262
|
+
export function countCompactions(entries: ReadonlyArray<EntryLike>): number {
|
|
263
|
+
return entries.filter((e) => e.type === "compaction").length;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 保留段已 Read 的文件集合(D13-11 去重:重注入跳过保留段已有的 Read 结果)。
|
|
268
|
+
* 保留段 = branchEntries 中 firstKeptEntryId 之后的 message entries;从其 toolCall 参数提取 path。
|
|
269
|
+
*/
|
|
270
|
+
export function collectKeptReadFiles(branchEntries: ReadonlyArray<unknown>, firstKeptEntryId: string): Set<string> {
|
|
271
|
+
const kept = new Set<string>();
|
|
272
|
+
let found = false;
|
|
273
|
+
for (const entry of branchEntries) {
|
|
274
|
+
const e = entry as { id?: string; message?: { role?: string; content?: unknown } };
|
|
275
|
+
if (!found) {
|
|
276
|
+
if (e.id === firstKeptEntryId) found = true;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const msg = e.message;
|
|
280
|
+
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") {
|
|
284
|
+
kept.add(block.arguments.path);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return kept;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 被压段 token 估算(收缩校验分母:仅 messagesToSummarize;turnPrefixMessages 是保留段前缀,
|
|
293
|
+
* 不属于被压段,不计入)。pi 的 estimateTokens 按 message 内容估算;此处 chars/4 的保守替代:
|
|
294
|
+
* serialize 后长度 / 4(与 pi 同口径量级,用于"摘要 >= 原文"的粗判已足)。
|
|
295
|
+
*/
|
|
296
|
+
export function estimateShadowedTokens(
|
|
297
|
+
messagesToSummarize: ReadonlyArray<{ role: string; content?: unknown }>,
|
|
298
|
+
): number {
|
|
299
|
+
let chars = 0;
|
|
300
|
+
for (const m of messagesToSummarize) {
|
|
301
|
+
const content = m.content;
|
|
302
|
+
if (typeof content === "string") {
|
|
303
|
+
chars += content.length;
|
|
304
|
+
} else if (Array.isArray(content)) {
|
|
305
|
+
for (const b of content as ReadonlyArray<{ type?: string; text?: string }>) {
|
|
306
|
+
if (typeof b.text === "string") chars += b.text.length;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return Math.ceil(chars / CHARS_PER_TOKEN_ESTIMATE);
|
|
311
|
+
}
|
package/src/reminder.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提醒与通知文案构造(D3/D4/D5/D13-12)。
|
|
3
|
+
*
|
|
4
|
+
* 全部为纯函数:文案 + 越档判定。注入由 src/index.ts 用 pi.sendUserMessage 执行。
|
|
5
|
+
* 措辞原则(目标 3):提醒是数据投递不是指令——给三条件自查清单 + 明确的"可忽略"出口,
|
|
6
|
+
* 避免 agent 见提醒就压缩。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEGRADATION_HINT_MIN_COMPACTIONS,
|
|
11
|
+
formatK,
|
|
12
|
+
} from "./pure.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 阈值提醒消息(D3/D4):越档信息 + 用量数据 + 工具名 + 三条件自查 + 可忽略出口。
|
|
16
|
+
* 多档同时越过合并为一条(D3 去重规则)。
|
|
17
|
+
*/
|
|
18
|
+
export function buildThresholdReminder(
|
|
19
|
+
crossedThresholds: readonly number[],
|
|
20
|
+
tokens: number,
|
|
21
|
+
contextWindow: number,
|
|
22
|
+
compactionCount: number,
|
|
23
|
+
): string {
|
|
24
|
+
const percent = contextWindow > 0 ? ((tokens / contextWindow) * 100).toFixed(1) : "?";
|
|
25
|
+
const tiers = crossedThresholds
|
|
26
|
+
.map((t, index) => `${formatK(t)}(第 ${index + 1} 档)`)
|
|
27
|
+
.join("、");
|
|
28
|
+
const lines = [
|
|
29
|
+
`[smart-context 提示] 上下文当前 ${formatK(tokens)} / ${formatK(contextWindow)} tokens(${percent}%),已超过提醒阈值 ${tiers}。`,
|
|
30
|
+
``,
|
|
31
|
+
`compact_context 工具可用于压缩上下文。请自行判断是否现在压缩——仅当以下三个条件同时满足时才调用:`,
|
|
32
|
+
`1. 当前任务的一个阶段已完成并验证(如一批文件改完、测试通过);`,
|
|
33
|
+
`2. 后续工作不再依赖将被压缩的早期细节;`,
|
|
34
|
+
`3. 上下文已超过阈值(本提示即第 3 条的数据依据)。`,
|
|
35
|
+
``,
|
|
36
|
+
`若任务仍在进行中、或近期仍需引用早期上下文,忽略本提示继续工作即可(本档位不会重复提醒,压缩后会重置)。`,
|
|
37
|
+
];
|
|
38
|
+
if (compactionCount >= DEGRADATION_HINT_MIN_COMPACTIONS) {
|
|
39
|
+
lines.push(``, buildDegradationHintLine());
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 降智提示单行(D13-12,工具结果复用)。 */
|
|
45
|
+
export function buildDegradationHintLine(): string {
|
|
46
|
+
return `Note: this session has been compacted multiple times; fine-grained details may be lost. If the task allows, consider starting a new session.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 切换跨界通知(D5):进入排除 / 恢复可用 两态。 */
|
|
50
|
+
export function buildSwitchNotice(kind: "unavailable" | "available", modelId: string): string {
|
|
51
|
+
if (kind === "unavailable") {
|
|
52
|
+
return `[smart-context] 压缩工具暂时不可用:当前模型 ${modelId} 已配置为排除(smart-context excludedModels),本会话将使用 pi 原生压缩行为。`;
|
|
53
|
+
}
|
|
54
|
+
return `[smart-context] 压缩工具恢复可用:当前模型 ${modelId} 支持压缩(不在排除列表)。超阈值时将收到提醒。`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* downshift 检测(D5):新模型窗口更小且当前 tokens 将触线 → 提醒建议先压缩。
|
|
59
|
+
* 返回 null = 无需提醒。
|
|
60
|
+
*/
|
|
61
|
+
export function buildDownshiftNotice(
|
|
62
|
+
tokens: number | null | undefined,
|
|
63
|
+
previousWindow: number | undefined,
|
|
64
|
+
newWindow: number | undefined,
|
|
65
|
+
): string | null {
|
|
66
|
+
if (typeof tokens !== "number" || typeof previousWindow !== "number" || typeof newWindow !== "number") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (newWindow >= previousWindow) return null;
|
|
70
|
+
const triggerLine = newWindow - 16_384; // pi 内建触发线(window − reserveTokens 默认值)
|
|
71
|
+
if (tokens < triggerLine) return null;
|
|
72
|
+
return `[smart-context] 当前上下文 ${formatK(tokens)} tokens,已接近新模型窗口上限(${formatK(newWindow)})。建议尽快压缩(调用 compact_context 或 /compact),否则内建自动压缩将在触线时强制执行。`;
|
|
73
|
+
}
|