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 +6 -0
- package/README.zh-CN.md +6 -0
- package/locales/fallback-renderer.json +26 -26
- package/locales/index.json +20 -0
- package/package.json +1 -1
- package/src/fallback-renderer.ts +64 -36
- package/src/index.ts +438 -36
- package/src/output-limit.ts +0 -1
- package/src/summary-utils.ts +0 -6
- package/src/token-estimator.ts +40 -0
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": "
|
|
3
|
+
"zh-CN": "已回退原文",
|
|
4
|
+
"en-US": "Original restored"
|
|
9
5
|
},
|
|
10
6
|
"disabled": {
|
|
11
|
-
"zh-CN": "
|
|
12
|
-
"en-US": "
|
|
7
|
+
"zh-CN": "已禁用",
|
|
8
|
+
"en-US": "Disabled"
|
|
13
9
|
},
|
|
14
10
|
"off": {
|
|
15
|
-
"zh-CN": "
|
|
16
|
-
"en-US": "
|
|
11
|
+
"zh-CN": "关闭",
|
|
12
|
+
"en-US": "Off"
|
|
17
13
|
},
|
|
18
14
|
"original": {
|
|
19
|
-
"zh-CN": "
|
|
20
|
-
"en-US": "
|
|
15
|
+
"zh-CN": "原始输出",
|
|
16
|
+
"en-US": "Original"
|
|
21
17
|
},
|
|
22
18
|
"raw": {
|
|
23
|
-
"zh-CN": "
|
|
24
|
-
"en-US": "
|
|
19
|
+
"zh-CN": "RAW",
|
|
20
|
+
"en-US": "RAW"
|
|
25
21
|
},
|
|
26
22
|
"belowThreshold": {
|
|
27
|
-
"zh-CN": "
|
|
28
|
-
"en-US": "
|
|
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": "
|
|
31
|
+
"zh-CN": "读取失败",
|
|
32
|
+
"en-US": "Read failed"
|
|
37
33
|
},
|
|
38
34
|
"summaryFailed": {
|
|
39
|
-
"zh-CN": "
|
|
40
|
-
"en-US": "
|
|
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": "
|
|
52
|
+
"en-US": "tool"
|
|
49
53
|
},
|
|
50
54
|
"distill": {
|
|
51
55
|
"zh-CN": "提炼",
|
|
52
|
-
"en-US": "
|
|
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"
|
package/locales/index.json
CHANGED
|
@@ -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
package/src/fallback-renderer.ts
CHANGED
|
@@ -17,7 +17,10 @@ type AuditTone = "success" | "muted" | "dim" | "warning" | "error";
|
|
|
17
17
|
|
|
18
18
|
type DistillAuditView = {
|
|
19
19
|
lines: string[];
|
|
20
|
-
|
|
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("
|
|
62
|
-
const afterTitle = line.slice(
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return `${title}${theme.fg("
|
|
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, {
|
|
176
|
-
summarized: {
|
|
177
|
-
"summary-fallback": {
|
|
178
|
-
disabled: {
|
|
179
|
-
"disabled-by-config": {
|
|
180
|
-
"not-requested": {
|
|
181
|
-
"full-output": {
|
|
182
|
-
"below-threshold": {
|
|
183
|
-
"non-text-output": {
|
|
184
|
-
"diagnostic-failed": {
|
|
185
|
-
"summary-failed": {
|
|
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
|
|
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] ?? {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
231
|
+
mainMetric = `${formatCount(originalChars)} ${i18n.t("chars")}`;
|
|
208
232
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 >=
|
|
214
|
-
|
|
238
|
+
if (toolExecutionMs !== undefined && toolExecutionMs >= MIN_TOOL_DURATION_DISPLAY_MS) {
|
|
239
|
+
secondaryMetrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
|
|
215
240
|
}
|
|
216
|
-
if (summaryDurationMs !== undefined)
|
|
241
|
+
if (summaryDurationMs !== undefined) secondaryMetrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
|
|
217
242
|
|
|
218
243
|
const expandHint = expanded ? "" : i18n.t("expand");
|
|
219
|
-
const
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
728
|
+
if (!rawResponse) throw new SummaryAttemptError("Summarizer returned no text", usage);
|
|
408
729
|
const summaryModel = `${model.provider}/${model.id}`;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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",
|
|
1075
|
-
|
|
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)
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
}
|
package/src/output-limit.ts
CHANGED
|
@@ -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) }],
|
package/src/summary-utils.ts
CHANGED
|
@@ -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
|
+
}
|