@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
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 三轴注入提示词(策略模式)。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 铁律:每个策略的注入块都必须是【逐字节稳定】的恒定字符串 —— 组合仍要
|
|
5
|
+
* 恒定拼接,追加在系统提示末尾;改动任何字节都会破坏提示前缀的 KV 缓存命中,
|
|
6
|
+
* 直接抬高 input miss 成本(DeepSeek miss 1 元/百万)。发布后若要改文案,
|
|
7
|
+
* 只能文档化迁移、不能在原位编辑(认知修正 #1)。
|
|
8
|
+
*
|
|
9
|
+
* show 轴文案沿用 v0.1(逐字节未动);write/do 为 v0.2 新增。
|
|
10
|
+
* 单块均 ≤160 token;三轴全开合计 ~450 token/轮,仍远低于 caveman 规则
|
|
11
|
+
* 每轮 1-1.5k 输入 token 的量级。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DoMode, RuntimeShowMode, WriteMode } from "./modes.ts";
|
|
15
|
+
|
|
16
|
+
/** 单个注入块策略接口(每轴每档 = 一个策略类) */
|
|
17
|
+
interface BlobStrategy {
|
|
18
|
+
/** 返回该档注入的恒定文案(不注入的档返回空串) */
|
|
19
|
+
inject(): string;
|
|
20
|
+
/** 档位一句话说明(/prompt status 与补全用) */
|
|
21
|
+
readonly description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 公共基类:统一拼块结构,保证文案恒定 */
|
|
25
|
+
abstract class BaseBlobStrategy implements BlobStrategy {
|
|
26
|
+
abstract readonly description: string;
|
|
27
|
+
/** 档位专属规则(各子类提供常量) */
|
|
28
|
+
protected abstract body(): string;
|
|
29
|
+
|
|
30
|
+
inject(): string {
|
|
31
|
+
const body = this.body();
|
|
32
|
+
return body === "" ? "" : body;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── show 轴(输出风格)─────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** show=normal:不注入任何文案(基础行为) */
|
|
39
|
+
class ShowNormalStrategy extends BaseBlobStrategy {
|
|
40
|
+
readonly description = "不注入输出风格(基础行为)";
|
|
41
|
+
protected body(): string {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** show=lite:温和 —— 只提基本原则,风险最低 */
|
|
47
|
+
class ShowLiteStrategy extends BaseBlobStrategy {
|
|
48
|
+
readonly description = "温和:直接作答、不重复上下文、不堆废话";
|
|
49
|
+
protected body(): string {
|
|
50
|
+
return "输出精炼:直接给结论/代码,不重复用户已提供的上下文,不为凑篇幅解释显然之事。保留:错误处理、安全提示、用户明确要求的信息。Be terse: answer directly, don't restate given context, skip obvious filler. Keep error handling, security notes, and explicitly requested info.";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** show=full:均衡 —— 精简散文 + 明确边界(默认档位,文案同 v0.1) */
|
|
55
|
+
class ShowFullStrategy extends BaseBlobStrategy {
|
|
56
|
+
readonly description = "均衡:代码/结论优先、冗长限行、保留关键信息(默认)";
|
|
57
|
+
protected body(): string {
|
|
58
|
+
return "输出精炼(ACTIVE EVERY RESPONSE):\n- 先答/先代码,再至多三行说明;说明比代码长就删说明。\n- 不重复用户上下文;不奉承、不寒暄、不邀请追问。\n- 保留:错误处理、安全提示、用户明确要求的信息、必要的路径/报错原文。\n- 用户明确要求详细解释时,照给全文,不因精炼而砍。\nConcise every response: answer/code first, at most 3 lines of prose, no restating context or flattery; keep error handling, security, and explicitly requested detail. Turn off: /prompt";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** show=ultra:激进 —— 电报式极简(caveman 精神,代价是观感冷硬) */
|
|
63
|
+
class ShowUltraStrategy extends BaseBlobStrategy {
|
|
64
|
+
readonly description = "激进:电报式短句/列表,最大压缩散文";
|
|
65
|
+
protected body(): string {
|
|
66
|
+
return "极简电报式输出:\n- 单词/短句;避免散文、连接词、客套、复述与前缀铺垫。\n- 列表优先;不解释“为什么这样写”除非被问;不奉承不邀约。\n- 绝不砍:错误处理、安全与数据保护、用户点名要的信息、代码/路径/报错原文。\nUltra-terse telegraphic output: short statements or terse bullets, no prose, no filler, no repetition. Never trim error handling, security, or explicitly requested content.";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── write 轴(代码写量)───────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** write=normal:不注入(基线,不做额外约束) */
|
|
73
|
+
class WriteNormalStrategy extends BaseBlobStrategy {
|
|
74
|
+
readonly description = "代码写量不约束(基线)";
|
|
75
|
+
protected body(): string {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** write=minimal:最短可行 —— 保守版(不砍安全、只去超纲封装) */
|
|
81
|
+
class WriteMinimalStrategy extends BaseBlobStrategy {
|
|
82
|
+
readonly description = "最小:给最短可行实现,注释只留必要、不做超纲抽象";
|
|
83
|
+
protected body(): string {
|
|
84
|
+
return "写最少能跑的代码:只交付问题所需的最小实现;注释仅在必要处;不做未要求的类/抽象/防御性扩展。不砍:错误处理与既有功能的完整可用性。Minimal runnable code: smallest change that does the job, no speculative abstraction, no unrequested expansion. Keep error handling and full usability.";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** write=complete:完整交付 —— 可直接落地的完整实现 */
|
|
89
|
+
class WriteCompleteStrategy extends BaseBlobStrategy {
|
|
90
|
+
readonly description = "完整:给可直接落地的完整实现(导入/类型/边界/错误处理)";
|
|
91
|
+
protected body(): string {
|
|
92
|
+
return "完整交付:给出可直接落地的完整实现,含必要的导入、类型、边界条件与错误处理;示例按可运行标准写全。Complete: ship a ready-to-run implementation with imports, types, edge cases and error handling.";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── do 轴(行为力度)───────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** do=normal:不注入(基线) */
|
|
99
|
+
class DoNormalStrategy extends BaseBlobStrategy {
|
|
100
|
+
readonly description = "行为力度不约束(基线)";
|
|
101
|
+
protected body(): string {
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** do=direct:只做被明确要求的事 */
|
|
107
|
+
class DoDirectStrategy extends BaseBlobStrategy {
|
|
108
|
+
readonly description = "只做被明确要求的事:不自动扩大范围、不擅自调研";
|
|
109
|
+
protected body(): string {
|
|
110
|
+
return "只做被明确要求的事:不自动扩大范围、不读无关文件/跑无关命令、不预热后续步骤;需要延展时先问,而不是擅自做。Do only what's asked: no scope creep, no unrequested investigation or extra steps; ask before expanding.";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** do=deep:深入钻研 —— 查根因与边界后再给结论 */
|
|
115
|
+
class DoDeepStrategy extends BaseBlobStrategy {
|
|
116
|
+
readonly description = "深入:主动查证根因/边界(读源码/文档/历史)再给结论";
|
|
117
|
+
protected body(): string {
|
|
118
|
+
return "深入钻研:主动查证根因与边界(读源码/文档/历史),把一场调研做透再给结论;允许扩展范围以覆盖相邻隐患。Go deep: investigate root causes and edge cases before concluding; may expand scope to cover adjacent risks.";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 三轴策略注册表:轴 → 档 → 策略实例(含描述/文案)。
|
|
124
|
+
* 内部按轴分族组织;textFor 返回单块文案,compose 负责固定顺序拼接。
|
|
125
|
+
*/
|
|
126
|
+
export class PromptRegistry {
|
|
127
|
+
/** show 轴注册表:档位(不含 auto,auto 由选档器解析后再走这里) */
|
|
128
|
+
private readonly show: Readonly<Record<RuntimeShowMode, BlobStrategy>>;
|
|
129
|
+
/** write 轴注册表 */
|
|
130
|
+
private readonly write: Readonly<Record<WriteMode, BlobStrategy>>;
|
|
131
|
+
/** do 轴注册表 */
|
|
132
|
+
private readonly do: Readonly<Record<DoMode, BlobStrategy>>;
|
|
133
|
+
|
|
134
|
+
constructor() {
|
|
135
|
+
this.show = {
|
|
136
|
+
normal: new ShowNormalStrategy(),
|
|
137
|
+
lite: new ShowLiteStrategy(),
|
|
138
|
+
full: new ShowFullStrategy(),
|
|
139
|
+
ultra: new ShowUltraStrategy(),
|
|
140
|
+
};
|
|
141
|
+
this.write = {
|
|
142
|
+
minimal: new WriteMinimalStrategy(),
|
|
143
|
+
normal: new WriteNormalStrategy(),
|
|
144
|
+
complete: new WriteCompleteStrategy(),
|
|
145
|
+
};
|
|
146
|
+
this.do = {
|
|
147
|
+
direct: new DoDirectStrategy(),
|
|
148
|
+
normal: new DoNormalStrategy(),
|
|
149
|
+
deep: new DoDeepStrategy(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** show 轴单块文案;normal 返回 undefined(不注入此块) */
|
|
154
|
+
textForShow(mode: RuntimeShowMode): string | undefined {
|
|
155
|
+
return this.show[mode].inject() || undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** write 轴单块文案;normal 返回 undefined */
|
|
159
|
+
textForWrite(mode: WriteMode): string | undefined {
|
|
160
|
+
return this.write[mode].inject() || undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** do 轴单块文案;normal 返回 undefined */
|
|
164
|
+
textForDo(mode: DoMode): string | undefined {
|
|
165
|
+
return this.do[mode].inject() || undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** show 轴档位说明 */
|
|
169
|
+
describeShow(mode: RuntimeShowMode): string {
|
|
170
|
+
return this.show[mode].description;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** write 轴档位说明 */
|
|
174
|
+
describeWrite(mode: WriteMode): string {
|
|
175
|
+
return this.write[mode].description;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** do 轴档位说明 */
|
|
179
|
+
describeDo(mode: DoMode): string {
|
|
180
|
+
return this.do[mode].description;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 组合注入文案:固定顺序拼接(show→write→do),头部一行档位标识。
|
|
185
|
+
* 组合确定性 → 每个 (show,write,do) 三元组都是恒定字符串(缓存友好)。
|
|
186
|
+
* 三块全空(show normal + write normal + do normal)返回 undefined(不注入)。
|
|
187
|
+
*/
|
|
188
|
+
compose(show: RuntimeShowMode, write: WriteMode, doMode: DoMode): string | undefined {
|
|
189
|
+
const blocks = [
|
|
190
|
+
this.textForShow(show),
|
|
191
|
+
this.textForWrite(write),
|
|
192
|
+
this.textForDo(doMode),
|
|
193
|
+
].filter((block): block is string => block !== undefined);
|
|
194
|
+
if (blocks.length === 0) return undefined;
|
|
195
|
+
return `[PI-PROMPT ${show}|${write}|${doMode}]\n${blocks.join("\n")}`;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求层纯函数:before_provider_request 事件辅助。
|
|
3
|
+
*
|
|
4
|
+
* v0.3.0:max_tokens 上限钳制(默认关;config `maxTokensCap`)。
|
|
5
|
+
* payload 为 OpenAI 兼容结构,字段 `max_tokens`;
|
|
6
|
+
* 防御式:非对象/无字段/非法值一律原样返回,只在值存在且正整数时执行 Math.min。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 将 payload 中的 max_tokens(或 max_completion_tokens)钳制到 cap。
|
|
11
|
+
* 返回新对象(引用安全);payload 非对象/缺字段/值非法时返回原引用。
|
|
12
|
+
*/
|
|
13
|
+
export function clampMaxTokens(payload: unknown, cap: number | null): unknown {
|
|
14
|
+
if (cap === null || typeof payload !== "object" || payload === null) return payload;
|
|
15
|
+
const p = payload as Record<string, unknown>;
|
|
16
|
+
const result = { ...p };
|
|
17
|
+
if (typeof p.max_tokens === "number" && p.max_tokens > 0) {
|
|
18
|
+
result.max_tokens = Math.min(p.max_tokens, cap);
|
|
19
|
+
}
|
|
20
|
+
// max_completion_tokens 是 OpenAI 新字段,同样钳制以保安全
|
|
21
|
+
if (typeof p.max_completion_tokens === "number" && p.max_completion_tokens > 0) {
|
|
22
|
+
result.max_completion_tokens = Math.min(p.max_completion_tokens, cap);
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用量台账:turn_end 采集 token 用量 → ~/.pi/pi-prompt-usage.jsonl。
|
|
3
|
+
*
|
|
4
|
+
* 只记真实 token 数,不做权威计费 —— 钱的事交给 pi-usager(含自定义计价/峰谷)。
|
|
5
|
+
* 本模块只提供"估算节省"(保守假设,show 轴缩减率),供 /prompt usage 给用户
|
|
6
|
+
* 一个参考量级;假设全部集中在本文件(REDUCTION_BY_SHOW),诚实标注(诚实账本原则)。
|
|
7
|
+
*
|
|
8
|
+
* 台账字段(v0.2):show/write/do 为三轴实际生效值(show 已把 auto 解析成具体档);
|
|
9
|
+
* taskBin/decision 记录任务分桶与选档决策,供 calibration.ts 自进化拟合。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { isDeepSeekPeakHours, toDate } from "./time.ts";
|
|
16
|
+
import { normalizeRuntimeShow, SHOW_ICONS, type DoMode, type RuntimeShowMode, type WriteMode } from "./modes.ts";
|
|
17
|
+
|
|
18
|
+
/** 台账文件路径(~/.pi/pi-prompt-usage.jsonl) */
|
|
19
|
+
export const USAGE_LOG_PATH: string = join(homedir(), ".pi", "pi-prompt-usage.jsonl");
|
|
20
|
+
|
|
21
|
+
/** 单轮 token 用量(口径对齐 pi 的 usage:input/output/cacheRead/cacheWrite) */
|
|
22
|
+
export interface TurnUsage {
|
|
23
|
+
input: number;
|
|
24
|
+
output: number;
|
|
25
|
+
cacheRead: number;
|
|
26
|
+
cacheWrite: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 任务粗略分桶(按用户请求 token 估算,µ 估计与校准分组用) */
|
|
30
|
+
export type TaskBin = "tiny" | "short" | "mid" | "long" | "xlong";
|
|
31
|
+
|
|
32
|
+
/** 一条台账记录(v0.2 字段;history 兼容 records 的 mode 字段) */
|
|
33
|
+
export interface UsageRecord extends TurnUsage {
|
|
34
|
+
ts: number;
|
|
35
|
+
provider: string;
|
|
36
|
+
model: string;
|
|
37
|
+
/** show 轴实际注入档(auto 已解析);历史记录可能只有 mode 字段 */
|
|
38
|
+
show: RuntimeShowMode;
|
|
39
|
+
/** write 轴实际档 */
|
|
40
|
+
write: WriteMode;
|
|
41
|
+
/** do 轴实际档 */
|
|
42
|
+
do: DoMode;
|
|
43
|
+
/** 任务分桶 */
|
|
44
|
+
taskBin: TaskBin;
|
|
45
|
+
/** 选档决策说明(如 "auto: µ=350,cold→ultra" 或 "manual" / "probe") */
|
|
46
|
+
decision?: string;
|
|
47
|
+
/** 扩展实例会话号(usage 按会话展开明细用) */
|
|
48
|
+
session?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* show 轴缩减率先验(相对"不精简基线 normal"的保守假设)——校准数据的默认值来源。
|
|
53
|
+
* v0.2.2 别名:保留 "off" 键(=0)兜底历史台账/旧系数文件的读取。
|
|
54
|
+
*/
|
|
55
|
+
export const REDUCTION_BY_SHOW: Readonly<Record<RuntimeShowMode, number> & { off?: number }> = {
|
|
56
|
+
normal: 0,
|
|
57
|
+
lite: 0.08,
|
|
58
|
+
full: 0.2,
|
|
59
|
+
ultra: 0.45,
|
|
60
|
+
off: 0,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** show 轴每轮注入 token 数(注入成本估算用;off 别名同 normal=0) */
|
|
64
|
+
export const INJECTION_TOKENS_BY_SHOW: Readonly<Record<RuntimeShowMode, number> & { off?: number }> = {
|
|
65
|
+
normal: 0,
|
|
66
|
+
lite: 40,
|
|
67
|
+
full: 90,
|
|
68
|
+
ultra: 150,
|
|
69
|
+
off: 0,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 内置兜底价表(**非权威**):仅在 pi-pricer 未安装 / 价格解析失败时使用。
|
|
74
|
+
*
|
|
75
|
+
* 权威价格在用户自己维护的 `~/.pi/model-pricing.json`(pi-pricer 提供)。
|
|
76
|
+
* 这里的数值只是"能让成本估算不报错"的近似先验,不保证与厂商现行价一致,
|
|
77
|
+
* 也不建模峰谷(峰谷判断见 fallbackResolver)。改动这些数字不会影响
|
|
78
|
+
* 已安装 pi-pricer 的用户。
|
|
79
|
+
*/
|
|
80
|
+
/** 近似输入单价(¥/百万 token):miss 未命中 / hit 缓存命中 */
|
|
81
|
+
export const INPUT_PRICE_BY_PROVIDER: Readonly<Record<string, { miss: number; hit: number }>> = {
|
|
82
|
+
deepseek: { miss: 1, hit: 0.02 },
|
|
83
|
+
glm: { miss: 0.8, hit: 0.23 },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** 近似输出单价(¥/百万 token;空闲价);权威价在 pi-pricer,峰谷见 fallbackResolver */
|
|
87
|
+
export const OUTPUT_PRICE_BY_PROVIDER: Readonly<Record<string, number>> = {
|
|
88
|
+
deepseek: 4,
|
|
89
|
+
glm: 2.8,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** 未知厂商兜底价(按 ¥4 输出 / miss ¥1) */
|
|
93
|
+
export const FALLBACK_PRICES: { miss: number; hit: number; output: number } = { miss: 1, hit: 0.02, output: 4 };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 计费解析器:按 (model, provider, ts) 取三类价格(等效 pi-pricer 的 ResolvedPrice)。
|
|
97
|
+
* 结构类型声明,避免对 @foolsecret/pi-pricer 的强类型绑定(可选集成)。
|
|
98
|
+
*/
|
|
99
|
+
export interface CostPrice {
|
|
100
|
+
inputMiss: number;
|
|
101
|
+
inputHit: number;
|
|
102
|
+
output: number;
|
|
103
|
+
isPeak?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type PricingResolver = (model: string, provider: string, timestamp?: Date | number) => CostPrice;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 兜底解析器:pi-pricer 未安装时用内置近似价表。
|
|
110
|
+
* 峰谷:无配置源可依,只能按 DeepSeek 公开的固定时段(工作日 9-12/14-18 北京)
|
|
111
|
+
* 翻倍输出价——这是**硬编码近似**,仅在兜底路径使用;装了 pi-pricer 后峰谷
|
|
112
|
+
* 完全由用户在 model-pricing.json 里配置的 schedule 决定。
|
|
113
|
+
*/
|
|
114
|
+
function fallbackResolver(model: string, provider: string, timestamp?: Date | number): CostPrice {
|
|
115
|
+
const prices = INPUT_PRICE_BY_PROVIDER[provider] ?? FALLBACK_PRICES;
|
|
116
|
+
const baseOutput = OUTPUT_PRICE_BY_PROVIDER[provider] ?? FALLBACK_PRICES.output;
|
|
117
|
+
const isPeak = provider === "deepseek" && isDeepSeekPeakHours(toDate(timestamp));
|
|
118
|
+
return {
|
|
119
|
+
inputMiss: prices.miss,
|
|
120
|
+
inputHit: prices.hit,
|
|
121
|
+
output: isPeak ? baseOutput * 2 : baseOutput,
|
|
122
|
+
isPeak,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 价格来源三态:可见性用(让用户知道钱按什么价算的)。
|
|
128
|
+
* - `pi-pricer`:已加载用户 JSON 价表(权威)
|
|
129
|
+
* - `builtin`:pi-pricer 未安装,用内置近似兜底
|
|
130
|
+
* - `failed`:pi-pricer 已装但加载/解析失败,已回退兜底(附原因)
|
|
131
|
+
*/
|
|
132
|
+
export type PricingSource = "pi-pricer" | "builtin" | "failed";
|
|
133
|
+
|
|
134
|
+
/** 当前生效的价格解析器;默认兜底,ensurePricingResolver() 调用后升级为 JSON 价表 */
|
|
135
|
+
let activeResolver: PricingResolver = fallbackResolver;
|
|
136
|
+
|
|
137
|
+
/** 当前价格来源状态(provider 可见性);初始 builtin、加载成 pi-pricer、失败 failed */
|
|
138
|
+
let currentSource: PricingSource = "builtin";
|
|
139
|
+
|
|
140
|
+
/** 加载失败的原因(仅 source === "failed" 时有意义) */
|
|
141
|
+
let lastFailure: string | undefined;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 同步读取当前生效的价格解析器。
|
|
145
|
+
* auto/calibration 是逐回合的同步调用,不能 await —— 靠在 mount 期完成预热,
|
|
146
|
+
* 预热前读到兜底,预热后自动切到 JSON 价表,调用点无需改动。
|
|
147
|
+
*/
|
|
148
|
+
export function getActiveResolver(): PricingResolver {
|
|
149
|
+
return activeResolver;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 当前价格来源(含失败原因),/prompt check 与 usage 标注用 */
|
|
153
|
+
export function getPricingSource(): { source: PricingSource; reason?: string } {
|
|
154
|
+
return lastFailure === undefined ? { source: currentSource } : { source: currentSource, reason: lastFailure };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 加载中的 promise(并发防抖);null = 空闲 */
|
|
158
|
+
let loading: Promise<PricingResolver> | null = null;
|
|
159
|
+
|
|
160
|
+
/** 价格解析器的懒加载器(可注入便于单测兜底路径) */
|
|
161
|
+
export type PricingLoader = (filePath?: string) => Promise<PricingResolver | null>;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 默认加载器:动态 import pi-pricer 的 pricing 模块。
|
|
165
|
+
* 区分两种情况:
|
|
166
|
+
* - 模块本身 import 失败(未安装)→ 返回 null(调用方归为 builtin)
|
|
167
|
+
* - 模块在但创建解析器抛错(如 JSON 损坏)→ 向上抛(调用方归为 failed 并附原因)
|
|
168
|
+
*/
|
|
169
|
+
const DEFAULT_LOADER: PricingLoader = async (filePath?: string): Promise<PricingResolver | null> => {
|
|
170
|
+
let create: ((filePath?: string) => PricingResolver) | undefined;
|
|
171
|
+
// 用变量拼接说明符:pi-pricer 是可选 peer 依赖,未安装时不应让 tsc 报模块缺失
|
|
172
|
+
// (静态字面量 import() 会被 TS 在编译期解析 → 未安装环境 typecheck 失败)
|
|
173
|
+
const moduleName = "@foolsecret/pi-pricer/pricing";
|
|
174
|
+
try {
|
|
175
|
+
const mod = (await import(moduleName)) as { createPricingResolver?: (filePath?: string) => PricingResolver };
|
|
176
|
+
create = mod.createPricingResolver;
|
|
177
|
+
} catch {
|
|
178
|
+
// 未安装(或模块解析失败):静默兜底
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (typeof create !== "function") return null;
|
|
182
|
+
// 已安装:让创建/解析错误向上传播,供 failed 状态与 /prompt check 暴露原因
|
|
183
|
+
return create(filePath);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** 加载结果:拿到解析器 / 未安装(missing)/ 已装但失败(reason) */
|
|
187
|
+
type LoadOutcome = { resolver: PricingResolver | null; missing?: boolean; reason?: string };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 确保已加载 JSON 价表解析器(幂等)。
|
|
191
|
+
* 在扩展接线处调用一次;失败静默保持兜底(本地集成可选,不破坏主功能),
|
|
192
|
+
* 但会记录 source/reason 供 /prompt check 曝光。
|
|
193
|
+
*/
|
|
194
|
+
export async function ensurePricingResolver(
|
|
195
|
+
loader: PricingLoader = DEFAULT_LOADER,
|
|
196
|
+
filePath?: string,
|
|
197
|
+
): Promise<PricingResolver> {
|
|
198
|
+
if (activeResolver !== fallbackResolver) return activeResolver;
|
|
199
|
+
if (!loading) {
|
|
200
|
+
loading = (async () => {
|
|
201
|
+
let outcome: LoadOutcome;
|
|
202
|
+
try {
|
|
203
|
+
const resolver = await loader(filePath);
|
|
204
|
+
// 约定:loader 返回 null = pi-pricer 未安装(正常情况);抛错 = 已装但失败
|
|
205
|
+
outcome = resolver
|
|
206
|
+
? { resolver, missing: false }
|
|
207
|
+
: { resolver: null, missing: true, reason: "pi-pricer 未安装" };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// 已安装但加载/读取失败(文件损坏、模块错误等)——记为 failed 并附原因
|
|
210
|
+
outcome = { resolver: null, missing: false, reason: error instanceof Error ? error.message : String(error) };
|
|
211
|
+
}
|
|
212
|
+
if (outcome.resolver) {
|
|
213
|
+
activeResolver = outcome.resolver;
|
|
214
|
+
currentSource = "pi-pricer";
|
|
215
|
+
lastFailure = undefined;
|
|
216
|
+
} else if (outcome.missing) {
|
|
217
|
+
// 未安装:静默用兜底,不算失败(不向用户报错)
|
|
218
|
+
currentSource = "builtin";
|
|
219
|
+
lastFailure = undefined;
|
|
220
|
+
} else {
|
|
221
|
+
currentSource = "failed";
|
|
222
|
+
lastFailure = outcome.reason ?? "未知原因";
|
|
223
|
+
}
|
|
224
|
+
return activeResolver;
|
|
225
|
+
})().finally(() => {
|
|
226
|
+
loading = null;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return loading;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 测试专用:复位回兜底解析器与 builtin 来源,隔离 loader 注入 */
|
|
233
|
+
export function resetPricingResolver(): void {
|
|
234
|
+
activeResolver = fallbackResolver;
|
|
235
|
+
currentSource = "builtin";
|
|
236
|
+
lastFailure = undefined;
|
|
237
|
+
loading = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 估计算法(纯函数,供单测):只按 show 轴缩减率估节省 —— write/do 的 token
|
|
242
|
+
* 收益无法诚实量化,不编数。
|
|
243
|
+
* savedOutput = Σ output × reduction(show)
|
|
244
|
+
* savedCNY = savedOutput × price(provider).output(价表优先 pi-pricer,兜底内置近似价)
|
|
245
|
+
*/
|
|
246
|
+
export function estimateSaved(records: readonly UsageRecord[], price: PricingResolver = activeResolver): {
|
|
247
|
+
outputTokens: number;
|
|
248
|
+
savedOutputTokens: number;
|
|
249
|
+
savedCNY: number;
|
|
250
|
+
} {
|
|
251
|
+
let outputTokens = 0;
|
|
252
|
+
let savedOutputTokens = 0;
|
|
253
|
+
let savedCNY = 0;
|
|
254
|
+
for (const record of records) {
|
|
255
|
+
outputTokens += record.output;
|
|
256
|
+
const reduction = REDUCTION_BY_SHOW[record.show] ?? 0;
|
|
257
|
+
const saved = record.output * reduction;
|
|
258
|
+
savedOutputTokens += saved;
|
|
259
|
+
const p = price(record.model, record.provider, record.ts);
|
|
260
|
+
savedCNY += (saved / 1_000_000) * p.output;
|
|
261
|
+
}
|
|
262
|
+
return { outputTokens, savedOutputTokens, savedCNY };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** usage 聚合行:按 天×模型 归并 */
|
|
266
|
+
export interface UsageRow {
|
|
267
|
+
day: string;
|
|
268
|
+
model: string;
|
|
269
|
+
turns: number;
|
|
270
|
+
input: number;
|
|
271
|
+
cacheRead: number;
|
|
272
|
+
output: number;
|
|
273
|
+
/** 估算成本(¥;含输出与输入 miss,缓存只按 hit 价的注入成本忽略——粗口径) */
|
|
274
|
+
costCNY: number;
|
|
275
|
+
savedCNY: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** /prompt usage 结果:聚合行 + 总览 + 按会话明细 */
|
|
279
|
+
export interface UsageSummary {
|
|
280
|
+
rows: UsageRow[];
|
|
281
|
+
total: { turns: number; input: number; cacheRead: number; output: number; costCNY: number };
|
|
282
|
+
sessions: Array<{ session: string; turns: number; input: number; output: number; costCNY: number; savedCNY: number }>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 单条记录的成本估算:升级口径 = 输入miss×miss价 + 缓存读×hit价 + 输出×输出价。
|
|
287
|
+
* 输出价由 price() 按 ts 自动含峰谷;hit 价计入后,成本更接近平台账单
|
|
288
|
+
* (旧口径忽略 cacheRead,本实现不省略)。
|
|
289
|
+
*/
|
|
290
|
+
export function recordCost(record: UsageRecord, price: PricingResolver = activeResolver): number {
|
|
291
|
+
const p = price(record.model, record.provider, record.ts);
|
|
292
|
+
return (record.input * p.inputMiss + record.cacheRead * p.inputHit + record.output * p.output) / 1_000_000;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 汇总台账(纯函数):按天×模型聚合 + 按会话展开明细。
|
|
297
|
+
* 供 /prompt usage 渲染成 DeepSeek 平台风格的 token/金额表。
|
|
298
|
+
*/
|
|
299
|
+
export function summarizeUsage(records: readonly UsageRecord[], price: PricingResolver = activeResolver): UsageSummary {
|
|
300
|
+
const byDayModel = new Map<string, UsageRow>();
|
|
301
|
+
const bySession = new Map<string, { session: string; turns: number; input: number; output: number; costCNY: number; savedCNY: number }>();
|
|
302
|
+
let totalInput = 0;
|
|
303
|
+
let totalCache = 0;
|
|
304
|
+
let totalOutput = 0;
|
|
305
|
+
let totalTurns = 0;
|
|
306
|
+
let totalCost = 0;
|
|
307
|
+
|
|
308
|
+
for (const record of records) {
|
|
309
|
+
totalTurns += 1;
|
|
310
|
+
totalInput += record.input;
|
|
311
|
+
totalCache += record.cacheRead;
|
|
312
|
+
totalOutput += record.output;
|
|
313
|
+
const cost = recordCost(record, price);
|
|
314
|
+
totalCost += cost;
|
|
315
|
+
|
|
316
|
+
const day = new Date(record.ts).toISOString().slice(0, 10);
|
|
317
|
+
const key = `${day}|${record.model}`;
|
|
318
|
+
const row = byDayModel.get(key) ?? { day, model: record.model, turns: 0, input: 0, cacheRead: 0, output: 0, costCNY: 0, savedCNY: 0 };
|
|
319
|
+
row.turns += 1;
|
|
320
|
+
row.input += record.input;
|
|
321
|
+
row.cacheRead += record.cacheRead;
|
|
322
|
+
row.output += record.output;
|
|
323
|
+
row.costCNY += cost;
|
|
324
|
+
const reduction = REDUCTION_BY_SHOW[record.show] ?? 0;
|
|
325
|
+
row.savedCNY += ((record.output * reduction) / 1_000_000) * price(record.model, record.provider, record.ts).output;
|
|
326
|
+
byDayModel.set(key, row);
|
|
327
|
+
|
|
328
|
+
if (record.session !== undefined) {
|
|
329
|
+
const sess = bySession.get(record.session) ?? { session: record.session, turns: 0, input: 0, output: 0, costCNY: 0, savedCNY: 0 };
|
|
330
|
+
sess.turns += 1;
|
|
331
|
+
sess.input += record.input;
|
|
332
|
+
sess.output += record.output;
|
|
333
|
+
sess.costCNY += cost;
|
|
334
|
+
sess.savedCNY += ((record.output * reduction) / 1_000_000) * price(record.model, record.provider, record.ts).output;
|
|
335
|
+
bySession.set(record.session, sess);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const rows = [...byDayModel.values()].sort((a, b) => (a.day < b.day ? 1 : -1));
|
|
340
|
+
const sessions = [...bySession.values()].sort((a, b) => a.session.localeCompare(b.session));
|
|
341
|
+
return {
|
|
342
|
+
rows,
|
|
343
|
+
total: { turns: totalTurns, input: totalInput, cacheRead: totalCache, output: totalOutput, costCNY: totalCost },
|
|
344
|
+
sessions,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 台账管理器类:追加记录 + 汇总读取 + 节省估算 */
|
|
349
|
+
export class UsageLedger {
|
|
350
|
+
constructor(private readonly filePath: string = USAGE_LOG_PATH) {}
|
|
351
|
+
|
|
352
|
+
/** 追写一条记录(幂等写入,无读锁需求) */
|
|
353
|
+
append(record: UsageRecord): void {
|
|
354
|
+
try {
|
|
355
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
356
|
+
appendFileSync(this.filePath, JSON.stringify(record) + "\n", "utf8");
|
|
357
|
+
} catch {
|
|
358
|
+
// 写台账失败不阻断对话:静默降级
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 读取全部历史记录(跳过损坏行);坏行数以 integrity 形式给出 */
|
|
363
|
+
readAll(): UsageRecord[] {
|
|
364
|
+
let raw: string;
|
|
365
|
+
try {
|
|
366
|
+
raw = readFileSync(this.filePath, "utf8");
|
|
367
|
+
} catch {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
const records: UsageRecord[] = [];
|
|
371
|
+
for (const line of raw.split("\n")) {
|
|
372
|
+
if (line.trim() === "") continue;
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(line) as Partial<UsageRecord> & { mode?: string };
|
|
375
|
+
if (typeof parsed.output !== "number") continue;
|
|
376
|
+
// v0.1 记录只有 mode:迁移成 show 轴(normalizeRuntimeShow 顺带把
|
|
377
|
+
// v0.2 历史 "off" 归一为 normal);无法识别的旧档(review 等)回退 normal
|
|
378
|
+
records.push({
|
|
379
|
+
ts: typeof parsed.ts === "number" ? parsed.ts : 0,
|
|
380
|
+
provider: typeof parsed.provider === "string" ? parsed.provider : "unknown",
|
|
381
|
+
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
|
382
|
+
show: normalizeRuntimeShow(parsed.show ?? parsed.mode) ?? "normal",
|
|
383
|
+
write: (parsed.write as WriteMode) ?? "normal",
|
|
384
|
+
do: (parsed.do as DoMode) ?? "normal",
|
|
385
|
+
taskBin: (parsed.taskBin as TaskBin) ?? "mid",
|
|
386
|
+
decision: parsed.decision,
|
|
387
|
+
session: parsed.session,
|
|
388
|
+
input: parsed.input ?? 0,
|
|
389
|
+
output: parsed.output,
|
|
390
|
+
cacheRead: parsed.cacheRead ?? 0,
|
|
391
|
+
cacheWrite: parsed.cacheWrite ?? 0,
|
|
392
|
+
});
|
|
393
|
+
} catch {
|
|
394
|
+
// 跳过无法解析的行
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return records;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 台账完整性检查:总行数 / 合法行 / 损坏行(/prompt check 用) */
|
|
401
|
+
integrity(): { totalLines: number; validLines: number; damagedLines: number; filePath: string } {
|
|
402
|
+
let raw: string;
|
|
403
|
+
try {
|
|
404
|
+
raw = readFileSync(this.filePath, "utf8");
|
|
405
|
+
} catch {
|
|
406
|
+
return { totalLines: 0, validLines: 0, damagedLines: 0, filePath: this.filePath };
|
|
407
|
+
}
|
|
408
|
+
const lines = raw.split("\n").filter((line) => line.trim() !== "");
|
|
409
|
+
let valid = 0;
|
|
410
|
+
for (const line of lines) {
|
|
411
|
+
try {
|
|
412
|
+
const parsed = JSON.parse(line);
|
|
413
|
+
if (typeof parsed.output === "number") valid += 1;
|
|
414
|
+
} catch {
|
|
415
|
+
// 计入损坏
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { totalLines: lines.length, validLines: valid, damagedLines: lines.length - valid, filePath: this.filePath };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 状态栏/提示文案:show 档 → 带图标短标签(如 "⚡ FULL";normal 显示 NORMAL) */
|
|
423
|
+
export function showLabel(mode: RuntimeShowMode): string {
|
|
424
|
+
return `${(SHOW_ICONS[mode] ?? "")} ${mode.toUpperCase()}`.trim();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 兼容旧的 modeLabel 命名(v0.1 导出名,仅 show 轴) */
|
|
428
|
+
export function modeLabel(mode: RuntimeShowMode): string {
|
|
429
|
+
return showLabel(mode);
|
|
430
|
+
}
|