@foolsecret/pi-prompt 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/LICENSE +667 -0
- package/README.md +121 -0
- package/extensions/index.ts +12 -0
- package/package.json +57 -0
- package/prompts/prompt-review.skill.md +28 -0
- package/src/auto.ts +146 -0
- package/src/calibration.ts +205 -0
- package/src/command.ts +54 -0
- package/src/config.ts +280 -0
- package/src/format.ts +11 -0
- package/src/modes.ts +113 -0
- package/src/prompt-extension.ts +623 -0
- package/src/prompts.ts +197 -0
- package/src/request.ts +25 -0
- package/src/stats.ts +430 -0
- package/src/time.ts +40 -0
- package/src/ui.ts +156 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-prompt 扩展主类:把策略/配置/台账/校准/选档器装配到 pi 事件与命令上。
|
|
3
|
+
*
|
|
4
|
+
* 职责划分(OOP):
|
|
5
|
+
* - PromptRegistry 三轴注入组合(恒定文案)
|
|
6
|
+
* - PromptConfigManager 配置解析/持久化(+ v0.1 迁移)
|
|
7
|
+
* - UsageLedger 用量台账
|
|
8
|
+
* - CalibrationManager 自进化系数(惰性更新)
|
|
9
|
+
* - AutoTierChooser show=auto 时的运行时选档(成本函数)
|
|
10
|
+
* - 本类 事件接线 + 会话状态 + 命令分发 + 探针校准
|
|
11
|
+
*
|
|
12
|
+
* 档位解析:会话显式设档优先,否则 perProvider → env → 配置默认(show 还可 auto)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parsePromptCommand, type AxisName } from "./command.ts";
|
|
16
|
+
import { PromptConfigManager } from "./config.ts";
|
|
17
|
+
import { CalibrationManager, pricingFor } from "./calibration.ts";
|
|
18
|
+
import { AutoTierChooser, beijingDate, estimateMuFromInput, taskBinForInput } from "./auto.ts";
|
|
19
|
+
import { clampMaxTokens } from "./request.ts";
|
|
20
|
+
import { PromptConfigDrawer } from "./ui.ts";
|
|
21
|
+
import type { TaskBin } from "./stats.ts";
|
|
22
|
+
import {
|
|
23
|
+
isDeactivationCommand,
|
|
24
|
+
normalizeDoMode,
|
|
25
|
+
normalizeShowMode,
|
|
26
|
+
normalizeWriteMode,
|
|
27
|
+
type DoMode,
|
|
28
|
+
type RuntimeShowMode,
|
|
29
|
+
type ShowMode,
|
|
30
|
+
type WriteMode,
|
|
31
|
+
} from "./modes.ts";
|
|
32
|
+
import { PromptRegistry } from "./prompts.ts";
|
|
33
|
+
import { REDUCTION_BY_SHOW, UsageLedger, estimateSaved, ensurePricingResolver, getPricingSource, summarizeUsage, showLabel } from "./stats.ts";
|
|
34
|
+
import { formatTokens } from "./format.ts";
|
|
35
|
+
import type {
|
|
36
|
+
ExtensionAPI,
|
|
37
|
+
ExtensionCommandContext,
|
|
38
|
+
ExtensionContext,
|
|
39
|
+
} from "@earendil-works/pi-coding-agent";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 会话条目的自定义载荷结构(三轴持久化 + 清除)
|
|
43
|
+
*/
|
|
44
|
+
interface PromptAxesEntry {
|
|
45
|
+
type: string;
|
|
46
|
+
customType: string;
|
|
47
|
+
data?: {
|
|
48
|
+
cleared?: boolean;
|
|
49
|
+
show?: string;
|
|
50
|
+
write?: string;
|
|
51
|
+
do?: string;
|
|
52
|
+
/** v0.1 遗留 prompt-mode 条目的旧字段(show 旧称 mode) */
|
|
53
|
+
mode?: string;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 状态栏灰化 ANSI:pi 的内置 footer(pwd/token 统计)是 dimGray,而扩展 status
|
|
59
|
+
* 文本默认原色渲染会偏亮;用 SGR dim 包一层与 pi-tui 内部 dim 用法(\x1b[2m…\x1b[22m)
|
|
60
|
+
* 对齐,在任何终端主题下都呈现"默认灰"。
|
|
61
|
+
*/
|
|
62
|
+
const DIM_ON = "\x1b[2m";
|
|
63
|
+
const DIM_OFF = "\x1b[22m";
|
|
64
|
+
|
|
65
|
+
/** 单轮 token 用量的真实形状(pi 的 usage 字段够用即可,超出忽略) */
|
|
66
|
+
interface RawUsage {
|
|
67
|
+
input?: number;
|
|
68
|
+
output?: number;
|
|
69
|
+
cacheRead?: number;
|
|
70
|
+
cacheWrite?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 会话状态引用(status 刷新可能来自命令或事件) */
|
|
74
|
+
type AnyContext = ExtensionContext | ExtensionCommandContext;
|
|
75
|
+
|
|
76
|
+
/** 校准探针数量(/prompt check --calibrate 跑几轮对照组) */
|
|
77
|
+
const PROBE_TURNS: number = 3;
|
|
78
|
+
|
|
79
|
+
/** 扩展主类:装配事件与命令 */
|
|
80
|
+
export class PromptExtension {
|
|
81
|
+
/** 会话显式三轴;null=未设(走自动解析);{cleared} 已重置 */
|
|
82
|
+
private sessionAxes: { show?: ShowMode; write?: WriteMode; do?: DoMode } | null = null;
|
|
83
|
+
/** 状态栏是否隐藏(可配置,隐藏不关规则) */
|
|
84
|
+
private hideStatus = false;
|
|
85
|
+
/** 最近一次渲染上下文 */
|
|
86
|
+
private lastCtx: AnyContext | undefined;
|
|
87
|
+
/** pi API 句柄 */
|
|
88
|
+
private pi: ExtensionAPI | undefined;
|
|
89
|
+
/** 本次扩展实例会话号(usage 按会话展开明细) */
|
|
90
|
+
private readonly runId: string;
|
|
91
|
+
/** 最近一次用户消息的粗 token 估算(µ 估计输入) */
|
|
92
|
+
private lastUserTokens = 120;
|
|
93
|
+
/** 上一回合是否命中缓存(auto 成本函数输入) */
|
|
94
|
+
private cacheState = false;
|
|
95
|
+
/** 价格来源提醒是否已发过(每会话一次,避免刷屏) */
|
|
96
|
+
private pricingHintShown = false;
|
|
97
|
+
/** 最近一次 before_agent_start 的实际解析结果(台账记录用) */
|
|
98
|
+
private lastResolved: { show: RuntimeShowMode; write: WriteMode; do: DoMode; taskBin: TaskBin; decision: string } | null = null;
|
|
99
|
+
/** 剩余探针回合数(>0 时本轮不注入,采集对照组) */
|
|
100
|
+
private probeTurnsLeft = 0;
|
|
101
|
+
/** 探针采集完成后待重拟合标记 */
|
|
102
|
+
private pendingRecalibrate = false;
|
|
103
|
+
/** auto 低频抽样状态(默认关;见 config autoSample) */
|
|
104
|
+
private turnsSinceSample = 0;
|
|
105
|
+
private todaySampleDay = ""; // 北京时区 YYYY-MM-DD
|
|
106
|
+
private todaySampleCount = 0;
|
|
107
|
+
|
|
108
|
+
/** 三轴注入组合器 */
|
|
109
|
+
private readonly registry: PromptRegistry;
|
|
110
|
+
/** 配置管理器 */
|
|
111
|
+
private readonly config: PromptConfigManager;
|
|
112
|
+
/** 用量台账 */
|
|
113
|
+
private readonly ledger: UsageLedger;
|
|
114
|
+
/** 校准管理器(自进化) */
|
|
115
|
+
private readonly calibration: CalibrationManager;
|
|
116
|
+
/** show=auto 选档器 */
|
|
117
|
+
private readonly chooser: AutoTierChooser;
|
|
118
|
+
/** 三轴设置抽屉(/settings 同款交互,见 DESIGN.md) */
|
|
119
|
+
private readonly drawer: PromptConfigDrawer;
|
|
120
|
+
|
|
121
|
+
/** 依赖注入构造:测试可替换 config/registry/ledger/calibration/chooser */
|
|
122
|
+
constructor(
|
|
123
|
+
config?: PromptConfigManager,
|
|
124
|
+
registry?: PromptRegistry,
|
|
125
|
+
ledger?: UsageLedger,
|
|
126
|
+
calibration?: CalibrationManager,
|
|
127
|
+
chooser?: AutoTierChooser,
|
|
128
|
+
) {
|
|
129
|
+
this.config = config ?? new PromptConfigManager();
|
|
130
|
+
this.registry = registry ?? new PromptRegistry();
|
|
131
|
+
this.ledger = ledger ?? new UsageLedger();
|
|
132
|
+
this.calibration = calibration ?? new CalibrationManager();
|
|
133
|
+
this.chooser =
|
|
134
|
+
chooser ??
|
|
135
|
+
new AutoTierChooser(
|
|
136
|
+
(provider, model, ts) => pricingFor(provider, model, ts),
|
|
137
|
+
(provider, bin, mode) => this.calibration.reductionFor(provider, bin, mode) ?? REDUCTION_BY_SHOW[mode],
|
|
138
|
+
(provider, bin) => this.calibration.sampleCountFor(provider, bin),
|
|
139
|
+
);
|
|
140
|
+
this.runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
141
|
+
this.drawer = new PromptConfigDrawer(
|
|
142
|
+
() => {
|
|
143
|
+
const provider = this.lastCtx?.model?.provider;
|
|
144
|
+
return {
|
|
145
|
+
session: {
|
|
146
|
+
show: this.effectiveShow(provider),
|
|
147
|
+
write: this.effectiveWrite(provider),
|
|
148
|
+
do: this.effectiveDo(provider),
|
|
149
|
+
},
|
|
150
|
+
defaults: this.config.getDefaults(),
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
{
|
|
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
|
+
}
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
(context) => {
|
|
166
|
+
// 无 TUI(非交互模式):回退为文字状态
|
|
167
|
+
this.showStatus(context as AnyContext);
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── 档位解析 ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** show 轴生效档位(会话显式 > perProvider/env/默认;可为 auto) */
|
|
175
|
+
private effectiveShow(provider: string | undefined): ShowMode {
|
|
176
|
+
return this.sessionAxes?.show ?? this.config.showForProvider(provider);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** write 轴生效档位 */
|
|
180
|
+
private effectiveWrite(provider: string | undefined): WriteMode {
|
|
181
|
+
return this.sessionAxes?.write ?? this.config.writeForProvider(provider);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** do 轴生效档位 */
|
|
185
|
+
private effectiveDo(provider: string | undefined): DoMode {
|
|
186
|
+
return this.sessionAxes?.do ?? this.config.doForProvider(provider);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 读取会话持久化的三轴(末尾优先;prompt-axes 优先于旧 prompt-mode) */
|
|
190
|
+
private readSessionAxes(ctx: ExtensionContext): { show?: ShowMode; write?: WriteMode; do?: DoMode } | null {
|
|
191
|
+
const entries = (ctx.sessionManager.getEntries() as PromptAxesEntry[]) ?? [];
|
|
192
|
+
let legacyShow: ShowMode | null = null;
|
|
193
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
194
|
+
const entry = entries[i];
|
|
195
|
+
if (entry?.type !== "custom") continue;
|
|
196
|
+
if (entry.customType === "prompt-axes") {
|
|
197
|
+
const data = entry.data;
|
|
198
|
+
// 最近一条 prompt-axes 决定会话覆盖:cleared → 无覆盖
|
|
199
|
+
if (data?.cleared === true) return null;
|
|
200
|
+
const axes: { show?: ShowMode; write?: WriteMode; do?: DoMode } = {};
|
|
201
|
+
const show = normalizeShowMode(data?.show);
|
|
202
|
+
const write = normalizeWriteMode(data?.write);
|
|
203
|
+
const doMode = normalizeDoMode(data?.do);
|
|
204
|
+
if (show !== null) axes.show = show;
|
|
205
|
+
if (write !== null) axes.write = write;
|
|
206
|
+
if (doMode !== null) axes.do = doMode;
|
|
207
|
+
return Object.keys(axes).length === 0 ? null : axes;
|
|
208
|
+
}
|
|
209
|
+
if (entry.customType === "prompt-mode") {
|
|
210
|
+
// v0.1 遗留单档条目的兜底(仅当没有 prompt-axes 时使用)
|
|
211
|
+
legacyShow = normalizeShowMode(entry.data?.show ?? entry.data?.mode);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return legacyShow !== null ? { show: legacyShow } : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
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
|
+
// ── 运行时解析(auto → 具体档) ──────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* auto 低频自动抽样:每 interval 轮 + 当日≤maxPerDay + 仅 show=auto + 不与手动探针重叠 →
|
|
230
|
+
* 下一回合走 normal(不注入)采对照组样本,turn_end 重拟合。
|
|
231
|
+
* 返回 true 当且仅当本轮应触发抽样(状态已更新,调用方直接返回 sample 决策)。
|
|
232
|
+
*/
|
|
233
|
+
private shouldSample(): boolean {
|
|
234
|
+
if (!this.config.isAutoSample()) return false;
|
|
235
|
+
if (this.probeTurnsLeft > 0) return false;
|
|
236
|
+
const provider = this.lastCtx?.model?.provider;
|
|
237
|
+
if (this.effectiveShow(provider) !== "auto") return false;
|
|
238
|
+
const now = new Date();
|
|
239
|
+
const day = beijingDate(now);
|
|
240
|
+
if (day !== this.todaySampleDay) { this.todaySampleDay = day; this.todaySampleCount = 0; }
|
|
241
|
+
this.turnsSinceSample += 1;
|
|
242
|
+
if (this.turnsSinceSample < this.config.autoSampleInterval()) return false;
|
|
243
|
+
if (this.todaySampleCount >= this.config.autoSampleMaxPerDay()) return false;
|
|
244
|
+
// 触发:重置计数,由 turn_end 负责 pendingRecalibrate
|
|
245
|
+
this.turnsSinceSample = 0;
|
|
246
|
+
this.todaySampleCount += 1;
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 解析并缓存本回合三轴实际值(auto show 走选档器;探针/抽样回合强制不注入) */
|
|
251
|
+
private resolveAxes(provider: string | undefined, model: string | undefined): { show: RuntimeShowMode; write: WriteMode; do: DoMode; decision: string } {
|
|
252
|
+
if (this.probeTurnsLeft > 0) {
|
|
253
|
+
this.probeTurnsLeft -= 1;
|
|
254
|
+
if (this.probeTurnsLeft === 0) this.pendingRecalibrate = true;
|
|
255
|
+
return { show: "normal", write: "normal", do: "normal", decision: "probe" };
|
|
256
|
+
}
|
|
257
|
+
// auto 低频抽样(每 interval 轮采一次对照组;decision="sample",turn_end 触发重拟合)
|
|
258
|
+
if (this.shouldSample()) {
|
|
259
|
+
this.pendingRecalibrate = true;
|
|
260
|
+
return { show: "normal", write: "normal", do: "normal", decision: "sample" };
|
|
261
|
+
}
|
|
262
|
+
const write = this.effectiveWrite(provider);
|
|
263
|
+
const doMode = this.effectiveDo(provider);
|
|
264
|
+
const preferred = this.effectiveShow(provider);
|
|
265
|
+
if (preferred !== "auto") {
|
|
266
|
+
return { show: preferred, write, do: doMode, decision: "manual" };
|
|
267
|
+
}
|
|
268
|
+
const bin = taskBinForInput(this.lastUserTokens);
|
|
269
|
+
const estMu = this.calibration.muFor(provider ?? "unknown", bin) ?? estimateMuFromInput(this.lastUserTokens);
|
|
270
|
+
// 峰谷不再由这里硬编码推算:由价格源(pi-pricer / 兜底)随价格返回 isPeak;
|
|
271
|
+
// peakUpgrade:false 时传 false,关闭"高峰升档"这层优化(仅影响升档定价)
|
|
272
|
+
const peakUpgrade = this.config.isPeakUpgrade();
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
const modelId = model ?? "unknown";
|
|
275
|
+
const chosen = this.chooser.choose(provider ?? "unknown", modelId, bin, estMu, this.cacheState, now, peakUpgrade);
|
|
276
|
+
const isPeak = peakUpgrade && pricingFor(provider ?? "unknown", modelId, now).isPeak;
|
|
277
|
+
return {
|
|
278
|
+
show: chosen,
|
|
279
|
+
write,
|
|
280
|
+
do: doMode,
|
|
281
|
+
decision: `auto: µ=${estMu},${this.cacheState ? "cached" : "cold"}${isPeak ? ",peak" : ""}→${chosen}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── 状态栏 ───────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 渲染状态栏文本(v0.2.2 footer 风格):三轴全显、纯文字、去掉 ●/○ 与档位图标。
|
|
289
|
+
* show=auto 时显示本轮实际解析档;尚无解析的首帧回退显示 auto。
|
|
290
|
+
* 恒常显示(不做"全局关停即隐藏")——用户拍板"一直显示"。
|
|
291
|
+
*/
|
|
292
|
+
private statusText(provider: string | undefined): string {
|
|
293
|
+
const preferred = this.effectiveShow(provider);
|
|
294
|
+
const show = preferred === "auto" ? (this.lastResolved?.show ?? "auto") : preferred;
|
|
295
|
+
return `PROMPT ${show} · ${this.effectiveWrite(provider)} · ${this.effectiveDo(provider)}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 同步状态栏(hideStatus 配置可隐藏;文本永远是三轴全显,灰化显示) */
|
|
299
|
+
private syncStatus(ctx: AnyContext | undefined): void {
|
|
300
|
+
if (this.hideStatus || !ctx) return;
|
|
301
|
+
try {
|
|
302
|
+
ctx.ui.setStatus("pi-prompt", `${DIM_ON}${this.statusText(ctx.model?.provider)}${DIM_OFF}`);
|
|
303
|
+
} catch {
|
|
304
|
+
// 主题/状态栏异常一律静默
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── 会话档位更新与持久化 ─────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/** 设置某轴会话档位:合并持久化 + 同步状态栏 + 提示 */
|
|
311
|
+
private applyAxis(axis: AxisName, value: ShowMode | WriteMode | DoMode, ctx: AnyContext): void {
|
|
312
|
+
const current = this.sessionAxes ?? {};
|
|
313
|
+
if (axis === "show") current.show = value as ShowMode;
|
|
314
|
+
if (axis === "write") current.write = value as WriteMode;
|
|
315
|
+
if (axis === "do") current.do = value as DoMode;
|
|
316
|
+
this.sessionAxes = current;
|
|
317
|
+
this.pi?.appendEntry("prompt-axes", { show: current.show, write: current.write, do: current.do });
|
|
318
|
+
this.syncStatus(ctx);
|
|
319
|
+
ctx.ui.notify(`pi-prompt: ${axis}=${String(value)}`, "info");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 全局回到三轴默认:清空会话覆盖并持久化清除标记 */
|
|
323
|
+
private resetToDefaults(ctx: AnyContext): void {
|
|
324
|
+
this.sessionAxes = null;
|
|
325
|
+
this.pi?.appendEntry("prompt-axes", { cleared: true });
|
|
326
|
+
this.syncStatus(ctx);
|
|
327
|
+
const defaults = this.config.getDefaults();
|
|
328
|
+
ctx.ui.notify(`pi-prompt 已回到默认:show=${String(defaults.show)}`, "info");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── 展示 ─────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/** /prompt status:三轴现状/默认/厂商映射/台账估算 */
|
|
334
|
+
private showStatus(ctx: AnyContext): void {
|
|
335
|
+
const provider = ctx.model?.provider;
|
|
336
|
+
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 ? "(会话显式)" : "(自动)"}`);
|
|
343
|
+
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
|
+
);
|
|
352
|
+
const records = this.ledger.readAll();
|
|
353
|
+
if (records.length > 0) {
|
|
354
|
+
const est = estimateSaved(records);
|
|
355
|
+
lines.push(` 台账估算: 输出 ${formatTokens(est.outputTokens)},估省 ${formatTokens(est.savedOutputTokens)} ≈ ¥${est.savedCNY.toFixed(4)}(保守假设)`);
|
|
356
|
+
} else {
|
|
357
|
+
lines.push(" 台账: 暂无记录");
|
|
358
|
+
}
|
|
359
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** /prompt usage:DeepSeek 风格的 token/金额台账表 */
|
|
363
|
+
private showUsage(ctx: AnyContext): void {
|
|
364
|
+
const records = this.ledger.readAll();
|
|
365
|
+
if (records.length === 0) {
|
|
366
|
+
ctx.ui.notify("pi-prompt 台账为空(还没有 turn_end 用量记录)", "info");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
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)}`);
|
|
373
|
+
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)}`);
|
|
375
|
+
}
|
|
376
|
+
if (summary.sessions.length > 0) {
|
|
377
|
+
lines.push("按会话:");
|
|
378
|
+
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)}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 价格来源提醒(每种情况每会话只提一次)。
|
|
387
|
+
* - 未装 pi-pricer:建议安装以获得用户自配的精准价格
|
|
388
|
+
* - 装了但解析失败:暴露原因(否则用户会以为算的是自己配的价)
|
|
389
|
+
*/
|
|
390
|
+
private notifyPricingFallbackOnce(ctx: AnyContext): void {
|
|
391
|
+
if (this.pricingHintShown) return;
|
|
392
|
+
const { source, reason } = getPricingSource();
|
|
393
|
+
switch (source) {
|
|
394
|
+
case "pi-pricer":
|
|
395
|
+
return; // 权威价表,无需提醒
|
|
396
|
+
case "failed":
|
|
397
|
+
this.pricingHintShown = true;
|
|
398
|
+
ctx.ui.notify(`pi-pricer 价格解析失败(${reason ?? "未知原因"}),本次按内置兜底价估算。请检查 ~/.pi/model-pricing.json。`, "warning");
|
|
399
|
+
return;
|
|
400
|
+
case "builtin":
|
|
401
|
+
this.pricingHintShown = true;
|
|
402
|
+
ctx.ui.notify("当前用内置兜底近似价估算成本。安装 pi-pricer 并维护 ~/.pi/model-pricing.json,即可让成本与 auto 选档按你自己配置的真实价格(含峰谷)计算。", "info");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 价格来源人读文案:让用户知道成本/auto 按什么价算的。
|
|
409
|
+
* pi-pricer 已装但加载失败时给出原因(不再完全静默)。
|
|
410
|
+
*/
|
|
411
|
+
private describePricingSource(): string {
|
|
412
|
+
const { source, reason } = getPricingSource();
|
|
413
|
+
switch (source) {
|
|
414
|
+
case "pi-pricer":
|
|
415
|
+
return "pi-pricer JSON 价表(~/.pi/model-pricing.json,含自定义峰谷)";
|
|
416
|
+
case "failed":
|
|
417
|
+
return `内置兜底价(pi-pricer 解析失败:${reason ?? "未知原因"})`;
|
|
418
|
+
case "builtin":
|
|
419
|
+
return "内置兜底近似价(未安装 pi-pricer,建议安装以获得你自配的精准价格)";
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** /prompt check:台账完整性 + 校准状态;--calibrate 触发探针 */
|
|
424
|
+
private showCheck(ctx: AnyContext, calibrate: boolean): void {
|
|
425
|
+
const integrity = this.ledger.integrity();
|
|
426
|
+
const calFile = this.calibration.refresh();
|
|
427
|
+
const fitted: string[] = [];
|
|
428
|
+
for (const [provider, providerFit] of Object.entries(calFile.byProvider)) {
|
|
429
|
+
for (const [bin, binFit] of Object.entries(providerFit.byBin)) {
|
|
430
|
+
if (binFit.mu !== undefined) fitted.push(`${provider}#${bin}(µ${binFit.mu}, ctl${binFit.normal?.n ?? binFit.off?.n ?? 0})`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
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 轮对照组探针(本轮不注入地采集基线)并重拟合");
|
|
438
|
+
if (calibrate) {
|
|
439
|
+
lines.push(" 已排队 3 轮探针…");
|
|
440
|
+
this.queueProbes(ctx);
|
|
441
|
+
}
|
|
442
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 排队校准探针:剩余探针回合 + 依次发送固定题(仅 idle 才发,否则 followUp) */
|
|
446
|
+
private queueProbes(ctx: AnyContext): void {
|
|
447
|
+
if (!this.pi) return;
|
|
448
|
+
this.probeTurnsLeft = PROBE_TURNS;
|
|
449
|
+
const probePrompts: string[] = [
|
|
450
|
+
"pi-prompt 校准探针 1/3:请用约 3 句话解释什么是回调函数。",
|
|
451
|
+
"pi-prompt 校准探针 2/3:请用约 3 句话说明 git rebase 与 merge 的区别。",
|
|
452
|
+
"pi-prompt 校准探针 3/3:请用约 3 句话介绍 HOF 与装饰器。",
|
|
453
|
+
];
|
|
454
|
+
for (const prompt of probePrompts) {
|
|
455
|
+
if (ctx.isIdle?.() === false) {
|
|
456
|
+
this.pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
457
|
+
} else {
|
|
458
|
+
this.pi.sendUserMessage(prompt);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── 台账 ─────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/** turn_end:采集用量追加台账;探针采集完触发重拟合 */
|
|
466
|
+
private recordUsage(raw: RawUsage | undefined, provider: string | undefined, model: string | undefined, ctx: AnyContext): void {
|
|
467
|
+
const resolved = this.lastResolved;
|
|
468
|
+
if (raw && resolved) {
|
|
469
|
+
this.ledger.append({
|
|
470
|
+
ts: Date.now(),
|
|
471
|
+
provider: provider ?? "unknown",
|
|
472
|
+
model: model ?? "unknown",
|
|
473
|
+
show: resolved.show,
|
|
474
|
+
write: resolved.write,
|
|
475
|
+
do: resolved.do,
|
|
476
|
+
taskBin: resolved.taskBin,
|
|
477
|
+
decision: resolved.decision,
|
|
478
|
+
session: this.runId,
|
|
479
|
+
input: raw.input ?? 0,
|
|
480
|
+
output: raw.output ?? 0,
|
|
481
|
+
cacheRead: raw.cacheRead ?? 0,
|
|
482
|
+
cacheWrite: raw.cacheWrite ?? 0,
|
|
483
|
+
});
|
|
484
|
+
// 缓存状态滚动更新(auto 的成本项输入)
|
|
485
|
+
this.cacheState = (raw.cacheRead ?? 0) > 0;
|
|
486
|
+
}
|
|
487
|
+
if (this.pendingRecalibrate) {
|
|
488
|
+
this.pendingRecalibrate = false;
|
|
489
|
+
const records = this.ledger.readAll();
|
|
490
|
+
const result = this.calibration.recalibrate(records);
|
|
491
|
+
ctx.ui.notify(
|
|
492
|
+
result.changed ? "校准完成:已按台账重拟合缩减率" : "校准完成:样本不足未改动系数(需 ≥5 对照组 / ≥3 各档)",
|
|
493
|
+
"info",
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── 装配 ─────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/** 装配所有命令与事件(pi 生命周期入口) */
|
|
501
|
+
mount(pi: ExtensionAPI): void {
|
|
502
|
+
this.pi = pi;
|
|
503
|
+
// 后台预热 JSON 价表(pi-pricer 可选集成;失败静默保持内置近似价)
|
|
504
|
+
void ensurePricingResolver();
|
|
505
|
+
|
|
506
|
+
pi.registerCommand("prompt", {
|
|
507
|
+
description: "三轴输出控制:config/status/usage/check",
|
|
508
|
+
getArgumentCompletions: () => [
|
|
509
|
+
{ value: "config", label: "config", description: "打开三轴设置抽屉(会话档 + 默认档)" },
|
|
510
|
+
{ value: "status", label: "status", description: "显示三轴现状与台账估算" },
|
|
511
|
+
{ value: "usage", label: "usage", description: "token/金额台账表" },
|
|
512
|
+
{ value: "check", label: "check", description: "自检(--calibrate 触发探针校准)" },
|
|
513
|
+
],
|
|
514
|
+
handler: async (args, ctx) => {
|
|
515
|
+
this.lastCtx = ctx;
|
|
516
|
+
const cmd = parsePromptCommand(args);
|
|
517
|
+
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);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
case "status":
|
|
530
|
+
this.showStatus(ctx);
|
|
531
|
+
return;
|
|
532
|
+
case "usage":
|
|
533
|
+
// 先用 JSON 价表(pi-pricer)解析价格,失败自动兜底内置近似价
|
|
534
|
+
await ensurePricingResolver();
|
|
535
|
+
this.notifyPricingFallbackOnce(ctx);
|
|
536
|
+
this.showUsage(ctx);
|
|
537
|
+
return;
|
|
538
|
+
case "check":
|
|
539
|
+
this.showCheck(ctx, cmd.calibrate);
|
|
540
|
+
return;
|
|
541
|
+
case "config":
|
|
542
|
+
await this.drawer.open(ctx);
|
|
543
|
+
return;
|
|
544
|
+
case "invalid":
|
|
545
|
+
ctx.ui.notify(`未知参数 "${cmd.arg}"\n用法: /prompt [config | status | usage | check [--calibrate]](无参=全局开关)`, "warning");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
pi.registerCommand("prompt-review", {
|
|
552
|
+
description: "运行 /skill:prompt-review 审计上一条回复的冗余",
|
|
553
|
+
handler: async (_args, ctx) => {
|
|
554
|
+
this.lastCtx = ctx;
|
|
555
|
+
if (ctx.isIdle?.() === false) {
|
|
556
|
+
pi.sendUserMessage("/skill:prompt-review", { deliverAs: "followUp" });
|
|
557
|
+
ctx.ui.notify("prompt-review 已排队为下一条", "info");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
pi.sendUserMessage("/skill:prompt-review");
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// 全词关停:"stop pi-prompt" / "normal mode" → 三轴回默认
|
|
565
|
+
pi.on("input", (event) => {
|
|
566
|
+
if (event?.source === "extension") return;
|
|
567
|
+
if (typeof event?.text === "string") {
|
|
568
|
+
const raw = event.text;
|
|
569
|
+
this.lastUserTokens = Math.max(1, Math.round(raw.length / 2.2));
|
|
570
|
+
}
|
|
571
|
+
if (!isDeactivationCommand(event?.text)) return;
|
|
572
|
+
const ctx = this.lastCtx;
|
|
573
|
+
if (ctx) this.resetToDefaults(ctx);
|
|
574
|
+
else this.sessionAxes = null;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// 会话启动/恢复:读会话三轴 + 隐藏开关
|
|
578
|
+
pi.on("session_start", (_event, ctx) => {
|
|
579
|
+
this.lastCtx = ctx;
|
|
580
|
+
this.hideStatus = this.config.isHideStatus();
|
|
581
|
+
this.sessionAxes = this.readSessionAxes(ctx);
|
|
582
|
+
this.syncStatus(ctx);
|
|
583
|
+
if (!this.config.isQuietStartup()) {
|
|
584
|
+
const show = this.effectiveShow(ctx.model?.provider);
|
|
585
|
+
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");
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// 切换模型:状态栏即时刷新
|
|
590
|
+
pi.on("model_select", (_event, ctx) => {
|
|
591
|
+
this.syncStatus(ctx);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// max_tokens 上限钳制(默认关;config maxTokensCap 或 env PI_PROMPT_MAX_TOKENS)
|
|
595
|
+
pi.on("before_provider_request", (event) => {
|
|
596
|
+
const cap = this.config.maxTokensCap();
|
|
597
|
+
if (cap === null) return;
|
|
598
|
+
const payload = event?.payload;
|
|
599
|
+
if (payload === undefined || payload === null) return;
|
|
600
|
+
return { payload: clampMaxTokens(payload, cap) };
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// 核心注入:解析三轴(auto→选档)→ 固定顺序拼接恒定文案追加到系统提示末尾
|
|
604
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
605
|
+
const provider = ctx.model?.provider;
|
|
606
|
+
const model = ctx.model?.id;
|
|
607
|
+
const resolved = this.resolveAxes(provider, model);
|
|
608
|
+
this.lastResolved = { ...resolved, taskBin: taskBinForInput(this.lastUserTokens) };
|
|
609
|
+
// footer 状态同步为"本轮实际档"(show=auto 时)
|
|
610
|
+
this.syncStatus(ctx);
|
|
611
|
+
const text = this.registry.compose(resolved.show, resolved.write, resolved.do);
|
|
612
|
+
if (!text) return;
|
|
613
|
+
const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
|
|
614
|
+
return { systemPrompt: `${base}${text}` };
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// turn_end:采集真实 token 用量 → 台账 + 探针后重拟合
|
|
618
|
+
pi.on("turn_end", (event, ctx) => {
|
|
619
|
+
const raw = (event.message as { usage?: RawUsage }).usage;
|
|
620
|
+
this.recordUsage(raw, ctx.model?.provider, ctx.model?.id, ctx);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|