pi-distill 1.2.1 → 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
@@ -189,6 +189,12 @@ Configuration-file fields take precedence over environment variables. Unspecifie
189
189
  `/pi-distill` remains available as a compatibility alias.
190
190
  | `render.*` | Controls the audit card, prompt preview, and result preview. |
191
191
 
192
+ ## Session statistics
193
+
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
+
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
+
192
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`.
193
199
 
194
200
  ## Requirements
package/README.zh-CN.md CHANGED
@@ -192,6 +192,12 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
192
192
  `/pi-distill` 仍作为兼容别名保留。
193
193
  | `render.*` | 控制审计卡片、prompt 预览和结果预览。 |
194
194
 
195
+ ## Session 统计
196
+
197
+ 使用 `/distill:stats` 查看当前 Pi 会话的提炼统计。统计只保存在内存中,在会话开始时重置,不保存原始工具输出。
198
+
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
+
195
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`。
196
202
 
197
203
  ## 要求
@@ -1,64 +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
  },
42
+ "tokens": {
43
+ "zh-CN": "tok",
44
+ "en-US": "tok"
45
+ },
46
+ "compressionTokens": {
47
+ "zh-CN": "用量",
48
+ "en-US": "usage"
49
+ },
46
50
  "tool": {
47
51
  "zh-CN": "工具",
48
- "en-US": "Tool"
52
+ "en-US": "tool"
49
53
  },
50
54
  "distill": {
51
55
  "zh-CN": "提炼",
52
- "en-US": "Distill"
56
+ "en-US": "distill"
53
57
  },
54
58
  "expand": {
55
59
  "zh-CN": " • Ctrl+O 展开",
56
60
  "en-US": " • Ctrl+O to expand"
57
61
  },
58
- "header": {
59
- "zh-CN": "◇ Distill {status}",
60
- "en-US": "◇ Distill {status}"
61
- },
62
62
  "summary": {
63
63
  "zh-CN": "摘要",
64
64
  "en-US": "Summary"
@@ -163,6 +163,14 @@
163
163
  "zh-CN": "非超时异常",
164
164
  "en-US": "a non-timeout error"
165
165
  },
166
+ "outputRequestUnavailable": {
167
+ "zh-CN": "无法扩展工具 {tool} 的参数结构;outputRequest 不可用。",
168
+ "en-US": "Could not extend the {tool} parameter schema; outputRequest is unavailable."
169
+ },
170
+ "extendOutputRequestFailed": {
171
+ "zh-CN": "扩展 outputRequest 参数失败:{error}",
172
+ "en-US": "Failed to extend the outputRequest parameter: {error}"
173
+ },
166
174
  "thresholdTitle": {
167
175
  "zh-CN": "长输出阈值",
168
176
  "en-US": "Long-output threshold"
@@ -171,6 +179,18 @@
171
179
  "zh-CN": "交互式配置工具输出提炼",
172
180
  "en-US": "Configure tool-output distillation interactively"
173
181
  },
182
+ "statsCommandDescription": {
183
+ "zh-CN": "查看本次会话的工具输出提炼统计",
184
+ "en-US": "Show tool-output distillation statistics for this session"
185
+ },
186
+ "statsUnavailable": {
187
+ "zh-CN": "不可用",
188
+ "en-US": "n/a"
189
+ },
190
+ "statsReport": {
191
+ "zh-CN": "Distill 会话统计\n工具结果 {toolResults}|成功 {summarizedResults}|回退 {fallbackResults}|失败 {failedResults}\nRAW {rawResults}|跳过 {skippedResults}|非文本 {nonTextResults}\n尝试 {summaryAttempts}|重试 {retryCount}|耗时 {summaryDuration}(平均 {summaryAverageDuration})\n字符:{originalOutputChars} → {summaryChars}({compressionRatio}x)\nToken(估算):{estimatedOriginalOutputTokens} → {estimatedSummaryTokens}(节省 {estimatedTokensSaved})\n模型总 Token:{summaryTotalTokens}(输入 {summaryInputTokens}|输出 {summaryOutputTokens}|推理 {summaryReasoningTokens})\n缓存 Token:读 {summaryCacheReadTokens}|写 {summaryCacheWriteTokens}\n模型成本:{summaryCost}",
192
+ "en-US": "Distill session statistics\nTool results {toolResults} | summarized {summarizedResults} | fallback {fallbackResults} | failed {failedResults}\nRAW {rawResults} | skipped {skippedResults} | non-text {nonTextResults}\nAttempts {summaryAttempts} | retries {retryCount} | time {summaryDuration} (avg {summaryAverageDuration})\nChars: {originalOutputChars} → {summaryChars} ({compressionRatio}x)\nTokens (estimated): {estimatedOriginalOutputTokens} → {estimatedSummaryTokens} (saved {estimatedTokensSaved})\nModel total tokens: {summaryTotalTokens} (input {summaryInputTokens} | output {summaryOutputTokens} | reasoning {summaryReasoningTokens})\nCache tokens: read {summaryCacheReadTokens} | write {summaryCacheWriteTokens}\nModel cost: {summaryCost}"
193
+ },
174
194
  "outputRequest": {
175
195
  "zh-CN": "outputRequest",
176
196
  "en-US": "outputRequest"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-distill",
3
- "version": "1.2.1",
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
  }
@@ -56,15 +61,22 @@ function formatCount(value: number): string {
56
61
  return Math.round(value).toLocaleString("en-US");
57
62
  }
58
63
 
64
+ function formatCompactCount(value: number): string {
65
+ const rounded = Math.round(value);
66
+ if (rounded >= 1_000_000) return `${(rounded / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
67
+ if (rounded >= 1_000) return `${(rounded / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
68
+ return String(rounded);
69
+ }
70
+
59
71
  function renderDistillAuditLine(audit: DistillAuditView, line: string, index: number, theme: RenderTheme): string {
60
72
  if (index === 0) {
61
- const title = theme.fg("accent", theme.bold(" Distill"));
62
- const afterTitle = line.slice("◇ Distill".length);
63
- const statusIndex = afterTitle.indexOf(audit.statusLabel);
64
- if (statusIndex < 0) return `${title}${theme.fg("muted", afterTitle)}`;
65
- const beforeStatus = afterTitle.slice(0, statusIndex);
66
- const afterStatus = afterTitle.slice(statusIndex + audit.statusLabel.length);
67
- 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)}`;
68
80
  }
69
81
 
70
82
  const section = line.match(/^([├└]─ )([^ ]+)( )(.*)$/);
@@ -172,17 +184,17 @@ export function buildDistillAuditLines(
172
184
  const status = getString(details.outputSummaryStatus);
173
185
  if (!status) return undefined;
174
186
 
175
- const statusViews: Record<string, { label: string; tone: AuditTone }> = {
176
- summarized: { label: i18n.t("summarized"), tone: "success" },
177
- "summary-fallback": { label: i18n.t("summaryFallback"), tone: "warning" },
178
- disabled: { label: i18n.t("disabled"), tone: "dim" },
179
- "disabled-by-config": { label: i18n.t("off"), tone: "dim" },
180
- "not-requested": { label: i18n.t("original"), tone: "muted" },
181
- "full-output": { label: i18n.t("raw"), tone: "warning" },
182
- "below-threshold": { label: i18n.t("belowThreshold"), tone: "dim" },
183
- "non-text-output": { label: i18n.t("nonTextOutput"), tone: "muted" },
184
- "diagnostic-failed": { label: i18n.t("readFailed"), tone: "warning" },
185
- "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" },
186
198
  };
187
199
  const anomalies = Array.isArray(details.outputSummaryAnomalies)
188
200
  ? details.outputSummaryAnomalies.filter((value): value is string => typeof value === "string")
@@ -191,34 +203,49 @@ export function buildDistillAuditLines(
191
203
  const error = getString(details.outputSummaryError);
192
204
  const originalChars = getFiniteNumber(details.originalOutputChars);
193
205
  const summaryChars = getFiniteNumber(details.summaryChars);
194
- const compressionRatio = getFiniteNumber(details.compressionRatio);
206
+ const estimatedOriginalTokens = getFiniteNumber(details.estimatedOriginalOutputTokens);
207
+ const estimatedSummaryTokens = getFiniteNumber(details.estimatedSummaryTokens);
208
+ const estimatedTokensSaved = getFiniteNumber(details.estimatedTokensSaved);
209
+ const summaryTotalTokens = getFiniteNumber(details.summaryTotalTokens);
195
210
  const compressionSavedPercent = getFiniteNumber(details.compressionSavedPercent);
196
211
  const toolExecutionMs = getFiniteNumber(details.toolExecutionMs);
197
212
  const summaryDurationMs = getFiniteNumber(details.summaryDurationMs);
198
213
  const fullOutputPath = getString(details.fullOutputPath);
199
214
  const outputRequest = getString(details.outputSummaryPrompt);
200
215
  const summaryText = getString(details.summaryText);
201
- const statusView = statusViews[status] ?? { label: status, tone: "muted" as const };
202
- const metrics: string[] = [];
203
-
204
- if (originalChars !== undefined && summaryChars !== undefined) {
205
- metrics.push(`${formatCount(originalChars)} ${formatCount(summaryChars)} ${i18n.t("chars")}`);
216
+ const statusView = statusViews[status] ?? { icon: "○", tone: "muted" as const };
217
+ const statusText = statusView.textKey ? i18n.t(statusView.textKey) : status in statusViews ? "" : status;
218
+
219
+ let mainMetric = "";
220
+ if (estimatedOriginalTokens !== undefined && estimatedSummaryTokens !== undefined) {
221
+ mainMetric = `${formatCompactCount(estimatedOriginalTokens)} → ${formatCompactCount(estimatedSummaryTokens)} ${i18n.t("tokens")}`;
222
+ if (estimatedTokensSaved !== undefined && estimatedTokensSaved > 0 && compressionSavedPercent !== undefined) {
223
+ mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
224
+ }
225
+ } else if (originalChars !== undefined && summaryChars !== undefined) {
226
+ mainMetric = `${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`;
227
+ if (compressionSavedPercent !== undefined) {
228
+ mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
229
+ }
206
230
  } else if (originalChars !== undefined) {
207
- metrics.push(`${formatCount(originalChars)} ${i18n.t("chars")}`);
231
+ mainMetric = `${formatCount(originalChars)} ${i18n.t("chars")}`;
208
232
  }
209
- if (compressionRatio !== undefined) metrics.push(`${compressionRatio.toFixed(2)}×`);
210
- if (compressionSavedPercent !== undefined) {
211
- metrics.push(`${compressionSavedPercent.toFixed(1)}% saved`);
233
+
234
+ const secondaryMetrics: string[] = [];
235
+ if (summaryTotalTokens !== undefined && summaryTotalTokens > 0) {
236
+ secondaryMetrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)}`);
212
237
  }
213
- if (toolExecutionMs !== undefined && toolExecutionMs >= 50) {
214
- 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)}`);
215
240
  }
216
- if (summaryDurationMs !== undefined) metrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
241
+ if (summaryDurationMs !== undefined) secondaryMetrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
217
242
 
218
243
  const expandHint = expanded ? "" : i18n.t("expand");
219
- const lines = [
220
- i18n.t("header", { status: `${statusView.label}${metrics.length > 0 ? ` ${metrics.join(" · ")}` : ""}${expandHint}` }),
221
- ];
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}`];
222
249
  if (expanded) {
223
250
  const sections: Array<{ label: string; text: string }> = [];
224
251
  if (render.showPrompt && outputRequest) sections.push({ label: i18n.t("outputRequest"), text: outputRequest });
@@ -243,7 +270,8 @@ export function buildDistillAuditLines(
243
270
 
244
271
  return {
245
272
  lines,
246
- statusLabel: statusView.label,
273
+ statusIcon: statusView.icon,
274
+ statusText,
247
275
  statusTone: statusView.tone,
248
276
  };
249
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
 
@@ -123,19 +124,69 @@ type SummaryDecision = {
123
124
  reason: string;
124
125
  };
125
126
 
127
+ type SummaryUsage = {
128
+ input?: number;
129
+ output?: number;
130
+ reasoning?: number;
131
+ cacheRead?: number;
132
+ cacheWrite?: number;
133
+ totalTokens?: number;
134
+ cost?: {
135
+ input?: number;
136
+ output?: number;
137
+ reasoning?: number;
138
+ cacheRead?: number;
139
+ cacheWrite?: number;
140
+ total?: number;
141
+ };
142
+ };
143
+
126
144
  type SummaryResult = {
127
145
  text: string;
128
146
  summaryChars: number;
129
147
  summaryFilePath?: string;
130
148
  summaryModel: string;
131
149
  decision: SummaryDecision;
150
+ usage?: SummaryUsage;
151
+ attempts?: number;
132
152
  };
133
153
 
134
154
  type SummaryCompletion = (...args: Parameters<typeof complete>) => ReturnType<typeof complete>;
155
+ type DistillWarningReporter = (message: string) => void;
156
+
157
+ class SummaryAttemptError extends Error {
158
+ constructor(message: string, readonly usage?: SummaryUsage) {
159
+ super(message);
160
+ this.name = "SummaryAttemptError";
161
+ }
162
+ }
163
+
164
+ class SummaryRetryError extends Error {
165
+ constructor(
166
+ message: string,
167
+ readonly attempts: number,
168
+ readonly usage?: SummaryUsage,
169
+ ) {
170
+ super(message);
171
+ this.name = "SummaryRetryError";
172
+ }
173
+ }
135
174
 
136
175
  type SummaryDiagnostics = {
137
176
  toolExecutionMs?: number;
138
177
  summaryDurationMs?: number;
178
+ summaryAttempts?: number;
179
+ summaryInputTokens?: number;
180
+ summaryOutputTokens?: number;
181
+ summaryReasoningTokens?: number;
182
+ summaryCacheReadTokens?: number;
183
+ summaryCacheWriteTokens?: number;
184
+ summaryTotalTokens?: number;
185
+ summaryCost?: number;
186
+ /** 原始输出与最终摘要的展示用 Token 数,由分段启发式估算(CJK 约每字 1 token,其余约 4 字符 1 token)。 */
187
+ estimatedOriginalOutputTokens?: number;
188
+ estimatedSummaryTokens?: number;
189
+ estimatedTokensSaved?: number;
139
190
  outputSummaryIntent?: string;
140
191
  outputSummaryPrompt?: string;
141
192
  outputSummaryRender?: DistillRenderConfig;
@@ -158,6 +209,272 @@ type SummaryDiagnostics = {
158
209
  missedCompressionRatio?: number;
159
210
  };
160
211
 
212
+ function toFiniteNumber(value: unknown): number | undefined {
213
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
214
+ }
215
+
216
+ function normalizeSummaryUsage(value: unknown): SummaryUsage | undefined {
217
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
218
+ const record = value as Record<string, unknown>;
219
+ const usage: SummaryUsage = {
220
+ input: toFiniteNumber(record.input),
221
+ output: toFiniteNumber(record.output),
222
+ reasoning: toFiniteNumber(record.reasoning),
223
+ cacheRead: toFiniteNumber(record.cacheRead),
224
+ cacheWrite: toFiniteNumber(record.cacheWrite),
225
+ totalTokens: toFiniteNumber(record.totalTokens),
226
+ };
227
+ if (record.cost && typeof record.cost === "object" && !Array.isArray(record.cost)) {
228
+ const cost = record.cost as Record<string, unknown>;
229
+ usage.cost = {
230
+ input: toFiniteNumber(cost.input),
231
+ output: toFiniteNumber(cost.output),
232
+ reasoning: toFiniteNumber(cost.reasoning),
233
+ cacheRead: toFiniteNumber(cost.cacheRead),
234
+ cacheWrite: toFiniteNumber(cost.cacheWrite),
235
+ total: toFiniteNumber(cost.total),
236
+ };
237
+ }
238
+ return Object.values(usage).some((entry) => typeof entry === "number") || usage.cost !== undefined
239
+ ? usage
240
+ : undefined;
241
+ }
242
+
243
+ function mergeSummaryUsage(first: SummaryUsage | undefined, second: SummaryUsage | undefined): SummaryUsage | undefined {
244
+ if (!first && !second) return undefined;
245
+ const merged: SummaryUsage = {};
246
+ for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"] as const) {
247
+ const value = (first?.[key] ?? 0) + (second?.[key] ?? 0);
248
+ if ((first?.[key] !== undefined || second?.[key] !== undefined) && Number.isFinite(value)) {
249
+ merged[key] = value;
250
+ }
251
+ }
252
+ if (first?.cost || second?.cost) {
253
+ merged.cost = {};
254
+ for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "total"] as const) {
255
+ const value = (first?.cost?.[key] ?? 0) + (second?.cost?.[key] ?? 0);
256
+ if ((first?.cost?.[key] !== undefined || second?.cost?.[key] !== undefined) && Number.isFinite(value)) {
257
+ merged.cost[key] = value;
258
+ }
259
+ }
260
+ }
261
+ return merged;
262
+ }
263
+
264
+ function getSummaryUsageDiagnostics(usage: SummaryUsage | undefined): Pick<
265
+ SummaryDiagnostics,
266
+ "summaryInputTokens" | "summaryOutputTokens" | "summaryReasoningTokens" | "summaryCacheReadTokens" | "summaryCacheWriteTokens" | "summaryTotalTokens" | "summaryCost"
267
+ > {
268
+ return {
269
+ summaryInputTokens: usage?.input,
270
+ summaryOutputTokens: usage?.output,
271
+ summaryReasoningTokens: usage?.reasoning,
272
+ summaryCacheReadTokens: usage?.cacheRead,
273
+ summaryCacheWriteTokens: usage?.cacheWrite,
274
+ summaryTotalTokens: usage?.totalTokens,
275
+ summaryCost: usage?.cost?.total,
276
+ };
277
+ }
278
+
279
+ function getTokenCompressionDiagnostics(
280
+ originalOutput: string,
281
+ finalOutput: string,
282
+ ): Pick<SummaryDiagnostics, "estimatedOriginalOutputTokens" | "estimatedSummaryTokens" | "estimatedTokensSaved"> {
283
+ const estimatedOriginalOutputTokens = estimateHeuristicTokens(originalOutput);
284
+ const estimatedSummaryTokens = estimateHeuristicTokens(finalOutput);
285
+ return {
286
+ estimatedOriginalOutputTokens,
287
+ estimatedSummaryTokens,
288
+ estimatedTokensSaved: Math.max(0, estimatedOriginalOutputTokens - estimatedSummaryTokens),
289
+ };
290
+ }
291
+
292
+ type DistillSessionStats = {
293
+ startedAt: number;
294
+ toolResults: number;
295
+ summarizedResults: number;
296
+ fallbackResults: number;
297
+ failedResults: number;
298
+ rawResults: number;
299
+ skippedResults: number;
300
+ nonTextResults: number;
301
+ summaryAttempts: number;
302
+ retryCount: number;
303
+ originalOutputChars: number;
304
+ summaryChars: number;
305
+ summaryDurationMs: number;
306
+ summaryInputTokens: number;
307
+ summaryOutputTokens: number;
308
+ summaryReasoningTokens: number;
309
+ summaryCacheReadTokens: number;
310
+ summaryCacheWriteTokens: number;
311
+ summaryTotalTokens: number;
312
+ hasSummaryTokenUsage: boolean;
313
+ estimatedOriginalOutputTokens: number;
314
+ estimatedSummaryTokens: number;
315
+ estimatedTokensSaved: number;
316
+ summaryCost: number;
317
+ hasSummaryCost: boolean;
318
+ };
319
+
320
+ function createDistillSessionStats(): DistillSessionStats {
321
+ return {
322
+ startedAt: Date.now(),
323
+ toolResults: 0,
324
+ summarizedResults: 0,
325
+ fallbackResults: 0,
326
+ failedResults: 0,
327
+ rawResults: 0,
328
+ skippedResults: 0,
329
+ nonTextResults: 0,
330
+ summaryAttempts: 0,
331
+ retryCount: 0,
332
+ originalOutputChars: 0,
333
+ summaryChars: 0,
334
+ summaryDurationMs: 0,
335
+ summaryInputTokens: 0,
336
+ summaryOutputTokens: 0,
337
+ summaryReasoningTokens: 0,
338
+ summaryCacheReadTokens: 0,
339
+ summaryCacheWriteTokens: 0,
340
+ summaryTotalTokens: 0,
341
+ hasSummaryTokenUsage: false,
342
+ estimatedOriginalOutputTokens: 0,
343
+ estimatedSummaryTokens: 0,
344
+ estimatedTokensSaved: 0,
345
+ summaryCost: 0,
346
+ hasSummaryCost: false,
347
+ };
348
+ }
349
+
350
+ function getDetailNumber(details: Record<string, unknown> | undefined, key: string): number | undefined {
351
+ return toFiniteNumber(details?.[key]);
352
+ }
353
+
354
+ function recordDistillSessionResult(
355
+ stats: DistillSessionStats,
356
+ details: Record<string, unknown> | undefined,
357
+ ): void {
358
+ stats.toolResults += 1;
359
+ const status = typeof details?.outputSummaryStatus === "string"
360
+ ? details.outputSummaryStatus
361
+ : undefined;
362
+ if (status === "summarized") stats.summarizedResults += 1;
363
+ else if (status === "summary-fallback") stats.fallbackResults += 1;
364
+ else if (status === "summary-failed") stats.failedResults += 1;
365
+ else if (status === "full-output") stats.rawResults += 1;
366
+ else if (status === "non-text-output") stats.nonTextResults += 1;
367
+ else stats.skippedResults += 1;
368
+
369
+ stats.summaryAttempts += getDetailNumber(details, "summaryAttempts") ?? 0;
370
+ const attempts = getDetailNumber(details, "summaryAttempts");
371
+ if (attempts !== undefined) stats.retryCount += Math.max(0, attempts - 1);
372
+ stats.originalOutputChars += getDetailNumber(details, "originalOutputChars") ?? 0;
373
+ stats.summaryChars += getDetailNumber(details, "summaryChars") ?? 0;
374
+ stats.summaryDurationMs += getDetailNumber(details, "summaryDurationMs") ?? 0;
375
+ stats.summaryInputTokens += getDetailNumber(details, "summaryInputTokens") ?? 0;
376
+ stats.summaryOutputTokens += getDetailNumber(details, "summaryOutputTokens") ?? 0;
377
+ stats.summaryReasoningTokens += getDetailNumber(details, "summaryReasoningTokens") ?? 0;
378
+ stats.summaryCacheReadTokens += getDetailNumber(details, "summaryCacheReadTokens") ?? 0;
379
+ stats.summaryCacheWriteTokens += getDetailNumber(details, "summaryCacheWriteTokens") ?? 0;
380
+ const summaryTotalTokens = getDetailNumber(details, "summaryTotalTokens");
381
+ if (summaryTotalTokens !== undefined) {
382
+ stats.summaryTotalTokens += summaryTotalTokens;
383
+ }
384
+ if (
385
+ getDetailNumber(details, "summaryInputTokens") !== undefined
386
+ || getDetailNumber(details, "summaryOutputTokens") !== undefined
387
+ || getDetailNumber(details, "summaryTotalTokens") !== undefined
388
+ ) {
389
+ stats.hasSummaryTokenUsage = true;
390
+ }
391
+ stats.estimatedOriginalOutputTokens += getDetailNumber(details, "estimatedOriginalOutputTokens") ?? 0;
392
+ stats.estimatedSummaryTokens += getDetailNumber(details, "estimatedSummaryTokens") ?? 0;
393
+ stats.estimatedTokensSaved += getDetailNumber(details, "estimatedTokensSaved") ?? 0;
394
+ const cost = getDetailNumber(details, "summaryCost");
395
+ if (cost !== undefined) {
396
+ stats.summaryCost += cost;
397
+ stats.hasSummaryCost = true;
398
+ }
399
+ }
400
+
401
+ export function formatCompactCount(value: number): string {
402
+ const absolute = Math.abs(value);
403
+ const sign = value < 0 ? "-" : "";
404
+ const formatScaled = (scaled: number): string => {
405
+ const rounded = Math.round(scaled * 10) / 10;
406
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
407
+ };
408
+ if (absolute >= 1_000_000) return `${sign}${formatScaled(absolute / 1_000_000)}m`;
409
+ if (absolute >= 1_000) return `${sign}${formatScaled(absolute / 1_000)}k`;
410
+ return String(Math.round(value));
411
+ }
412
+
413
+ export function formatSessionDuration(milliseconds: number): string {
414
+ if (milliseconds < 1_000) return `${Math.max(0, Math.round(milliseconds))}ms`;
415
+ const seconds = milliseconds / 1_000;
416
+ if (seconds < 60) return `${seconds.toFixed(1).replace(/\.0$/, "")}s`;
417
+ const minutes = seconds / 60;
418
+ return `${minutes.toFixed(1).replace(/\.0$/, "")}min`;
419
+ }
420
+
421
+ function formatDistillSessionStats(stats: DistillSessionStats): string {
422
+ const compressionRatio = stats.summaryChars > 0
423
+ ? (stats.originalOutputChars / stats.summaryChars).toFixed(2)
424
+ : "-";
425
+ const cost = stats.hasSummaryCost ? stats.summaryCost.toFixed(6) : i18n.t("statsUnavailable");
426
+ const summaryOperations = stats.summarizedResults
427
+ + stats.fallbackResults
428
+ + stats.failedResults
429
+ + stats.rawResults;
430
+ const averageDurationMs = summaryOperations > 0
431
+ ? Math.round(stats.summaryDurationMs / summaryOperations)
432
+ : 0;
433
+ const tokenUsage = stats.hasSummaryTokenUsage
434
+ ? {
435
+ input: formatCompactCount(stats.summaryInputTokens),
436
+ output: formatCompactCount(stats.summaryOutputTokens),
437
+ reasoning: formatCompactCount(stats.summaryReasoningTokens),
438
+ cacheRead: formatCompactCount(stats.summaryCacheReadTokens),
439
+ cacheWrite: formatCompactCount(stats.summaryCacheWriteTokens),
440
+ total: formatCompactCount(stats.summaryTotalTokens),
441
+ }
442
+ : {
443
+ input: i18n.t("statsUnavailable"),
444
+ output: i18n.t("statsUnavailable"),
445
+ reasoning: i18n.t("statsUnavailable"),
446
+ cacheRead: i18n.t("statsUnavailable"),
447
+ cacheWrite: i18n.t("statsUnavailable"),
448
+ total: i18n.t("statsUnavailable"),
449
+ };
450
+ return i18n.t("statsReport", {
451
+ toolResults: formatCompactCount(stats.toolResults),
452
+ summarizedResults: formatCompactCount(stats.summarizedResults),
453
+ fallbackResults: formatCompactCount(stats.fallbackResults),
454
+ failedResults: formatCompactCount(stats.failedResults),
455
+ rawResults: formatCompactCount(stats.rawResults),
456
+ skippedResults: formatCompactCount(stats.skippedResults),
457
+ nonTextResults: formatCompactCount(stats.nonTextResults),
458
+ summaryAttempts: formatCompactCount(stats.summaryAttempts),
459
+ retryCount: formatCompactCount(stats.retryCount),
460
+ originalOutputChars: formatCompactCount(stats.originalOutputChars),
461
+ summaryChars: formatCompactCount(stats.summaryChars),
462
+ compressionRatio,
463
+ estimatedOriginalOutputTokens: formatCompactCount(stats.estimatedOriginalOutputTokens),
464
+ estimatedSummaryTokens: formatCompactCount(stats.estimatedSummaryTokens),
465
+ estimatedTokensSaved: formatCompactCount(stats.estimatedTokensSaved),
466
+ summaryDuration: formatSessionDuration(stats.summaryDurationMs),
467
+ summaryAverageDuration: formatSessionDuration(averageDurationMs),
468
+ summaryInputTokens: tokenUsage.input,
469
+ summaryOutputTokens: tokenUsage.output,
470
+ summaryReasoningTokens: tokenUsage.reasoning,
471
+ summaryCacheReadTokens: tokenUsage.cacheRead,
472
+ summaryCacheWriteTokens: tokenUsage.cacheWrite,
473
+ summaryTotalTokens: tokenUsage.total,
474
+ summaryCost: cost,
475
+ });
476
+ }
477
+
161
478
  function attachDiagnostics(result: ToolResult, diagnostics: SummaryDiagnostics): ToolResult {
162
479
  return {
163
480
  ...result,
@@ -394,8 +711,12 @@ async function summarizeOutput(
394
711
  },
395
712
  );
396
713
 
714
+ const usage = normalizeSummaryUsage(response.usage);
397
715
  if (response.stopReason === "error" || response.stopReason === "aborted") {
398
- throw new Error(response.errorMessage ?? `Summarizer stopped with reason: ${response.stopReason}`);
716
+ throw new SummaryAttemptError(
717
+ response.errorMessage ?? `Summarizer stopped with reason: ${response.stopReason}`,
718
+ usage,
719
+ );
399
720
  }
400
721
 
401
722
  const rawResponse = response.content
@@ -404,17 +725,23 @@ async function summarizeOutput(
404
725
  .join("\n")
405
726
  .trim();
406
727
 
407
- if (!rawResponse) throw new Error("Summarizer returned no text");
728
+ if (!rawResponse) throw new SummaryAttemptError("Summarizer returned no text", usage);
408
729
  const summaryModel = `${model.provider}/${model.id}`;
409
- const parsed = parseSummaryResponse(rawResponse, summaryModel);
410
- if (parsed.decision.mode === "RAW") return parsed;
411
- if (parsed.summaryChars <= config.maxChars) return parsed;
730
+ let parsed: SummaryResult;
731
+ try {
732
+ parsed = parseSummaryResponse(rawResponse, summaryModel);
733
+ } catch (error) {
734
+ throw new SummaryAttemptError(error instanceof Error ? error.message : String(error), usage);
735
+ }
736
+ if (parsed.decision.mode === "RAW") return { ...parsed, usage };
737
+ if (parsed.summaryChars <= config.maxChars) return { ...parsed, usage };
412
738
 
413
739
  const summaryFilePath = await writeSummaryFile(parsed.text);
414
740
  return {
415
741
  ...parsed,
416
742
  text: `Summary exceeded ${config.maxChars} chars and was written to: ${summaryFilePath}`,
417
743
  summaryFilePath,
744
+ usage,
418
745
  };
419
746
  }
420
747
 
@@ -427,10 +754,12 @@ async function summarizeOutputWithRetries(
427
754
  ): Promise<SummaryResult> {
428
755
  let timeoutRetries = 0;
429
756
  let errorRetries = 0;
757
+ let attempts = 0;
758
+ let totalUsage: SummaryUsage | undefined;
430
759
 
431
760
  while (true) {
432
761
  if (context.signal?.aborted) {
433
- throw new Error("Summarization aborted");
762
+ throw new SummaryRetryError("Summarization aborted", attempts, totalUsage);
434
763
  }
435
764
 
436
765
  const attemptController = new AbortController();
@@ -445,9 +774,10 @@ async function summarizeOutputWithRetries(
445
774
  },
446
775
  config.timeoutSeconds * 1000,
447
776
  );
777
+ attempts += 1;
448
778
 
449
779
  try {
450
- return await summarizeOutput(
780
+ const result = await summarizeOutput(
451
781
  prompt,
452
782
  output,
453
783
  config,
@@ -455,19 +785,39 @@ async function summarizeOutputWithRetries(
455
785
  attemptController.signal,
456
786
  completion,
457
787
  );
788
+ totalUsage = mergeSummaryUsage(totalUsage, result.usage);
789
+ return {
790
+ ...result,
791
+ usage: totalUsage,
792
+ attempts,
793
+ };
458
794
  } catch (error) {
459
- if (context.signal?.aborted) throw error;
795
+ totalUsage = mergeSummaryUsage(
796
+ totalUsage,
797
+ error instanceof SummaryAttemptError || error instanceof SummaryRetryError
798
+ ? error.usage
799
+ : undefined,
800
+ );
801
+ if (context.signal?.aborted) {
802
+ throw new SummaryRetryError("Summarization aborted", attempts, totalUsage);
803
+ }
460
804
  const retryLimit = timedOut ? config.timeoutRetryCount : config.errorRetryCount;
461
805
  const retriesUsed = timedOut ? timeoutRetries : errorRetries;
462
- if (retriesUsed >= retryLimit) throw error;
806
+ if (retriesUsed >= retryLimit) {
807
+ throw new SummaryRetryError(
808
+ error instanceof Error ? error.message : String(error),
809
+ attempts,
810
+ totalUsage,
811
+ );
812
+ }
463
813
  if (timedOut) timeoutRetries += 1;
464
814
  else errorRetries += 1;
465
- console.warn(i18n.t("retryingSummary", {
815
+ context.ctx.ui.notify(i18n.t("retryingSummary", {
466
816
  kind: i18n.t(timedOut ? "retryKindTimeout" : "retryKindError"),
467
817
  retry: retriesUsed + 1,
468
818
  limit: retryLimit,
469
819
  error: error instanceof Error ? error.message : String(error),
470
- }));
820
+ }), "warning");
471
821
  } finally {
472
822
  clearTimeout(timeout);
473
823
  context.signal?.removeEventListener("abort", abortFromParent);
@@ -496,7 +846,9 @@ export async function processToolResult(
496
846
  const maxReturnedChars = config?.maxOutputChars ?? 10_000;
497
847
  const finish = (candidate: ToolResult) => limitReturnedToolResult(candidate, maxReturnedChars);
498
848
  if (loaded.warnings.length > 0) {
499
- console.warn(`[pi-distill] ${loaded.warnings.join(" | ")}`);
849
+ context.ctx.ui.notify(i18n.t("configWarnings", {
850
+ warnings: loaded.warnings.join(" "),
851
+ }), "warning");
500
852
  }
501
853
 
502
854
  if (config && loaded.enabled && !isDistillToolEnabled(config, context.toolName)) return result;
@@ -535,9 +887,7 @@ export async function processToolResult(
535
887
  try {
536
888
  output = await getCompleteOutput(result);
537
889
  } catch (error) {
538
- console.warn(
539
- `[tool-output-summary] ${context.toolName} could not read the full output; returning the original result: ${error instanceof Error ? error.message : String(error)}`,
540
- );
890
+ const errorMessage = error instanceof Error ? error.message : String(error);
541
891
  return finish(attachDiagnostics(result, {
542
892
  toolExecutionMs,
543
893
  outputSummaryPrompt: prompt || undefined,
@@ -547,6 +897,7 @@ export async function processToolResult(
547
897
  summaryTriggerMaxChars: null,
548
898
  summaryResultMaxChars: config.maxChars,
549
899
  missedCompressionRatio: config.missedCompressionRatio,
900
+ outputSummaryError: errorMessage,
550
901
  }));
551
902
  }
552
903
 
@@ -564,6 +915,7 @@ export async function processToolResult(
564
915
  summaryTriggerMaxChars: null,
565
916
  summaryResultMaxChars: config.maxChars,
566
917
  missedCompressionRatio: config.missedCompressionRatio,
918
+ ...getTokenCompressionDiagnostics(output, output),
567
919
  ...skippedDiagnostics,
568
920
  };
569
921
  const candidate = {
@@ -594,6 +946,9 @@ export async function processToolResult(
594
946
  const diagnostics: SummaryDiagnostics = {
595
947
  originalOutputChars: output.length,
596
948
  summaryChars: output.length,
949
+ summaryAttempts: summarized.attempts,
950
+ ...getSummaryUsageDiagnostics(summarized.usage),
951
+ ...getTokenCompressionDiagnostics(output, output),
597
952
  compressionRatio: 1,
598
953
  compressionSavedPercent: 0,
599
954
  ...rawDiagnostics,
@@ -628,6 +983,9 @@ export async function processToolResult(
628
983
  const summaryDiagnostics: SummaryDiagnostics = {
629
984
  originalOutputChars: output.length,
630
985
  summaryChars: summarized.summaryChars,
986
+ summaryAttempts: summarized.attempts,
987
+ ...getSummaryUsageDiagnostics(summarized.usage),
988
+ ...getTokenCompressionDiagnostics(output, summarized.text),
631
989
  ...compressionDiagnostics,
632
990
  };
633
991
  const agentDiagnostic = buildAgentDiagnosticText(summaryDiagnostics);
@@ -650,6 +1008,7 @@ export async function processToolResult(
650
1008
  outputSummaryReasonCode: summarized.decision.reasonCode,
651
1009
  outputSummaryReason: summarized.decision.reason,
652
1010
  ...summaryDiagnostics,
1011
+ ...getTokenCompressionDiagnostics(output, output),
653
1012
  }),
654
1013
  content: [{ type: "text", text: output }],
655
1014
  });
@@ -686,14 +1045,14 @@ export async function processToolResult(
686
1045
  } catch (error) {
687
1046
  const summaryDurationMs = Math.round(performance.now() - summaryStartedAt);
688
1047
  const errorMessage = error instanceof Error ? error.message : String(error);
1048
+ const retryError = error instanceof SummaryRetryError ? error : undefined;
689
1049
  // 总结链路任何异常都必须保留原始结果,不能把异常文本替换给 AI。
690
- console.warn(
691
- `[tool-output-summary] ${context.toolName} summarization failed; returning the original result: ${errorMessage}`,
692
- );
693
1050
  const diagnostics: SummaryDiagnostics = {
694
1051
  toolExecutionMs,
695
1052
  summaryDurationMs,
696
1053
  originalOutputChars: output.length,
1054
+ summaryAttempts: retryError?.attempts,
1055
+ ...getSummaryUsageDiagnostics(retryError?.usage),
697
1056
  outputSummaryIntent: decision.intent,
698
1057
  outputSummaryPrompt: prompt || undefined,
699
1058
  outputSummaryRender,
@@ -702,6 +1061,7 @@ export async function processToolResult(
702
1061
  summaryTriggerMaxChars: null,
703
1062
  summaryResultMaxChars: config.maxChars,
704
1063
  missedCompressionRatio: config.missedCompressionRatio,
1064
+ ...getTokenCompressionDiagnostics(output, output),
705
1065
  outputSummaryAnomalies: ["summary-failed"],
706
1066
  outputSummaryAdvice: `Summarization failed; the original output was preserved. Check model configuration or authentication. Requests still running after ${config.timeoutSeconds}s are treated as timed out.`,
707
1067
  outputSummaryError: errorMessage,
@@ -739,17 +1099,21 @@ function restoreOutputRequestParameter(parameters: Record<string, unknown>): boo
739
1099
  return true;
740
1100
  }
741
1101
 
742
- function extendOutputRequestParameter(tool: ToolInfo, enabled: boolean): boolean {
1102
+ function extendOutputRequestParameter(
1103
+ tool: ToolInfo,
1104
+ enabled: boolean,
1105
+ reportWarning: DistillWarningReporter,
1106
+ ): boolean {
743
1107
  const parameters = tool.parameters as unknown as Record<string, unknown> | undefined;
744
1108
  if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
745
- console.warn(`[pi-distill] Could not extend the ${tool.name} parameter schema; outputRequest is unavailable.`);
1109
+ reportWarning(i18n.t("outputRequestUnavailable", { tool: tool.name }));
746
1110
  return false;
747
1111
  }
748
1112
 
749
1113
  if (!enabled) return restoreOutputRequestParameter(parameters);
750
1114
 
751
1115
  if (parameters.type !== "object") {
752
- console.warn(`[pi-distill] Could not extend the ${tool.name} parameter schema; outputRequest is unavailable.`);
1116
+ reportWarning(i18n.t("outputRequestUnavailable", { tool: tool.name }));
753
1117
  return false;
754
1118
  }
755
1119
 
@@ -758,7 +1122,7 @@ function extendOutputRequestParameter(tool: ToolInfo, enabled: boolean): boolean
758
1122
  if (properties === undefined) {
759
1123
  parameters.properties = {};
760
1124
  } else if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
761
- console.warn(`[pi-distill] Could not extend the ${tool.name} parameter schema; outputRequest is unavailable.`);
1125
+ reportWarning(i18n.t("outputRequestUnavailable", { tool: tool.name }));
762
1126
  return false;
763
1127
  }
764
1128
 
@@ -790,11 +1154,12 @@ function extendOutputRequestParameter(tool: ToolInfo, enabled: boolean): boolean
790
1154
  export function extendDistillToolParameters(
791
1155
  pi: Pick<ExtensionAPI, "getAllTools">,
792
1156
  loaded = loadDistillConfig(),
1157
+ reportWarning: DistillWarningReporter = () => undefined,
793
1158
  ): number {
794
1159
  let extended = 0;
795
1160
  for (const tool of pi.getAllTools()) {
796
1161
  const enabled = loaded.enabled && Boolean(loaded.config) && isDistillToolEnabled(loaded.config, tool.name);
797
- if (extendOutputRequestParameter(tool, enabled) && enabled) extended += 1;
1162
+ if (extendOutputRequestParameter(tool, enabled, reportWarning) && enabled) extended += 1;
798
1163
  }
799
1164
  return extended;
800
1165
  }
@@ -1042,7 +1407,10 @@ async function runDistillConfigUi(
1042
1407
  }
1043
1408
  }
1044
1409
 
1045
- function registerDistillConfigCommand(pi: ExtensionAPI, onSaved: () => void): void {
1410
+ function registerDistillConfigCommand(
1411
+ pi: ExtensionAPI,
1412
+ onSaved: (ctx: ExtensionCommandContext) => void,
1413
+ ): void {
1046
1414
  const command = {
1047
1415
  description: i18n.t("commandDescription"),
1048
1416
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
@@ -1050,7 +1418,7 @@ function registerDistillConfigCommand(pi: ExtensionAPI, onSaved: () => void): vo
1050
1418
  ctx.ui.notify(i18n.t("interactiveOnly"), "warning");
1051
1419
  return;
1052
1420
  }
1053
- await runDistillConfigUi(ctx, pi, getDistillConfigPath(), onSaved);
1421
+ await runDistillConfigUi(ctx, pi, getDistillConfigPath(), () => onSaved(ctx));
1054
1422
  },
1055
1423
  };
1056
1424
  for (const name of ["config:distill", "pi-distill"] as const) {
@@ -1058,23 +1426,51 @@ function registerDistillConfigCommand(pi: ExtensionAPI, onSaved: () => void): vo
1058
1426
  }
1059
1427
  }
1060
1428
 
1429
+ function registerDistillStatsCommand(
1430
+ pi: ExtensionAPI,
1431
+ getStats: () => DistillSessionStats,
1432
+ ): void {
1433
+ pi.registerCommand("distill:stats", {
1434
+ description: i18n.t("statsCommandDescription"),
1435
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
1436
+ if (!ctx.hasUI) {
1437
+ ctx.ui.notify(i18n.t("interactiveOnly"), "warning");
1438
+ return;
1439
+ }
1440
+ ctx.ui.notify(formatDistillSessionStats(getStats()), "info");
1441
+ },
1442
+ });
1443
+ }
1444
+
1061
1445
  export default function piDistillExtension(pi: ExtensionAPI) {
1062
1446
  const pendingCalls = new Map<string, PendingDistillCall>();
1447
+ const reportedWarnings = new Set<string>();
1448
+ let sessionStats = createDistillSessionStats();
1063
1449
  let originalUserPrompt = "";
1064
1450
  const disposeToolDisplayMiddleware = registerDistillToolDisplayMiddleware();
1065
1451
  registerDistillFallbackRenderer(pi);
1066
- const extendParameters = () => {
1452
+ const extendParameters = (ctx: ExtensionContext) => {
1453
+ const reportWarning = (message: string) => {
1454
+ if (reportedWarnings.has(message)) return;
1455
+ reportedWarnings.add(message);
1456
+ ctx.ui.notify(message, "warning");
1457
+ };
1067
1458
  try {
1068
- extendDistillToolParameters(pi, loadDistillConfig());
1459
+ extendDistillToolParameters(pi, loadDistillConfig(), reportWarning);
1069
1460
  } catch (error) {
1070
- console.warn(`[pi-distill] Failed to extend the outputRequest parameter: ${error instanceof Error ? error.message : String(error)}`);
1461
+ reportWarning(i18n.t("extendOutputRequestFailed", {
1462
+ error: error instanceof Error ? error.message : String(error),
1463
+ }));
1071
1464
  }
1072
1465
  };
1073
1466
 
1074
- pi.on("session_start", extendParameters);
1075
- pi.on("before_agent_start", (event) => {
1467
+ pi.on("session_start", (_event, ctx) => {
1468
+ sessionStats = createDistillSessionStats();
1469
+ extendParameters(ctx);
1470
+ });
1471
+ pi.on("before_agent_start", (event, ctx) => {
1076
1472
  originalUserPrompt = typeof event.prompt === "string" ? event.prompt : "";
1077
- extendParameters();
1473
+ extendParameters(ctx);
1078
1474
  return {
1079
1475
  systemPrompt: [
1080
1476
  typeof event.systemPrompt === "string" ? event.systemPrompt : "",
@@ -1099,11 +1495,15 @@ export default function piDistillExtension(pi: ExtensionAPI) {
1099
1495
  pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
1100
1496
  const pending = pendingCalls.get(event.toolCallId);
1101
1497
  pendingCalls.delete(event.toolCallId);
1102
- if (pending && !pending.enabled) return toToolResultEventResult({
1103
- content: event.content,
1104
- details: event.details as Record<string, unknown> | undefined,
1105
- isError: event.isError,
1106
- });
1498
+ if (pending && !pending.enabled) {
1499
+ const untouchedResult: ToolResult = {
1500
+ content: event.content,
1501
+ details: event.details as Record<string, unknown> | undefined,
1502
+ isError: event.isError,
1503
+ };
1504
+ recordDistillSessionResult(sessionStats, untouchedResult.details);
1505
+ return toToolResultEventResult(untouchedResult);
1506
+ }
1107
1507
  const outputRequest = pending?.outputRequest ?? getOutputRequest(event.input);
1108
1508
  const result = await processToolResult(
1109
1509
  {
@@ -1120,6 +1520,7 @@ export default function piDistillExtension(pi: ExtensionAPI) {
1120
1520
  },
1121
1521
  pending ? Math.round(performance.now() - pending.startedAt) : 0,
1122
1522
  );
1523
+ recordDistillSessionResult(sessionStats, result.details);
1123
1524
  if (!isDistillToolDisplayMiddlewareActive(event.toolName)) {
1124
1525
  appendDistillFallbackAudit(pi, event.toolName, result.details, loadDistillConfig().render);
1125
1526
  }
@@ -1127,5 +1528,6 @@ export default function piDistillExtension(pi: ExtensionAPI) {
1127
1528
  });
1128
1529
  pi.on("agent_end", () => pendingCalls.clear());
1129
1530
  pi.on("session_shutdown", () => disposeToolDisplayMiddleware());
1531
+ registerDistillStatsCommand(pi, () => sessionStats);
1130
1532
  registerDistillConfigCommand(pi, extendParameters);
1131
1533
  }
@@ -59,7 +59,6 @@ export async function limitReturnedToolResult(
59
59
  };
60
60
  } catch (error) {
61
61
  const message = error instanceof Error ? error.message : String(error);
62
- console.warn(i18n.t("outputLimitWriteFailed", { error: message }));
63
62
  return {
64
63
  ...result,
65
64
  content: [{ type: "text", text: text.slice(0, maxChars) }],
@@ -160,9 +160,6 @@ export function parseBashSummaryConfig(
160
160
  missedCompressionRatio === undefined ||
161
161
  summarizeErrors === undefined
162
162
  ) {
163
- console.warn(
164
- "[pi-distill] Invalid distillation config; distillation disabled. Check 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 (legacy PI_BASH_SUMMARY_* variables remain supported).",
165
- );
166
163
  return undefined;
167
164
  }
168
165
 
@@ -181,9 +178,6 @@ export function parseBashSummaryConfig(
181
178
 
182
179
  const separator = modelRef.indexOf("/");
183
180
  if (separator <= 0 || separator === modelRef.length - 1) {
184
- console.warn(
185
- `[pi-distill] Invalid PI_DISTILL_MODEL; expected provider/model, got: ${modelRef}`,
186
- );
187
181
  return undefined;
188
182
  }
189
183
 
@@ -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
+ }