pi-distill 0.1.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/src/index.ts ADDED
@@ -0,0 +1,757 @@
1
+ /**
2
+ * pi-distill 工具输出提炼扩展
3
+ *
4
+ * 通过 Pi 的工具事件处理 bash、read、grep、find 工具结果,并在会话启动时
5
+ * 原地扩展最终生效工具的参数 schema。不注册同名工具,也不争夺工具所有权。
6
+ *
7
+ * 所有工具统一使用 outputPrompt:严格传入 RAW 时返回原始输出;其他非空
8
+ * outputPrompt 表示调用提炼模型,具体保留内容由 outputPrompt 决定。
9
+ * 提炼结果超过 maxChars 时写入临时文件,只返回文件路径。
10
+ *
11
+ * 配置文件优先;旧环境变量继续兼容:
12
+ * - ~/.pi/agent/extensions/pi-distill/config.json
13
+ * - PI_DISTILL_MODEL=provider/model
14
+ * - PI_DISTILL_MIN_CHARS=触发提炼的最小输出字符数,默认 200
15
+ * - PI_DISTILL_MAX_CHARS=提炼结果超过此字符数时写入文件,默认 100000
16
+ * - PI_DISTILL_MAX_OUTPUT_CHARS=最终返回内容超过此字符数时写入文件,默认 10000
17
+ * - PI_DISTILL_TIMEOUT_SECONDS=模型调用最长等待秒数,默认 10
18
+ * - PI_DISTILL_MISSED_COMPRESSION_RATIO=长输出提醒倍数,默认 10
19
+ * - 旧 PI_BASH_SUMMARY_* 变量作为兼容回退
20
+ */
21
+
22
+ import { complete } from "@earendil-works/pi-ai/compat";
23
+ import type {
24
+ ExtensionAPI,
25
+ ExtensionCommandContext,
26
+ ExtensionContext,
27
+ ToolInfo,
28
+ ToolResultEvent,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import { performance } from "node:perf_hooks";
31
+ import {
32
+ appendDistillFallbackAudit,
33
+ registerDistillFallbackRenderer,
34
+ } from "./fallback-renderer.ts";
35
+ import {
36
+ isDistillToolDisplayMiddlewareActive,
37
+ registerDistillToolDisplayMiddleware,
38
+ } from "./tool-display-bridge.ts";
39
+ import { getTextContent, limitReturnedToolResult } from "./output-limit.ts";
40
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
41
+ import { tmpdir } from "node:os";
42
+ import { dirname, join } from "node:path";
43
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
44
+ import {
45
+ buildSummaryPrompt,
46
+ decideOutputSummary,
47
+ getDistillConfigPath,
48
+ isRawSummary,
49
+ loadDistillConfig,
50
+ type BashSummaryConfig,
51
+ type DistillConfigFile,
52
+ type DistillRenderConfig,
53
+ type OutputSummaryDecision,
54
+ } from "./summary-utils.ts";
55
+
56
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
57
+
58
+ type ToolResult = {
59
+ content: Array<{ type?: string; text?: string }>;
60
+ isError?: boolean;
61
+ details?: {
62
+ fullOutputPath?: string;
63
+ [key: string]: unknown;
64
+ };
65
+ };
66
+
67
+ type DistillExecutionContext = {
68
+ toolName: string;
69
+ toolCallId: string;
70
+ params: Record<string, unknown>;
71
+ originalUserPrompt?: string;
72
+ signal?: AbortSignal;
73
+ ctx: ExtensionContext;
74
+ };
75
+
76
+ type PendingDistillCall = {
77
+ outputPrompt: string;
78
+ originalUserPrompt?: string;
79
+ startedAt: number;
80
+ };
81
+
82
+ type ToolResultEventPatch = {
83
+ content?: ToolResultEvent["content"];
84
+ details?: unknown;
85
+ isError?: boolean;
86
+ };
87
+
88
+ export const BASH_OUTPUT_PROMPT_DESCRIPTION = i18n.t("bashPromptDescription");
89
+ export const OUTPUT_PROMPT_DESCRIPTION = i18n.t("outputPromptDescription");
90
+
91
+ type SummaryResult = {
92
+ text: string;
93
+ summaryChars: number;
94
+ summaryFilePath?: string;
95
+ summaryModel: string;
96
+ };
97
+
98
+ type SummaryDiagnostics = {
99
+ toolExecutionMs?: number;
100
+ summaryDurationMs?: number;
101
+ outputSummaryIntent?: string;
102
+ outputSummaryPrompt?: string;
103
+ outputSummaryRender?: DistillRenderConfig;
104
+ outputSummaryStatus?: string;
105
+ outputSummaryAnomalies?: string[];
106
+ outputSummaryAdvice?: string;
107
+ /** 仅供 TUI 展示的底层错误,不追加到 Agent 可见 content。 */
108
+ outputSummaryError?: string;
109
+ summaryModel?: string;
110
+ originalOutputChars?: number;
111
+ summaryChars?: number;
112
+ compressionRatio?: number;
113
+ compressionSavedPercent?: number;
114
+ summaryTriggerMinChars?: number;
115
+ summaryTriggerMaxChars?: number | null;
116
+ summaryResultMaxChars?: number;
117
+ missedCompressionRatio?: number;
118
+ };
119
+
120
+ function attachDiagnostics(result: ToolResult, diagnostics: SummaryDiagnostics): ToolResult {
121
+ return {
122
+ ...result,
123
+ details: {
124
+ ...(result.details ?? {}),
125
+ ...diagnostics,
126
+ },
127
+ };
128
+ }
129
+
130
+ function getCompressionDiagnostics(
131
+ intent: string,
132
+ originalOutputChars: number,
133
+ summaryChars: number,
134
+ ): Pick<SummaryDiagnostics, "compressionRatio" | "compressionSavedPercent" | "outputSummaryAnomalies" | "outputSummaryAdvice"> {
135
+ const compressionRatio = summaryChars > 0 ? originalOutputChars / summaryChars : undefined;
136
+ const compressionSavedPercent = compressionRatio === undefined
137
+ ? undefined
138
+ : Math.max(0, 1 - summaryChars / originalOutputChars) * 100;
139
+ const anomalies: string[] = [];
140
+
141
+ if (intent === "full") {
142
+ anomalies.push("unexpected-compression");
143
+ }
144
+ if (compressionRatio !== undefined && compressionRatio < 1.2) {
145
+ anomalies.push("ineffective-compression");
146
+ }
147
+
148
+ return {
149
+ compressionRatio,
150
+ compressionSavedPercent,
151
+ outputSummaryAnomalies: anomalies.length > 0 ? anomalies : undefined,
152
+ outputSummaryAdvice: anomalies.length > 0
153
+ ? "Warning: summarization ran but saved little context, which may indicate the wrong handling mode. Use strict RAW when the exact original is required; use a clearer, more compression-oriented prompt when summarization is intended."
154
+ : undefined,
155
+ };
156
+ }
157
+
158
+ function getSkippedSummaryDiagnostics(
159
+ decision: OutputSummaryDecision,
160
+ outputChars: number | undefined,
161
+ config: BashSummaryConfig,
162
+ ): Pick<SummaryDiagnostics, "outputSummaryAnomalies" | "outputSummaryAdvice" | "missedCompressionRatio"> {
163
+ if (
164
+ outputChars === undefined ||
165
+ outputChars < config.minChars * config.missedCompressionRatio
166
+ ) {
167
+ return {};
168
+ }
169
+
170
+ if (decision.intent === "none") {
171
+ return {
172
+ missedCompressionRatio: config.missedCompressionRatio,
173
+ outputSummaryAnomalies: ["missed-compression"],
174
+ outputSummaryAdvice:
175
+ `Warning: this output has ${outputChars} chars, reaching ${config.missedCompressionRatio}x the summary threshold, but no summary prompt was provided. Use a non-RAW prompt unless the exact original is required; use strict RAW in that case.`,
176
+ };
177
+ }
178
+
179
+ if (decision.intent === "full") {
180
+ return {
181
+ missedCompressionRatio: config.missedCompressionRatio,
182
+ outputSummaryAdvice:
183
+ `This output has ${outputChars} chars, reaching ${config.missedCompressionRatio}x the summary threshold. RAW handling was selected, so the original was preserved without summarization. Use a compression-oriented prompt next time if the exact original is not required; use strict RAW in that case.`,
184
+ };
185
+ }
186
+
187
+ return {};
188
+ }
189
+
190
+ function buildAgentDiagnosticText(diagnostics: SummaryDiagnostics): string | undefined {
191
+ if (!diagnostics.outputSummaryAdvice && !diagnostics.outputSummaryAnomalies?.length) {
192
+ return undefined;
193
+ }
194
+
195
+ const lines = [
196
+ diagnostics.outputSummaryAnomalies?.length
197
+ ? "[Output handling error — action required]"
198
+ : "[Output handling diagnostics]",
199
+ ];
200
+ if (diagnostics.originalOutputChars !== undefined) {
201
+ lines.push(`Original chars: ${diagnostics.originalOutputChars}`);
202
+ }
203
+ if (diagnostics.summaryChars !== undefined) {
204
+ lines.push(`Summary chars: ${diagnostics.summaryChars}`);
205
+ }
206
+ if (diagnostics.compressionRatio !== undefined) {
207
+ lines.push(`Compression ratio: ${diagnostics.compressionRatio.toFixed(2)}x`);
208
+ }
209
+ if (diagnostics.compressionSavedPercent !== undefined) {
210
+ lines.push(`Context saved: ${diagnostics.compressionSavedPercent.toFixed(1)}%`);
211
+ }
212
+ if (diagnostics.missedCompressionRatio !== undefined) {
213
+ lines.push(`Long-output threshold: ${diagnostics.missedCompressionRatio.toFixed(1)}x`);
214
+ }
215
+ if (diagnostics.outputSummaryAnomalies?.length) {
216
+ lines.push(`Anomalies: ${diagnostics.outputSummaryAnomalies.join(", ")}`);
217
+ }
218
+ if (diagnostics.outputSummaryAdvice) {
219
+ lines.push(`Advice: ${diagnostics.outputSummaryAdvice}`);
220
+ }
221
+ return lines.join("\n");
222
+ }
223
+
224
+ async function getCompleteOutput(result: ToolResult): Promise<string> {
225
+ const fullOutputPath = result.details?.fullOutputPath;
226
+ if (fullOutputPath) {
227
+ return readFile(fullOutputPath, "utf8");
228
+ }
229
+ return getTextContent(result);
230
+ }
231
+
232
+ async function writeSummaryFile(summary: string): Promise<string> {
233
+ const directory = join(tmpdir(), "pi-distill");
234
+ await mkdir(directory, { recursive: true });
235
+ const filePath = join(
236
+ directory,
237
+ `summary-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
238
+ );
239
+ await writeFile(filePath, summary, "utf8");
240
+ return filePath;
241
+ }
242
+
243
+ async function summarizeOutput(
244
+ prompt: string,
245
+ output: string,
246
+ config: BashSummaryConfig,
247
+ context: DistillExecutionContext,
248
+ signal: AbortSignal,
249
+ ): Promise<SummaryResult> {
250
+ const model = config.modelProvider && config.modelId
251
+ ? context.ctx.modelRegistry.find(config.modelProvider, config.modelId)
252
+ : context.ctx.model;
253
+ if (!model) {
254
+ throw new Error(
255
+ "No model is available in the current session. Select a session model or set PI_BASH_SUMMARY_MODEL=provider/model.",
256
+ );
257
+ }
258
+
259
+ const auth = await context.ctx.modelRegistry.getApiKeyAndHeaders(model);
260
+ if (auth.ok === false) throw new Error(`Summarizer authentication failed: ${auth.error}`);
261
+
262
+ const response = await complete(
263
+ model,
264
+ {
265
+ messages: [
266
+ {
267
+ role: "user",
268
+ content: [{
269
+ type: "text",
270
+ text: buildSummaryPrompt(prompt, output, context.originalUserPrompt),
271
+ }],
272
+ timestamp: Date.now(),
273
+ },
274
+ ],
275
+ },
276
+ {
277
+ apiKey: auth.apiKey,
278
+ headers: auth.headers,
279
+ env: auth.env,
280
+ maxTokens: Math.max(256, Math.ceil(config.maxChars / 2)),
281
+ signal,
282
+ },
283
+ );
284
+
285
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
286
+ throw new Error(response.errorMessage ?? `Summarizer stopped with reason: ${response.stopReason}`);
287
+ }
288
+
289
+ const summary = response.content
290
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
291
+ .map((content) => content.text)
292
+ .join("\n")
293
+ .trim();
294
+
295
+ if (!summary) throw new Error("Summarizer returned no text");
296
+ if (summary.length <= config.maxChars) {
297
+ return {
298
+ text: summary,
299
+ summaryChars: summary.length,
300
+ summaryModel: `${model.provider}/${model.id}`,
301
+ };
302
+ }
303
+
304
+ const summaryFilePath = await writeSummaryFile(summary);
305
+ return {
306
+ text: `Summary exceeded ${config.maxChars} chars and was written to: ${summaryFilePath}`,
307
+ summaryChars: summary.length,
308
+ summaryFilePath,
309
+ summaryModel: `${model.provider}/${model.id}`,
310
+ };
311
+ }
312
+
313
+ function getOutputPrompt(params: Record<string, unknown>): string {
314
+ return typeof params.outputPrompt === "string"
315
+ ? params.outputPrompt.trim()
316
+ : "";
317
+ }
318
+
319
+ async function processToolResult(
320
+ context: DistillExecutionContext,
321
+ result: ToolResult,
322
+ toolExecutionMs: number,
323
+ ): Promise<ToolResult> {
324
+ const prompt = getOutputPrompt(context.params);
325
+ const loaded = loadDistillConfig();
326
+ const config = loaded.config;
327
+ const outputSummaryRender = { ...loaded.render };
328
+ const maxReturnedChars = config?.maxOutputChars ?? 10_000;
329
+ const finish = (candidate: ToolResult) =>
330
+ limitReturnedToolResult(candidate, maxReturnedChars);
331
+ if (loaded.warnings.length > 0) {
332
+ console.warn(`[pi-distill] ${loaded.warnings.join(" | ")}`);
333
+ }
334
+
335
+ if (!config || !loaded.enabled) {
336
+ const diagnostics: SummaryDiagnostics = {
337
+ toolExecutionMs,
338
+ outputSummaryPrompt: prompt || undefined,
339
+ outputSummaryRender,
340
+ outputSummaryStatus: loaded.enabled ? "disabled" : "disabled-by-config",
341
+ outputSummaryAdvice: loaded.warnings.length > 0
342
+ ? `Distill is disabled: ${loaded.warnings.join(" ")}`
343
+ : loaded.enabled
344
+ ? "Distill is disabled: invalid configuration. Check /pi-distill."
345
+ : "Distill is disabled by configuration.",
346
+ };
347
+ const agentDiagnostic = buildAgentDiagnosticText(diagnostics);
348
+ return finish({
349
+ ...attachDiagnostics(result, diagnostics),
350
+ content: agentDiagnostic
351
+ ? [...result.content, { type: "text", text: agentDiagnostic }]
352
+ : result.content,
353
+ });
354
+ }
355
+
356
+ let output: string;
357
+ try {
358
+ output = await getCompleteOutput(result);
359
+ } catch (error) {
360
+ console.warn(
361
+ `[tool-output-summary] ${context.toolName} could not read the full output; returning the original result: ${error instanceof Error ? error.message : String(error)}`,
362
+ );
363
+ return finish(attachDiagnostics(result, {
364
+ toolExecutionMs,
365
+ outputSummaryPrompt: prompt || undefined,
366
+ outputSummaryRender,
367
+ outputSummaryStatus: "diagnostic-failed",
368
+ summaryTriggerMinChars: config.minChars,
369
+ summaryTriggerMaxChars: null,
370
+ summaryResultMaxChars: config.maxChars,
371
+ missedCompressionRatio: config.missedCompressionRatio,
372
+ }));
373
+ }
374
+
375
+ const decision = decideOutputSummary(prompt, output, config, result.isError === true);
376
+ if (!decision.shouldSummarize) {
377
+ const skippedDiagnostics = getSkippedSummaryDiagnostics(decision, output.length, config);
378
+ const diagnostics: SummaryDiagnostics = {
379
+ toolExecutionMs,
380
+ originalOutputChars: output.length,
381
+ outputSummaryIntent: decision.intent,
382
+ outputSummaryPrompt: prompt || undefined,
383
+ outputSummaryRender,
384
+ outputSummaryStatus: decision.reason,
385
+ summaryTriggerMinChars: config.minChars,
386
+ summaryTriggerMaxChars: null,
387
+ summaryResultMaxChars: config.maxChars,
388
+ missedCompressionRatio: config.missedCompressionRatio,
389
+ ...skippedDiagnostics,
390
+ };
391
+ const agentDiagnostic = buildAgentDiagnosticText(diagnostics);
392
+ const candidate = {
393
+ ...attachDiagnostics(result, diagnostics),
394
+ content: agentDiagnostic
395
+ ? [...result.content, { type: "text", text: agentDiagnostic }]
396
+ : result.content,
397
+ };
398
+ return finish(candidate);
399
+ }
400
+
401
+ const summaryStartedAt = performance.now();
402
+ const timeoutController = new AbortController();
403
+ const abortFromParent = () => timeoutController.abort();
404
+ context.signal?.addEventListener("abort", abortFromParent, { once: true });
405
+ const timeout = setTimeout(() => timeoutController.abort(), config.timeoutSeconds * 1000);
406
+ try {
407
+ const summarized = await summarizeOutput(prompt, output, config, context, timeoutController.signal);
408
+ const summaryDurationMs = Math.round(performance.now() - summaryStartedAt);
409
+ if (isRawSummary(summarized.text)) {
410
+ // RAW 是总结模型的控制哨兵,不是要交给 Agent 的正文;原文仍通过同一条 final limiter。
411
+ const rawDecision: OutputSummaryDecision = {
412
+ intent: "full",
413
+ shouldSummarize: false,
414
+ reason: "full-output",
415
+ };
416
+ const rawDiagnostics = getSkippedSummaryDiagnostics(rawDecision, output.length, config);
417
+ const diagnostics: SummaryDiagnostics = {
418
+ originalOutputChars: output.length,
419
+ summaryChars: output.length,
420
+ compressionRatio: 1,
421
+ compressionSavedPercent: 0,
422
+ ...rawDiagnostics,
423
+ };
424
+ const agentDiagnostic = buildAgentDiagnosticText(diagnostics);
425
+ const candidate = {
426
+ ...attachDiagnostics(result, {
427
+ toolExecutionMs,
428
+ summaryDurationMs,
429
+ outputSummaryIntent: "full",
430
+ outputSummaryPrompt: prompt || undefined,
431
+ outputSummaryRender,
432
+ outputSummaryStatus: "full-output",
433
+ summaryTriggerMinChars: config.minChars,
434
+ summaryTriggerMaxChars: null,
435
+ summaryResultMaxChars: config.maxChars,
436
+ missedCompressionRatio: config.missedCompressionRatio,
437
+ summaryModel: summarized.summaryModel,
438
+ ...diagnostics,
439
+ }),
440
+ content: [
441
+ { type: "text", text: output },
442
+ ...(agentDiagnostic ? [{ type: "text", text: agentDiagnostic }] : []),
443
+ ],
444
+ };
445
+ return finish(candidate);
446
+ }
447
+ const compressionDiagnostics = getCompressionDiagnostics(
448
+ decision.intent,
449
+ output.length,
450
+ summarized.summaryChars,
451
+ );
452
+ const diagnostics: SummaryDiagnostics = {
453
+ originalOutputChars: output.length,
454
+ summaryChars: summarized.summaryChars,
455
+ ...compressionDiagnostics,
456
+ };
457
+ const agentDiagnostic = buildAgentDiagnosticText(diagnostics);
458
+
459
+ return finish({
460
+ // 输出处理参数只影响结果上下文,不改变原工具的业务执行。
461
+ // 异常诊断额外作为文本传给 Agent;普通成功总结不增加噪音。
462
+ content: [
463
+ { type: "text", text: summarized.text },
464
+ ...(agentDiagnostic ? [{ type: "text", text: agentDiagnostic }] : []),
465
+ ],
466
+ details: {
467
+ ...(result.details ?? {}),
468
+ toolExecutionMs,
469
+ summaryDurationMs,
470
+ outputSummaryIntent: decision.intent,
471
+ outputSummaryPrompt: prompt || undefined,
472
+ outputSummaryRender,
473
+ outputSummaryStatus: "summarized",
474
+ summaryTriggerMinChars: config.minChars,
475
+ summaryTriggerMaxChars: null,
476
+ summaryResultMaxChars: config.maxChars,
477
+ missedCompressionRatio: config.missedCompressionRatio,
478
+ summaryModel: summarized.summaryModel,
479
+ summaryText: summarized.text,
480
+ summaryFilePath: summarized.summaryFilePath,
481
+ ...diagnostics,
482
+ },
483
+ });
484
+ } catch (error) {
485
+ const summaryDurationMs = Math.round(performance.now() - summaryStartedAt);
486
+ const errorMessage = error instanceof Error ? error.message : String(error);
487
+ // 总结链路任何异常都必须保留原始结果,不能把异常文本替换给 AI。
488
+ console.warn(
489
+ `[tool-output-summary] ${context.toolName} summarization failed; returning the original result: ${errorMessage}`,
490
+ );
491
+ const diagnostics: SummaryDiagnostics = {
492
+ toolExecutionMs,
493
+ summaryDurationMs,
494
+ originalOutputChars: output.length,
495
+ outputSummaryIntent: decision.intent,
496
+ outputSummaryPrompt: prompt || undefined,
497
+ outputSummaryRender,
498
+ outputSummaryStatus: "summary-failed",
499
+ summaryTriggerMinChars: config.minChars,
500
+ summaryTriggerMaxChars: null,
501
+ summaryResultMaxChars: config.maxChars,
502
+ missedCompressionRatio: config.missedCompressionRatio,
503
+ outputSummaryAnomalies: ["summary-failed"],
504
+ 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.`,
505
+ outputSummaryError: errorMessage,
506
+ };
507
+ const agentDiagnostic = buildAgentDiagnosticText(diagnostics);
508
+ const candidate = {
509
+ ...attachDiagnostics(result, diagnostics),
510
+ content: agentDiagnostic
511
+ ? [...result.content, { type: "text", text: agentDiagnostic }]
512
+ : result.content,
513
+ };
514
+ return finish(candidate);
515
+ } finally {
516
+ clearTimeout(timeout);
517
+ context.signal?.removeEventListener("abort", abortFromParent);
518
+ }
519
+ }
520
+
521
+ const DISTILL_TOOL_NAMES = ["bash", "read", "grep", "find"] as const;
522
+ type DistillToolName = typeof DISTILL_TOOL_NAMES[number];
523
+
524
+ function isDistillToolName(toolName: string): toolName is DistillToolName {
525
+ return (DISTILL_TOOL_NAMES as readonly string[]).includes(toolName);
526
+ }
527
+
528
+ function extendOutputPromptParameter(tool: ToolInfo): boolean {
529
+ if (!isDistillToolName(tool.name)) return false;
530
+ const parameters = tool.parameters as unknown as Record<string, unknown>;
531
+ const properties = parameters?.properties;
532
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
533
+ console.warn(`[pi-distill] Could not extend the ${tool.name} parameter schema; outputPrompt is unavailable.`);
534
+ return false;
535
+ }
536
+
537
+ (properties as Record<string, unknown>).outputPrompt = {
538
+ type: "string",
539
+ description: tool.name === "bash"
540
+ ? BASH_OUTPUT_PROMPT_DESCRIPTION
541
+ : OUTPUT_PROMPT_DESCRIPTION,
542
+ };
543
+ const required = Array.isArray(parameters.required)
544
+ ? parameters.required.filter((value): value is string =>
545
+ typeof value === "string" && value !== "outputPrompt")
546
+ : [];
547
+ parameters.required = [...required, "outputPrompt"];
548
+ return true;
549
+ }
550
+
551
+ export function extendDistillToolParameters(pi: Pick<ExtensionAPI, "getAllTools">): number {
552
+ let extended = 0;
553
+ for (const tool of pi.getAllTools()) {
554
+ if (extendOutputPromptParameter(tool)) extended += 1;
555
+ }
556
+ return extended;
557
+ }
558
+
559
+ function toToolResultEventResult(result: ToolResult): ToolResultEventPatch {
560
+ return {
561
+ content: result.content as ToolResultEvent["content"],
562
+ details: result.details,
563
+ isError: result.isError,
564
+ };
565
+ }
566
+
567
+ type DistillUiConfig = Required<Pick<DistillConfigFile, "enabled" | "model" | "minChars" | "maxChars" | "maxOutputChars" | "timeoutSeconds" | "missedCompressionRatio" | "summarizeErrors">> & {
568
+ render: DistillRenderConfig;
569
+ };
570
+
571
+ function getDistillUiConfig(): DistillUiConfig {
572
+ const loaded = loadDistillConfig();
573
+ const config = loaded.config;
574
+ return {
575
+ enabled: loaded.enabled,
576
+ model: config?.modelProvider && config.modelId
577
+ ? `${config.modelProvider}/${config.modelId}`
578
+ : "",
579
+ minChars: config?.minChars ?? 200,
580
+ maxChars: config?.maxChars ?? 100_000,
581
+ maxOutputChars: config?.maxOutputChars ?? 10_000,
582
+ timeoutSeconds: config?.timeoutSeconds ?? 10,
583
+ missedCompressionRatio: config?.missedCompressionRatio ?? 10,
584
+ summarizeErrors: config?.summarizeErrors ?? true,
585
+ render: { ...loaded.render },
586
+ };
587
+ }
588
+
589
+ async function editDistillNumber(
590
+ ctx: ExtensionCommandContext,
591
+ title: string,
592
+ current: number,
593
+ ): Promise<number | undefined> {
594
+ const value = await ctx.ui.input(title, String(current));
595
+ if (value === undefined) return undefined;
596
+ if (!/^\d+$/.test(value.trim()) || Number(value) <= 0) {
597
+ ctx.ui.notify(i18n.t("positiveInteger"), "error");
598
+ return undefined;
599
+ }
600
+ return Number(value);
601
+ }
602
+
603
+ async function editDistillModel(
604
+ ctx: ExtensionCommandContext,
605
+ current: string,
606
+ ): Promise<string | undefined> {
607
+ const value = await ctx.ui.input(
608
+ i18n.t("modelInput"),
609
+ current || "llm-proxy/LOW",
610
+ );
611
+ if (value === undefined) return undefined;
612
+ const normalized = value.trim();
613
+ if (normalized && !/^[^/\s]+\/[^/\s]+$/.test(normalized)) {
614
+ ctx.ui.notify(i18n.t("modelInvalid"), "error");
615
+ return undefined;
616
+ }
617
+ return normalized;
618
+ }
619
+
620
+ async function runDistillConfigUi(ctx: ExtensionCommandContext, configPath: string): Promise<void> {
621
+ const loaded = loadDistillConfig();
622
+ if (loaded.warnings.length > 0) {
623
+ ctx.ui.notify(i18n.t("configWarnings", { warnings: loaded.warnings.join(" ") }), "warning");
624
+ }
625
+ const config = getDistillUiConfig();
626
+
627
+ while (true) {
628
+ const choices = [
629
+ i18n.t("status", { value: config.enabled ? i18n.t("on") : i18n.t("off") }),
630
+ i18n.t("model", { value: config.model || i18n.t("currentModel") }),
631
+ i18n.t("minOutput", { value: config.minChars }),
632
+ i18n.t("summaryLimit", { value: config.maxChars }),
633
+ i18n.t("finalLimit", { value: config.maxOutputChars }),
634
+ i18n.t("timeout", { value: config.timeoutSeconds }),
635
+ i18n.t("threshold", { value: config.missedCompressionRatio }),
636
+ i18n.t("summarizeErrors", { value: config.summarizeErrors ? i18n.t("on") : i18n.t("off") }),
637
+ i18n.t("auditRenderer", { value: config.render.enabled ? i18n.t("on") : i18n.t("off") }),
638
+ i18n.t("showPrompt", { value: config.render.showPrompt ? i18n.t("on") : i18n.t("off") }),
639
+ i18n.t("showSummary", { value: config.render.showResult ? i18n.t("on") : i18n.t("off") }),
640
+ i18n.t("saveExit"),
641
+ i18n.t("discard"),
642
+ ];
643
+ const choice = await ctx.ui.select(i18n.t("settingsTitle"), choices);
644
+ if (choice === undefined || choice === i18n.t("discard")) return;
645
+
646
+ if (choice === choices[0]) {
647
+ config.enabled = !config.enabled;
648
+ } else if (choice === choices[1]) {
649
+ const value = await editDistillModel(ctx, config.model);
650
+ if (value !== undefined) config.model = value;
651
+ } else if (choice === choices[2]) {
652
+ const value = await editDistillNumber(ctx, i18n.t("minOutputTitle"), config.minChars);
653
+ if (value !== undefined) config.minChars = value;
654
+ } else if (choice === choices[3]) {
655
+ const value = await editDistillNumber(ctx, i18n.t("summaryLimitTitle"), config.maxChars);
656
+ if (value !== undefined) config.maxChars = value;
657
+ } else if (choice === choices[4]) {
658
+ const value = await editDistillNumber(ctx, i18n.t("finalLimitTitle"), config.maxOutputChars);
659
+ if (value !== undefined) config.maxOutputChars = value;
660
+ } else if (choice === choices[5]) {
661
+ const value = await editDistillNumber(ctx, i18n.t("timeoutTitle"), config.timeoutSeconds);
662
+ if (value !== undefined) config.timeoutSeconds = value;
663
+ } else if (choice === choices[6]) {
664
+ const value = await editDistillNumber(ctx, i18n.t("thresholdTitle"), config.missedCompressionRatio);
665
+ if (value !== undefined) config.missedCompressionRatio = value;
666
+ } else if (choice === choices[7]) {
667
+ config.summarizeErrors = !config.summarizeErrors;
668
+ } else if (choice === choices[8]) {
669
+ config.render.enabled = !config.render.enabled;
670
+ } else if (choice === choices[9]) {
671
+ config.render.showPrompt = !config.render.showPrompt;
672
+ } else if (choice === choices[10]) {
673
+ config.render.showResult = !config.render.showResult;
674
+ } else if (choice === choices[11]) {
675
+ await mkdir(dirname(configPath), { recursive: true });
676
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
677
+ const saved = loadDistillConfig();
678
+ if (saved.warnings.length > 0) {
679
+ ctx.ui.notify(i18n.t("savedWarnings", { warnings: saved.warnings.join(" ") }), "warning");
680
+ } else {
681
+ ctx.ui.notify(i18n.t("saved"), "info");
682
+ }
683
+ return;
684
+ }
685
+ }
686
+ }
687
+
688
+ function registerDistillConfigCommand(pi: ExtensionAPI): void {
689
+ pi.registerCommand("pi-distill", {
690
+ description: i18n.t("commandDescription"),
691
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
692
+ if (!ctx.hasUI) {
693
+ ctx.ui.notify(i18n.t("interactiveOnly"), "warning");
694
+ return;
695
+ }
696
+ await runDistillConfigUi(ctx, getDistillConfigPath());
697
+ },
698
+ });
699
+ }
700
+
701
+ export default function piDistillExtension(pi: ExtensionAPI) {
702
+ const pendingCalls = new Map<string, PendingDistillCall>();
703
+ let originalUserPrompt = "";
704
+ const disposeToolDisplayMiddleware = registerDistillToolDisplayMiddleware();
705
+ registerDistillFallbackRenderer(pi);
706
+ const extendParameters = () => {
707
+ try {
708
+ extendDistillToolParameters(pi);
709
+ } catch (error) {
710
+ console.warn(`[pi-distill] Failed to extend the outputPrompt parameter: ${error instanceof Error ? error.message : String(error)}`);
711
+ }
712
+ };
713
+
714
+ pi.on("session_start", extendParameters);
715
+ pi.on("before_agent_start", (event) => {
716
+ originalUserPrompt = typeof event.prompt === "string" ? event.prompt : "";
717
+ extendParameters();
718
+ });
719
+ pi.on("tool_call", (event) => {
720
+ if (!isDistillToolName(event.toolName)) return;
721
+ pendingCalls.set(event.toolCallId, {
722
+ outputPrompt: getOutputPrompt(event.input),
723
+ originalUserPrompt,
724
+ startedAt: performance.now(),
725
+ });
726
+ // outputPrompt 只控制结果处理,不能泄漏给底层内置工具。
727
+ delete (event.input as Record<string, unknown>).outputPrompt;
728
+ });
729
+ pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
730
+ if (!isDistillToolName(event.toolName)) return;
731
+ const pending = pendingCalls.get(event.toolCallId);
732
+ pendingCalls.delete(event.toolCallId);
733
+ const outputPrompt = pending?.outputPrompt ?? getOutputPrompt(event.input);
734
+ const result = await processToolResult(
735
+ {
736
+ toolName: event.toolName,
737
+ toolCallId: event.toolCallId,
738
+ params: { ...event.input, outputPrompt },
739
+ originalUserPrompt: pending?.originalUserPrompt ?? originalUserPrompt,
740
+ ctx,
741
+ },
742
+ {
743
+ content: event.content,
744
+ details: event.details as Record<string, unknown> | undefined,
745
+ isError: event.isError,
746
+ },
747
+ pending ? Math.round(performance.now() - pending.startedAt) : 0,
748
+ );
749
+ if (!isDistillToolDisplayMiddlewareActive(event.toolName)) {
750
+ appendDistillFallbackAudit(pi, event.toolName, result.details, loadDistillConfig().render);
751
+ }
752
+ return toToolResultEventResult(result);
753
+ });
754
+ pi.on("agent_end", () => pendingCalls.clear());
755
+ pi.on("session_shutdown", () => disposeToolDisplayMiddleware());
756
+ registerDistillConfigCommand(pi);
757
+ }