@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.
- package/CHANGELOG.md +184 -0
- package/README.md +42 -5
- package/package.json +55 -55
- package/src/auto.ts +109 -13
- package/src/command.ts +10 -2
- package/src/compare.ts +228 -0
- package/src/config.ts +152 -1
- package/src/context.ts +136 -0
- package/src/draft.ts +178 -0
- package/src/info-page.ts +115 -0
- package/src/prompt-extension.ts +344 -70
- package/src/prompts.ts +48 -6
- package/src/stats.ts +50 -5
- package/src/tool-output.ts +128 -0
- package/src/ui.ts +542 -98
package/src/prompt-extension.ts
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
const mapped = Object.entries(
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
487
|
+
md.push(`- 输出 ${formatTokens(est.outputTokens)},估省 ${formatTokens(est.savedOutputTokens)} ≈ ¥${est.savedCNY.toFixed(4)}(保守假设)`);
|
|
356
488
|
} else {
|
|
357
|
-
|
|
489
|
+
md.push("- 暂无记录");
|
|
358
490
|
}
|
|
359
|
-
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
+
md.push("## 按会话(最近 8 个)");
|
|
378
513
|
for (const sess of summary.sessions.slice(-8)) {
|
|
379
|
-
|
|
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.
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
618
|
+
md.push("- 已排队 3 轮探针…");
|
|
440
619
|
this.queueProbes(ctx);
|
|
441
620
|
}
|
|
442
|
-
ctx.
|
|
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 "
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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]]
|
|
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
|
}
|