@foolsecret/pi-prompt 0.4.0 → 0.4.9

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/src/compare.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * /prompt compare:对比"注入 vs 不注入"的成本,回答"插件到底省了多少"。
3
+ *
4
+ * 诚实账本原则(本模块的核心约束):
5
+ * 对比有两种口径,必须分开标注,绝不混为一谈——
6
+ * 1) 推算(estimate):从当前台账反推"若无注入会怎样",依赖 REDUCTION_BY_SHOW
7
+ * 先验假设(full=0.2 / ultra=0.45 …)。这是**假设**,不是实测。
8
+ * 2) 实测(measured):用台账里 show=normal(不注入)的样本与注入样本对比。
9
+ * 这是**真实数据**,但样本非随机(normal 多来自小任务),有选择偏差。
10
+ * 两者都返回,由表现层分别标注;缺样本时实测列为空并给出采集指引。
11
+ *
12
+ * 口径范围:只量化 show 轴(write/do 无法诚实量化,见 AGENTS 讨论记录)。
13
+ *
14
+ * 免责(重要):台账跨版本累积(v0.1 无 auto、v0.2 有振荡 bug、v0.4.1 才加迟滞);
15
+ * 旧记录(pv=legacy)与当前行为不兼容。且 auto 选档与缓存状态强相关(非随机),
16
+ * 因此本模块的"实测对照"仅作参考,不是因果结论;真正结论需受控 A/B。
17
+ * 见 AGENTS.md 认知修正 #42/#43。
18
+ */
19
+
20
+ import { REDUCTION_BY_SHOW, type UsageRecord } from "./stats.ts";
21
+ import type { RuntimeShowMode } from "./modes.ts";
22
+ import type { PricingResolver } from "./stats.ts";
23
+ import { getActiveResolver } from "./stats.ts";
24
+
25
+ /** 单个注入档的对比行 */
26
+ export interface CompareRow {
27
+ /** 注入档(不含 normal,normal 是基线) */
28
+ mode: Exclude<RuntimeShowMode, "normal">;
29
+ /** 该档实际产生的输出 token(台账实测) */
30
+ actualOutput: number;
31
+ /** 该档的记录数 */
32
+ turns: number;
33
+ /** 推算的"若无注入"输出(actualOutput / (1 − reduction)) */
34
+ estimatedBaselineOutput: number;
35
+ /** 推算省下的输出 token */
36
+ estimatedSavedTokens: number;
37
+ /** 推算省下的钱(¥) */
38
+ estimatedSavedCNY: number;
39
+ }
40
+
41
+ /** 实测对照(仅当有可比样本时给出) */
42
+ export interface MeasuredContrast {
43
+ /** 不注入样本数(show=normal) */
44
+ normalTurns: number;
45
+ /** 不注入样本的中位输出 token */
46
+ normalMedianOutput: number;
47
+ /** 注入样本数(show≠normal) */
48
+ injectedTurns: number;
49
+ /** 注入样本的中位输出 token */
50
+ injectedMedianOutput: number;
51
+ /** 实测缩减率 = 1 − injected/normal(中位数口径) */
52
+ measuredReduction: number;
53
+ /**
54
+ * 偏差警告:样本非随机(normal 样本多来自小任务),
55
+ * 直接比较可能高估或低估。有值即表示应谨慎解读。
56
+ */
57
+ biasNote?: string;
58
+ }
59
+
60
+ /** /prompt compare 的完整结果 */
61
+ export interface CompareResult {
62
+ /** 实际花费(¥,读时按当前价表算) */
63
+ actualCostCNY: number;
64
+ /** 推算总省(¥) */
65
+ estimatedSavedCNY: number;
66
+ /** 推算总省(输出 token) */
67
+ estimatedSavedTokens: number;
68
+ /** 按档拆分的推算行 */
69
+ rows: CompareRow[];
70
+ /** 实测对照(样本不足时为 undefined) */
71
+ measured?: MeasuredContrast;
72
+ /** 是否有任何记录 */
73
+ hasData: boolean;
74
+ }
75
+
76
+ /** 中位数(偶数取中间两数均值;空数组返回 0) */
77
+ function median(values: readonly number[]): number {
78
+ if (values.length === 0) return 0;
79
+ const sorted = [...values].sort((a, b) => a - b);
80
+ const mid = Math.floor(sorted.length / 2);
81
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
82
+ }
83
+
84
+ /** 归一化 show 档(历史 off 视为 normal;未知回退 normal) */
85
+ function runtimeShow(record: UsageRecord): RuntimeShowMode {
86
+ const show = record.show as string;
87
+ if (show === "off") return "normal";
88
+ return show === "normal" || show === "lite" || show === "full" || show === "ultra" ? show : "normal";
89
+ }
90
+
91
+ /** 缩减率先验:未知档回退 0(不虚报) */
92
+ function reductionOf(mode: RuntimeShowMode): number {
93
+ return REDUCTION_BY_SHOW[mode] ?? 0;
94
+ }
95
+
96
+ /**
97
+ * 对比注入与不注入(纯函数)。
98
+ * @param records 台账记录
99
+ * @param price 价格解析器(默认读当前 activeResolver,与 /prompt usage 同源)
100
+ */
101
+ export function compareInjection(
102
+ records: readonly UsageRecord[],
103
+ price: PricingResolver = getActiveResolver(),
104
+ ): CompareResult {
105
+ if (records.length === 0) {
106
+ return { actualCostCNY: 0, estimatedSavedCNY: 0, estimatedSavedTokens: 0, rows: [], hasData: false };
107
+ }
108
+
109
+ // 按档聚合输出 token(推算用)
110
+ const byMode = new Map<RuntimeShowMode, { output: number; turns: number; costCNY: number }>();
111
+ // 实测对照分两组收集(normal vs 注入),逐条保留输出量算中位数
112
+ const normalOutputs: number[] = [];
113
+ const injectedOutputs: number[] = [];
114
+ let actualCostCNY = 0;
115
+
116
+ for (const record of records) {
117
+ const mode = runtimeShow(record);
118
+ const p = price(record.model, record.provider, record.ts);
119
+ const cost = (record.input * p.inputMiss + record.cacheRead * p.inputHit + record.output * p.output) / 1e6;
120
+ actualCostCNY += cost;
121
+
122
+ const agg = byMode.get(mode) ?? { output: 0, turns: 0, costCNY: 0 };
123
+ agg.output += record.output;
124
+ agg.turns += 1;
125
+ agg.costCNY += cost;
126
+ byMode.set(mode, agg);
127
+
128
+ if (mode === "normal") normalOutputs.push(record.output);
129
+ else injectedOutputs.push(record.output);
130
+ }
131
+
132
+ // 按档推算:"若无注入"输出 = 实际 / (1 − reduction)
133
+ const rows: CompareRow[] = [];
134
+ let estimatedSavedTokens = 0;
135
+ let estimatedSavedCNY = 0;
136
+ for (const mode of ["lite", "full", "ultra"] as const) {
137
+ const agg = byMode.get(mode);
138
+ if (!agg || agg.turns === 0) continue;
139
+ const reduction = reductionOf(mode);
140
+ if (reduction <= 0 || reduction >= 1) continue;
141
+ const baseline = agg.output / (1 - reduction);
142
+ const savedTokens = baseline - agg.output;
143
+ // 输出单价:按该档记录的均价近似(用实际成本/实际输出反推平均输出单价)
144
+ const avgOutputPrice = agg.output > 0 ? (agg.costCNY / agg.output) * 1e6 : 0;
145
+ const savedCNY = (savedTokens / 1e6) * avgOutputPrice;
146
+ estimatedSavedTokens += savedTokens;
147
+ estimatedSavedCNY += savedCNY;
148
+ rows.push({
149
+ mode,
150
+ actualOutput: agg.output,
151
+ turns: agg.turns,
152
+ estimatedBaselineOutput: baseline,
153
+ estimatedSavedTokens: savedTokens,
154
+ estimatedSavedCNY: savedCNY,
155
+ });
156
+ }
157
+
158
+ // 实测对照:需两组都有足够样本(阈值对齐 calibration:对照组 ≥5)
159
+ const MIN_SAMPLES = 5;
160
+ let measured: MeasuredContrast | undefined;
161
+ if (normalOutputs.length >= MIN_SAMPLES && injectedOutputs.length >= MIN_SAMPLES) {
162
+ const normalMedian = median(normalOutputs);
163
+ const injectedMedian = median(injectedOutputs);
164
+ measured = {
165
+ normalTurns: normalOutputs.length,
166
+ normalMedianOutput: normalMedian,
167
+ injectedTurns: injectedOutputs.length,
168
+ injectedMedianOutput: injectedMedian,
169
+ measuredReduction: normalMedian > 0 ? 1 - injectedMedian / normalMedian : 0,
170
+ biasNote:
171
+ "样本非随机:不注入样本多来自小任务,与注入样本任务分布不同,实测缩减率仅供参考",
172
+ };
173
+ }
174
+
175
+ return { actualCostCNY, estimatedSavedCNY, estimatedSavedTokens, rows, measured, hasData: true };
176
+ }
177
+
178
+ /** 人读金额(¥,保留 4 位;0 显示 0) */
179
+ export function fmtCNY(value: number): string {
180
+ return `¥${value.toFixed(4)}`;
181
+ }
182
+
183
+ /** 人读 token(千分位) */
184
+ export function fmtTok(value: number): string {
185
+ return Math.round(value).toLocaleString("en-US");
186
+ }
187
+
188
+ /**
189
+ * 渲染纯文本对比表(headless 与 TUI 共用;表现层只做展示,不做计算)。
190
+ * 诚实标注:推算列标"估算(假设)",实测列标"实测(N 样本)"。
191
+ */
192
+ export function renderCompare(result: CompareResult): string {
193
+ if (!result.hasData) return "pi-prompt 台账为空,暂无可对比数据。";
194
+
195
+ const lines: string[] = ["pi-prompt 对比:注入 vs 不注入(只量化 show 轴)"];
196
+ lines.push(`实际花费: ${fmtCNY(result.actualCostCNY)}`);
197
+ lines.push(
198
+ `省下(估算): ${fmtCNY(result.estimatedSavedCNY)} · 输出 ${fmtTok(result.estimatedSavedTokens)} token(基于先验缩减率,非实测)`,
199
+ );
200
+ lines.push("");
201
+ lines.push("按档(估算):");
202
+ lines.push(" 档位 | 实际输出 | 若无注入 | 省下 token | 省下");
203
+ if (result.rows.length === 0) {
204
+ lines.push(" (无 lite/full/ultra 注入记录)");
205
+ }
206
+ for (const row of result.rows) {
207
+ lines.push(
208
+ ` ${row.mode.padEnd(5)} | ${fmtTok(row.actualOutput).padStart(9)} | ${fmtTok(row.estimatedBaselineOutput).padStart(9)} | ${fmtTok(row.estimatedSavedTokens).padStart(11)} | ${fmtCNY(row.estimatedSavedCNY)}`,
209
+ );
210
+ }
211
+
212
+ lines.push("");
213
+ if (result.measured) {
214
+ const m = result.measured;
215
+ lines.push("实测对照(不注入 vs 注入):");
216
+ lines.push(` 不注入中位输出: ${fmtTok(m.normalMedianOutput)}(${m.normalTurns} 样本)`);
217
+ lines.push(` 注入中位输出: ${fmtTok(m.injectedMedianOutput)}(${m.injectedTurns} 样本)`);
218
+ lines.push(` 实测缩减率: ${(m.measuredReduction * 100).toFixed(1)}%`);
219
+ if (m.biasNote) lines.push(` ⚠ ${m.biasNote}`);
220
+ lines.push(" ⚠ 非因果:auto 选档与缓存状态相关,且台账含旧版本(pv=legacy)记录;");
221
+ lines.push(" 精确结论需受控 A/B(同批任务随机关/开注入)。");
222
+ } else {
223
+ lines.push("实测对照: 样本不足(需不注入 ≥5 且注入 ≥5)");
224
+ lines.push(" 跑 /prompt check --calibrate 采集不注入对照组,或开启 autoSample 自动采样");
225
+ }
226
+
227
+ return lines.join("\n");
228
+ }
package/src/config.ts CHANGED
@@ -55,9 +55,46 @@ export interface PromptConfigFile {
55
55
  autoSampleInterval?: number;
56
56
  /** 当日抽样上限(默认 3) */
57
57
  autoSampleMaxPerDay?: number;
58
+ /** auto 选档迟滞比例(默认 0.5;0 关闭迟滞=旧的无状态 argmax) */
59
+ hysteresis?: number;
60
+ /** 自动上下文压缩(默认关;true 开启) */
61
+ autoCompact?: boolean;
62
+ /** 自动压缩的绝对上限 token(缺省 400000);触发时取 min(此值, percent×窗口) */
63
+ autoCompactMaxTokens?: number;
64
+ /** 自动压缩的窗口百分比上限(缺省 0.6) */
65
+ autoCompactPercent?: number;
66
+ /** 会话至少跑多少轮才允许自动压(缺省 50;短会话压不划算) */
67
+ autoCompactMinTurns?: number;
68
+ /**
69
+ * 追加给 pi 压缩的指令(引导摘要保留“工作状态”)。
70
+ * 空串 = 不追加(回到 pi 原生行为)。
71
+ */
72
+ compactInstructions?: string;
73
+ /** 工具输出截断(默认关;对象形式开启并分层) */
74
+ toolOutput?: ToolOutputConfig;
58
75
  perProvider?: Record<string, string | ProviderAxes>;
59
76
  }
60
77
 
78
+ /** 工具输出截断配置(字节;0 = 不截该工具) */
79
+ export interface ToolOutputConfig {
80
+ /** 总开关(默认 false) */
81
+ enabled?: boolean;
82
+ /** 兵底上限(默认 8192) */
83
+ default?: number;
84
+ /** 按工具覆盖上限 */
85
+ grep?: number;
86
+ read?: number;
87
+ bash?: number;
88
+ }
89
+
90
+ /**
91
+ * 内置压缩指令(用户可用配置/env 覆盖,显式空串=不追加)。
92
+ * 为何需要:pi 的摘要器倾向“总结历史对话”,压缩后会丢失“当前在干什么”,
93
+ * 导致后续回合重新勘测、重复劳动。这条指令引导它保留工作状态。
94
+ */
95
+ export const DEFAULT_COMPACT_INSTRUCTIONS =
96
+ "摘要时请额外保留:当前正在进行的任务、已完成到哪一步、下一步计划、未解决的阻塞。不要只总结历史对话,要保留工作状态。";
97
+
61
98
  /** env 布尔开关解析:值存在且非空/0/false/no 视为真;未设置返回 undefined */
62
99
  function envTruthy(env: NodeJS.ProcessEnv | undefined, name: string): boolean | undefined {
63
100
  const value = env?.[name];
@@ -90,7 +127,13 @@ export class PromptConfigManager {
90
127
  this.loaded = config !== undefined;
91
128
  }
92
129
 
93
- /** 惰性读档(strip BOM;损坏回退空配置;v0.1 defaultMode → defaultShow) */
130
+ /**
131
+ * 惰性读档(strip BOM;损坏回退空配置;v0.1 defaultMode → defaultShow)。
132
+ *
133
+ * 注意:必须原样保留**所有**已知字段——早期版本只白名单了 6 个,导致
134
+ * peakUpgrade / maxTokensCap / autoSample / hysteresis / autoCompact /
135
+ * toolOutput 等从配置文件写入后重读会被静默丢弃(只有 env 与内存注入生效)。
136
+ */
94
137
  private load(): PromptConfigFile {
95
138
  if (this.loaded) return this.config;
96
139
  this.loaded = true;
@@ -98,6 +141,7 @@ export class PromptConfigManager {
98
141
  const raw = readFileSync(this.configPath, "utf8").replace(/^\uFEFF/, "");
99
142
  const parsed = JSON.parse(raw) as (Partial<PromptConfigFile> & { defaultMode?: unknown }) | null;
100
143
  this.config = {
144
+ ...parsed,
101
145
  defaultShow:
102
146
  typeof parsed?.defaultShow === "string"
103
147
  ? parsed.defaultShow
@@ -237,6 +281,104 @@ export class PromptConfigManager {
237
281
  return typeof v === "number" && v >= 1 ? v : 3;
238
282
  }
239
283
 
284
+ /**
285
+ * auto 选档迟滞比例(默认 0.5;0 = 关闭迟滞,退回旧的无状态 argmax)。
286
+ * env PI_PROMPT_HYSTERESIS 优先(非负数字);其次配置 hysteresis。
287
+ * 语义:新最优档净收益必须比上一轮档位高出 bestNet×比例才换档。
288
+ * P0 实测:注入在系统提示中段,换档浪费 ∝ 前缀大小,故要"值得"才换。
289
+ */
290
+ hysteresis(): number {
291
+ const envRaw = this.env?.PI_PROMPT_HYSTERESIS?.trim();
292
+ if (envRaw !== undefined && envRaw !== "" && /^\d+(\.\d+)?$/.test(envRaw)) return Number(envRaw);
293
+ const v = this.load().hysteresis;
294
+ return typeof v === "number" && v >= 0 ? v : 0.5;
295
+ }
296
+
297
+ /**
298
+ * 自动上下文压缩是否开启(默认**关**)。
299
+ * 为何默认关:压缩是"投资"——DeepSeek 缓存读极便宜(0.02 元/百万),
300
+ * 重发旧上下文几乎免费,而压缩需整段重算(价差 0.98)。
301
+ * 算下:剩 ≤30 轮时压缩**永远不划算**;只有长会话才值。
302
+ * env PI_PROMPT_AUTO_COMPACT=true/false 优先;其次配置 autoCompact。
303
+ */
304
+ isAutoCompact(): boolean {
305
+ const env = envTruthy(this.env, "PI_PROMPT_AUTO_COMPACT");
306
+ if (env !== undefined) return env;
307
+ return this.load().autoCompact === true;
308
+ }
309
+
310
+ /** 自动压缩的绝对上限 token(默认 400000;防 1M 窗口下比例阈值形同虚设) */
311
+ autoCompactMaxTokens(): number {
312
+ const envRaw = this.env?.PI_PROMPT_AUTO_COMPACT_MAX_TOKENS?.trim();
313
+ if (envRaw !== undefined && envRaw !== "" && /^\d+$/.test(envRaw)) return Number(envRaw);
314
+ const v = this.load().autoCompactMaxTokens;
315
+ return typeof v === "number" && v > 0 ? v : 400_000;
316
+ }
317
+
318
+ /** 自动压缩的窗口百分比上限(默认 0.6;与绝对上限取小) */
319
+ autoCompactPercent(): number {
320
+ const envRaw = this.env?.PI_PROMPT_AUTO_COMPACT_PERCENT?.trim();
321
+ if (envRaw !== undefined && envRaw !== "" && /^\d+(\.\d+)?$/.test(envRaw)) {
322
+ const v = Number(envRaw);
323
+ return v > 0 && v < 1 ? v : 0.6;
324
+ }
325
+ const v = this.load().autoCompactPercent;
326
+ return typeof v === "number" && v > 0 && v < 1 ? v : 0.6;
327
+ }
328
+
329
+ /** 会话至少跑多少轮才允许自动压(默认 50;短会话压不划算) */
330
+ autoCompactMinTurns(): number {
331
+ const envRaw = this.env?.PI_PROMPT_AUTO_COMPACT_MIN_TURNS?.trim();
332
+ if (envRaw !== undefined && envRaw !== "" && /^\d+$/.test(envRaw)) return Number(envRaw);
333
+ const v = this.load().autoCompactMinTurns;
334
+ return typeof v === "number" && v >= 0 ? v : 50;
335
+ }
336
+
337
+ /**
338
+ * 追加给 pi 压缩的指令(引导摘要保留“工作状态”)。
339
+ * 优先级:env PI_PROMPT_COMPACT_INSTRUCTIONS > 配置 compactInstructions > 内置默认。
340
+ * 显式空串(env/配置)= 不追加,回到 pi 原生行为。
341
+ */
342
+ compactInstructions(): string {
343
+ const envRaw = this.env?.PI_PROMPT_COMPACT_INSTRUCTIONS;
344
+ if (envRaw !== undefined) return envRaw.trim();
345
+ const v = this.load().compactInstructions;
346
+ if (typeof v === "string") return v.trim();
347
+ return DEFAULT_COMPACT_INSTRUCTIONS;
348
+ }
349
+
350
+ /**
351
+ * 工具输出截断是否开启(默认 **关**)。
352
+ * 为何默认关:pi 自己已把工具输出截到 50KB(read/grep/bash/find)。
353
+ * 本项是“更激进的上限”,会改变模型看到的内容(行为风险)——由用户显式开。
354
+ * env PI_PROMPT_TOOL_OUTPUT=true/false 优先;其次配置 toolOutput.enabled。
355
+ */
356
+ isToolOutputCap(): boolean {
357
+ const env = envTruthy(this.env, "PI_PROMPT_TOOL_OUTPUT");
358
+ if (env !== undefined) return env;
359
+ return this.load().toolOutput?.enabled === true;
360
+ }
361
+
362
+ /**
363
+ * 取某工具的输出上限(字节;0 = 该工具不截)。
364
+ * 优先级:env PI_TOOL_CAP_<TOOL> > 配置 toolOutput.<tool> > 配置 default > 内置默认。
365
+ * 内置默认:grep/bash/default 8192(均输出小、信息冗余高);
366
+ * read 16384(模型主动要看,砍很会引发重读 → 反增输出成本)。
367
+ */
368
+ toolOutputCap(tool: "grep" | "read" | "bash" | "default"): number {
369
+ const envName = `PI_PROMPT_TOOL_CAP_${tool.toUpperCase()}`;
370
+ const envRaw = this.env?.[envName]?.trim();
371
+ if (envRaw !== undefined && envRaw !== "" && /^\d+$/.test(envRaw)) return Number(envRaw);
372
+ const cfg = this.load().toolOutput;
373
+ const v = cfg?.[tool];
374
+ if (typeof v === "number" && v >= 0) return v;
375
+ if (tool !== "default") {
376
+ const d = cfg?.default;
377
+ if (typeof d === "number" && d >= 0) return d;
378
+ }
379
+ return tool === "read" ? 16_384 : 8_192;
380
+ }
381
+
240
382
  /** 持久化某轴默认档(按轴校验;非法拒绝返回 false) */
241
383
  writeDefaultAxis(axis: "show" | "write" | "do", value: string): boolean {
242
384
  switch (axis) {
@@ -258,6 +400,41 @@ export class PromptConfigManager {
258
400
  }
259
401
  }
260
402
 
403
+ /** 持久化布尔开关(合并写,不丢未展示字段) */
404
+ writeFlag(name: "peakUpgrade" | "quietStartup" | "hideStatus" | "autoSample" | "autoCompact", value: boolean): boolean {
405
+ return this.persist({ [name]: value } as Partial<PromptConfigFile>);
406
+ }
407
+
408
+ /**
409
+ * 持久化数值参数(校验:非负;percent 类必须 0~1)。
410
+ * @param name 顶层数值字段名
411
+ * @param value 数值;非法返回 false
412
+ */
413
+ writeNumber(
414
+ name: "maxTokensCap" | "autoSampleInterval" | "autoSampleMaxPerDay" | "hysteresis" | "autoCompactMaxTokens" | "autoCompactPercent" | "autoCompactMinTurns",
415
+ value: number,
416
+ ): boolean {
417
+ if (!Number.isFinite(value) || value < 0) return false;
418
+ if (name === "autoCompactPercent" && value >= 1) return false;
419
+ return this.persist({ [name]: value } as Partial<PromptConfigFile>);
420
+ }
421
+
422
+ /** 持久化某工具输出上限(合并写;0 = 该工具不截) */
423
+ writeToolOutput(patch: Partial<ToolOutputConfig>): boolean {
424
+ const merged: ToolOutputConfig = { ...this.load().toolOutput, ...patch };
425
+ return this.persist({ toolOutput: merged });
426
+ }
427
+
428
+ /** 当前内存/磁盘配置的原始快照(供草稿层深拷贝) */
429
+ rawSnapshot(): PromptConfigFile {
430
+ return structuredClone(this.load());
431
+ }
432
+
433
+ /** 合并写任意配置字段(草稿层 save 用;合并写保证不丢未展示字段) */
434
+ persistMerged(patch: Partial<PromptConfigFile>): boolean {
435
+ return this.persist(patch);
436
+ }
437
+
261
438
  /** 写盘(mkdirSync 防御目录缺失):合并当前磁盘内容(防多写互相覆盖);失败返回 false */
262
439
  private persist(patch: Partial<PromptConfigFile>): boolean {
263
440
  try {
package/src/context.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 自动上下文压缩决策(纯函数,可单测)。
3
+ *
4
+ * 背景(勘测 2026-09-12):
5
+ * - pi 自带的压缩阈值为 `contextWindow − reserveTokens`。对 1M 窗口模型
6
+ * (本用户的 deepseek-v4-pro / glm-5.3-flash 均为 1,000,000)== 983,616 token,
7
+ * 正常使用下**永远不会触发** → 上下文一直涨(实测会话峰值 33 万仍在涨)。
8
+ * - 但**压缩不是免费的**:压缩会整段重算(miss 价),而不压缩只是每轮付
9
+ * 缓存读价(hit 价)。以 DeepSeek 为例 miss=1 / hit=0.02(元/百万),
10
+ * 价差 0.98 —— 压缩一次 ≈ 重发 48 次的缓存读成本。
11
+ *
12
+ * 平衡点(成本数学):设当前上下文 T、压缩后降到 K、还会走 N 轮——
13
+ * 不压:N × T × hit
14
+ * 压: T × (miss − hit) + N × K × hit
15
+ * 两者相等 → T_be = N × K × hit / (N × hit − (miss − hit))
16
+ * DeepSeek(hit=0.02, gap=0.98)下 N≤30 无解(永远不划算);
17
+ * N=100 需 T>3.9 万;N=200 需 T>2.6 万。
18
+ *
19
+ * 策略:**三重条件**(不是单一阈值)——
20
+ * 1) 绝对上限:`min(autoCompactMaxTokens, percent × contextWindow)` 以上必压(防失控);
21
+ * 2) 会话够长:已跑 ≥ autoCompactMinTurns 轮(说明是长会话,还会继续);
22
+ * 3) 成本划算:预期省 > 0(用真实价表算)。
23
+ * 三者同时满足才压。
24
+ */
25
+
26
+ /** 压缩决策所需输入 */
27
+ export interface CompactDecisionInput {
28
+ /** 当前上下文 token(getContextUsage().tokens;null = 未知,不压) */
29
+ tokens: number | null;
30
+ /** 模型上下文窗口 */
31
+ contextWindow: number;
32
+ /** 本会话已跑轮数 */
33
+ turns: number;
34
+ /** 输入 miss 价(¥/百万) */
35
+ inputMissPrice: number;
36
+ /** 输入 hit 价(¥/百万) */
37
+ inputHitPrice: number;
38
+ /** 压缩后预计降到多少 token(pi 的 keepRecentTokens,默认 20000) */
39
+ keepRecentTokens?: number;
40
+ /** 绝对上限 token */
41
+ maxTokens: number;
42
+ /** 窗口百分比上限(0~1) */
43
+ percent: number;
44
+ /** 会话最少轮数 */
45
+ minTurns: number;
46
+ /** 距离上次压缩的轮数(冷却;< cooldown 则跳过) */
47
+ turnsSinceLastCompact?: number;
48
+ /** 冷却轮数(默认 10) */
49
+ compactCooldown?: number;
50
+ /** 预估本次会话还会走多少轮(用户无法预知;用会话平均轮数近似,默认 100) */
51
+ expectedRemainingTurns?: number;
52
+ }
53
+
54
+ /** 压缩决策结果(含归因,便于 /prompt check 显示"为何压/不压") */
55
+ export interface CompactDecision {
56
+ /** 是否应触发压缩 */
57
+ should: boolean;
58
+ /** 不压的原因(should=false 时有值) */
59
+ reason?: "unknown-usage" | "below-threshold" | "too-few-turns" | "cooldown" | "not-worth-it";
60
+ /** 触发阈值(min(绝对, 比例×窗口)) */
61
+ threshold: number;
62
+ /** 机会成本比较:省下的钱(¥;负=压缩亏) */
63
+ netSavingCNY: number;
64
+ }
65
+
66
+ /** 默认压缩后保留量(对齐 pi 的 keepRecentTokens) */
67
+ const DEFAULT_KEEP = 20_000;
68
+
69
+ /** 默认冷却轮数 */
70
+ const DEFAULT_COOLDOWN = 10;
71
+
72
+ /** 默认预期剩余轮数(保守:不清高估收益) */
73
+ const DEFAULT_REMAINING = 100;
74
+
75
+ /**
76
+ * 成本比较(纯函数):压缩 vs 不压缩的**预期差**(¥;正 = 压缩更省)。
77
+ * 不压:remaining × T × hit
78
+ * 压: T × (miss − hit) + remaining × K × hit
79
+ * 注意:这里比的是"从现在到会话结束",不含已花的部分。
80
+ */
81
+ export function compactionSaving(
82
+ tokens: number,
83
+ contextWindow: number,
84
+ inputMissPrice: number,
85
+ inputHitPrice: number,
86
+ keepRecentTokens: number = DEFAULT_KEEP,
87
+ expectedRemainingTurns: number = DEFAULT_REMAINING,
88
+ ): number {
89
+ const gap = Math.max(0, inputMissPrice - inputHitPrice);
90
+ const noCompact = (expectedRemainingTurns * tokens * inputHitPrice) / 1e6;
91
+ const compact = (tokens * gap + expectedRemainingTurns * keepRecentTokens * inputHitPrice) / 1e6;
92
+ return noCompact - compact;
93
+ }
94
+
95
+ /**
96
+ * 自动压缩决策(纯函数)。
97
+ * @param input 见 CompactDecisionInput
98
+ */
99
+ export function decideCompact(input: CompactDecisionInput): CompactDecision {
100
+ const threshold = Math.min(input.maxTokens, input.percent * input.contextWindow);
101
+ const keep = input.keepRecentTokens ?? DEFAULT_KEEP;
102
+ const cooldown = input.compactCooldown ?? DEFAULT_COOLDOWN;
103
+ const remaining = input.expectedRemainingTurns ?? DEFAULT_REMAINING;
104
+
105
+ // 1) 用量未知(压缩后/首帧)不动
106
+ if (input.tokens === null || input.tokens <= 0) {
107
+ return { should: false, reason: "unknown-usage", threshold, netSavingCNY: 0 };
108
+ }
109
+ // 2) 成本划算(先算,供返回)
110
+ const netSaving = compactionSaving(
111
+ input.tokens,
112
+ input.contextWindow,
113
+ input.inputMissPrice,
114
+ input.inputHitPrice,
115
+ keep,
116
+ remaining,
117
+ );
118
+ // 3) 低于阈值不压
119
+ if (input.tokens < threshold) {
120
+ return { should: false, reason: "below-threshold", threshold, netSavingCNY: netSaving };
121
+ }
122
+ // 4) 会话太短不压(短会话压缩纯亏)
123
+ if (input.turns < input.minTurns) {
124
+ return { should: false, reason: "too-few-turns", threshold, netSavingCNY: netSaving };
125
+ }
126
+ // 5) 冷却中不压(防抖动)
127
+ const since = input.turnsSinceLastCompact ?? Number.POSITIVE_INFINITY;
128
+ if (since < cooldown) {
129
+ return { should: false, reason: "cooldown", threshold, netSavingCNY: netSaving };
130
+ }
131
+ // 6) 成本不划算不压(关键:缓存太便宜时压缩反而亏)
132
+ if (netSaving <= 0) {
133
+ return { should: false, reason: "not-worth-it", threshold, netSavingCNY: netSaving };
134
+ }
135
+ return { should: true, threshold, netSavingCNY: netSaving };
136
+ }