pi-distill 1.2.0 → 1.3.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, `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.
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
+ 统计包括工具结果数量、成功/失败/回退次数、模型尝试次数、原始与摘要字符数、压缩比、按 `chars/4` 估算的原文/摘要 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
  ## 要求
@@ -43,6 +43,22 @@
43
43
  "zh-CN": "字符",
44
44
  "en-US": "chars"
45
45
  },
46
+ "tokens": {
47
+ "zh-CN": "Token",
48
+ "en-US": "tokens"
49
+ },
50
+ "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"
61
+ },
46
62
  "tool": {
47
63
  "zh-CN": "工具",
48
64
  "en-US": "Tool"
@@ -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.0",
3
+ "version": "1.3.0",
4
4
  "description": "Pi tool-output distillation with file-first configuration",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -56,6 +56,13 @@ function formatCount(value: number): string {
56
56
  return Math.round(value).toLocaleString("en-US");
57
57
  }
58
58
 
59
+ function formatCompactCount(value: number): string {
60
+ const rounded = Math.round(value);
61
+ if (rounded >= 1_000_000) return `${(rounded / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
62
+ if (rounded >= 1_000) return `${(rounded / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
63
+ return String(rounded);
64
+ }
65
+
59
66
  function renderDistillAuditLine(audit: DistillAuditView, line: string, index: number, theme: RenderTheme): string {
60
67
  if (index === 0) {
61
68
  const title = theme.fg("accent", theme.bold("◇ Distill"));
@@ -191,6 +198,10 @@ export function buildDistillAuditLines(
191
198
  const error = getString(details.outputSummaryError);
192
199
  const originalChars = getFiniteNumber(details.originalOutputChars);
193
200
  const summaryChars = getFiniteNumber(details.summaryChars);
201
+ const estimatedOriginalTokens = getFiniteNumber(details.estimatedOriginalOutputTokens);
202
+ const estimatedSummaryTokens = getFiniteNumber(details.estimatedSummaryTokens);
203
+ const estimatedTokensSaved = getFiniteNumber(details.estimatedTokensSaved);
204
+ const summaryTotalTokens = getFiniteNumber(details.summaryTotalTokens);
194
205
  const compressionRatio = getFiniteNumber(details.compressionRatio);
195
206
  const compressionSavedPercent = getFiniteNumber(details.compressionSavedPercent);
196
207
  const toolExecutionMs = getFiniteNumber(details.toolExecutionMs);
@@ -201,14 +212,23 @@ export function buildDistillAuditLines(
201
212
  const statusView = statusViews[status] ?? { label: status, tone: "muted" as const };
202
213
  const metrics: string[] = [];
203
214
 
204
- if (originalChars !== undefined && summaryChars !== undefined) {
215
+ 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
+ } else if (originalChars !== undefined && summaryChars !== undefined) {
205
222
  metrics.push(`${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`);
223
+ if (compressionRatio !== undefined) metrics.push(`${compressionRatio.toFixed(2)}×`);
224
+ if (compressionSavedPercent !== undefined) {
225
+ metrics.push(`${compressionSavedPercent.toFixed(1)}% ${i18n.t("savedPercent")}`);
226
+ }
206
227
  } else if (originalChars !== undefined) {
207
228
  metrics.push(`${formatCount(originalChars)} ${i18n.t("chars")}`);
208
229
  }
209
- if (compressionRatio !== undefined) metrics.push(`${compressionRatio.toFixed(2)}×`);
210
- if (compressionSavedPercent !== undefined) {
211
- metrics.push(`${compressionSavedPercent.toFixed(1)}% saved`);
230
+ if (summaryTotalTokens !== undefined && summaryTotalTokens > 0) {
231
+ metrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)} ${i18n.t("tokens")}`);
212
232
  }
213
233
  if (toolExecutionMs !== undefined && toolExecutionMs >= 50) {
214
234
  metrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
package/src/index.ts CHANGED
@@ -123,19 +123,69 @@ type SummaryDecision = {
123
123
  reason: string;
124
124
  };
125
125
 
126
+ type SummaryUsage = {
127
+ input?: number;
128
+ output?: number;
129
+ reasoning?: number;
130
+ cacheRead?: number;
131
+ cacheWrite?: number;
132
+ totalTokens?: number;
133
+ cost?: {
134
+ input?: number;
135
+ output?: number;
136
+ reasoning?: number;
137
+ cacheRead?: number;
138
+ cacheWrite?: number;
139
+ total?: number;
140
+ };
141
+ };
142
+
126
143
  type SummaryResult = {
127
144
  text: string;
128
145
  summaryChars: number;
129
146
  summaryFilePath?: string;
130
147
  summaryModel: string;
131
148
  decision: SummaryDecision;
149
+ usage?: SummaryUsage;
150
+ attempts?: number;
132
151
  };
133
152
 
134
153
  type SummaryCompletion = (...args: Parameters<typeof complete>) => ReturnType<typeof complete>;
154
+ type DistillWarningReporter = (message: string) => void;
155
+
156
+ class SummaryAttemptError extends Error {
157
+ constructor(message: string, readonly usage?: SummaryUsage) {
158
+ super(message);
159
+ this.name = "SummaryAttemptError";
160
+ }
161
+ }
162
+
163
+ class SummaryRetryError extends Error {
164
+ constructor(
165
+ message: string,
166
+ readonly attempts: number,
167
+ readonly usage?: SummaryUsage,
168
+ ) {
169
+ super(message);
170
+ this.name = "SummaryRetryError";
171
+ }
172
+ }
135
173
 
136
174
  type SummaryDiagnostics = {
137
175
  toolExecutionMs?: number;
138
176
  summaryDurationMs?: number;
177
+ summaryAttempts?: number;
178
+ summaryInputTokens?: number;
179
+ summaryOutputTokens?: number;
180
+ summaryReasoningTokens?: number;
181
+ summaryCacheReadTokens?: number;
182
+ summaryCacheWriteTokens?: number;
183
+ summaryTotalTokens?: number;
184
+ summaryCost?: number;
185
+ /** 原始输出与最终摘要的 Token 数是按 Pi 的 chars/4 规则估算的。 */
186
+ estimatedOriginalOutputTokens?: number;
187
+ estimatedSummaryTokens?: number;
188
+ estimatedTokensSaved?: number;
139
189
  outputSummaryIntent?: string;
140
190
  outputSummaryPrompt?: string;
141
191
  outputSummaryRender?: DistillRenderConfig;
@@ -158,6 +208,278 @@ type SummaryDiagnostics = {
158
208
  missedCompressionRatio?: number;
159
209
  };
160
210
 
211
+ function toFiniteNumber(value: unknown): number | undefined {
212
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
213
+ }
214
+
215
+ function normalizeSummaryUsage(value: unknown): SummaryUsage | undefined {
216
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
217
+ const record = value as Record<string, unknown>;
218
+ const usage: SummaryUsage = {
219
+ input: toFiniteNumber(record.input),
220
+ output: toFiniteNumber(record.output),
221
+ reasoning: toFiniteNumber(record.reasoning),
222
+ cacheRead: toFiniteNumber(record.cacheRead),
223
+ cacheWrite: toFiniteNumber(record.cacheWrite),
224
+ totalTokens: toFiniteNumber(record.totalTokens),
225
+ };
226
+ if (record.cost && typeof record.cost === "object" && !Array.isArray(record.cost)) {
227
+ const cost = record.cost as Record<string, unknown>;
228
+ usage.cost = {
229
+ input: toFiniteNumber(cost.input),
230
+ output: toFiniteNumber(cost.output),
231
+ reasoning: toFiniteNumber(cost.reasoning),
232
+ cacheRead: toFiniteNumber(cost.cacheRead),
233
+ cacheWrite: toFiniteNumber(cost.cacheWrite),
234
+ total: toFiniteNumber(cost.total),
235
+ };
236
+ }
237
+ return Object.values(usage).some((entry) => typeof entry === "number") || usage.cost !== undefined
238
+ ? usage
239
+ : undefined;
240
+ }
241
+
242
+ function mergeSummaryUsage(first: SummaryUsage | undefined, second: SummaryUsage | undefined): SummaryUsage | undefined {
243
+ if (!first && !second) return undefined;
244
+ const merged: SummaryUsage = {};
245
+ for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"] as const) {
246
+ const value = (first?.[key] ?? 0) + (second?.[key] ?? 0);
247
+ if ((first?.[key] !== undefined || second?.[key] !== undefined) && Number.isFinite(value)) {
248
+ merged[key] = value;
249
+ }
250
+ }
251
+ if (first?.cost || second?.cost) {
252
+ merged.cost = {};
253
+ for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "total"] as const) {
254
+ const value = (first?.cost?.[key] ?? 0) + (second?.cost?.[key] ?? 0);
255
+ if ((first?.cost?.[key] !== undefined || second?.cost?.[key] !== undefined) && Number.isFinite(value)) {
256
+ merged.cost[key] = value;
257
+ }
258
+ }
259
+ }
260
+ return merged;
261
+ }
262
+
263
+ function getSummaryUsageDiagnostics(usage: SummaryUsage | undefined): Pick<
264
+ SummaryDiagnostics,
265
+ "summaryInputTokens" | "summaryOutputTokens" | "summaryReasoningTokens" | "summaryCacheReadTokens" | "summaryCacheWriteTokens" | "summaryTotalTokens" | "summaryCost"
266
+ > {
267
+ return {
268
+ summaryInputTokens: usage?.input,
269
+ summaryOutputTokens: usage?.output,
270
+ summaryReasoningTokens: usage?.reasoning,
271
+ summaryCacheReadTokens: usage?.cacheRead,
272
+ summaryCacheWriteTokens: usage?.cacheWrite,
273
+ summaryTotalTokens: usage?.totalTokens,
274
+ summaryCost: usage?.cost?.total,
275
+ };
276
+ }
277
+
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
+ function getTokenCompressionDiagnostics(
285
+ originalOutput: string,
286
+ finalOutput: string,
287
+ ): Pick<SummaryDiagnostics, "estimatedOriginalOutputTokens" | "estimatedSummaryTokens" | "estimatedTokensSaved"> {
288
+ const estimatedOriginalOutputTokens = estimateTextTokens(originalOutput);
289
+ const estimatedSummaryTokens = estimateTextTokens(finalOutput);
290
+ return {
291
+ estimatedOriginalOutputTokens,
292
+ estimatedSummaryTokens,
293
+ estimatedTokensSaved: Math.max(0, estimatedOriginalOutputTokens - estimatedSummaryTokens),
294
+ };
295
+ }
296
+
297
+ type DistillSessionStats = {
298
+ startedAt: number;
299
+ toolResults: number;
300
+ summarizedResults: number;
301
+ fallbackResults: number;
302
+ failedResults: number;
303
+ rawResults: number;
304
+ skippedResults: number;
305
+ nonTextResults: number;
306
+ summaryAttempts: number;
307
+ retryCount: number;
308
+ originalOutputChars: number;
309
+ summaryChars: number;
310
+ summaryDurationMs: number;
311
+ summaryInputTokens: number;
312
+ summaryOutputTokens: number;
313
+ summaryReasoningTokens: number;
314
+ summaryCacheReadTokens: number;
315
+ summaryCacheWriteTokens: number;
316
+ summaryTotalTokens: number;
317
+ hasSummaryTokenUsage: boolean;
318
+ estimatedOriginalOutputTokens: number;
319
+ estimatedSummaryTokens: number;
320
+ estimatedTokensSaved: number;
321
+ summaryCost: number;
322
+ hasSummaryCost: boolean;
323
+ };
324
+
325
+ function createDistillSessionStats(): DistillSessionStats {
326
+ return {
327
+ startedAt: Date.now(),
328
+ toolResults: 0,
329
+ summarizedResults: 0,
330
+ fallbackResults: 0,
331
+ failedResults: 0,
332
+ rawResults: 0,
333
+ skippedResults: 0,
334
+ nonTextResults: 0,
335
+ summaryAttempts: 0,
336
+ retryCount: 0,
337
+ originalOutputChars: 0,
338
+ summaryChars: 0,
339
+ summaryDurationMs: 0,
340
+ summaryInputTokens: 0,
341
+ summaryOutputTokens: 0,
342
+ summaryReasoningTokens: 0,
343
+ summaryCacheReadTokens: 0,
344
+ summaryCacheWriteTokens: 0,
345
+ summaryTotalTokens: 0,
346
+ hasSummaryTokenUsage: false,
347
+ estimatedOriginalOutputTokens: 0,
348
+ estimatedSummaryTokens: 0,
349
+ estimatedTokensSaved: 0,
350
+ summaryCost: 0,
351
+ hasSummaryCost: false,
352
+ };
353
+ }
354
+
355
+ function getDetailNumber(details: Record<string, unknown> | undefined, key: string): number | undefined {
356
+ return toFiniteNumber(details?.[key]);
357
+ }
358
+
359
+ function recordDistillSessionResult(
360
+ stats: DistillSessionStats,
361
+ details: Record<string, unknown> | undefined,
362
+ ): void {
363
+ stats.toolResults += 1;
364
+ const status = typeof details?.outputSummaryStatus === "string"
365
+ ? details.outputSummaryStatus
366
+ : undefined;
367
+ if (status === "summarized") stats.summarizedResults += 1;
368
+ else if (status === "summary-fallback") stats.fallbackResults += 1;
369
+ else if (status === "summary-failed") stats.failedResults += 1;
370
+ else if (status === "full-output") stats.rawResults += 1;
371
+ else if (status === "non-text-output") stats.nonTextResults += 1;
372
+ else stats.skippedResults += 1;
373
+
374
+ stats.summaryAttempts += getDetailNumber(details, "summaryAttempts") ?? 0;
375
+ const attempts = getDetailNumber(details, "summaryAttempts");
376
+ if (attempts !== undefined) stats.retryCount += Math.max(0, attempts - 1);
377
+ stats.originalOutputChars += getDetailNumber(details, "originalOutputChars") ?? 0;
378
+ stats.summaryChars += getDetailNumber(details, "summaryChars") ?? 0;
379
+ stats.summaryDurationMs += getDetailNumber(details, "summaryDurationMs") ?? 0;
380
+ stats.summaryInputTokens += getDetailNumber(details, "summaryInputTokens") ?? 0;
381
+ stats.summaryOutputTokens += getDetailNumber(details, "summaryOutputTokens") ?? 0;
382
+ stats.summaryReasoningTokens += getDetailNumber(details, "summaryReasoningTokens") ?? 0;
383
+ stats.summaryCacheReadTokens += getDetailNumber(details, "summaryCacheReadTokens") ?? 0;
384
+ stats.summaryCacheWriteTokens += getDetailNumber(details, "summaryCacheWriteTokens") ?? 0;
385
+ const summaryTotalTokens = getDetailNumber(details, "summaryTotalTokens");
386
+ if (summaryTotalTokens !== undefined) {
387
+ stats.summaryTotalTokens += summaryTotalTokens;
388
+ }
389
+ if (
390
+ getDetailNumber(details, "summaryInputTokens") !== undefined
391
+ || getDetailNumber(details, "summaryOutputTokens") !== undefined
392
+ || getDetailNumber(details, "summaryTotalTokens") !== undefined
393
+ ) {
394
+ stats.hasSummaryTokenUsage = true;
395
+ }
396
+ stats.estimatedOriginalOutputTokens += getDetailNumber(details, "estimatedOriginalOutputTokens") ?? 0;
397
+ stats.estimatedSummaryTokens += getDetailNumber(details, "estimatedSummaryTokens") ?? 0;
398
+ stats.estimatedTokensSaved += getDetailNumber(details, "estimatedTokensSaved") ?? 0;
399
+ const cost = getDetailNumber(details, "summaryCost");
400
+ if (cost !== undefined) {
401
+ stats.summaryCost += cost;
402
+ stats.hasSummaryCost = true;
403
+ }
404
+ }
405
+
406
+ export function formatCompactCount(value: number): string {
407
+ const absolute = Math.abs(value);
408
+ const sign = value < 0 ? "-" : "";
409
+ const formatScaled = (scaled: number): string => {
410
+ const rounded = Math.round(scaled * 10) / 10;
411
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
412
+ };
413
+ if (absolute >= 1_000_000) return `${sign}${formatScaled(absolute / 1_000_000)}m`;
414
+ if (absolute >= 1_000) return `${sign}${formatScaled(absolute / 1_000)}k`;
415
+ return String(Math.round(value));
416
+ }
417
+
418
+ export function formatSessionDuration(milliseconds: number): string {
419
+ if (milliseconds < 1_000) return `${Math.max(0, Math.round(milliseconds))}ms`;
420
+ const seconds = milliseconds / 1_000;
421
+ if (seconds < 60) return `${seconds.toFixed(1).replace(/\.0$/, "")}s`;
422
+ const minutes = seconds / 60;
423
+ return `${minutes.toFixed(1).replace(/\.0$/, "")}min`;
424
+ }
425
+
426
+ function formatDistillSessionStats(stats: DistillSessionStats): string {
427
+ const compressionRatio = stats.summaryChars > 0
428
+ ? (stats.originalOutputChars / stats.summaryChars).toFixed(2)
429
+ : "-";
430
+ const cost = stats.hasSummaryCost ? stats.summaryCost.toFixed(6) : i18n.t("statsUnavailable");
431
+ const summaryOperations = stats.summarizedResults
432
+ + stats.fallbackResults
433
+ + stats.failedResults
434
+ + stats.rawResults;
435
+ const averageDurationMs = summaryOperations > 0
436
+ ? Math.round(stats.summaryDurationMs / summaryOperations)
437
+ : 0;
438
+ const tokenUsage = stats.hasSummaryTokenUsage
439
+ ? {
440
+ input: formatCompactCount(stats.summaryInputTokens),
441
+ output: formatCompactCount(stats.summaryOutputTokens),
442
+ reasoning: formatCompactCount(stats.summaryReasoningTokens),
443
+ cacheRead: formatCompactCount(stats.summaryCacheReadTokens),
444
+ cacheWrite: formatCompactCount(stats.summaryCacheWriteTokens),
445
+ total: formatCompactCount(stats.summaryTotalTokens),
446
+ }
447
+ : {
448
+ input: i18n.t("statsUnavailable"),
449
+ output: i18n.t("statsUnavailable"),
450
+ reasoning: i18n.t("statsUnavailable"),
451
+ cacheRead: i18n.t("statsUnavailable"),
452
+ cacheWrite: i18n.t("statsUnavailable"),
453
+ total: i18n.t("statsUnavailable"),
454
+ };
455
+ return i18n.t("statsReport", {
456
+ toolResults: formatCompactCount(stats.toolResults),
457
+ summarizedResults: formatCompactCount(stats.summarizedResults),
458
+ fallbackResults: formatCompactCount(stats.fallbackResults),
459
+ failedResults: formatCompactCount(stats.failedResults),
460
+ rawResults: formatCompactCount(stats.rawResults),
461
+ skippedResults: formatCompactCount(stats.skippedResults),
462
+ nonTextResults: formatCompactCount(stats.nonTextResults),
463
+ summaryAttempts: formatCompactCount(stats.summaryAttempts),
464
+ retryCount: formatCompactCount(stats.retryCount),
465
+ originalOutputChars: formatCompactCount(stats.originalOutputChars),
466
+ summaryChars: formatCompactCount(stats.summaryChars),
467
+ compressionRatio,
468
+ estimatedOriginalOutputTokens: formatCompactCount(stats.estimatedOriginalOutputTokens),
469
+ estimatedSummaryTokens: formatCompactCount(stats.estimatedSummaryTokens),
470
+ estimatedTokensSaved: formatCompactCount(stats.estimatedTokensSaved),
471
+ summaryDuration: formatSessionDuration(stats.summaryDurationMs),
472
+ summaryAverageDuration: formatSessionDuration(averageDurationMs),
473
+ summaryInputTokens: tokenUsage.input,
474
+ summaryOutputTokens: tokenUsage.output,
475
+ summaryReasoningTokens: tokenUsage.reasoning,
476
+ summaryCacheReadTokens: tokenUsage.cacheRead,
477
+ summaryCacheWriteTokens: tokenUsage.cacheWrite,
478
+ summaryTotalTokens: tokenUsage.total,
479
+ summaryCost: cost,
480
+ });
481
+ }
482
+
161
483
  function attachDiagnostics(result: ToolResult, diagnostics: SummaryDiagnostics): ToolResult {
162
484
  return {
163
485
  ...result,
@@ -394,8 +716,12 @@ async function summarizeOutput(
394
716
  },
395
717
  );
396
718
 
719
+ const usage = normalizeSummaryUsage(response.usage);
397
720
  if (response.stopReason === "error" || response.stopReason === "aborted") {
398
- throw new Error(response.errorMessage ?? `Summarizer stopped with reason: ${response.stopReason}`);
721
+ throw new SummaryAttemptError(
722
+ response.errorMessage ?? `Summarizer stopped with reason: ${response.stopReason}`,
723
+ usage,
724
+ );
399
725
  }
400
726
 
401
727
  const rawResponse = response.content
@@ -404,17 +730,23 @@ async function summarizeOutput(
404
730
  .join("\n")
405
731
  .trim();
406
732
 
407
- if (!rawResponse) throw new Error("Summarizer returned no text");
733
+ if (!rawResponse) throw new SummaryAttemptError("Summarizer returned no text", usage);
408
734
  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;
735
+ let parsed: SummaryResult;
736
+ try {
737
+ parsed = parseSummaryResponse(rawResponse, summaryModel);
738
+ } catch (error) {
739
+ throw new SummaryAttemptError(error instanceof Error ? error.message : String(error), usage);
740
+ }
741
+ if (parsed.decision.mode === "RAW") return { ...parsed, usage };
742
+ if (parsed.summaryChars <= config.maxChars) return { ...parsed, usage };
412
743
 
413
744
  const summaryFilePath = await writeSummaryFile(parsed.text);
414
745
  return {
415
746
  ...parsed,
416
747
  text: `Summary exceeded ${config.maxChars} chars and was written to: ${summaryFilePath}`,
417
748
  summaryFilePath,
749
+ usage,
418
750
  };
419
751
  }
420
752
 
@@ -427,10 +759,12 @@ async function summarizeOutputWithRetries(
427
759
  ): Promise<SummaryResult> {
428
760
  let timeoutRetries = 0;
429
761
  let errorRetries = 0;
762
+ let attempts = 0;
763
+ let totalUsage: SummaryUsage | undefined;
430
764
 
431
765
  while (true) {
432
766
  if (context.signal?.aborted) {
433
- throw new Error("Summarization aborted");
767
+ throw new SummaryRetryError("Summarization aborted", attempts, totalUsage);
434
768
  }
435
769
 
436
770
  const attemptController = new AbortController();
@@ -445,9 +779,10 @@ async function summarizeOutputWithRetries(
445
779
  },
446
780
  config.timeoutSeconds * 1000,
447
781
  );
782
+ attempts += 1;
448
783
 
449
784
  try {
450
- return await summarizeOutput(
785
+ const result = await summarizeOutput(
451
786
  prompt,
452
787
  output,
453
788
  config,
@@ -455,19 +790,39 @@ async function summarizeOutputWithRetries(
455
790
  attemptController.signal,
456
791
  completion,
457
792
  );
793
+ totalUsage = mergeSummaryUsage(totalUsage, result.usage);
794
+ return {
795
+ ...result,
796
+ usage: totalUsage,
797
+ attempts,
798
+ };
458
799
  } catch (error) {
459
- if (context.signal?.aborted) throw error;
800
+ totalUsage = mergeSummaryUsage(
801
+ totalUsage,
802
+ error instanceof SummaryAttemptError || error instanceof SummaryRetryError
803
+ ? error.usage
804
+ : undefined,
805
+ );
806
+ if (context.signal?.aborted) {
807
+ throw new SummaryRetryError("Summarization aborted", attempts, totalUsage);
808
+ }
460
809
  const retryLimit = timedOut ? config.timeoutRetryCount : config.errorRetryCount;
461
810
  const retriesUsed = timedOut ? timeoutRetries : errorRetries;
462
- if (retriesUsed >= retryLimit) throw error;
811
+ if (retriesUsed >= retryLimit) {
812
+ throw new SummaryRetryError(
813
+ error instanceof Error ? error.message : String(error),
814
+ attempts,
815
+ totalUsage,
816
+ );
817
+ }
463
818
  if (timedOut) timeoutRetries += 1;
464
819
  else errorRetries += 1;
465
- console.warn(i18n.t("retryingSummary", {
820
+ context.ctx.ui.notify(i18n.t("retryingSummary", {
466
821
  kind: i18n.t(timedOut ? "retryKindTimeout" : "retryKindError"),
467
822
  retry: retriesUsed + 1,
468
823
  limit: retryLimit,
469
824
  error: error instanceof Error ? error.message : String(error),
470
- }));
825
+ }), "warning");
471
826
  } finally {
472
827
  clearTimeout(timeout);
473
828
  context.signal?.removeEventListener("abort", abortFromParent);
@@ -496,7 +851,9 @@ export async function processToolResult(
496
851
  const maxReturnedChars = config?.maxOutputChars ?? 10_000;
497
852
  const finish = (candidate: ToolResult) => limitReturnedToolResult(candidate, maxReturnedChars);
498
853
  if (loaded.warnings.length > 0) {
499
- console.warn(`[pi-distill] ${loaded.warnings.join(" | ")}`);
854
+ context.ctx.ui.notify(i18n.t("configWarnings", {
855
+ warnings: loaded.warnings.join(" "),
856
+ }), "warning");
500
857
  }
501
858
 
502
859
  if (config && loaded.enabled && !isDistillToolEnabled(config, context.toolName)) return result;
@@ -535,9 +892,7 @@ export async function processToolResult(
535
892
  try {
536
893
  output = await getCompleteOutput(result);
537
894
  } 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
- );
895
+ const errorMessage = error instanceof Error ? error.message : String(error);
541
896
  return finish(attachDiagnostics(result, {
542
897
  toolExecutionMs,
543
898
  outputSummaryPrompt: prompt || undefined,
@@ -547,6 +902,7 @@ export async function processToolResult(
547
902
  summaryTriggerMaxChars: null,
548
903
  summaryResultMaxChars: config.maxChars,
549
904
  missedCompressionRatio: config.missedCompressionRatio,
905
+ outputSummaryError: errorMessage,
550
906
  }));
551
907
  }
552
908
 
@@ -564,6 +920,7 @@ export async function processToolResult(
564
920
  summaryTriggerMaxChars: null,
565
921
  summaryResultMaxChars: config.maxChars,
566
922
  missedCompressionRatio: config.missedCompressionRatio,
923
+ ...getTokenCompressionDiagnostics(output, output),
567
924
  ...skippedDiagnostics,
568
925
  };
569
926
  const candidate = {
@@ -594,6 +951,9 @@ export async function processToolResult(
594
951
  const diagnostics: SummaryDiagnostics = {
595
952
  originalOutputChars: output.length,
596
953
  summaryChars: output.length,
954
+ summaryAttempts: summarized.attempts,
955
+ ...getSummaryUsageDiagnostics(summarized.usage),
956
+ ...getTokenCompressionDiagnostics(output, output),
597
957
  compressionRatio: 1,
598
958
  compressionSavedPercent: 0,
599
959
  ...rawDiagnostics,
@@ -628,6 +988,9 @@ export async function processToolResult(
628
988
  const summaryDiagnostics: SummaryDiagnostics = {
629
989
  originalOutputChars: output.length,
630
990
  summaryChars: summarized.summaryChars,
991
+ summaryAttempts: summarized.attempts,
992
+ ...getSummaryUsageDiagnostics(summarized.usage),
993
+ ...getTokenCompressionDiagnostics(output, summarized.text),
631
994
  ...compressionDiagnostics,
632
995
  };
633
996
  const agentDiagnostic = buildAgentDiagnosticText(summaryDiagnostics);
@@ -650,6 +1013,7 @@ export async function processToolResult(
650
1013
  outputSummaryReasonCode: summarized.decision.reasonCode,
651
1014
  outputSummaryReason: summarized.decision.reason,
652
1015
  ...summaryDiagnostics,
1016
+ ...getTokenCompressionDiagnostics(output, output),
653
1017
  }),
654
1018
  content: [{ type: "text", text: output }],
655
1019
  });
@@ -686,14 +1050,14 @@ export async function processToolResult(
686
1050
  } catch (error) {
687
1051
  const summaryDurationMs = Math.round(performance.now() - summaryStartedAt);
688
1052
  const errorMessage = error instanceof Error ? error.message : String(error);
1053
+ const retryError = error instanceof SummaryRetryError ? error : undefined;
689
1054
  // 总结链路任何异常都必须保留原始结果,不能把异常文本替换给 AI。
690
- console.warn(
691
- `[tool-output-summary] ${context.toolName} summarization failed; returning the original result: ${errorMessage}`,
692
- );
693
1055
  const diagnostics: SummaryDiagnostics = {
694
1056
  toolExecutionMs,
695
1057
  summaryDurationMs,
696
1058
  originalOutputChars: output.length,
1059
+ summaryAttempts: retryError?.attempts,
1060
+ ...getSummaryUsageDiagnostics(retryError?.usage),
697
1061
  outputSummaryIntent: decision.intent,
698
1062
  outputSummaryPrompt: prompt || undefined,
699
1063
  outputSummaryRender,
@@ -702,6 +1066,7 @@ export async function processToolResult(
702
1066
  summaryTriggerMaxChars: null,
703
1067
  summaryResultMaxChars: config.maxChars,
704
1068
  missedCompressionRatio: config.missedCompressionRatio,
1069
+ ...getTokenCompressionDiagnostics(output, output),
705
1070
  outputSummaryAnomalies: ["summary-failed"],
706
1071
  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
1072
  outputSummaryError: errorMessage,
@@ -739,17 +1104,21 @@ function restoreOutputRequestParameter(parameters: Record<string, unknown>): boo
739
1104
  return true;
740
1105
  }
741
1106
 
742
- function extendOutputRequestParameter(tool: ToolInfo, enabled: boolean): boolean {
1107
+ function extendOutputRequestParameter(
1108
+ tool: ToolInfo,
1109
+ enabled: boolean,
1110
+ reportWarning: DistillWarningReporter,
1111
+ ): boolean {
743
1112
  const parameters = tool.parameters as unknown as Record<string, unknown> | undefined;
744
1113
  if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
745
- console.warn(`[pi-distill] Could not extend the ${tool.name} parameter schema; outputRequest is unavailable.`);
1114
+ reportWarning(i18n.t("outputRequestUnavailable", { tool: tool.name }));
746
1115
  return false;
747
1116
  }
748
1117
 
749
1118
  if (!enabled) return restoreOutputRequestParameter(parameters);
750
1119
 
751
1120
  if (parameters.type !== "object") {
752
- console.warn(`[pi-distill] Could not extend the ${tool.name} parameter schema; outputRequest is unavailable.`);
1121
+ reportWarning(i18n.t("outputRequestUnavailable", { tool: tool.name }));
753
1122
  return false;
754
1123
  }
755
1124
 
@@ -758,7 +1127,7 @@ function extendOutputRequestParameter(tool: ToolInfo, enabled: boolean): boolean
758
1127
  if (properties === undefined) {
759
1128
  parameters.properties = {};
760
1129
  } 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.`);
1130
+ reportWarning(i18n.t("outputRequestUnavailable", { tool: tool.name }));
762
1131
  return false;
763
1132
  }
764
1133
 
@@ -790,11 +1159,12 @@ function extendOutputRequestParameter(tool: ToolInfo, enabled: boolean): boolean
790
1159
  export function extendDistillToolParameters(
791
1160
  pi: Pick<ExtensionAPI, "getAllTools">,
792
1161
  loaded = loadDistillConfig(),
1162
+ reportWarning: DistillWarningReporter = () => undefined,
793
1163
  ): number {
794
1164
  let extended = 0;
795
1165
  for (const tool of pi.getAllTools()) {
796
1166
  const enabled = loaded.enabled && Boolean(loaded.config) && isDistillToolEnabled(loaded.config, tool.name);
797
- if (extendOutputRequestParameter(tool, enabled) && enabled) extended += 1;
1167
+ if (extendOutputRequestParameter(tool, enabled, reportWarning) && enabled) extended += 1;
798
1168
  }
799
1169
  return extended;
800
1170
  }
@@ -1042,7 +1412,10 @@ async function runDistillConfigUi(
1042
1412
  }
1043
1413
  }
1044
1414
 
1045
- function registerDistillConfigCommand(pi: ExtensionAPI, onSaved: () => void): void {
1415
+ function registerDistillConfigCommand(
1416
+ pi: ExtensionAPI,
1417
+ onSaved: (ctx: ExtensionCommandContext) => void,
1418
+ ): void {
1046
1419
  const command = {
1047
1420
  description: i18n.t("commandDescription"),
1048
1421
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
@@ -1050,7 +1423,7 @@ function registerDistillConfigCommand(pi: ExtensionAPI, onSaved: () => void): vo
1050
1423
  ctx.ui.notify(i18n.t("interactiveOnly"), "warning");
1051
1424
  return;
1052
1425
  }
1053
- await runDistillConfigUi(ctx, pi, getDistillConfigPath(), onSaved);
1426
+ await runDistillConfigUi(ctx, pi, getDistillConfigPath(), () => onSaved(ctx));
1054
1427
  },
1055
1428
  };
1056
1429
  for (const name of ["config:distill", "pi-distill"] as const) {
@@ -1058,23 +1431,51 @@ function registerDistillConfigCommand(pi: ExtensionAPI, onSaved: () => void): vo
1058
1431
  }
1059
1432
  }
1060
1433
 
1434
+ function registerDistillStatsCommand(
1435
+ pi: ExtensionAPI,
1436
+ getStats: () => DistillSessionStats,
1437
+ ): void {
1438
+ pi.registerCommand("distill:stats", {
1439
+ description: i18n.t("statsCommandDescription"),
1440
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
1441
+ if (!ctx.hasUI) {
1442
+ ctx.ui.notify(i18n.t("interactiveOnly"), "warning");
1443
+ return;
1444
+ }
1445
+ ctx.ui.notify(formatDistillSessionStats(getStats()), "info");
1446
+ },
1447
+ });
1448
+ }
1449
+
1061
1450
  export default function piDistillExtension(pi: ExtensionAPI) {
1062
1451
  const pendingCalls = new Map<string, PendingDistillCall>();
1452
+ const reportedWarnings = new Set<string>();
1453
+ let sessionStats = createDistillSessionStats();
1063
1454
  let originalUserPrompt = "";
1064
1455
  const disposeToolDisplayMiddleware = registerDistillToolDisplayMiddleware();
1065
1456
  registerDistillFallbackRenderer(pi);
1066
- const extendParameters = () => {
1457
+ const extendParameters = (ctx: ExtensionContext) => {
1458
+ const reportWarning = (message: string) => {
1459
+ if (reportedWarnings.has(message)) return;
1460
+ reportedWarnings.add(message);
1461
+ ctx.ui.notify(message, "warning");
1462
+ };
1067
1463
  try {
1068
- extendDistillToolParameters(pi, loadDistillConfig());
1464
+ extendDistillToolParameters(pi, loadDistillConfig(), reportWarning);
1069
1465
  } catch (error) {
1070
- console.warn(`[pi-distill] Failed to extend the outputRequest parameter: ${error instanceof Error ? error.message : String(error)}`);
1466
+ reportWarning(i18n.t("extendOutputRequestFailed", {
1467
+ error: error instanceof Error ? error.message : String(error),
1468
+ }));
1071
1469
  }
1072
1470
  };
1073
1471
 
1074
- pi.on("session_start", extendParameters);
1075
- pi.on("before_agent_start", (event) => {
1472
+ pi.on("session_start", (_event, ctx) => {
1473
+ sessionStats = createDistillSessionStats();
1474
+ extendParameters(ctx);
1475
+ });
1476
+ pi.on("before_agent_start", (event, ctx) => {
1076
1477
  originalUserPrompt = typeof event.prompt === "string" ? event.prompt : "";
1077
- extendParameters();
1478
+ extendParameters(ctx);
1078
1479
  return {
1079
1480
  systemPrompt: [
1080
1481
  typeof event.systemPrompt === "string" ? event.systemPrompt : "",
@@ -1099,11 +1500,15 @@ export default function piDistillExtension(pi: ExtensionAPI) {
1099
1500
  pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
1100
1501
  const pending = pendingCalls.get(event.toolCallId);
1101
1502
  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
- });
1503
+ if (pending && !pending.enabled) {
1504
+ const untouchedResult: ToolResult = {
1505
+ content: event.content,
1506
+ details: event.details as Record<string, unknown> | undefined,
1507
+ isError: event.isError,
1508
+ };
1509
+ recordDistillSessionResult(sessionStats, untouchedResult.details);
1510
+ return toToolResultEventResult(untouchedResult);
1511
+ }
1107
1512
  const outputRequest = pending?.outputRequest ?? getOutputRequest(event.input);
1108
1513
  const result = await processToolResult(
1109
1514
  {
@@ -1120,6 +1525,7 @@ export default function piDistillExtension(pi: ExtensionAPI) {
1120
1525
  },
1121
1526
  pending ? Math.round(performance.now() - pending.startedAt) : 0,
1122
1527
  );
1528
+ recordDistillSessionResult(sessionStats, result.details);
1123
1529
  if (!isDistillToolDisplayMiddlewareActive(event.toolName)) {
1124
1530
  appendDistillFallbackAudit(pi, event.toolName, result.details, loadDistillConfig().render);
1125
1531
  }
@@ -1127,5 +1533,6 @@ export default function piDistillExtension(pi: ExtensionAPI) {
1127
1533
  });
1128
1534
  pi.on("agent_end", () => pendingCalls.clear());
1129
1535
  pi.on("session_shutdown", () => disposeToolDisplayMiddleware());
1536
+ registerDistillStatsCommand(pi, () => sessionStats);
1130
1537
  registerDistillConfigCommand(pi, extendParameters);
1131
1538
  }
@@ -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