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