pi-distill 1.3.0 → 1.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/README.md CHANGED
@@ -193,7 +193,7 @@ Configuration-file fields take precedence over environment variables. Unspecifie
193
193
 
194
194
  Use `/distill:stats` to view distillation statistics for the current Pi session. Statistics are kept in memory, reset when the session starts, and never store raw tool output.
195
195
 
196
- The report includes tool-result counts, success/failure/fallback counts, model attempts, original and summary character totals, compression ratio, `chars/4` estimated original/summary tokens, estimated tokens saved, and model-reported input/output/cache/total tokens and cost. Counts use compact `k` and `m` units at 1,000 and 1,000,000; durations use `ms`, `s`, or `min` based on their value. Estimated context tokens are labeled as estimates; usage and cost fields are shown as unavailable when the provider does not return usage data.
196
+ The report includes tool-result counts, success/failure/fallback counts, model attempts, original and summary character totals, compression ratio, estimated original/summary tokens (heuristic: CJK characters count ~1 token each, other text ~4 chars/token), estimated tokens saved, and model-reported input/output/cache/total tokens and cost. Counts use compact `k` and `m` units at 1,000 and 1,000,000; durations use `ms`, `s`, or `min` based on their value. Estimated context tokens are labeled as estimates; usage and cost fields are shown as unavailable when the provider does not return usage data.
197
197
 
198
198
  The main environment variables are `PI_DISTILL_MODEL`, `PI_DISTILL_MIN_CHARS`, `PI_DISTILL_MAX_CHARS`, `PI_DISTILL_MAX_OUTPUT_CHARS`, `PI_DISTILL_TIMEOUT_SECONDS`, `PI_DISTILL_TIMEOUT_RETRY_COUNT`, `PI_DISTILL_ERROR_RETRY_COUNT`, `PI_DISTILL_MISSED_COMPRESSION_RATIO`, and `PI_DISTILL_SUMMARIZE_ERRORS`.
199
199
 
package/README.zh-CN.md CHANGED
@@ -196,7 +196,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
196
196
 
197
197
  使用 `/distill:stats` 查看当前 Pi 会话的提炼统计。统计只保存在内存中,在会话开始时重置,不保存原始工具输出。
198
198
 
199
- 统计包括工具结果数量、成功/失败/回退次数、模型尝试次数、原始与摘要字符数、压缩比、按 `chars/4` 估算的原文/摘要 Token、预计节省 Token、提炼实际消耗的 input/output/cache/total Token 和成本。数量达到 1,000 或 1,000,000 时分别使用 `k` 或 `m` 紧凑显示;耗时会根据数值显示为 `ms`、`s` 或 `min`。原文/摘要 Token 是估算值;provider 未返回 usage 时,提炼消耗 Token 或成本字段显示为不可用。
199
+ 统计包括工具结果数量、成功/失败/回退次数、模型尝试次数、原始与摘要字符数、压缩比、估算的原文/摘要 Token(启发式:CJK 字符约每字 1 token,其余文本约 4 字符 1 token)、预计节省 Token、提炼实际消耗的 input/output/cache/total Token 和成本。数量达到 1,000 或 1,000,000 时分别使用 `k` 或 `m` 紧凑显示;耗时会根据数值显示为 `ms`、`s` 或 `min`。原文/摘要 Token 是估算值;provider 未返回 usage 时,提炼消耗 Token 或成本字段显示为不可用。
200
200
 
201
201
  主要环境变量包括 `PI_DISTILL_MODEL`、`PI_DISTILL_MIN_CHARS`、`PI_DISTILL_MAX_CHARS`、`PI_DISTILL_MAX_OUTPUT_CHARS`、`PI_DISTILL_TIMEOUT_SECONDS`、`PI_DISTILL_TIMEOUT_RETRY_COUNT`、`PI_DISTILL_ERROR_RETRY_COUNT`、`PI_DISTILL_MISSED_COMPRESSION_RATIO` 和 `PI_DISTILL_SUMMARIZE_ERRORS`。
202
202
 
@@ -1,80 +1,64 @@
1
1
  {
2
- "summarized": {
3
- "zh-CN": "✓ 已提炼",
4
- "en-US": "✓ Summarized"
5
- },
6
2
  "summaryFallback": {
7
- "zh-CN": "已回退原文",
8
- "en-US": "Original restored"
3
+ "zh-CN": "已回退原文",
4
+ "en-US": "Original restored"
9
5
  },
10
6
  "disabled": {
11
- "zh-CN": "已禁用",
12
- "en-US": "Disabled"
7
+ "zh-CN": "已禁用",
8
+ "en-US": "Disabled"
13
9
  },
14
10
  "off": {
15
- "zh-CN": "关闭",
16
- "en-US": "Off"
11
+ "zh-CN": "关闭",
12
+ "en-US": "Off"
17
13
  },
18
14
  "original": {
19
- "zh-CN": "原始输出",
20
- "en-US": "Original"
15
+ "zh-CN": "原始输出",
16
+ "en-US": "Original"
21
17
  },
22
18
  "raw": {
23
- "zh-CN": "RAW",
24
- "en-US": "RAW"
19
+ "zh-CN": "RAW",
20
+ "en-US": "RAW"
25
21
  },
26
22
  "belowThreshold": {
27
- "zh-CN": "低于阈值",
28
- "en-US": "Below threshold"
23
+ "zh-CN": "低于阈值",
24
+ "en-US": "Below threshold"
29
25
  },
30
26
  "nonTextOutput": {
31
27
  "zh-CN": "非文本结果",
32
28
  "en-US": "Non-text output"
33
29
  },
34
30
  "readFailed": {
35
- "zh-CN": "! 读取失败",
36
- "en-US": "! Read failed"
31
+ "zh-CN": "读取失败",
32
+ "en-US": "Read failed"
37
33
  },
38
34
  "summaryFailed": {
39
- "zh-CN": "提炼失败",
40
- "en-US": "Summary failed"
35
+ "zh-CN": "提炼失败",
36
+ "en-US": "Summary failed"
41
37
  },
42
38
  "chars": {
43
39
  "zh-CN": "字符",
44
40
  "en-US": "chars"
45
41
  },
46
42
  "tokens": {
47
- "zh-CN": "Token",
48
- "en-US": "tokens"
43
+ "zh-CN": "tok",
44
+ "en-US": "tok"
49
45
  },
50
46
  "compressionTokens": {
51
- "zh-CN": "压缩消耗",
52
- "en-US": "compression"
53
- },
54
- "tokensSaved": {
55
- "zh-CN": "节省",
56
- "en-US": "saved"
57
- },
58
- "savedPercent": {
59
- "zh-CN": "节省",
60
- "en-US": "saved"
47
+ "zh-CN": "用量",
48
+ "en-US": "usage"
61
49
  },
62
50
  "tool": {
63
51
  "zh-CN": "工具",
64
- "en-US": "Tool"
52
+ "en-US": "tool"
65
53
  },
66
54
  "distill": {
67
55
  "zh-CN": "提炼",
68
- "en-US": "Distill"
56
+ "en-US": "distill"
69
57
  },
70
58
  "expand": {
71
59
  "zh-CN": " • Ctrl+O 展开",
72
60
  "en-US": " • Ctrl+O to expand"
73
61
  },
74
- "header": {
75
- "zh-CN": "◇ Distill {status}",
76
- "en-US": "◇ Distill {status}"
77
- },
78
62
  "summary": {
79
63
  "zh-CN": "摘要",
80
64
  "en-US": "Summary"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-distill",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Pi tool-output distillation with file-first configuration",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,7 +17,10 @@ type AuditTone = "success" | "muted" | "dim" | "warning" | "error";
17
17
 
18
18
  type DistillAuditView = {
19
19
  lines: string[];
20
- statusLabel: string;
20
+ /** 状态图标同时承担品牌位(替换原 ◇),是语言无关符号,不走 locale。 */
21
+ statusIcon: string;
22
+ /** 状态文字;summarized 等自解释状态为空。 */
23
+ statusText: string;
21
24
  statusTone: AuditTone;
22
25
  };
23
26
 
@@ -34,6 +37,8 @@ function getString(value: unknown): string | undefined {
34
37
  return typeof value === "string" && value.trim() ? value : undefined;
35
38
  }
36
39
 
40
+ const MIN_TOOL_DURATION_DISPLAY_MS = 50;
41
+
37
42
  function formatDuration(milliseconds: number): string {
38
43
  return `${(milliseconds / 1000).toFixed(1)}s`;
39
44
  }
@@ -65,13 +70,13 @@ function formatCompactCount(value: number): string {
65
70
 
66
71
  function renderDistillAuditLine(audit: DistillAuditView, line: string, index: number, theme: RenderTheme): string {
67
72
  if (index === 0) {
68
- const title = theme.fg("accent", theme.bold(" Distill"));
69
- const afterTitle = line.slice("◇ Distill".length);
70
- const statusIndex = afterTitle.indexOf(audit.statusLabel);
71
- if (statusIndex < 0) return `${title}${theme.fg("muted", afterTitle)}`;
72
- const beforeStatus = afterTitle.slice(0, statusIndex);
73
- const afterStatus = afterTitle.slice(statusIndex + audit.statusLabel.length);
74
- return `${title}${theme.fg("toolTitle", beforeStatus)}${theme.fg(audit.statusTone, audit.statusLabel)}${theme.fg("muted", afterStatus)}`;
73
+ const title = `${theme.fg(audit.statusTone, theme.bold(audit.statusIcon))}${theme.fg("accent", theme.bold(" Distill"))}`;
74
+ const afterTitle = line.slice(`${audit.statusIcon} Distill`.length);
75
+ const statusPrefix = audit.statusText ? ` ${audit.statusText}` : "";
76
+ if (statusPrefix && afterTitle.startsWith(statusPrefix)) {
77
+ return `${title}${theme.fg(audit.statusTone, statusPrefix)}${theme.fg("muted", afterTitle.slice(statusPrefix.length))}`;
78
+ }
79
+ return `${title}${theme.fg("muted", afterTitle)}`;
75
80
  }
76
81
 
77
82
  const section = line.match(/^([├└]─ )([^ ]+)( )(.*)$/);
@@ -179,17 +184,17 @@ export function buildDistillAuditLines(
179
184
  const status = getString(details.outputSummaryStatus);
180
185
  if (!status) return undefined;
181
186
 
182
- const statusViews: Record<string, { label: string; tone: AuditTone }> = {
183
- summarized: { label: i18n.t("summarized"), tone: "success" },
184
- "summary-fallback": { label: i18n.t("summaryFallback"), tone: "warning" },
185
- disabled: { label: i18n.t("disabled"), tone: "dim" },
186
- "disabled-by-config": { label: i18n.t("off"), tone: "dim" },
187
- "not-requested": { label: i18n.t("original"), tone: "muted" },
188
- "full-output": { label: i18n.t("raw"), tone: "warning" },
189
- "below-threshold": { label: i18n.t("belowThreshold"), tone: "dim" },
190
- "non-text-output": { label: i18n.t("nonTextOutput"), tone: "muted" },
191
- "diagnostic-failed": { label: i18n.t("readFailed"), tone: "warning" },
192
- "summary-failed": { label: i18n.t("summaryFailed"), tone: "error" },
187
+ const statusViews: Record<string, { icon: string; textKey?: string; tone: AuditTone }> = {
188
+ summarized: { icon: "", tone: "success" },
189
+ "summary-fallback": { icon: "↺", textKey: "summaryFallback", tone: "warning" },
190
+ disabled: { icon: "○", textKey: "disabled", tone: "dim" },
191
+ "disabled-by-config": { icon: "○", textKey: "off", tone: "dim" },
192
+ "not-requested": { icon: "○", textKey: "original", tone: "muted" },
193
+ "full-output": { icon: "↺", textKey: "raw", tone: "warning" },
194
+ "below-threshold": { icon: "–", textKey: "belowThreshold", tone: "dim" },
195
+ "non-text-output": { icon: "○", textKey: "nonTextOutput", tone: "muted" },
196
+ "diagnostic-failed": { icon: "!", textKey: "readFailed", tone: "warning" },
197
+ "summary-failed": { icon: "✕", textKey: "summaryFailed", tone: "error" },
193
198
  };
194
199
  const anomalies = Array.isArray(details.outputSummaryAnomalies)
195
200
  ? details.outputSummaryAnomalies.filter((value): value is string => typeof value === "string")
@@ -202,43 +207,45 @@ export function buildDistillAuditLines(
202
207
  const estimatedSummaryTokens = getFiniteNumber(details.estimatedSummaryTokens);
203
208
  const estimatedTokensSaved = getFiniteNumber(details.estimatedTokensSaved);
204
209
  const summaryTotalTokens = getFiniteNumber(details.summaryTotalTokens);
205
- const compressionRatio = getFiniteNumber(details.compressionRatio);
206
210
  const compressionSavedPercent = getFiniteNumber(details.compressionSavedPercent);
207
211
  const toolExecutionMs = getFiniteNumber(details.toolExecutionMs);
208
212
  const summaryDurationMs = getFiniteNumber(details.summaryDurationMs);
209
213
  const fullOutputPath = getString(details.fullOutputPath);
210
214
  const outputRequest = getString(details.outputSummaryPrompt);
211
215
  const summaryText = getString(details.summaryText);
212
- const statusView = statusViews[status] ?? { label: status, tone: "muted" as const };
213
- const metrics: string[] = [];
216
+ const statusView = statusViews[status] ?? { icon: "○", tone: "muted" as const };
217
+ const statusText = statusView.textKey ? i18n.t(statusView.textKey) : status in statusViews ? "" : status;
214
218
 
219
+ let mainMetric = "";
215
220
  if (estimatedOriginalTokens !== undefined && estimatedSummaryTokens !== undefined) {
216
- const tokenMetric = `≈${formatCompactCount(estimatedOriginalTokens)} → ${formatCompactCount(estimatedSummaryTokens)} ${i18n.t("tokens")}`;
217
- const savedMetric = estimatedTokensSaved !== undefined && estimatedTokensSaved > 0
218
- ? `(${i18n.t("tokensSaved")} ≈${formatCompactCount(estimatedTokensSaved)}${compressionSavedPercent !== undefined ? `,${compressionSavedPercent.toFixed(1)}%` : ""})`
219
- : "";
220
- metrics.push(`${tokenMetric}${savedMetric}`);
221
+ mainMetric = `${formatCompactCount(estimatedOriginalTokens)} → ${formatCompactCount(estimatedSummaryTokens)} ${i18n.t("tokens")}`;
222
+ if (estimatedTokensSaved !== undefined && estimatedTokensSaved > 0 && compressionSavedPercent !== undefined) {
223
+ mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
224
+ }
221
225
  } else if (originalChars !== undefined && summaryChars !== undefined) {
222
- metrics.push(`${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`);
223
- if (compressionRatio !== undefined) metrics.push(`${compressionRatio.toFixed(2)}×`);
226
+ mainMetric = `${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`;
224
227
  if (compressionSavedPercent !== undefined) {
225
- metrics.push(`${compressionSavedPercent.toFixed(1)}% ${i18n.t("savedPercent")}`);
228
+ mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
226
229
  }
227
230
  } else if (originalChars !== undefined) {
228
- metrics.push(`${formatCount(originalChars)} ${i18n.t("chars")}`);
231
+ mainMetric = `${formatCount(originalChars)} ${i18n.t("chars")}`;
229
232
  }
233
+
234
+ const secondaryMetrics: string[] = [];
230
235
  if (summaryTotalTokens !== undefined && summaryTotalTokens > 0) {
231
- metrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)} ${i18n.t("tokens")}`);
236
+ secondaryMetrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)}`);
232
237
  }
233
- if (toolExecutionMs !== undefined && toolExecutionMs >= 50) {
234
- metrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
238
+ if (toolExecutionMs !== undefined && toolExecutionMs >= MIN_TOOL_DURATION_DISPLAY_MS) {
239
+ secondaryMetrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
235
240
  }
236
- if (summaryDurationMs !== undefined) metrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
241
+ if (summaryDurationMs !== undefined) secondaryMetrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
237
242
 
238
243
  const expandHint = expanded ? "" : i18n.t("expand");
239
- const lines = [
240
- i18n.t("header", { status: `${statusView.label}${metrics.length > 0 ? ` ${metrics.join(" · ")}` : ""}${expandHint}` }),
241
- ];
244
+ const metricParts = [mainMetric, ...secondaryMetrics].filter((part) => part.length > 0);
245
+ const headerSegments = [`${statusView.icon} Distill`];
246
+ if (statusText) headerSegments.push(statusText);
247
+ if (metricParts.length > 0) headerSegments.push(metricParts.join(" · "));
248
+ const lines = [`${headerSegments.join(" ")}${expandHint}`];
242
249
  if (expanded) {
243
250
  const sections: Array<{ label: string; text: string }> = [];
244
251
  if (render.showPrompt && outputRequest) sections.push({ label: i18n.t("outputRequest"), text: outputRequest });
@@ -263,7 +270,8 @@ export function buildDistillAuditLines(
263
270
 
264
271
  return {
265
272
  lines,
266
- statusLabel: statusView.label,
273
+ statusIcon: statusView.icon,
274
+ statusText,
267
275
  statusTone: statusView.tone,
268
276
  };
269
277
  }
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  type DistillToolConfig,
62
62
  type OutputSummaryDecision,
63
63
  } from "./summary-utils.ts";
64
+ import { estimateHeuristicTokens } from "./token-estimator.ts";
64
65
 
65
66
  const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
66
67
 
@@ -182,7 +183,7 @@ type SummaryDiagnostics = {
182
183
  summaryCacheWriteTokens?: number;
183
184
  summaryTotalTokens?: number;
184
185
  summaryCost?: number;
185
- /** 原始输出与最终摘要的 Token 数是按 Pi chars/4 规则估算的。 */
186
+ /** 原始输出与最终摘要的展示用 Token 数,由分段启发式估算(CJK 约每字 1 token,其余约 4 字符 1 token)。 */
186
187
  estimatedOriginalOutputTokens?: number;
187
188
  estimatedSummaryTokens?: number;
188
189
  estimatedTokensSaved?: number;
@@ -275,18 +276,12 @@ function getSummaryUsageDiagnostics(usage: SummaryUsage | undefined): Pick<
275
276
  };
276
277
  }
277
278
 
278
- const ESTIMATED_CHARS_PER_TOKEN = 4;
279
-
280
- function estimateTextTokens(text: string): number {
281
- return Math.ceil(text.length / ESTIMATED_CHARS_PER_TOKEN);
282
- }
283
-
284
279
  function getTokenCompressionDiagnostics(
285
280
  originalOutput: string,
286
281
  finalOutput: string,
287
282
  ): Pick<SummaryDiagnostics, "estimatedOriginalOutputTokens" | "estimatedSummaryTokens" | "estimatedTokensSaved"> {
288
- const estimatedOriginalOutputTokens = estimateTextTokens(originalOutput);
289
- const estimatedSummaryTokens = estimateTextTokens(finalOutput);
283
+ const estimatedOriginalOutputTokens = estimateHeuristicTokens(originalOutput);
284
+ const estimatedSummaryTokens = estimateHeuristicTokens(finalOutput);
290
285
  return {
291
286
  estimatedOriginalOutputTokens,
292
287
  estimatedSummaryTokens,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 展示用 token 估算。跨模型没有通用的精确 tokenizer(实测误差 24%~65%),
3
+ * Anthropic 官方 JS 包的词表也停留在 Claude 2 时代,所以不引入第三方
4
+ * tokenizer 依赖,统一使用分段启发式:
5
+ *
6
+ * - CJK、假名、谚文和全角字符:现代 BPE 分词器通常每字 1 token;
7
+ * - 其余字符:约 4 字符 1 token(与 Pi 的 chars/4 规则一致)。
8
+ *
9
+ * 相比纯 chars/4,中文内容不再被低估约 4 倍。
10
+ */
11
+
12
+ /** 现代 BPE 分词器通常按单字编码的 Unicode 区间(CJK/假名/谚文/全角)。 */
13
+ const DENSE_SCRIPT_RANGES: ReadonlyArray<readonly [number, number]> = [
14
+ [0x2e80, 0x9fff], // CJK 部首补充、假名、CJK 统一表意文字等
15
+ [0xac00, 0xd7af], // 谚文音节
16
+ [0xf900, 0xfaff], // CJK 兼容表意文字
17
+ [0xff00, 0xffef], // 全角字符
18
+ [0x20000, 0x2fa1f], // CJK 扩展 B–F 及兼容补充(代理对区间)
19
+ ];
20
+
21
+ /** 判断码点是否属于 dense script 区间(这些文字通常每字 1 token)。 */
22
+ function isDenseScript(codePoint: number): boolean {
23
+ return DENSE_SCRIPT_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
24
+ }
25
+
26
+ const ASCII_CHARS_PER_TOKEN = 4;
27
+
28
+ /**
29
+ * 分段启发式估算 token 数:dense script 字符按 1 token/字,其余按
30
+ * 4 字符 1 token。结果是展示用估算值,不保证与任何具体模型 tokenizer 一致。
31
+ */
32
+ export function estimateHeuristicTokens(text: string): number {
33
+ let dense = 0;
34
+ let rest = 0;
35
+ for (const char of text) {
36
+ if (isDenseScript(char.codePointAt(0) ?? 0)) dense += 1;
37
+ else rest += 1;
38
+ }
39
+ return dense + Math.ceil(rest / ASCII_CHARS_PER_TOKEN);
40
+ }