@foolsecret/pi-prompt 0.4.0 → 0.4.8

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.
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { parsePromptCommand, type AxisName } from "./command.ts";
16
- import { PromptConfigManager } from "./config.ts";
16
+ import { PromptConfigManager, type PromptConfigFile } from "./config.ts";
17
17
  import { CalibrationManager, pricingFor } from "./calibration.ts";
18
18
  import { AutoTierChooser, beijingDate, estimateMuFromInput, taskBinForInput } from "./auto.ts";
19
19
  import { clampMaxTokens } from "./request.ts";
@@ -29,9 +29,14 @@ import {
29
29
  type ShowMode,
30
30
  type WriteMode,
31
31
  } from "./modes.ts";
32
- import { PromptRegistry } from "./prompts.ts";
33
- import { REDUCTION_BY_SHOW, UsageLedger, estimateSaved, ensurePricingResolver, getPricingSource, summarizeUsage, showLabel } from "./stats.ts";
32
+ import { PromptRegistry, prefixHash } from "./prompts.ts";
33
+ import { REDUCTION_BY_SHOW, UsageLedger, USAGE_SCHEMA_VERSION, cacheHitRate, currentPluginVersion, estimateSaved, ensurePricingResolver, getPricingSource, summarizeUsage, showLabel } from "./stats.ts";
34
34
  import { formatTokens } from "./format.ts";
35
+ import { compareInjection, renderCompare } from "./compare.ts";
36
+ import { decideCompact } from "./context.ts";
37
+ import { PromptDraft, type SessionAxes } from "./draft.ts";
38
+ import { InfoPage, type DrawerTheme } from "./info-page.ts";
39
+ import { truncateToolOutput } from "./tool-output.ts";
35
40
  import type {
36
41
  ExtensionAPI,
37
42
  ExtensionCommandContext,
@@ -94,8 +99,28 @@ export class PromptExtension {
94
99
  private cacheState = false;
95
100
  /** 价格来源提醒是否已发过(每会话一次,避免刷屏) */
96
101
  private pricingHintShown = false;
102
+ /** 缓存友好提示是否已发过(每会话一次) */
103
+ private cacheHintShown = false;
97
104
  /** 最近一次 before_agent_start 的实际解析结果(台账记录用) */
98
105
  private lastResolved: { show: RuntimeShowMode; write: WriteMode; do: DoMode; taskBin: TaskBin; decision: string } | null = null;
106
+ /** 上一轮 auto 实际生效档(迟滞用;避免换档连带失效前缀缓存) */
107
+ private lastAutoShow: RuntimeShowMode | undefined = undefined;
108
+ /** 上一轮 prompt 总 token(换档缓存失效成本估算用) */
109
+ private prevPromptTokens = 0;
110
+ /** 本会话已跑轮数(自动压缩的“会话够长”判定) */
111
+ private turnCount = 0;
112
+ /** 上次自动压缩时的轮数(冷却用);-1 = 从未 */
113
+ private lastCompactTurn = -1;
114
+ /** 本会话自动压缩次数(/prompt check 展示) */
115
+ private compactCount = 0;
116
+ /** 本会话工具输出截断省下的字节数 */
117
+ private toolTruncatedBytes = 0;
118
+ /** 本会话工具输出截断次数 */
119
+ private toolTruncatedCount = 0;
120
+ /** 抽屉打开期间的内存草稿(关闭即释放) */
121
+ private draft: PromptDraft | null = null;
122
+ /** 最近一次实际注入的完整文本(/prompt check 前缀哈希用) */
123
+ private lastInjectedText: string | undefined = undefined;
99
124
  /** 剩余探针回合数(>0 时本轮不注入,采集对照组) */
100
125
  private probeTurnsLeft = 0;
101
126
  /** 探针采集完成后待重拟合标记 */
@@ -141,6 +166,38 @@ export class PromptExtension {
141
166
  this.drawer = new PromptConfigDrawer(
142
167
  () => {
143
168
  const provider = this.lastCtx?.model?.provider;
169
+ // 有草稿时以草稿为准(否则看不到未保存的编辑)
170
+ const draft = this.draft;
171
+ if (draft) {
172
+ const { config: dc, axes: da } = draft.snapshot();
173
+ return {
174
+ session: { show: da.show, write: da.write, do: da.do },
175
+ defaults: { show: (dc.defaultShow ?? "auto") as never, write: (dc.defaultWrite ?? "normal") as never, do: (dc.defaultDo ?? "normal") as never },
176
+ flags: {
177
+ peakUpgrade: dc.peakUpgrade !== false,
178
+ quietStartup: dc.quietStartup === true,
179
+ hideStatus: dc.hideStatus === true,
180
+ autoSample: dc.autoSample === true,
181
+ autoCompact: dc.autoCompact === true,
182
+ },
183
+ numbers: {
184
+ maxTokensCap: dc.maxTokensCap ?? 0,
185
+ hysteresis: dc.hysteresis ?? 0.5,
186
+ autoCompactMaxTokens: dc.autoCompactMaxTokens ?? 400000,
187
+ autoCompactPercent: dc.autoCompactPercent ?? 0.6,
188
+ autoCompactMinTurns: dc.autoCompactMinTurns ?? 50,
189
+ autoSampleInterval: dc.autoSampleInterval ?? 50,
190
+ autoSampleMaxPerDay: dc.autoSampleMaxPerDay ?? 3,
191
+ },
192
+ toolCaps: {
193
+ grep: dc.toolOutput?.grep ?? dc.toolOutput?.default ?? 8192,
194
+ read: dc.toolOutput?.read ?? 16384,
195
+ bash: dc.toolOutput?.bash ?? dc.toolOutput?.default ?? 8192,
196
+ default: dc.toolOutput?.default ?? 8192,
197
+ },
198
+ toolOutputEnabled: dc.toolOutput?.enabled === true,
199
+ };
200
+ }
144
201
  return {
145
202
  session: {
146
203
  show: this.effectiveShow(provider),
@@ -148,27 +205,81 @@ export class PromptExtension {
148
205
  do: this.effectiveDo(provider),
149
206
  },
150
207
  defaults: this.config.getDefaults(),
208
+ flags: {
209
+ peakUpgrade: this.config.isPeakUpgrade(),
210
+ quietStartup: this.config.isQuietStartup(),
211
+ hideStatus: this.config.isHideStatus(),
212
+ autoSample: this.config.isAutoSample(),
213
+ autoCompact: this.config.isAutoCompact(),
214
+ },
215
+ numbers: {
216
+ maxTokensCap: this.config.maxTokensCap() ?? 0,
217
+ hysteresis: this.config.hysteresis(),
218
+ autoCompactMaxTokens: this.config.autoCompactMaxTokens(),
219
+ autoCompactPercent: this.config.autoCompactPercent(),
220
+ autoCompactMinTurns: this.config.autoCompactMinTurns(),
221
+ autoSampleInterval: this.config.autoSampleInterval(),
222
+ autoSampleMaxPerDay: this.config.autoSampleMaxPerDay(),
223
+ },
224
+ toolCaps: {
225
+ grep: this.config.toolOutputCap("grep"),
226
+ read: this.config.toolOutputCap("read"),
227
+ bash: this.config.toolOutputCap("bash"),
228
+ default: this.config.toolOutputCap("default"),
229
+ },
230
+ toolOutputEnabled: this.config.isToolOutputCap(),
151
231
  };
152
232
  },
153
233
  {
154
- onSessionAxisChange: (axis, value) => {
155
- if (this.lastCtx) this.applyAxis(axis, value as ShowMode | WriteMode | DoMode, this.lastCtx);
156
- },
157
- onDefaultAxisChange: (axis, value) => {
158
- const ok = this.config.writeDefaultAxis(axis, value);
159
- if (this.lastCtx) {
160
- this.lastCtx.ui.notify(ok ? `默认 ${axis}=${value} 已持久化` : `默认 ${axis}#${value} 写入失败`, ok ? "info" : "error");
161
- this.syncStatus(this.lastCtx);
162
- }
234
+ // 所有编辑只改草稿(Ctrl+S 才落盘);返回 boolean = 是否变更成功
235
+ onSessionAxisChange: (axis, value) => this.draft?.setAxis(axis, value) ?? false,
236
+ onDefaultAxisChange: (axis, value) => this.draft?.setDefaultAxis(axis, value) ?? false,
237
+ onFlagChange: (name, value) => this.draft?.setFlag(name as Parameters<PromptDraft["setFlag"]>[0], value) ?? false,
238
+ onNumberChange: (name, value) => this.draft?.setNumber(name as Parameters<PromptDraft["setNumber"]>[0], value) ?? false,
239
+ onCapChange: (tool, value) => this.draft?.setToolCap(tool, value) ?? false,
240
+ onSave: () => this.draft?.save() ?? { ok: false, reason: "无草稿" },
241
+ onReset: () => {
242
+ this.draft?.reset(this.currentConfigSnapshot(), this.currentAxesSnapshot());
163
243
  },
164
244
  },
165
245
  (context) => {
166
246
  // 无 TUI(非交互模式):回退为文字状态
167
247
  this.showStatus(context as AnyContext);
168
248
  },
249
+ {
250
+ isDirty: () => this.draft?.isDirty ?? false,
251
+ changedAreas: () => this.draft?.changedAreas ?? [],
252
+ },
169
253
  );
170
254
  }
171
255
 
256
+ /** 当前磁盘配置快照(草稿 reset 用) */
257
+ private currentConfigSnapshot(): PromptConfigFile {
258
+ return this.config.rawSnapshot();
259
+ }
260
+
261
+ /** 当前会话三轴快照(草稿 reset 用) */
262
+ private currentAxesSnapshot(): SessionAxes {
263
+ const d = this.config.getDefaults();
264
+ return {
265
+ show: (this.sessionAxes?.show ?? d.show) as SessionAxes["show"],
266
+ write: (this.sessionAxes?.write ?? d.write) as SessionAxes["write"],
267
+ do: (this.sessionAxes?.do ?? d.do) as SessionAxes["do"],
268
+ };
269
+ }
270
+
271
+ /** 打开抽屉前创建草稿(编辑隔离在内存,Ctrl+S 才落盘) */
272
+ private createDraft(): void {
273
+ this.draft = new PromptDraft(this.currentConfigSnapshot(), this.currentAxesSnapshot(), {
274
+ writeConfig: (patch) => this.config.persistMerged(patch),
275
+ writeSessionAxes: (axes) => {
276
+ this.sessionAxes = { show: axes.show, write: axes.write, do: axes.do };
277
+ this.pi?.appendEntry("prompt-axes", { show: axes.show, write: axes.write, do: axes.do });
278
+ if (this.lastCtx) this.syncStatus(this.lastCtx);
279
+ },
280
+ });
281
+ }
282
+
172
283
  // ── 档位解析 ────────────────────────────────────────────────────────
173
284
 
174
285
  /** show 轴生效档位(会话显式 > perProvider/env/默认;可为 auto) */
@@ -214,15 +325,6 @@ export class PromptExtension {
214
325
  return legacyShow !== null ? { show: legacyShow } : null;
215
326
  }
216
327
 
217
- /** 当前三轴是否等价于"全局关闭"(show normal + 其余 normal) */
218
- private isGloballyOff(provider: string | undefined): boolean {
219
- return (
220
- this.effectiveShow(provider) === "normal" &&
221
- this.effectiveWrite(provider) === "normal" &&
222
- this.effectiveDo(provider) === "normal"
223
- );
224
- }
225
-
226
328
  // ── 运行时解析(auto → 具体档) ──────────────────────────────────────
227
329
 
228
330
  /**
@@ -272,13 +374,26 @@ export class PromptExtension {
272
374
  const peakUpgrade = this.config.isPeakUpgrade();
273
375
  const now = Date.now();
274
376
  const modelId = model ?? "unknown";
275
- const chosen = this.chooser.choose(provider ?? "unknown", modelId, bin, estMu, this.cacheState, now, peakUpgrade);
377
+ const detailed = this.chooser.chooseDetailed(
378
+ provider ?? "unknown",
379
+ modelId,
380
+ bin,
381
+ estMu,
382
+ this.cacheState,
383
+ now,
384
+ peakUpgrade,
385
+ this.lastAutoShow,
386
+ this.config.hysteresis(),
387
+ this.prevPromptTokens,
388
+ );
389
+ const chosen = detailed.mode;
276
390
  const isPeak = peakUpgrade && pricingFor(provider ?? "unknown", modelId, now).isPeak;
391
+ this.lastAutoShow = chosen;
277
392
  return {
278
393
  show: chosen,
279
394
  write,
280
395
  do: doMode,
281
- decision: `auto: µ=${estMu},${this.cacheState ? "cached" : "cold"}${isPeak ? ",peak" : ""}→${chosen}`,
396
+ decision: `auto: µ=${estMu},${this.cacheState ? "cached" : "cold"}${isPeak ? ",peak" : ""}${detailed.held ? ",hold" : ""}→${chosen}`,
282
397
  };
283
398
  }
284
399
 
@@ -286,12 +401,14 @@ export class PromptExtension {
286
401
 
287
402
  /**
288
403
  * 渲染状态栏文本(v0.2.2 footer 风格):三轴全显、纯文字、去掉 ●/○ 与档位图标。
289
- * show=auto 时显示本轮实际解析档;尚无解析的首帧回退显示 auto
290
- * 恒常显示(不做"全局关停即隐藏")——用户拍板"一直显示"。
404
+ * show=auto 时显示"auto→本轮实际档"(如 `auto→normal`)—— 只显示解析后的档会让
405
+ * 用户误以为 auto 设置丢了(v0.4.2 修:footer 显示 normal 但设置是 auto 的困惑)。
406
+ * 尚未解析的首帧显示裸 `auto`。恒常显示(不做"全局关停即隐藏")。
291
407
  */
292
408
  private statusText(provider: string | undefined): string {
293
409
  const preferred = this.effectiveShow(provider);
294
- const show = preferred === "auto" ? (this.lastResolved?.show ?? "auto") : preferred;
410
+ const resolved = this.lastResolved?.show;
411
+ const show = preferred === "auto" ? (resolved !== undefined ? `auto→${resolved}` : "auto") : preferred;
295
412
  return `PROMPT ${show} · ${this.effectiveWrite(provider)} · ${this.effectiveDo(provider)}`;
296
413
  }
297
414
 
@@ -330,33 +447,49 @@ export class PromptExtension {
330
447
 
331
448
  // ── 展示 ─────────────────────────────────────────────────────────────
332
449
 
450
+ /**
451
+ * 在 TUI 下用 InfoPage(Markdown + 滚动)展示长内容;无 TUI 回退纯文本 notify。
452
+ * @param markdown Markdown 版(TUI 用)
453
+ * @param plain 纯文本版(headless 用)
454
+ */
455
+ private openInfoPage(ctx: AnyContext, markdown: string, plain: string, rootTitle: string): void {
456
+ const ui = (ctx as { ui?: { custom?: unknown; notify: (m: string, t?: string) => void } }).ui;
457
+ if (typeof ui?.custom !== "function") {
458
+ ui?.notify(plain, "info");
459
+ return;
460
+ }
461
+ void (ui.custom as (f: unknown) => Promise<unknown>)((tui: unknown, theme: DrawerTheme, _kb: unknown, done: (r?: undefined) => void) => {
462
+ void tui;
463
+ return new InfoPage(markdown, theme, () => done(undefined), [rootTitle]);
464
+ });
465
+ }
466
+
333
467
  /** /prompt status:三轴现状/默认/厂商映射/台账估算 */
334
468
  private showStatus(ctx: AnyContext): void {
335
469
  const provider = ctx.model?.provider;
336
470
  const showCfg = this.effectiveShow(provider);
337
- const lines: string[] = ["pi-prompt 状态"];
338
- lines.push(
339
- ` show: ${showCfg === "auto" ? "🔄 AUTO" : showLabel(showCfg)}${this.sessionAxes?.show !== undefined ? "(会话显式)" : "(自动)"}${this.lastResolved ? ` ·本轮→${showLabel(this.lastResolved.show)}` : ""}`,
340
- );
341
- lines.push(` write: ${String(this.effectiveWrite(provider))}${this.sessionAxes?.write !== undefined ? "(会话显式)" : "(自动)"}`);
342
- lines.push(` do: ${String(this.effectiveDo(provider))}${this.sessionAxes?.do !== undefined ? "(会话显式)" : "(自动)"}`);
471
+ const axisNote = (explicit: boolean) => (explicit ? "(会话显式)" : "(自动)");
472
+ const md: string[] = ["## pi-prompt 状态"];
473
+ md.push("## 当前档位");
474
+ md.push(`- **风格(show)**: \`${showCfg === "auto" ? "auto" : showLabel(showCfg)}\` ${axisNote(this.sessionAxes?.show !== undefined)}${this.lastResolved ? ` · 本轮 → \`${showLabel(this.lastResolved.show)}\`` : ""}`);
475
+ md.push(`- **代码(write)**: \`${String(this.effectiveWrite(provider))}\` ${axisNote(this.sessionAxes?.write !== undefined)}`);
476
+ md.push(`- **行动(do)**: \`${String(this.effectiveDo(provider))}\` ${axisNote(this.sessionAxes?.do !== undefined)}`);
343
477
  const defaults = this.config.getDefaults();
344
- lines.push(` 默认: show=${String(defaults.show)} / write=${String(defaults.write)} / do=${String(defaults.do)}`);
345
- const perProvider = this.config.perProviderConfig();
346
- const mapped = Object.entries(perProvider);
347
- lines.push(
348
- mapped.length === 0
349
- ? " 厂商映射: 无"
350
- : ` 厂商映射: ${mapped.map(([providerId, axes]) => `${providerId}→show:${axes.show ?? "-"} write:${axes.write ?? "-"} do:${axes.do ?? "-"}`).join(" | ")}`,
351
- );
478
+ md.push("## 新会话默认");
479
+ md.push(`- 风格 \`${String(defaults.show)}\` · 代码 \`${String(defaults.write)}\` · 行动 \`${String(defaults.do)}\``);
480
+ const mapped = Object.entries(this.config.perProviderConfig());
481
+ md.push("## 厂商映射");
482
+ md.push(mapped.length === 0 ? "- 无" : mapped.map(([p, a]) => `- ${p} → 风格 ${a.show ?? "-"} · 代码 ${a.write ?? "-"} · 行动 ${a.do ?? "-"}`).join("\n"));
352
483
  const records = this.ledger.readAll();
484
+ md.push("## 台账估算");
353
485
  if (records.length > 0) {
354
486
  const est = estimateSaved(records);
355
- lines.push(` 台账估算: 输出 ${formatTokens(est.outputTokens)},估省 ${formatTokens(est.savedOutputTokens)} ≈ ¥${est.savedCNY.toFixed(4)}(保守假设)`);
487
+ md.push(`- 输出 ${formatTokens(est.outputTokens)},估省 ${formatTokens(est.savedOutputTokens)} ≈ ¥${est.savedCNY.toFixed(4)}(保守假设)`);
356
488
  } else {
357
- lines.push(" 台账: 暂无记录");
489
+ md.push("- 暂无记录");
358
490
  }
359
- ctx.ui.notify(lines.join("\n"), "info");
491
+ const plain = md.join("\n");
492
+ this.openInfoPage(ctx, md.join("\n"), plain, "Esc 返回");
360
493
  }
361
494
 
362
495
  /** /prompt usage:DeepSeek 风格的 token/金额台账表 */
@@ -367,19 +500,38 @@ export class PromptExtension {
367
500
  return;
368
501
  }
369
502
  const summary = summarizeUsage(records);
370
- const lines: string[] = ["pi-prompt 台账(估算口径,权威计价在 pi-usager"];
371
- lines.push(`价格来源: ${this.describePricingSource()}`);
372
- lines.push(`总览: ${summary.total.turns} 回合 · 输入 ${formatTokens(summary.total.input)} · 缓存命中 ${formatTokens(summary.total.cacheRead)} · 输出 ${formatTokens(summary.total.output)} · 估算 ¥${summary.total.costCNY.toFixed(4)}`);
503
+ const md: string[] = ["## pi-prompt 台账", "", `> 估算口径;权威计价在 pi-usager。价格来源: \`${this.describePricingSource()}\``, ""];
504
+ md.push("## 总览");
505
+ md.push(`- ${summary.total.turns} 回合 · 输入 ${formatTokens(summary.total.input)} · 缓存命中 ${formatTokens(summary.total.cacheRead)} · 输出 ${formatTokens(summary.total.output)}`);
506
+ md.push(`- 估算 ¥${summary.total.costCNY.toFixed(4)} · 缓存命中率 **${summary.total.hitRate.toFixed(1)}%**(累计;单轮会波动)`);
507
+ md.push("## 按天/模型");
373
508
  for (const row of summary.rows) {
374
- lines.push(` ${row.day} ${row.model} — ${row.turns}回合 入${formatTokens(row.input)} 缓存${formatTokens(row.cacheRead)} 出${formatTokens(row.output)} ≈¥${row.costCNY.toFixed(4)} 估省¥${row.savedCNY.toFixed(4)}`);
509
+ md.push(`- \`${row.day}\` ${row.model} — ${row.turns} 回合 · 入 ${formatTokens(row.input)} · 缓存 ${formatTokens(row.cacheRead)} · 出 ${formatTokens(row.output)} · 命中 ${cacheHitRate(row.input, row.cacheRead).toFixed(0)}% · ≈¥${row.costCNY.toFixed(4)}(估省 ¥${row.savedCNY.toFixed(4)})`);
375
510
  }
376
511
  if (summary.sessions.length > 0) {
377
- lines.push("按会话:");
512
+ md.push("## 按会话(最近 8 个)");
378
513
  for (const sess of summary.sessions.slice(-8)) {
379
- lines.push(` ${sess.session} — ${sess.turns}回合 入${formatTokens(sess.input)} 出${formatTokens(sess.output)} ≈¥${sess.costCNY.toFixed(4)} 估省¥${sess.savedCNY.toFixed(4)}`);
514
+ md.push(`- \`${sess.session}\` — ${sess.turns} 回合 · 入 ${formatTokens(sess.input)} · 出 ${formatTokens(sess.output)} · 命中 ${sess.hitRate.toFixed(0)}% · ≈¥${sess.costCNY.toFixed(4)}(估省 ¥${sess.savedCNY.toFixed(4)})`);
380
515
  }
381
516
  }
382
- ctx.ui.notify(lines.join("\n"), "info");
517
+ this.openInfoPage(ctx, md.join("\n"), md.join("\n"), "Esc 返回");
518
+ }
519
+
520
+ /**
521
+ * 启动时提示"缓存友好状态"(每会话一次):让用户知道当前配置是否会主动保前缀缓存。
522
+ * 只报一次关键信息——auto 是否带迟滞、迟滞多大;不做说教。
523
+ */
524
+ private notifyCacheFriendlyOnce(ctx: AnyContext): void {
525
+ if (this.cacheHintShown) return;
526
+ this.cacheHintShown = true;
527
+ const provider = ctx.model?.provider;
528
+ if (this.effectiveShow(provider) !== "auto") return; // 固定档位本就不换档,天然缓存友好
529
+ const hyst = this.config.hysteresis();
530
+ if (hyst <= 0) {
531
+ ctx.ui.notify("pi-prompt:换档迟滞已关闭,auto 可能频繁换档(会反复清前缀缓存)。设 hysteresis>0 可保护缓存。", "warning");
532
+ return;
533
+ }
534
+ ctx.ui.notify(`pi-prompt:auto 换档迟滞已启用(${hyst}),换档会先扣掉缓存失效成本——长会话下更省输入费。`, "info");
383
535
  }
384
536
 
385
537
  /**
@@ -430,16 +582,44 @@ export class PromptExtension {
430
582
  if (binFit.mu !== undefined) fitted.push(`${provider}#${bin}(µ${binFit.mu}, ctl${binFit.normal?.n ?? binFit.off?.n ?? 0})`);
431
583
  }
432
584
  }
433
- const lines: string[] = ["pi-prompt 自检"];
434
- lines.push(` 台账: ${integrity.validLines}/${integrity.totalLines} 合法行${integrity.damagedLines > 0 ? `,损坏 ${integrity.damagedLines} 行(将跳过)` : ""} @ ${integrity.filePath}`);
435
- lines.push(` 价格来源: ${this.describePricingSource()}`);
436
- lines.push(` 校准: ${calFile.updatedAt === 0 ? "无拟合数据(auto 用先验)" : `已拟合 ${fitted.join(", ") || "(空)"}(更新于 ${new Date(calFile.updatedAt).toISOString().slice(0, 10)})`}`);
437
- lines.push(" --calibrate 将跑 3 轮对照组探针(本轮不注入地采集基线)并重拟合");
585
+ const md: string[] = ["## pi-prompt 自检", "", "## 台账与价格"];
586
+ md.push(`- 台账: ${integrity.validLines}/${integrity.totalLines} 合法行${integrity.damagedLines > 0 ? `,损坏 ${integrity.damagedLines} 行(将跳过)` : ""}`);
587
+ md.push(`- 路径: \`${integrity.filePath}\``);
588
+ md.push(`- 价格来源: \`${this.describePricingSource()}\``);
589
+ const records = this.ledger.readAll();
590
+ md.push("", "## 缓存与换档");
591
+ if (records.length > 0) {
592
+ const summary = summarizeUsage(records);
593
+ md.push(`- 缓存命中率: **${summary.total.hitRate.toFixed(1)}%**(累计 ${summary.total.turns} 回合 · 输入 ${formatTokens(summary.total.input)} / 命中 ${formatTokens(summary.total.cacheRead)})`);
594
+ }
595
+ md.push(`- 注入前缀哈希: \`${prefixHash(this.lastInjectedText)}\`(变了 = 换档/改档,前缀缓存失效)`);
596
+ md.push(`- 换档迟滞: \`${this.config.hysteresis()}\`(0=关闭;越大越不易换档、越保住缓存)`);
597
+ const usage = ctx.getContextUsage?.();
598
+ md.push("", "## 上下文与压缩");
599
+ if (usage) {
600
+ const pct = usage.percent === null ? "?" : `${(usage.percent * 100).toFixed(1)}%`;
601
+ const tokensStr = usage.tokens === null ? "未知" : formatTokens(usage.tokens);
602
+ const autoT = Math.min(this.config.autoCompactMaxTokens(), this.config.autoCompactPercent() * usage.contextWindow);
603
+ md.push(`- 用量: ${tokensStr} / 窗口 ${formatTokens(usage.contextWindow)}(${pct})· 本会话已压 ${this.compactCount} 次`);
604
+ md.push(`- 自动压缩: **${this.config.isAutoCompact() ? "开" : "关"}**(阈值 min(${formatTokens(this.config.autoCompactMaxTokens())}, ${(this.config.autoCompactPercent() * 100).toFixed(0)}% 窗口) = ${formatTokens(autoT)};pi 自带阈值 ${formatTokens(usage.contextWindow - 16384)})`);
605
+ }
606
+ md.push("", "## 工具输出");
607
+ if (this.config.isToolOutputCap()) {
608
+ const caps = (["grep", "read", "bash"] as const).map((k) => `${k}=${formatTokens(this.config.toolOutputCap(k))}`).join(" · ");
609
+ md.push(`- 截断: **开**(${caps} · 兜底 ${formatTokens(this.config.toolOutputCap("default"))})`);
610
+ md.push(`- 本会话省: ${formatTokens(Math.round(this.toolTruncatedBytes / 4))} token(${this.toolTruncatedCount} 次)`);
611
+ } else {
612
+ md.push("- 截断: 关(pi 自带 50KB 上限仍生效)");
613
+ }
614
+ md.push("", "## 校准");
615
+ md.push(`- ${calFile.updatedAt === 0 ? "无拟合数据(auto 用先验)" : `已拟合 ${fitted.join(", ") || "(空)"}(更新于 ${new Date(calFile.updatedAt).toISOString().slice(0, 10)})`}`);
616
+ md.push("- \`--calibrate\` 将跑 3 轮对照组探针并重拟合");
438
617
  if (calibrate) {
439
- lines.push(" 已排队 3 轮探针…");
618
+ md.push("- 已排队 3 轮探针…");
440
619
  this.queueProbes(ctx);
441
620
  }
442
- ctx.ui.notify(lines.join("\n"), "info");
621
+ this.openInfoPage(ctx, md.join("\n"), md.join("\n"), "Esc 返回");
622
+
443
623
  }
444
624
 
445
625
  /** 排队校准探针:剩余探针回合 + 依次发送固定题(仅 idle 才发,否则 followUp) */
@@ -462,11 +642,61 @@ export class PromptExtension {
462
642
 
463
643
  // ── 台账 ─────────────────────────────────────────────────────────────
464
644
 
645
+ /**
646
+ * 自动上下文压缩检查(turn_end 后)。默认关(config autoCompact)。
647
+ * 理念(见 context.ts):压缩是“投资”——DeepSeek 缓存读极便宜,
648
+ * 短会话压缩纯亏;需三重条件(绝对阈值 + 会话够长 + 成本划算)。
649
+ * 为何不用 pi 自带压缩:1M 窗口下 pi 阈值 ≈98 万,正常使用永不触发。
650
+ */
651
+ private maybeAutoCompact(ctx: AnyContext): void {
652
+ if (!this.config.isAutoCompact()) return;
653
+ const usage = ctx.getContextUsage?.();
654
+ if (!usage) return;
655
+ const price = this.resolverForCompact();
656
+ const decision = decideCompact({
657
+ tokens: usage.tokens,
658
+ contextWindow: usage.contextWindow,
659
+ turns: this.turnCount,
660
+ inputMissPrice: price.miss,
661
+ inputHitPrice: price.hit,
662
+ maxTokens: this.config.autoCompactMaxTokens(),
663
+ percent: this.config.autoCompactPercent(),
664
+ minTurns: this.config.autoCompactMinTurns(),
665
+ turnsSinceLastCompact: this.lastCompactTurn < 0 ? Number.POSITIVE_INFINITY : this.turnCount - this.lastCompactTurn,
666
+ });
667
+ if (!decision.should) return;
668
+ this.lastCompactTurn = this.turnCount;
669
+ const before = usage.tokens ?? 0;
670
+ ctx.compact({
671
+ onComplete: () => {
672
+ this.compactCount += 1;
673
+ ctx.ui.notify(
674
+ `pi-prompt: 上下文达 ${formatTokens(before)} token,已自动压缩(预计省 ¥${decision.netSavingCNY.toFixed(4)})`,
675
+ "info",
676
+ );
677
+ },
678
+ onError: (error: Error) => {
679
+ // 压缩失败不回滚计数(避免反复重试烧钱),仅提示
680
+ ctx.ui.notify(`pi-prompt: 自动压缩失败(${error.message}),下轮再试`, "warning");
681
+ },
682
+ });
683
+ }
684
+
685
+ /** 取当前价(供压缩成本判断;与 auto 同源) */
686
+ private resolverForCompact(): { miss: number; hit: number } {
687
+ const provider = this.lastCtx?.model?.provider ?? "unknown";
688
+ const model = this.lastCtx?.model?.id ?? "unknown";
689
+ const p = pricingFor(provider, model, Date.now());
690
+ return { miss: p.miss, hit: p.hit };
691
+ }
692
+
465
693
  /** turn_end:采集用量追加台账;探针采集完触发重拟合 */
466
694
  private recordUsage(raw: RawUsage | undefined, provider: string | undefined, model: string | undefined, ctx: AnyContext): void {
467
695
  const resolved = this.lastResolved;
468
696
  if (raw && resolved) {
469
697
  this.ledger.append({
698
+ v: USAGE_SCHEMA_VERSION,
699
+ pv: currentPluginVersion(),
470
700
  ts: Date.now(),
471
701
  provider: provider ?? "unknown",
472
702
  model: model ?? "unknown",
@@ -483,6 +713,8 @@ export class PromptExtension {
483
713
  });
484
714
  // 缓存状态滚动更新(auto 的成本项输入)
485
715
  this.cacheState = (raw.cacheRead ?? 0) > 0;
716
+ // 上一轮 prompt 总量(换档失效成本估算:input + cacheRead + cacheWrite)
717
+ this.prevPromptTokens = (raw.input ?? 0) + (raw.cacheRead ?? 0) + (raw.cacheWrite ?? 0);
486
718
  }
487
719
  if (this.pendingRecalibrate) {
488
720
  this.pendingRecalibrate = false;
@@ -509,23 +741,26 @@ export class PromptExtension {
509
741
  { value: "config", label: "config", description: "打开三轴设置抽屉(会话档 + 默认档)" },
510
742
  { value: "status", label: "status", description: "显示三轴现状与台账估算" },
511
743
  { value: "usage", label: "usage", description: "token/金额台账表" },
744
+ { value: "compare", label: "compare", description: "对比注入 vs 不注入的成本(估算 + 实测)" },
512
745
  { value: "check", label: "check", description: "自检(--calibrate 触发探针校准)" },
513
746
  ],
514
747
  handler: async (args, ctx) => {
515
748
  this.lastCtx = ctx;
516
749
  const cmd = parsePromptCommand(args);
517
750
  switch (cmd.type) {
518
- case "toggle": {
519
- if (this.isGloballyOff(ctx.model?.provider)) {
520
- this.resetToDefaults(ctx);
521
- return;
522
- }
523
- // 全局关闭 = 三轴全部不注入(show normal + write/do normal)
524
- this.applyAxis("show", "normal", ctx);
525
- this.applyAxis("write", "normal", ctx);
526
- this.applyAxis("do", "normal", ctx);
751
+ case "help":
752
+ ctx.ui.notify(
753
+ [
754
+ "pi-prompt 命令:",
755
+ " /prompt config 打开设置抽屉(风格/代码/行动 · 工具 · 上下文 · 其他)",
756
+ " /prompt status 查看当前档位与台账估算",
757
+ " /prompt usage 查看 token/金额台账",
758
+ " /prompt compare 对比注入 vs 不注入的成本",
759
+ " /prompt check 健康自检(--calibrate 触发探针校准)",
760
+ ].join("\n"),
761
+ "info",
762
+ );
527
763
  return;
528
- }
529
764
  case "status":
530
765
  this.showStatus(ctx);
531
766
  return;
@@ -535,14 +770,26 @@ export class PromptExtension {
535
770
  this.notifyPricingFallbackOnce(ctx);
536
771
  this.showUsage(ctx);
537
772
  return;
773
+ case "compare": {
774
+ await ensurePricingResolver();
775
+ this.notifyPricingFallbackOnce(ctx);
776
+ const result = compareInjection(this.ledger.readAll());
777
+ ctx.ui.notify(renderCompare(result), "info");
778
+ return;
779
+ }
538
780
  case "check":
539
781
  this.showCheck(ctx, cmd.calibrate);
540
782
  return;
541
783
  case "config":
542
- await this.drawer.open(ctx);
784
+ this.createDraft();
785
+ try {
786
+ await this.drawer.open(ctx);
787
+ } finally {
788
+ this.draft = null;
789
+ }
543
790
  return;
544
791
  case "invalid":
545
- ctx.ui.notify(`未知参数 "${cmd.arg}"\n用法: /prompt [config | status | usage | check [--calibrate]](无参=全局开关)`, "warning");
792
+ ctx.ui.notify(`未知参数 "${cmd.arg}"\n用法: /prompt [help | config | status | usage | compare | check [--calibrate]]`, "warning");
546
793
  return;
547
794
  }
548
795
  },
@@ -583,6 +830,7 @@ export class PromptExtension {
583
830
  if (!this.config.isQuietStartup()) {
584
831
  const show = this.effectiveShow(ctx.model?.provider);
585
832
  ctx.ui.notify(`pi-prompt loaded: show=${show === "auto" ? "🔄 AUTO" : showLabel(show)}, write=${String(this.effectiveWrite(ctx.model?.provider))}, do=${String(this.effectiveDo(ctx.model?.provider))}`, "info");
833
+ this.notifyCacheFriendlyOnce(ctx);
586
834
  }
587
835
  });
588
836
 
@@ -609,15 +857,41 @@ export class PromptExtension {
609
857
  // footer 状态同步为"本轮实际档"(show=auto 时)
610
858
  this.syncStatus(ctx);
611
859
  const text = this.registry.compose(resolved.show, resolved.write, resolved.do);
860
+ this.lastInjectedText = text;
612
861
  if (!text) return;
613
862
  const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
614
863
  return { systemPrompt: `${base}${text}` };
615
864
  });
616
865
 
617
- // turn_end:采集真实 token 用量 → 台账 + 探针后重拟合
866
+ // turn_end:采集真实 token 用量 → 台账 + 探针后重拟合 + 自动压缩检查
618
867
  pi.on("turn_end", (event, ctx) => {
619
868
  const raw = (event.message as { usage?: RawUsage }).usage;
869
+ this.turnCount += 1;
620
870
  this.recordUsage(raw, ctx.model?.provider, ctx.model?.id, ctx);
871
+ this.maybeAutoCompact(ctx);
872
+ });
873
+ // tool_result:工具输出截断(创建时一次,此后字节不变 → 缓存友好)
874
+ pi.on("tool_result", (event) => {
875
+ if (!this.config.isToolOutputCap()) return undefined;
876
+ // 错误结果不截(错误信息必须完整)
877
+ if (event.isError) return undefined;
878
+ const tool = event.toolName;
879
+ const key = tool === "grep" || tool === "read" || tool === "bash" ? tool : "default";
880
+ const cap = this.config.toolOutputCap(key);
881
+ if (!cap) return undefined;
882
+ // 仅处理文本内容;图片/其他类型原样保留
883
+ let changed = false;
884
+ const content = (event.content ?? []).map((part) => {
885
+ if (part.type !== "text" || typeof part.text !== "string") return part;
886
+ const r = truncateToolOutput(part.text, tool, cap);
887
+ if (!r.truncated) return part;
888
+ changed = true;
889
+ this.toolTruncatedBytes += r.savedBytes;
890
+ this.toolTruncatedCount += 1;
891
+ return { ...part, text: r.text };
892
+ });
893
+ if (!changed) return undefined;
894
+ return { content };
621
895
  });
622
896
  }
623
897
  }