pi-distill 1.1.2 → 1.2.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
@@ -110,7 +110,7 @@ Agent consumes a result suited to the current decision, with auditable diagnosti
110
110
  2. The `tool_call` handler captures the parameter and removes it before forwarding the call, so the underlying tool never receives the extension-only field.
111
111
  3. The `tool_result` handler sees the actual output and decides what to do; it does not rely on the agent predicting the output size.
112
112
  4. Every tool call must include a non-empty `outputRequest`. A prompt containing only `RAW` explicitly requests the original. Any other non-empty prompt permits distillation once the configured threshold is reached.
113
- 5. If distillation fails, no model is available, or compression is ineffective, the original facts are retained and the status is exposed through details and the audit card.
113
+ 5. A timed-out attempt is retried according to `timeoutRetryCount`, while other failures use `errorRetryCount` (both default to one retry). If all attempts fail, no model is available, or compression is ineffective, the original facts are retained and the status is exposed through details and the audit card. JSON responses wrapped in Markdown fences such as `````json … ````` are also accepted.
114
114
 
115
115
  ## Output contract
116
116
 
@@ -118,7 +118,7 @@ Agent consumes a result suited to the current decision, with auditable diagnosti
118
118
  | --- | --- | --- |
119
119
  | Omitted | Invalid tool call; Pi rejects the call before the underlying tool runs | Never omit it; use `RAW` when no compression is explicitly requested |
120
120
  | Exactly `RAW` (case-insensitive) | Skip the distillation model and keep the complete original text; oversized text is handled by Pi's own output-limiting mechanism | You need to inspect, copy, or verify exact output |
121
- | Any non-empty value other than `RAW` | Call the model once the output reaches the threshold; the prompt defines what to retain | “Keep errors, warnings, and final status” workflows |
121
+ | Any non-empty value other than `RAW` | Call the model when the output reaches the threshold; timed-out and other failed attempts use separate retry budgets, and the prompt defines what to retain | “Keep errors, warnings, and final status” workflows |
122
122
  | Any non-text content such as images or audio | Preserve the result as-is; do not send it to the distillation model or apply text truncation | Image reads, binary results, and mixed text/media results |
123
123
 
124
124
  `RAW` is the deterministic completeness signal. The distillation prompt tells the summarizer to return exactly `RAW` when the request clearly asks for complete extraction without omissions, especially for syntax, parameters, SQL, API calls, or other text that must be copied. Passing `RAW` directly remains the preferred option when the tool caller can control the parameter.
@@ -157,7 +157,10 @@ Start from [`config.example.json`](./config.example.json):
157
157
  "model": "",
158
158
  "minChars": 200,
159
159
  "maxChars": 100000,
160
+ "maxOutputChars": 10000,
160
161
  "timeoutSeconds": 10,
162
+ "timeoutRetryCount": 1,
163
+ "errorRetryCount": 1,
161
164
  "missedCompressionRatio": 10,
162
165
  "summarizeErrors": true,
163
166
  "render": {
@@ -174,8 +177,11 @@ Configuration-file fields take precedence over environment variables. Unspecifie
174
177
  | --- | --- |
175
178
  | `model` | Optional `provider/model`; empty uses the current Pi session model. |
176
179
  | `minChars` | Minimum output size before a summary is requested. |
177
- | `maxChars` | Maximum output budget for the distillation model (about `maxChars / 2` tokens) and a diagnostic reference; no longer used to write files. |
178
- | `timeoutSeconds` | Maximum time allowed for the distillation model call. |
180
+ | `maxChars` | Distilled text is written to a temporary file when it exceeds this length. |
181
+ | `maxOutputChars` | Maximum text returned to the Agent. Oversized text is written to a temporary file and replaced with a file pointer. |
182
+ | `timeoutSeconds` | Maximum time allowed for each distillation model attempt. |
183
+ | `timeoutRetryCount` | Number of additional attempts after a timeout. Defaults to `1`; set to `0` to disable timeout retries. |
184
+ | `errorRetryCount` | Number of additional attempts after any non-timeout error. Defaults to `1`; set to `0` to disable other error retries. |
179
185
  | `missedCompressionRatio` | Long-output threshold for a diagnostic when no summary prompt was supplied. |
180
186
  | `summarizeErrors` | Whether error results that meet `minChars` should still be sent to the distillation model. |
181
187
  | `tools.<name>.enabled` | Enables or disables `outputRequest` injection and result distillation for one tool. `edit` and `write` default to disabled; other unconfigured tools default to enabled. It can also be changed from `/config:distill`. |
@@ -183,7 +189,7 @@ Configuration-file fields take precedence over environment variables. Unspecifie
183
189
  `/pi-distill` remains available as a compatibility alias.
184
190
  | `render.*` | Controls the audit card, prompt preview, and result preview. |
185
191
 
186
- The main environment variables are `PI_DISTILL_MODEL`, `PI_DISTILL_MIN_CHARS`, `PI_DISTILL_MAX_CHARS`, `PI_DISTILL_TIMEOUT_SECONDS`, `PI_DISTILL_MISSED_COMPRESSION_RATIO`, and `PI_DISTILL_SUMMARIZE_ERRORS`. The legacy `maxOutputChars` / `PI_DISTILL_MAX_OUTPUT_CHARS` option is still parsed for backward compatibility but no longer has any effect.
192
+ 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`.
187
193
 
188
194
  ## Requirements
189
195
 
package/README.zh-CN.md CHANGED
@@ -112,7 +112,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
112
112
  2. `tool_call` 事件捕获这个参数,并在交给底层工具前移除它,因此原工具不会收到扩展专用字段。
113
113
  3. `tool_result` 事件拿到真实输出后再做判断,不依赖 Agent 对输出长度的预测。
114
114
  4. 每次工具调用都必须包含非空的 `outputRequest`;严格的 `RAW` 表示明确要求原文;其他非空 prompt 才允许进入提炼流程。
115
- 5. 提炼失败、没有可用模型或结果收益过低时,扩展保留原始事实,并通过 details 和审计卡片暴露状态。
115
+ 5. 单次提炼超时后按照 `timeoutRetryCount` 重试,其他异常按照 `errorRetryCount` 重试(两者默认都重试 1 次);全部尝试失败、没有可用模型或结果收益过低时,扩展保留原始事实,并通过 details 和审计卡片暴露状态;模型用 Markdown 的 JSON 代码围栏(如 `````json … `````)包裹响应时也会兼容解析。
116
116
 
117
117
  ## 输出处理契约
118
118
 
@@ -120,7 +120,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
120
120
  | --- | --- | --- |
121
121
  | 未提供 | 工具调用无效;Pi 会在底层工具执行前拒绝该调用 | 不要省略;未明确要求压缩时使用 `RAW` |
122
122
  | 严格为 `RAW`(大小写不敏感) | 不调用提炼模型,保留完整原始文本;超长时由 Pi 自身的输出限制机制处理 | 逐字核对、复制内容、需要完整日志时 |
123
- | 任意非空且非 `RAW` | 输出达到阈值后调用模型,具体保留内容由 prompt 决定 | “只保留错误、警告和最终状态”等场景 |
123
+ | 任意非空且非 `RAW` | 输出达到阈值后调用模型,超时与其他异常分别使用独立重试次数,具体保留内容由 prompt 决定 | “只保留错误、警告和最终状态”等场景 |
124
124
  | 包含图片、音频或其他非文本内容 | 原样保留,不发送给提炼模型,不做文本长度截断 | 图片读取、二进制结果、混合文本与图片结果 |
125
125
 
126
126
  `RAW` 是确定性的完整输出信号。提炼 prompt 会要求总结模型在用户明确要求“不遗漏地完整提取”时直接返回 `RAW`,尤其适用于语法、参数、SQL、API 调用或其他需要复制的精确文本。工具调用方可以控制参数时,直接传 `RAW` 仍然是首选方式。
@@ -159,7 +159,10 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
159
159
  "model": "",
160
160
  "minChars": 200,
161
161
  "maxChars": 100000,
162
+ "maxOutputChars": 10000,
162
163
  "timeoutSeconds": 10,
164
+ "timeoutRetryCount": 1,
165
+ "errorRetryCount": 1,
163
166
  "missedCompressionRatio": 10,
164
167
  "summarizeErrors": true,
165
168
  "tools": {},
@@ -177,8 +180,11 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
177
180
  | --- | --- |
178
181
  | `model` | 可选的 `provider/model`;为空时使用当前 Pi 会话模型。 |
179
182
  | `minChars` | 达到此输出长度后才请求提炼。 |
180
- | `maxChars` | 提炼模型的最大输出预算(约 `maxChars / 2` tokens),同时作为诊断参考;不再用于写文件。 |
181
- | `timeoutSeconds` | 提炼模型调用的最长等待时间。 |
183
+ | `maxChars` | 提炼结果超过此字符数时写入临时文件。 |
184
+ | `maxOutputChars` | 最终返回给 Agent 的文本上限。超出后写入临时文件,只返回文件指针。 |
185
+ | `timeoutSeconds` | 每次提炼模型尝试的最长等待时间。 |
186
+ | `timeoutRetryCount` | 超时后的额外重试次数。默认 `1`;设为 `0` 时不重试超时。 |
187
+ | `errorRetryCount` | 非超时异常后的额外重试次数。默认 `1`;设为 `0` 时不重试其他异常。 |
182
188
  | `missedCompressionRatio` | 没有提供摘要 prompt 时,用于长输出诊断的倍数阈值。 |
183
189
  | `summarizeErrors` | 工具返回错误且达到 `minChars` 时,是否仍发送给提炼模型。 |
184
190
  | `tools.<name>.enabled` | 按工具开启或关闭 `outputRequest` 注入和结果提炼。`edit` 和 `write` 默认关闭,其他未配置工具默认开启,也可以通过 `/config:distill` 修改。 |
@@ -186,7 +192,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
186
192
  `/pi-distill` 仍作为兼容别名保留。
187
193
  | `render.*` | 控制审计卡片、prompt 预览和结果预览。 |
188
194
 
189
- 主要环境变量包括 `PI_DISTILL_MODEL`、`PI_DISTILL_MIN_CHARS`、`PI_DISTILL_MAX_CHARS`、`PI_DISTILL_TIMEOUT_SECONDS`、`PI_DISTILL_MISSED_COMPRESSION_RATIO` 和 `PI_DISTILL_SUMMARIZE_ERRORS`。旧配置中的 `maxOutputChars` / `PI_DISTILL_MAX_OUTPUT_CHARS` 仍会被解析以兼容旧文件,但不再生效。
195
+ 主要环境变量包括 `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`。
190
196
 
191
197
  ## 要求
192
198
 
@@ -3,7 +3,10 @@
3
3
  "model": "",
4
4
  "minChars": 200,
5
5
  "maxChars": 100000,
6
+ "maxOutputChars": 10000,
6
7
  "timeoutSeconds": 10,
8
+ "timeoutRetryCount": 1,
9
+ "errorRetryCount": 1,
7
10
  "missedCompressionRatio": 10,
8
11
  "summarizeErrors": true,
9
12
  "tools": {},
@@ -11,6 +11,10 @@
11
11
  "zh-CN": "请输入大于 0 的整数。",
12
12
  "en-US": "Enter a positive integer."
13
13
  },
14
+ "nonNegativeInteger": {
15
+ "zh-CN": "请输入大于或等于 0 的整数。",
16
+ "en-US": "Enter a non-negative integer."
17
+ },
14
18
  "modelInput": {
15
19
  "zh-CN": "模型(provider/model;留空使用当前会话模型)",
16
20
  "en-US": "Model (provider/model; leave empty to use the current session model)"
@@ -71,6 +75,14 @@
71
75
  "zh-CN": "超时:{value}s",
72
76
  "en-US": "Timeout: {value}s"
73
77
  },
78
+ "timeoutRetryCount": {
79
+ "zh-CN": "超时重试:{value} 次",
80
+ "en-US": "Timeout retries: {value}"
81
+ },
82
+ "errorRetryCount": {
83
+ "zh-CN": "异常重试:{value} 次",
84
+ "en-US": "Error retries: {value}"
85
+ },
74
86
  "threshold": {
75
87
  "zh-CN": "长输出阈值:{value}x",
76
88
  "en-US": "Long-output threshold: {value}x"
@@ -119,10 +131,38 @@
119
131
  "zh-CN": "最终输出字符上限",
120
132
  "en-US": "Final output character limit"
121
133
  },
134
+ "outputLimitExceeded": {
135
+ "zh-CN": "输出超过 {maxChars} 个字符,完整内容已写入:{path}",
136
+ "en-US": "Output exceeded {maxChars} chars and was written to: {path}"
137
+ },
138
+ "outputLimitWriteFailed": {
139
+ "zh-CN": "无法写入超长输出临时文件,将返回截断内容:{error}",
140
+ "en-US": "Failed to write oversized output to a temp file; returning truncated content: {error}"
141
+ },
122
142
  "timeoutTitle": {
123
143
  "zh-CN": "提炼模型超时(秒)",
124
144
  "en-US": "Summarizer timeout (seconds)"
125
145
  },
146
+ "timeoutRetryCountTitle": {
147
+ "zh-CN": "提炼超时重试次数",
148
+ "en-US": "Distillation timeout retry count"
149
+ },
150
+ "errorRetryCountTitle": {
151
+ "zh-CN": "提炼非超时异常重试次数",
152
+ "en-US": "Distillation non-timeout error retry count"
153
+ },
154
+ "retryingSummary": {
155
+ "zh-CN": "提炼发生{kind},正在进行第 {retry}/{limit} 次重试:{error}",
156
+ "en-US": "Distillation hit {kind}; starting retry {retry}/{limit}: {error}"
157
+ },
158
+ "retryKindTimeout": {
159
+ "zh-CN": "超时",
160
+ "en-US": "a timeout"
161
+ },
162
+ "retryKindError": {
163
+ "zh-CN": "非超时异常",
164
+ "en-US": "a non-timeout error"
165
+ },
126
166
  "thresholdTitle": {
127
167
  "zh-CN": "长输出阈值",
128
168
  "en-US": "Long-output threshold"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-distill",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Pi tool-output distillation with file-first configuration",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/src/index.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * 所有工具统一使用 outputRequest:严格传入 RAW 时返回原始输出;其他非空
8
8
  * outputRequest 表示调用提炼模型,具体保留内容由 outputRequest 决定。
9
- * 提炼结果超过 maxChars 时写入临时文件,只返回文件路径。
9
+ * 提炼结果超过 maxChars 时写入临时文件,只返回文件路径;最终返回内容
10
+ * 超过 maxOutputChars 时同样写入临时文件,只把文件指针交给 Agent。
10
11
  *
11
12
  * 配置文件优先;旧环境变量继续兼容:
12
13
  * - ~/.pi/agent/extensions/pi-distill/config.json
@@ -15,6 +16,8 @@
15
16
  * - PI_DISTILL_MAX_CHARS=提炼结果超过此字符数时写入文件,默认 100000
16
17
  * - PI_DISTILL_MAX_OUTPUT_CHARS=最终返回内容超过此字符数时写入文件,默认 10000
17
18
  * - PI_DISTILL_TIMEOUT_SECONDS=模型调用最长等待秒数,默认 10
19
+ * - PI_DISTILL_TIMEOUT_RETRY_COUNT=提炼超时后的额外重试次数,默认 1;0 表示不重试
20
+ * - PI_DISTILL_ERROR_RETRY_COUNT=其他异常后的额外重试次数,默认 1;0 表示不重试
18
21
  * - PI_DISTILL_MISSED_COMPRESSION_RATIO=长输出提醒倍数,默认 10
19
22
  * - 旧 PI_BASH_SUMMARY_* 变量作为兼容回退
20
23
  */
@@ -36,7 +39,7 @@ import {
36
39
  isDistillToolDisplayMiddlewareActive,
37
40
  registerDistillToolDisplayMiddleware,
38
41
  } from "./tool-display-bridge.ts";
39
- import { getTextContent, hasNonTextContent } from "./output-limit.ts";
42
+ import { getTextContent, hasNonTextContent, limitReturnedToolResult } from "./output-limit.ts";
40
43
  import { mkdir, readFile, writeFile } from "node:fs/promises";
41
44
  import { tmpdir } from "node:os";
42
45
  import { dirname, join } from "node:path";
@@ -279,9 +282,10 @@ async function writeSummaryFile(summary: string): Promise<string> {
279
282
  }
280
283
 
281
284
  function parseSummaryResponse(text: string, summaryModel: string): SummaryResult {
285
+ const normalizedText = unwrapJsonCodeFence(text);
282
286
  let payload: unknown;
283
287
  try {
284
- payload = JSON.parse(text);
288
+ payload = JSON.parse(normalizedText);
285
289
  } catch (error) {
286
290
  throw new Error(`Summarizer returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
287
291
  }
@@ -336,6 +340,13 @@ function parseSummaryResponse(text: string, summaryModel: string): SummaryResult
336
340
  };
337
341
  }
338
342
 
343
+ /** 兼容模型用 Markdown JSON 代码围栏包裹结构化响应的常见输出格式。 */
344
+ function unwrapJsonCodeFence(text: string): string {
345
+ const trimmed = text.trim();
346
+ const match = trimmed.match(/^(`{3,})[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/i);
347
+ return match?.[2]?.trim() ?? trimmed;
348
+ }
349
+
339
350
  async function summarizeOutput(
340
351
  prompt: string,
341
352
  output: string,
@@ -407,6 +418,63 @@ async function summarizeOutput(
407
418
  };
408
419
  }
409
420
 
421
+ async function summarizeOutputWithRetries(
422
+ prompt: string,
423
+ output: string,
424
+ config: BashSummaryConfig,
425
+ context: DistillExecutionContext,
426
+ completion: SummaryCompletion,
427
+ ): Promise<SummaryResult> {
428
+ let timeoutRetries = 0;
429
+ let errorRetries = 0;
430
+
431
+ while (true) {
432
+ if (context.signal?.aborted) {
433
+ throw new Error("Summarization aborted");
434
+ }
435
+
436
+ const attemptController = new AbortController();
437
+ const abortFromParent = () => attemptController.abort();
438
+ context.signal?.addEventListener("abort", abortFromParent, { once: true });
439
+ if (context.signal?.aborted) attemptController.abort();
440
+ let timedOut = false;
441
+ const timeout = setTimeout(
442
+ () => {
443
+ timedOut = true;
444
+ attemptController.abort();
445
+ },
446
+ config.timeoutSeconds * 1000,
447
+ );
448
+
449
+ try {
450
+ return await summarizeOutput(
451
+ prompt,
452
+ output,
453
+ config,
454
+ context,
455
+ attemptController.signal,
456
+ completion,
457
+ );
458
+ } catch (error) {
459
+ if (context.signal?.aborted) throw error;
460
+ const retryLimit = timedOut ? config.timeoutRetryCount : config.errorRetryCount;
461
+ const retriesUsed = timedOut ? timeoutRetries : errorRetries;
462
+ if (retriesUsed >= retryLimit) throw error;
463
+ if (timedOut) timeoutRetries += 1;
464
+ else errorRetries += 1;
465
+ console.warn(i18n.t("retryingSummary", {
466
+ kind: i18n.t(timedOut ? "retryKindTimeout" : "retryKindError"),
467
+ retry: retriesUsed + 1,
468
+ limit: retryLimit,
469
+ error: error instanceof Error ? error.message : String(error),
470
+ }));
471
+ } finally {
472
+ clearTimeout(timeout);
473
+ context.signal?.removeEventListener("abort", abortFromParent);
474
+ }
475
+ }
476
+ }
477
+
410
478
  function getOutputRequest(params: Record<string, unknown>): string {
411
479
  return typeof params.outputRequest === "string"
412
480
  ? params.outputRequest.trim()
@@ -423,8 +491,10 @@ export async function processToolResult(
423
491
  const loaded = loadDistillConfig();
424
492
  const config = loaded.config;
425
493
  const outputSummaryRender = { ...loaded.render };
426
- // Merge with Pi's native output limiter; this extension must not truncate tool results itself.
427
- const finish = (candidate: ToolResult) => candidate;
494
+ // Pi may persist raw tool output before this hook runs. This second limit
495
+ // protects the post-distillation result, including RAW and fallbacks.
496
+ const maxReturnedChars = config?.maxOutputChars ?? 10_000;
497
+ const finish = (candidate: ToolResult) => limitReturnedToolResult(candidate, maxReturnedChars);
428
498
  if (loaded.warnings.length > 0) {
429
499
  console.warn(`[pi-distill] ${loaded.warnings.join(" | ")}`);
430
500
  }
@@ -504,17 +574,12 @@ export async function processToolResult(
504
574
  }
505
575
 
506
576
  const summaryStartedAt = performance.now();
507
- const timeoutController = new AbortController();
508
- const abortFromParent = () => timeoutController.abort();
509
- context.signal?.addEventListener("abort", abortFromParent, { once: true });
510
- const timeout = setTimeout(() => timeoutController.abort(), config.timeoutSeconds * 1000);
511
577
  try {
512
- const summarized = await summarizeOutput(
578
+ const summarized = await summarizeOutputWithRetries(
513
579
  prompt,
514
580
  output,
515
581
  config,
516
582
  context,
517
- timeoutController.signal,
518
583
  completion,
519
584
  );
520
585
  const summaryDurationMs = Math.round(performance.now() - summaryStartedAt);
@@ -649,9 +714,6 @@ export async function processToolResult(
649
714
  : result.content,
650
715
  };
651
716
  return finish(candidate);
652
- } finally {
653
- clearTimeout(timeout);
654
- context.signal?.removeEventListener("abort", abortFromParent);
655
717
  }
656
718
  }
657
719
 
@@ -745,7 +807,7 @@ function toToolResultEventResult(result: ToolResult): ToolResultEventPatch {
745
807
  };
746
808
  }
747
809
 
748
- type DistillUiConfig = Required<Pick<DistillConfigFile, "enabled" | "model" | "minChars" | "maxChars" | "maxOutputChars" | "timeoutSeconds" | "missedCompressionRatio" | "summarizeErrors">> & {
810
+ type DistillUiConfig = Required<Pick<DistillConfigFile, "enabled" | "model" | "minChars" | "maxChars" | "maxOutputChars" | "timeoutSeconds" | "timeoutRetryCount" | "errorRetryCount" | "missedCompressionRatio" | "summarizeErrors">> & {
749
811
  tools: DistillToolConfig;
750
812
  render: DistillRenderConfig;
751
813
  };
@@ -762,6 +824,8 @@ function getDistillUiConfig(): DistillUiConfig {
762
824
  maxChars: config?.maxChars ?? 100_000,
763
825
  maxOutputChars: config?.maxOutputChars ?? 10_000,
764
826
  timeoutSeconds: config?.timeoutSeconds ?? 10,
827
+ timeoutRetryCount: config?.timeoutRetryCount ?? 1,
828
+ errorRetryCount: config?.errorRetryCount ?? 1,
765
829
  missedCompressionRatio: config?.missedCompressionRatio ?? 10,
766
830
  summarizeErrors: config?.summarizeErrors ?? true,
767
831
  tools: Object.fromEntries(
@@ -785,6 +849,22 @@ async function editDistillNumber(
785
849
  return Number(value);
786
850
  }
787
851
 
852
+ async function editDistillNonNegativeInteger(
853
+ ctx: ExtensionCommandContext,
854
+ title: string,
855
+ current: number,
856
+ ): Promise<number | undefined> {
857
+ const value = await ctx.ui.input(title, String(current));
858
+ if (value === undefined) return undefined;
859
+ const normalized = value.trim();
860
+ const parsed = Number(normalized);
861
+ if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(parsed)) {
862
+ ctx.ui.notify(i18n.t("nonNegativeInteger"), "error");
863
+ return undefined;
864
+ }
865
+ return parsed;
866
+ }
867
+
788
868
  async function editDistillModel(
789
869
  ctx: ExtensionCommandContext,
790
870
  current: string,
@@ -873,6 +953,8 @@ async function runDistillConfigUi(
873
953
  i18n.t("summaryLimit", { value: config.maxChars }),
874
954
  i18n.t("finalLimit", { value: config.maxOutputChars }),
875
955
  i18n.t("timeout", { value: config.timeoutSeconds }),
956
+ i18n.t("timeoutRetryCount", { value: config.timeoutRetryCount }),
957
+ i18n.t("errorRetryCount", { value: config.errorRetryCount }),
876
958
  i18n.t("threshold", { value: config.missedCompressionRatio }),
877
959
  i18n.t("summarizeErrors", { value: config.summarizeErrors ? i18n.t("on") : i18n.t("off") }),
878
960
  i18n.t("auditRenderer", { value: config.render.enabled ? i18n.t("on") : i18n.t("off") }),
@@ -917,24 +999,44 @@ async function runDistillConfigUi(
917
999
  await saveDistillConfigFile(ctx, config, configPath, onSaved);
918
1000
  }
919
1001
  } else if (choice === choices[6]) {
1002
+ const value = await editDistillNonNegativeInteger(
1003
+ ctx,
1004
+ i18n.t("timeoutRetryCountTitle"),
1005
+ config.timeoutRetryCount,
1006
+ );
1007
+ if (value !== undefined) {
1008
+ config.timeoutRetryCount = value;
1009
+ await saveDistillConfigFile(ctx, config, configPath, onSaved);
1010
+ }
1011
+ } else if (choice === choices[7]) {
1012
+ const value = await editDistillNonNegativeInteger(
1013
+ ctx,
1014
+ i18n.t("errorRetryCountTitle"),
1015
+ config.errorRetryCount,
1016
+ );
1017
+ if (value !== undefined) {
1018
+ config.errorRetryCount = value;
1019
+ await saveDistillConfigFile(ctx, config, configPath, onSaved);
1020
+ }
1021
+ } else if (choice === choices[8]) {
920
1022
  const value = await editDistillNumber(ctx, i18n.t("thresholdTitle"), config.missedCompressionRatio);
921
1023
  if (value !== undefined) {
922
1024
  config.missedCompressionRatio = value;
923
1025
  await saveDistillConfigFile(ctx, config, configPath, onSaved);
924
1026
  }
925
- } else if (choice === choices[7]) {
1027
+ } else if (choice === choices[9]) {
926
1028
  config.summarizeErrors = !config.summarizeErrors;
927
1029
  await saveDistillConfigFile(ctx, config, configPath, onSaved);
928
- } else if (choice === choices[8]) {
1030
+ } else if (choice === choices[10]) {
929
1031
  config.render.enabled = !config.render.enabled;
930
1032
  await saveDistillConfigFile(ctx, config, configPath, onSaved);
931
- } else if (choice === choices[9]) {
1033
+ } else if (choice === choices[11]) {
932
1034
  config.render.showPrompt = !config.render.showPrompt;
933
1035
  await saveDistillConfigFile(ctx, config, configPath, onSaved);
934
- } else if (choice === choices[10]) {
1036
+ } else if (choice === choices[12]) {
935
1037
  config.render.showResult = !config.render.showResult;
936
1038
  await saveDistillConfigFile(ctx, config, configPath, onSaved);
937
- } else if (choice === choices[11]) {
1039
+ } else if (choice === choices[13]) {
938
1040
  await runDistillToolConfigUi(ctx, pi, config, configPath, onSaved);
939
1041
  }
940
1042
  }
@@ -1,3 +1,10 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
5
+
6
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
7
+
1
8
  export type OutputLimitToolResult = {
2
9
  content: Array<{ type?: string; text?: string }>;
3
10
  details?: {
@@ -15,3 +22,53 @@ export function getTextContent(result: OutputLimitToolResult): string {
15
22
  export function hasNonTextContent(result: OutputLimitToolResult): boolean {
16
23
  return result.content.some((content) => content.type !== "text" || typeof content.text !== "string");
17
24
  }
25
+
26
+ async function writeOutputFile(text: string): Promise<string> {
27
+ const directory = join(tmpdir(), "pi-distill");
28
+ await mkdir(directory, { recursive: true });
29
+ const filePath = join(
30
+ directory,
31
+ `output-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
32
+ );
33
+ await writeFile(filePath, text, "utf8");
34
+ return filePath;
35
+ }
36
+
37
+ /** Limit text entering the next Agent context; preserve non-text content. */
38
+ export async function limitReturnedToolResult(
39
+ result: OutputLimitToolResult,
40
+ maxChars: number,
41
+ ): Promise<OutputLimitToolResult> {
42
+ if (hasNonTextContent(result)) return result;
43
+
44
+ const text = getTextContent(result);
45
+ if (text.length <= maxChars) return result;
46
+
47
+ try {
48
+ const filePath = await writeOutputFile(text);
49
+ const pointer = i18n.t("outputLimitExceeded", { maxChars, path: filePath });
50
+ return {
51
+ ...result,
52
+ content: [{ type: "text", text: pointer.slice(0, maxChars) }],
53
+ details: {
54
+ ...(result.details ?? {}),
55
+ fullOutputPath: filePath,
56
+ outputTruncated: true,
57
+ outputLimitChars: maxChars,
58
+ },
59
+ };
60
+ } catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ console.warn(i18n.t("outputLimitWriteFailed", { error: message }));
63
+ return {
64
+ ...result,
65
+ content: [{ type: "text", text: text.slice(0, maxChars) }],
66
+ details: {
67
+ ...(result.details ?? {}),
68
+ outputTruncated: true,
69
+ outputLimitChars: maxChars,
70
+ outputFileError: message,
71
+ },
72
+ };
73
+ }
74
+ }
@@ -9,6 +9,8 @@ const DEFAULT_MIN_CHARS = 200;
9
9
  const DEFAULT_MAX_CHARS = 100_000;
10
10
  const DEFAULT_MAX_OUTPUT_CHARS = 10_000;
11
11
  const DEFAULT_TIMEOUT_SECONDS = 10;
12
+ const DEFAULT_TIMEOUT_RETRY_COUNT = 1;
13
+ const DEFAULT_ERROR_RETRY_COUNT = 1;
12
14
  const DEFAULT_MISSED_COMPRESSION_RATIO = 10;
13
15
  const DEFAULT_SUMMARIZE_ERRORS = true;
14
16
  const DEFAULT_RENDER_ENABLED = true;
@@ -30,6 +32,10 @@ export interface BashSummaryConfig {
30
32
  maxOutputChars: number;
31
33
  /** 模型调用最长等待时间。 */
32
34
  timeoutSeconds: number;
35
+ /** 单次提炼因超时失败后的额外重试次数。 */
36
+ timeoutRetryCount: number;
37
+ /** 单次提炼因非超时异常失败后的额外重试次数。 */
38
+ errorRetryCount: number;
33
39
  /** 无 prompt 的长输出触发 missed-compression 提醒所需的倍数。 */
34
40
  missedCompressionRatio: number;
35
41
  /** 工具返回错误且达到最小长度时是否仍调用提炼模型。 */
@@ -60,6 +66,8 @@ export interface DistillConfigFile {
60
66
  maxChars?: number;
61
67
  maxOutputChars?: number;
62
68
  timeoutSeconds?: number;
69
+ timeoutRetryCount?: number;
70
+ errorRetryCount?: number;
63
71
  missedCompressionRatio?: number;
64
72
  summarizeErrors?: boolean;
65
73
  tools?: DistillToolConfig;
@@ -109,6 +117,8 @@ export function parseBashSummaryConfig(
109
117
  const timeoutSecondsValue = (
110
118
  env.PI_DISTILL_TIMEOUT_SECONDS ?? env.PI_BASH_SUMMARY_TIMEOUT_SECONDS
111
119
  )?.trim();
120
+ const timeoutRetryCountValue = env.PI_DISTILL_TIMEOUT_RETRY_COUNT?.trim();
121
+ const errorRetryCountValue = env.PI_DISTILL_ERROR_RETRY_COUNT?.trim();
112
122
  const missedCompressionRatioValue = (
113
123
  env.PI_DISTILL_MISSED_COMPRESSION_RATIO ?? env.PI_BASH_SUMMARY_MISSED_COMPRESSION_RATIO
114
124
  )?.trim();
@@ -127,6 +137,12 @@ export function parseBashSummaryConfig(
127
137
  const timeoutSeconds = timeoutSecondsValue
128
138
  ? parsePositiveInteger(timeoutSecondsValue)
129
139
  : DEFAULT_TIMEOUT_SECONDS;
140
+ const timeoutRetryCount = timeoutRetryCountValue
141
+ ? parseNonNegativeInteger(timeoutRetryCountValue)
142
+ : DEFAULT_TIMEOUT_RETRY_COUNT;
143
+ const errorRetryCount = errorRetryCountValue
144
+ ? parseNonNegativeInteger(errorRetryCountValue)
145
+ : DEFAULT_ERROR_RETRY_COUNT;
130
146
  const missedCompressionRatio = missedCompressionRatioValue
131
147
  ? parsePositiveNumber(missedCompressionRatioValue)
132
148
  : DEFAULT_MISSED_COMPRESSION_RATIO;
@@ -138,12 +154,14 @@ export function parseBashSummaryConfig(
138
154
  minChars === undefined ||
139
155
  maxChars === undefined ||
140
156
  timeoutSeconds === undefined ||
157
+ timeoutRetryCount === undefined ||
158
+ errorRetryCount === undefined ||
141
159
  maxOutputChars === undefined ||
142
160
  missedCompressionRatio === undefined ||
143
161
  summarizeErrors === undefined
144
162
  ) {
145
163
  console.warn(
146
- "[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_MISSED_COMPRESSION_RATIO, and PI_DISTILL_SUMMARIZE_ERRORS (legacy PI_BASH_SUMMARY_* variables remain supported).",
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).",
147
165
  );
148
166
  return undefined;
149
167
  }
@@ -154,6 +172,8 @@ export function parseBashSummaryConfig(
154
172
  maxChars,
155
173
  maxOutputChars,
156
174
  timeoutSeconds,
175
+ timeoutRetryCount,
176
+ errorRetryCount,
157
177
  missedCompressionRatio,
158
178
  summarizeErrors,
159
179
  };
@@ -174,6 +194,8 @@ export function parseBashSummaryConfig(
174
194
  maxChars,
175
195
  maxOutputChars,
176
196
  timeoutSeconds,
197
+ timeoutRetryCount,
198
+ errorRetryCount,
177
199
  missedCompressionRatio,
178
200
  summarizeErrors,
179
201
  };
@@ -189,6 +211,12 @@ function parsePositiveInteger(value: string | undefined): number | undefined {
189
211
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
190
212
  }
191
213
 
214
+ function parseNonNegativeInteger(value: string | undefined): number | undefined {
215
+ if (!value || !/^\d+$/.test(value)) return undefined;
216
+ const parsed = Number(value);
217
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
218
+ }
219
+
192
220
  function parsePositiveNumber(value: string): number | undefined {
193
221
  if (!/^\d+(?:\.\d+)?$/.test(value)) return undefined;
194
222
  const parsed = Number(value);
@@ -279,6 +307,19 @@ function appendFileValueToEnv(
279
307
  return;
280
308
  }
281
309
 
310
+ if (
311
+ key === "timeoutRetryCount" ||
312
+ key === "errorRetryCount"
313
+ ) {
314
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
315
+ warnings.push(`Config field ${key} must be a non-negative integer.`);
316
+ env[envKey] = "__invalid_file_value__";
317
+ return;
318
+ }
319
+ env[envKey] = String(value);
320
+ return;
321
+ }
322
+
282
323
  if (typeof value !== "number" || !Number.isFinite(value)) {
283
324
  warnings.push(`Config field ${key} must be a positive number.`);
284
325
  env[envKey] = "__invalid_file_value__";
@@ -323,6 +364,20 @@ export function loadDistillConfig(
323
364
  appendFileValueToEnv(effectiveEnv, file, "maxChars", "PI_DISTILL_MAX_CHARS", warnings);
324
365
  appendFileValueToEnv(effectiveEnv, file, "maxOutputChars", "PI_DISTILL_MAX_OUTPUT_CHARS", warnings);
325
366
  appendFileValueToEnv(effectiveEnv, file, "timeoutSeconds", "PI_DISTILL_TIMEOUT_SECONDS", warnings);
367
+ appendFileValueToEnv(
368
+ effectiveEnv,
369
+ file,
370
+ "timeoutRetryCount",
371
+ "PI_DISTILL_TIMEOUT_RETRY_COUNT",
372
+ warnings,
373
+ );
374
+ appendFileValueToEnv(
375
+ effectiveEnv,
376
+ file,
377
+ "errorRetryCount",
378
+ "PI_DISTILL_ERROR_RETRY_COUNT",
379
+ warnings,
380
+ );
326
381
  appendFileValueToEnv(
327
382
  effectiveEnv,
328
383
  file,
@@ -357,6 +412,8 @@ export function defaultDistillConfigFile(): DistillConfigFile {
357
412
  maxChars: DEFAULT_MAX_CHARS,
358
413
  maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS,
359
414
  timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
415
+ timeoutRetryCount: DEFAULT_TIMEOUT_RETRY_COUNT,
416
+ errorRetryCount: DEFAULT_ERROR_RETRY_COUNT,
360
417
  missedCompressionRatio: DEFAULT_MISSED_COMPRESSION_RATIO,
361
418
  summarizeErrors: DEFAULT_SUMMARIZE_ERRORS,
362
419
  tools: {},