@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.
- package/CHANGELOG.md +203 -0
- package/README.md +43 -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 +178 -1
- package/src/context.ts +136 -0
- package/src/draft.ts +189 -0
- package/src/info-page.ts +115 -0
- package/src/prompt-extension.ts +350 -70
- package/src/prompts.ts +48 -6
- package/src/stats.ts +50 -5
- package/src/tool-output.ts +128 -0
- package/src/ui.ts +598 -96
package/src/prompts.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { DoMode, RuntimeShowMode, WriteMode } from "./modes.ts";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
|
|
16
17
|
/** 单个注入块策略接口(每轴每档 = 一个策略类) */
|
|
17
18
|
interface BlobStrategy {
|
|
@@ -181,17 +182,58 @@ export class PromptRegistry {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
185
|
+
* 组合注入文案:三块独立拼接,每块带自己的固定边界标记。
|
|
186
|
+
*
|
|
187
|
+
* 缓存友好设计(v0.4.1):三轴各自成块、块内字节只随自己的档位变 ——
|
|
188
|
+
* 调 write/do **不再改变 show 块的字节**,把前缀失效面收缩到最小。
|
|
189
|
+
* 每块格式恒定:`[PI-PROMPT:<AXIS> <mode>]\n<body>`;不注入的轴整块省略。
|
|
190
|
+
*
|
|
191
|
+
* 注意:三块仍是同一个字符串,拼在系统提示的同一处 —— 中间块变动仍会
|
|
192
|
+
* 连带失效其后的内容(含 pi 追加的 project_context/skills/cwd)。真正的
|
|
193
|
+
* 隔离要靠"注入移到 system prompt 末尾"(见 prompt-extension)。
|
|
194
|
+
*
|
|
186
195
|
* 三块全空(show normal + write normal + do normal)返回 undefined(不注入)。
|
|
187
196
|
*/
|
|
188
197
|
compose(show: RuntimeShowMode, write: WriteMode, doMode: DoMode): string | undefined {
|
|
189
198
|
const blocks = [
|
|
190
|
-
this.textForShow(show),
|
|
191
|
-
this.textForWrite(write),
|
|
192
|
-
this.textForDo(doMode),
|
|
199
|
+
blockFor("SHOW", show, this.textForShow(show)),
|
|
200
|
+
blockFor("WRITE", write, this.textForWrite(write)),
|
|
201
|
+
blockFor("DO", doMode, this.textForDo(doMode)),
|
|
193
202
|
].filter((block): block is string => block !== undefined);
|
|
194
203
|
if (blocks.length === 0) return undefined;
|
|
195
|
-
|
|
204
|
+
// 直接拼接(非 join):每块自带尾部分隔符,块字节与邻块无关 ——
|
|
205
|
+
// 保证"调 write/do 不改 show 块字节"(否则 join 的 \n 会让末尾块少一个分隔符)。
|
|
206
|
+
return blocks.join("");
|
|
196
207
|
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 单轴独立块(供希望按轴分别拼接的调用方使用):返回该轴的恒定块或 undefined。
|
|
211
|
+
* 块字节只依赖该轴档位 —— 这是"调 write 不动 show 字节"的保证点。
|
|
212
|
+
*/
|
|
213
|
+
composeBlock(axis: "SHOW" | "WRITE" | "DO", mode: RuntimeShowMode | WriteMode | DoMode): string | undefined {
|
|
214
|
+
if (axis === "SHOW") return blockFor("SHOW", mode, this.textForShow(mode as RuntimeShowMode));
|
|
215
|
+
if (axis === "WRITE") return blockFor("WRITE", mode, this.textForWrite(mode as WriteMode));
|
|
216
|
+
return blockFor("DO", mode, this.textForDo(mode as DoMode));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 构造单轴注入块:`[PI-PROMPT:<AXIS> <mode>]\n<body>\n`。
|
|
222
|
+
* 尾部自带换行 → 块字节与邻块彻底解耦(拼接顺序无关),
|
|
223
|
+
* 这是"调 write 不动 show 块字节"的保证点。
|
|
224
|
+
* body 为空(不注入的档)时整块返回 undefined,调用方据此省略该轴。
|
|
225
|
+
*/
|
|
226
|
+
function blockFor(axis: "SHOW" | "WRITE" | "DO", mode: string, body: string | undefined): string | undefined {
|
|
227
|
+
if (body === undefined) return undefined;
|
|
228
|
+
return `[PI-PROMPT:${axis} ${mode}]\n${body}\n`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 注入串的短哈希(前 8 位)—— 学 Reasonix 的 CaptureShape:
|
|
233
|
+
* 让用户一眼看出"前缀有没有变"(换档/改档都会变),是排查缓存 miss 的第一现场。
|
|
234
|
+
* 纯函数,可直接单测。空串(不注入)返回 "(none)"。
|
|
235
|
+
*/
|
|
236
|
+
export function prefixHash(text: string | undefined): string {
|
|
237
|
+
if (text === undefined || text === "") return "(none)";
|
|
238
|
+
return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 8);
|
|
197
239
|
}
|
package/src/stats.ts
CHANGED
|
@@ -26,11 +26,33 @@ export interface TurnUsage {
|
|
|
26
26
|
cacheWrite: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 台账 schema 版本(写入记录时的格式版本)。
|
|
31
|
+
* v0=历史(无此字段)· v1 起记录 v/pv。字段只增不改,读取时按版本兼容。
|
|
32
|
+
*/
|
|
33
|
+
export const USAGE_SCHEMA_VERSION: number = 1;
|
|
34
|
+
|
|
35
|
+
/** 当前插件版本(写入台账的 pv 字段;从 package.json 读,失败回退 "unknown") */
|
|
36
|
+
export function currentPluginVersion(): string {
|
|
37
|
+
try {
|
|
38
|
+
// 相对本模块向上找 package.json(src/ 与发布后的 dist/ 均可)
|
|
39
|
+
const url = new URL("../package.json", import.meta.url);
|
|
40
|
+
const pkg = JSON.parse(readFileSync(url, "utf8")) as { version?: string };
|
|
41
|
+
return pkg.version ?? "unknown";
|
|
42
|
+
} catch {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
/** 任务粗略分桶(按用户请求 token 估算,µ 估计与校准分组用) */
|
|
30
48
|
export type TaskBin = "tiny" | "short" | "mid" | "long" | "xlong";
|
|
31
49
|
|
|
32
50
|
/** 一条台账记录(v0.2 字段;history 兼容 records 的 mode 字段) */
|
|
33
51
|
export interface UsageRecord extends TurnUsage {
|
|
52
|
+
/** schema 版本(历史记录无此字段 → 读取时补 0) */
|
|
53
|
+
v?: number;
|
|
54
|
+
/** 插件版本(历史记录无此字段 → 读取时补 "legacy") */
|
|
55
|
+
pv?: string;
|
|
34
56
|
ts: number;
|
|
35
57
|
provider: string;
|
|
36
58
|
model: string;
|
|
@@ -275,11 +297,23 @@ export interface UsageRow {
|
|
|
275
297
|
savedCNY: number;
|
|
276
298
|
}
|
|
277
299
|
|
|
300
|
+
/**
|
|
301
|
+
* 缓存命中率(纯函数,百分数 0~100):cacheRead / (input + cacheRead)。
|
|
302
|
+
* 分母 = 全部输入 token(未命中 input + 命中 cacheRead)。
|
|
303
|
+
* 口径说明:pi 的 usage 里 input 只计未命中部分(GLM 同理,见认知修正 #8),
|
|
304
|
+
* 故 input + cacheRead = prompt 总量;无输入时返回 0(不出现 NaN)。
|
|
305
|
+
*/
|
|
306
|
+
export function cacheHitRate(input: number, cacheRead: number): number {
|
|
307
|
+
const denom = input + cacheRead;
|
|
308
|
+
if (denom <= 0) return 0;
|
|
309
|
+
return (100 * cacheRead) / denom;
|
|
310
|
+
}
|
|
311
|
+
|
|
278
312
|
/** /prompt usage 结果:聚合行 + 总览 + 按会话明细 */
|
|
279
313
|
export interface UsageSummary {
|
|
280
314
|
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 }>;
|
|
315
|
+
total: { turns: number; input: number; cacheRead: number; output: number; costCNY: number; hitRate: number };
|
|
316
|
+
sessions: Array<{ session: string; turns: number; input: number; cacheRead: number; output: number; costCNY: number; savedCNY: number; hitRate: number }>;
|
|
283
317
|
}
|
|
284
318
|
|
|
285
319
|
/**
|
|
@@ -298,7 +332,7 @@ export function recordCost(record: UsageRecord, price: PricingResolver = activeR
|
|
|
298
332
|
*/
|
|
299
333
|
export function summarizeUsage(records: readonly UsageRecord[], price: PricingResolver = activeResolver): UsageSummary {
|
|
300
334
|
const byDayModel = new Map<string, UsageRow>();
|
|
301
|
-
const bySession = new Map<string, { session: string; turns: number; input: number; output: number; costCNY: number; savedCNY: number }>();
|
|
335
|
+
const bySession = new Map<string, { session: string; turns: number; input: number; cacheRead: number; output: number; costCNY: number; savedCNY: number; hitRate: number }>();
|
|
302
336
|
let totalInput = 0;
|
|
303
337
|
let totalCache = 0;
|
|
304
338
|
let totalOutput = 0;
|
|
@@ -326,12 +360,14 @@ export function summarizeUsage(records: readonly UsageRecord[], price: PricingRe
|
|
|
326
360
|
byDayModel.set(key, row);
|
|
327
361
|
|
|
328
362
|
if (record.session !== undefined) {
|
|
329
|
-
const sess = bySession.get(record.session) ?? { session: record.session, turns: 0, input: 0, output: 0, costCNY: 0, savedCNY: 0 };
|
|
363
|
+
const sess = bySession.get(record.session) ?? { session: record.session, turns: 0, input: 0, cacheRead: 0, output: 0, costCNY: 0, savedCNY: 0, hitRate: 0 };
|
|
330
364
|
sess.turns += 1;
|
|
331
365
|
sess.input += record.input;
|
|
366
|
+
sess.cacheRead += record.cacheRead;
|
|
332
367
|
sess.output += record.output;
|
|
333
368
|
sess.costCNY += cost;
|
|
334
369
|
sess.savedCNY += ((record.output * reduction) / 1_000_000) * price(record.model, record.provider, record.ts).output;
|
|
370
|
+
sess.hitRate = cacheHitRate(sess.input, sess.cacheRead);
|
|
335
371
|
bySession.set(record.session, sess);
|
|
336
372
|
}
|
|
337
373
|
}
|
|
@@ -340,7 +376,14 @@ export function summarizeUsage(records: readonly UsageRecord[], price: PricingRe
|
|
|
340
376
|
const sessions = [...bySession.values()].sort((a, b) => a.session.localeCompare(b.session));
|
|
341
377
|
return {
|
|
342
378
|
rows,
|
|
343
|
-
total: {
|
|
379
|
+
total: {
|
|
380
|
+
turns: totalTurns,
|
|
381
|
+
input: totalInput,
|
|
382
|
+
cacheRead: totalCache,
|
|
383
|
+
output: totalOutput,
|
|
384
|
+
costCNY: totalCost,
|
|
385
|
+
hitRate: cacheHitRate(totalInput, totalCache),
|
|
386
|
+
},
|
|
344
387
|
sessions,
|
|
345
388
|
};
|
|
346
389
|
}
|
|
@@ -376,6 +419,8 @@ export class UsageLedger {
|
|
|
376
419
|
// v0.1 记录只有 mode:迁移成 show 轴(normalizeRuntimeShow 顺带把
|
|
377
420
|
// v0.2 历史 "off" 归一为 normal);无法识别的旧档(review 等)回退 normal
|
|
378
421
|
records.push({
|
|
422
|
+
v: typeof parsed.v === "number" ? parsed.v : 0,
|
|
423
|
+
pv: typeof parsed.pv === "string" ? parsed.pv : "legacy",
|
|
379
424
|
ts: typeof parsed.ts === "number" ? parsed.ts : 0,
|
|
380
425
|
provider: typeof parsed.provider === "string" ? parsed.provider : "unknown",
|
|
381
426
|
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具输出截断(纯函数,确定性;对齐 Reasonix 的 tool-aware 方案)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:
|
|
5
|
+
* - 实测工具输出占上下文 **27~65%**(大会话),是输入成本的隐性大项。
|
|
6
|
+
* - pi 自己已把工具输出截到 **50KB**(read/grep/bash/find 全覆盖),
|
|
7
|
+
* 但 50KB 之上仍有大量冗余:实测 grep 均 13.6KB、read p95 9.5KB。
|
|
8
|
+
* - 本模块提供**更激进**的可选上限(默认关),按工具分层。
|
|
9
|
+
*
|
|
10
|
+
* 缓存友好性(硬要求):
|
|
11
|
+
* - 在 `tool_result` hook **创建时**截断一次,此后字节不变(不改历史)。
|
|
12
|
+
* - **确定性**:同输入必得同输出(否则破坏前缀缓存)。
|
|
13
|
+
* - 未超限时**原样返回**(零影响)。
|
|
14
|
+
*
|
|
15
|
+
* 为何分层(不照抄 Reasonix 的 32KB):
|
|
16
|
+
* - Reasonix 的 32KB 是**触发阈值**,实际净保留仅 11~16KB(head/tail 剪)。
|
|
17
|
+
* - read 是模型主动要看的内容,砍狠会引发重读 → 反而增加(最贵的)输出成本。
|
|
18
|
+
* 实测 read p50=1.3KB、p90=5.4KB → 8K 只影响 6% 的 read 调用,风险可控。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 截断结果 */
|
|
22
|
+
export interface ToolTruncateResult {
|
|
23
|
+
/** 截断后的文本(未超限时原样) */
|
|
24
|
+
text: string;
|
|
25
|
+
/** 是否发生截断 */
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
/** 省下的字节数(未截断为 0) */
|
|
28
|
+
savedBytes: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 各工具的默认上限(由调用方从 config 传入;这里只作兵底) */
|
|
32
|
+
export interface ToolCaps {
|
|
33
|
+
grep?: number;
|
|
34
|
+
read?: number;
|
|
35
|
+
bash?: number;
|
|
36
|
+
default?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 错误关键字(命中则多留尾部——错误信息多在输出末尾) */
|
|
40
|
+
const ERROR_MARKERS = ["error:", "panic:", "fatal:", "traceback", "exception"];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把字节上限换算成安全的字符串切点:UTF-8 字符不会被截半。
|
|
44
|
+
* 从 from 向 to 方向找最近的合法边界。
|
|
45
|
+
*/
|
|
46
|
+
function snapToBoundary(text: string, index: number, dir: "start" | "end"): number {
|
|
47
|
+
if (index <= 0) return 0;
|
|
48
|
+
if (index >= text.length) return text.length;
|
|
49
|
+
let i = index;
|
|
50
|
+
if (dir === "start") {
|
|
51
|
+
// 向后退到非续字节(0b10xxxxxx)之后
|
|
52
|
+
while (i > 0 && (text.charCodeAt(i) & 0xc0) === 0x80) i -= 1;
|
|
53
|
+
return i;
|
|
54
|
+
}
|
|
55
|
+
while (i < text.length && (text.charCodeAt(i) & 0xc0) === 0x80) i += 1;
|
|
56
|
+
return i;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 用 `Buffer.byteLength` 计字节(字符串 .length 是 UTF-16 码元,不等于字节)。
|
|
61
|
+
*/
|
|
62
|
+
function bytes(text: string): number {
|
|
63
|
+
return Buffer.byteLength(text, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 工具感知截断(纯函数、确定性)。
|
|
68
|
+
* 策略:保留 head + 中段标记 + tail(借用 Reasonix 的 head/tail 思路)。
|
|
69
|
+
* - bash:head/tail 各半(错误多在末尾)
|
|
70
|
+
* - read/grep/其他:head 为主、tail 少量(代码/匹配通常看开头)
|
|
71
|
+
* @param text 原始工具输出
|
|
72
|
+
* @param tool 工具名("read"/"grep"/"bash"/其他)
|
|
73
|
+
* @param cap 字节上限(<=0 = 不截)
|
|
74
|
+
*/
|
|
75
|
+
export function truncateToolOutput(text: string, tool: string, cap: number): ToolTruncateResult {
|
|
76
|
+
if (!cap || cap <= 0) return { text, truncated: false, savedBytes: 0 };
|
|
77
|
+
const total = bytes(text);
|
|
78
|
+
if (total <= cap) return { text, truncated: false, savedBytes: 0 };
|
|
79
|
+
|
|
80
|
+
// 留出标记文本的空间,避免截断后反而超限(小 cap 时按比例留,不硬扣 128)
|
|
81
|
+
const MARKER_RESERVE = Math.min(128, Math.floor(cap / 4));
|
|
82
|
+
const budget = Math.max(8, cap - MARKER_RESERVE);
|
|
83
|
+
|
|
84
|
+
// 分层比例:bash 偏尾(错误在末尾),其余偏头
|
|
85
|
+
let headChars: number;
|
|
86
|
+
let tailChars: number;
|
|
87
|
+
if (tool === "bash") {
|
|
88
|
+
headChars = Math.floor(budget / 2);
|
|
89
|
+
tailChars = budget - headChars;
|
|
90
|
+
} else if (tool === "read" || tool === "grep") {
|
|
91
|
+
headChars = Math.floor(budget * 0.85);
|
|
92
|
+
tailChars = budget - headChars;
|
|
93
|
+
} else {
|
|
94
|
+
headChars = Math.floor(budget * 0.75);
|
|
95
|
+
tailChars = budget - headChars;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 错误关键字 → 多留尾部(翻倍,但不超过预算)
|
|
99
|
+
const lower = text.slice(-4096).toLowerCase();
|
|
100
|
+
if (ERROR_MARKERS.some((m) => lower.includes(m))) {
|
|
101
|
+
const extra = Math.min(tailChars, Math.floor(budget / 3));
|
|
102
|
+
if (headChars + tailChars + extra <= budget + extra) {
|
|
103
|
+
tailChars += extra;
|
|
104
|
+
headChars = Math.max(0, budget - tailChars);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 按字节切(先粗切字符数,再向边界吸附)
|
|
109
|
+
const headRaw = text.slice(0, headChars);
|
|
110
|
+
const headEnd = headRaw.length >= text.length ? text.length : snapToBoundary(text, headRaw.length, "end");
|
|
111
|
+
const tailStartCandidate = text.length - tailChars;
|
|
112
|
+
const tailStart = tailStartCandidate <= headEnd ? headEnd : snapToBoundary(text, tailStartCandidate, "start");
|
|
113
|
+
|
|
114
|
+
const head = text.slice(0, headEnd);
|
|
115
|
+
const tail = text.slice(tailStart);
|
|
116
|
+
// 标记要极短(ASCII)且能进 MARKER_RESERVE
|
|
117
|
+
const marker = `\n[truncated ${total}>${cap}B]\n`;
|
|
118
|
+
const result = head + marker + tail;
|
|
119
|
+
|
|
120
|
+
// 双保险:若仍超限(多字节字符/标记开销),硬切到 cap(吸附到字符边界)
|
|
121
|
+
if (bytes(result) > cap) {
|
|
122
|
+
let cut = Math.min(cap, result.length);
|
|
123
|
+
while (cut > 0 && bytes(result.slice(0, cut)) > cap) cut -= 1;
|
|
124
|
+
const sliced = result.slice(0, cut);
|
|
125
|
+
return { text: sliced, truncated: true, savedBytes: total - bytes(sliced) };
|
|
126
|
+
}
|
|
127
|
+
return { text: result, truncated: true, savedBytes: total - bytes(result) };
|
|
128
|
+
}
|