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/README.md +68 -0
- package/config.example.json +15 -0
- package/index.ts +1 -0
- package/locales/fallback-renderer.json +74 -0
- package/locales/index.json +130 -0
- package/locales/summary-utils.json +34 -0
- package/package.json +60 -0
- package/src/fallback-renderer.ts +279 -0
- package/src/index.ts +757 -0
- package/src/output-limit.ts +64 -0
- package/src/summary-utils.ts +414 -0
- package/src/tool-display-bridge.ts +125 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type OutputLimitToolResult = {
|
|
6
|
+
content: Array<{ type?: string; text?: string }>;
|
|
7
|
+
details?: {
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function getTextContent(result: OutputLimitToolResult): string {
|
|
13
|
+
return result.content
|
|
14
|
+
.filter((content) => content.type === "text" && typeof content.text === "string")
|
|
15
|
+
.map((content) => content.text ?? "")
|
|
16
|
+
.join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function writeSummaryFile(summary: string): Promise<string> {
|
|
20
|
+
const directory = join(tmpdir(), "pi-distill");
|
|
21
|
+
await mkdir(directory, { recursive: true });
|
|
22
|
+
const filePath = join(
|
|
23
|
+
directory,
|
|
24
|
+
`summary-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
|
|
25
|
+
);
|
|
26
|
+
await writeFile(filePath, summary, "utf8");
|
|
27
|
+
return filePath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function limitReturnedToolResult(
|
|
31
|
+
result: OutputLimitToolResult,
|
|
32
|
+
maxChars: number,
|
|
33
|
+
): Promise<OutputLimitToolResult> {
|
|
34
|
+
const text = getTextContent(result);
|
|
35
|
+
if (text.length <= maxChars) return result;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const filePath = await writeSummaryFile(text);
|
|
39
|
+
const pointer = `Output exceeded ${maxChars} chars and was written to: ${filePath}`;
|
|
40
|
+
return {
|
|
41
|
+
...result,
|
|
42
|
+
content: [{ type: "text", text: pointer.slice(0, maxChars) }],
|
|
43
|
+
details: {
|
|
44
|
+
...(result.details ?? {}),
|
|
45
|
+
fullOutputPath: filePath,
|
|
46
|
+
outputTruncated: true,
|
|
47
|
+
outputLimitChars: maxChars,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
console.warn(`[pi-distill] Failed to write oversized output to a temp file; returning a truncated result: ${message}`);
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
content: [{ type: "text", text: text.slice(0, maxChars) }],
|
|
56
|
+
details: {
|
|
57
|
+
...(result.details ?? {}),
|
|
58
|
+
outputTruncated: true,
|
|
59
|
+
outputLimitChars: maxChars,
|
|
60
|
+
outputFileError: message,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } 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/summary-utils.json", import.meta.url)));
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MIN_CHARS = 200;
|
|
9
|
+
const DEFAULT_MAX_CHARS = 100_000;
|
|
10
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 10_000;
|
|
11
|
+
const DEFAULT_TIMEOUT_SECONDS = 10;
|
|
12
|
+
const DEFAULT_MISSED_COMPRESSION_RATIO = 10;
|
|
13
|
+
const DEFAULT_SUMMARIZE_ERRORS = true;
|
|
14
|
+
const DEFAULT_RENDER_ENABLED = true;
|
|
15
|
+
const DEFAULT_RENDER_PROMPT = true;
|
|
16
|
+
const DEFAULT_RENDER_RESULT = true;
|
|
17
|
+
const CONFIG_DIRECTORY = "pi-distill";
|
|
18
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
19
|
+
|
|
20
|
+
export interface BashSummaryConfig {
|
|
21
|
+
/** 未配置时使用当前会话模型。 */
|
|
22
|
+
modelProvider?: string;
|
|
23
|
+
modelId?: string;
|
|
24
|
+
/** 输出达到此字符数后才调用提炼模型。 */
|
|
25
|
+
minChars: number;
|
|
26
|
+
/** 提炼结果达到此字符数后写入文件。 */
|
|
27
|
+
maxChars: number;
|
|
28
|
+
/** 最终返回给 Agent 的内容达到此字符数后写入文件。 */
|
|
29
|
+
maxOutputChars: number;
|
|
30
|
+
/** 模型调用最长等待时间。 */
|
|
31
|
+
timeoutSeconds: number;
|
|
32
|
+
/** 无 prompt 的长输出触发 missed-compression 提醒所需的倍数。 */
|
|
33
|
+
missedCompressionRatio: number;
|
|
34
|
+
/** 工具返回错误结果时是否仍调用提炼模型。 */
|
|
35
|
+
summarizeErrors: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type DistillConfig = BashSummaryConfig;
|
|
39
|
+
|
|
40
|
+
export interface DistillRenderConfig {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
showPrompt: boolean;
|
|
43
|
+
showResult: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DistillConfigFile {
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
/** provider/model;为空时使用当前会话模型。 */
|
|
49
|
+
model?: string;
|
|
50
|
+
minChars?: number;
|
|
51
|
+
maxChars?: number;
|
|
52
|
+
maxOutputChars?: number;
|
|
53
|
+
timeoutSeconds?: number;
|
|
54
|
+
missedCompressionRatio?: number;
|
|
55
|
+
summarizeErrors?: boolean;
|
|
56
|
+
render?: Partial<DistillRenderConfig>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DistillConfigLoadResult {
|
|
60
|
+
config?: BashSummaryConfig;
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
render: DistillRenderConfig;
|
|
63
|
+
configPath: string;
|
|
64
|
+
warnings: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolvePiAgentDir(
|
|
68
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
69
|
+
homeDirectory = homedir(),
|
|
70
|
+
): string {
|
|
71
|
+
const configuredDir = env.PI_CODING_AGENT_DIR;
|
|
72
|
+
if (!configuredDir) return join(homeDirectory, ".pi", "agent");
|
|
73
|
+
if (configuredDir === "~") return homeDirectory;
|
|
74
|
+
if (configuredDir.startsWith("~/") || configuredDir.startsWith("~\\")) {
|
|
75
|
+
return join(homeDirectory, configuredDir.slice(2));
|
|
76
|
+
}
|
|
77
|
+
return configuredDir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getDistillConfigPath(
|
|
81
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
82
|
+
): string {
|
|
83
|
+
return join(resolvePiAgentDir(env), "extensions", CONFIG_DIRECTORY, CONFIG_FILE_NAME);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 解析环境变量配置。保留此函数作为旧调用方的兼容 API;配置文件优先级由
|
|
88
|
+
* loadDistillConfig() 负责处理。
|
|
89
|
+
*/
|
|
90
|
+
export function parseBashSummaryConfig(
|
|
91
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
92
|
+
): BashSummaryConfig | undefined {
|
|
93
|
+
const modelRef = (env.PI_DISTILL_MODEL ?? env.PI_BASH_SUMMARY_MODEL)?.trim();
|
|
94
|
+
const minCharsValue = (env.PI_DISTILL_MIN_CHARS ?? env.PI_BASH_SUMMARY_MIN_CHARS)?.trim();
|
|
95
|
+
const maxCharsValue = (env.PI_DISTILL_MAX_CHARS ?? env.PI_BASH_SUMMARY_MAX_CHARS)?.trim();
|
|
96
|
+
const maxOutputCharsValue = (
|
|
97
|
+
env.PI_DISTILL_MAX_OUTPUT_CHARS ?? env.PI_BASH_SUMMARY_MAX_OUTPUT_CHARS
|
|
98
|
+
)?.trim();
|
|
99
|
+
const timeoutSecondsValue = (
|
|
100
|
+
env.PI_DISTILL_TIMEOUT_SECONDS ?? env.PI_BASH_SUMMARY_TIMEOUT_SECONDS
|
|
101
|
+
)?.trim();
|
|
102
|
+
const missedCompressionRatioValue = (
|
|
103
|
+
env.PI_DISTILL_MISSED_COMPRESSION_RATIO ?? env.PI_BASH_SUMMARY_MISSED_COMPRESSION_RATIO
|
|
104
|
+
)?.trim();
|
|
105
|
+
const summarizeErrorsValue = (
|
|
106
|
+
env.PI_DISTILL_SUMMARIZE_ERRORS ?? env.PI_BASH_SUMMARY_SUMMARIZE_ERRORS
|
|
107
|
+
)?.trim();
|
|
108
|
+
const minChars = minCharsValue
|
|
109
|
+
? parsePositiveInteger(minCharsValue)
|
|
110
|
+
: DEFAULT_MIN_CHARS;
|
|
111
|
+
const maxChars = maxCharsValue
|
|
112
|
+
? parsePositiveInteger(maxCharsValue)
|
|
113
|
+
: DEFAULT_MAX_CHARS;
|
|
114
|
+
const maxOutputChars = maxOutputCharsValue
|
|
115
|
+
? parsePositiveInteger(maxOutputCharsValue)
|
|
116
|
+
: DEFAULT_MAX_OUTPUT_CHARS;
|
|
117
|
+
const timeoutSeconds = timeoutSecondsValue
|
|
118
|
+
? parsePositiveInteger(timeoutSecondsValue)
|
|
119
|
+
: DEFAULT_TIMEOUT_SECONDS;
|
|
120
|
+
const missedCompressionRatio = missedCompressionRatioValue
|
|
121
|
+
? parsePositiveNumber(missedCompressionRatioValue)
|
|
122
|
+
: DEFAULT_MISSED_COMPRESSION_RATIO;
|
|
123
|
+
const summarizeErrors = summarizeErrorsValue
|
|
124
|
+
? parseBoolean(summarizeErrorsValue)
|
|
125
|
+
: DEFAULT_SUMMARIZE_ERRORS;
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
minChars === undefined ||
|
|
129
|
+
maxChars === undefined ||
|
|
130
|
+
timeoutSeconds === undefined ||
|
|
131
|
+
maxOutputChars === undefined ||
|
|
132
|
+
missedCompressionRatio === undefined ||
|
|
133
|
+
summarizeErrors === undefined
|
|
134
|
+
) {
|
|
135
|
+
console.warn(
|
|
136
|
+
"[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).",
|
|
137
|
+
);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!modelRef) {
|
|
142
|
+
return {
|
|
143
|
+
minChars,
|
|
144
|
+
maxChars,
|
|
145
|
+
maxOutputChars,
|
|
146
|
+
timeoutSeconds,
|
|
147
|
+
missedCompressionRatio,
|
|
148
|
+
summarizeErrors,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const separator = modelRef.indexOf("/");
|
|
153
|
+
if (separator <= 0 || separator === modelRef.length - 1) {
|
|
154
|
+
console.warn(
|
|
155
|
+
`[pi-distill] Invalid PI_DISTILL_MODEL; expected provider/model, got: ${modelRef}`,
|
|
156
|
+
);
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
modelProvider: modelRef.slice(0, separator),
|
|
162
|
+
modelId: modelRef.slice(separator + 1),
|
|
163
|
+
minChars,
|
|
164
|
+
maxChars,
|
|
165
|
+
maxOutputChars,
|
|
166
|
+
timeoutSeconds,
|
|
167
|
+
missedCompressionRatio,
|
|
168
|
+
summarizeErrors,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
173
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parsePositiveInteger(value: string | undefined): number | undefined {
|
|
177
|
+
if (!value || !/^\d+$/.test(value)) return undefined;
|
|
178
|
+
const parsed = Number(value);
|
|
179
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parsePositiveNumber(value: string): number | undefined {
|
|
183
|
+
if (!/^\d+(?:\.\d+)?$/.test(value)) return undefined;
|
|
184
|
+
const parsed = Number(value);
|
|
185
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseBoolean(value: string): boolean | undefined {
|
|
189
|
+
if (value === "true" || value === "1") return true;
|
|
190
|
+
if (value === "false" || value === "0") return false;
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseRenderConfig(
|
|
195
|
+
file: Record<string, unknown> | undefined,
|
|
196
|
+
warnings: string[],
|
|
197
|
+
): DistillRenderConfig {
|
|
198
|
+
const render: DistillRenderConfig = {
|
|
199
|
+
enabled: DEFAULT_RENDER_ENABLED,
|
|
200
|
+
showPrompt: DEFAULT_RENDER_PROMPT,
|
|
201
|
+
showResult: DEFAULT_RENDER_RESULT,
|
|
202
|
+
};
|
|
203
|
+
if (!file || !("render" in file)) return render;
|
|
204
|
+
if (!isRecord(file.render)) {
|
|
205
|
+
warnings.push("Config field render must be an object.");
|
|
206
|
+
return render;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const key of ["enabled", "showPrompt", "showResult"] as const) {
|
|
210
|
+
if (!(key in file.render)) continue;
|
|
211
|
+
const value = file.render[key];
|
|
212
|
+
if (typeof value === "boolean") render[key] = value;
|
|
213
|
+
else warnings.push(`Config field render.${key} must be boolean.`);
|
|
214
|
+
}
|
|
215
|
+
return render;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function appendFileValueToEnv(
|
|
219
|
+
env: NodeJS.ProcessEnv,
|
|
220
|
+
file: Record<string, unknown>,
|
|
221
|
+
key: keyof DistillConfigFile,
|
|
222
|
+
envKey: string,
|
|
223
|
+
warnings: string[],
|
|
224
|
+
): void {
|
|
225
|
+
if (!(key in file)) return;
|
|
226
|
+
const value = file[key];
|
|
227
|
+
if (key === "model") {
|
|
228
|
+
if (value === undefined || value === null || value === "") {
|
|
229
|
+
env[envKey] = "";
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
233
|
+
warnings.push(`Config field ${key} must be a provider/model string.`);
|
|
234
|
+
env[envKey] = "__invalid_file_value__";
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
env[envKey] = value.trim();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (key === "summarizeErrors") {
|
|
242
|
+
if (typeof value !== "boolean") {
|
|
243
|
+
warnings.push(`Config field ${key} must be boolean.`);
|
|
244
|
+
env[envKey] = "__invalid_file_value__";
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
env[envKey] = String(value);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
252
|
+
warnings.push(`Config field ${key} must be a positive number.`);
|
|
253
|
+
env[envKey] = "__invalid_file_value__";
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
env[envKey] = String(value);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 读取 pi-distill 配置。配置文件字段优先于新旧环境变量;未在文件中声明的字段
|
|
261
|
+
* 回退到 PI_DISTILL_*、旧 PI_BASH_SUMMARY_*,再回退到默认值。
|
|
262
|
+
*/
|
|
263
|
+
export function loadDistillConfig(
|
|
264
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
265
|
+
configFile = getDistillConfigPath(env),
|
|
266
|
+
): DistillConfigLoadResult {
|
|
267
|
+
const warnings: string[] = [];
|
|
268
|
+
let enabled = true;
|
|
269
|
+
let file: Record<string, unknown> | undefined;
|
|
270
|
+
|
|
271
|
+
if (existsSync(configFile)) {
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(readFileSync(configFile, "utf8")) as unknown;
|
|
274
|
+
if (!isRecord(parsed)) {
|
|
275
|
+
warnings.push(`Distill config must be a JSON object: ${configFile}`);
|
|
276
|
+
} else {
|
|
277
|
+
file = parsed;
|
|
278
|
+
if ("enabled" in parsed) {
|
|
279
|
+
if (typeof parsed.enabled === "boolean") enabled = parsed.enabled;
|
|
280
|
+
else warnings.push("Config field enabled must be boolean.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
warnings.push(`Could not parse Distill config ${configFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const effectiveEnv = { ...env };
|
|
289
|
+
if (file) {
|
|
290
|
+
appendFileValueToEnv(effectiveEnv, file, "model", "PI_DISTILL_MODEL", warnings);
|
|
291
|
+
appendFileValueToEnv(effectiveEnv, file, "minChars", "PI_DISTILL_MIN_CHARS", warnings);
|
|
292
|
+
appendFileValueToEnv(effectiveEnv, file, "maxChars", "PI_DISTILL_MAX_CHARS", warnings);
|
|
293
|
+
appendFileValueToEnv(effectiveEnv, file, "maxOutputChars", "PI_DISTILL_MAX_OUTPUT_CHARS", warnings);
|
|
294
|
+
appendFileValueToEnv(effectiveEnv, file, "timeoutSeconds", "PI_DISTILL_TIMEOUT_SECONDS", warnings);
|
|
295
|
+
appendFileValueToEnv(
|
|
296
|
+
effectiveEnv,
|
|
297
|
+
file,
|
|
298
|
+
"missedCompressionRatio",
|
|
299
|
+
"PI_DISTILL_MISSED_COMPRESSION_RATIO",
|
|
300
|
+
warnings,
|
|
301
|
+
);
|
|
302
|
+
appendFileValueToEnv(
|
|
303
|
+
effectiveEnv,
|
|
304
|
+
file,
|
|
305
|
+
"summarizeErrors",
|
|
306
|
+
"PI_DISTILL_SUMMARIZE_ERRORS",
|
|
307
|
+
warnings,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const config = parseBashSummaryConfig(effectiveEnv);
|
|
312
|
+
const render = parseRenderConfig(file, warnings);
|
|
313
|
+
if (!config && warnings.length === 0) {
|
|
314
|
+
warnings.push("Distill config is invalid; output distillation is disabled.");
|
|
315
|
+
}
|
|
316
|
+
return { config, enabled, render, configPath: configFile, warnings };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function defaultDistillConfigFile(): DistillConfigFile {
|
|
320
|
+
return {
|
|
321
|
+
enabled: true,
|
|
322
|
+
model: "",
|
|
323
|
+
minChars: DEFAULT_MIN_CHARS,
|
|
324
|
+
maxChars: DEFAULT_MAX_CHARS,
|
|
325
|
+
maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS,
|
|
326
|
+
timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
|
|
327
|
+
missedCompressionRatio: DEFAULT_MISSED_COMPRESSION_RATIO,
|
|
328
|
+
summarizeErrors: DEFAULT_SUMMARIZE_ERRORS,
|
|
329
|
+
render: {
|
|
330
|
+
enabled: DEFAULT_RENDER_ENABLED,
|
|
331
|
+
showPrompt: DEFAULT_RENDER_PROMPT,
|
|
332
|
+
showResult: DEFAULT_RENDER_RESULT,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export type OutputSummaryIntent = "none" | "full" | "summary";
|
|
338
|
+
|
|
339
|
+
export type OutputSummaryDecision = {
|
|
340
|
+
intent: OutputSummaryIntent;
|
|
341
|
+
shouldSummarize: boolean;
|
|
342
|
+
reason: "disabled" | "not-requested" | "full-output" | "below-threshold" | "explicit-summary" | "error-output";
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
export function classifyOutputSummaryIntent(prompt: string | undefined): OutputSummaryIntent {
|
|
346
|
+
const normalizedPrompt = prompt?.trim() ?? "";
|
|
347
|
+
if (!normalizedPrompt) return "none";
|
|
348
|
+
if (/^RAW$/i.test(normalizedPrompt)) return "full";
|
|
349
|
+
return "summary";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 总结模型的保留原文哨兵,只接受不带其他内容的 RAW。 */
|
|
353
|
+
export function isRawSummary(text: string | undefined): boolean {
|
|
354
|
+
return typeof text === "string" && /^RAW$/i.test(text.trim());
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function decideOutputSummary(
|
|
358
|
+
prompt: string | undefined,
|
|
359
|
+
output: string,
|
|
360
|
+
config: BashSummaryConfig | undefined,
|
|
361
|
+
isError = false,
|
|
362
|
+
): OutputSummaryDecision {
|
|
363
|
+
const intent = classifyOutputSummaryIntent(prompt);
|
|
364
|
+
if (!config) return { intent, shouldSummarize: false, reason: "disabled" };
|
|
365
|
+
if (intent === "none") return { intent, shouldSummarize: false, reason: "not-requested" };
|
|
366
|
+
if (intent === "full") return { intent, shouldSummarize: false, reason: "full-output" };
|
|
367
|
+
if (isError && config.summarizeErrors) {
|
|
368
|
+
return { intent, shouldSummarize: true, reason: "error-output" };
|
|
369
|
+
}
|
|
370
|
+
if (output.length < config.minChars) {
|
|
371
|
+
return { intent, shouldSummarize: false, reason: "below-threshold" };
|
|
372
|
+
}
|
|
373
|
+
return { intent, shouldSummarize: true, reason: "explicit-summary" };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function shouldSummarizeOutput(
|
|
377
|
+
prompt: string | undefined,
|
|
378
|
+
output: string,
|
|
379
|
+
config: BashSummaryConfig | undefined,
|
|
380
|
+
isError = false,
|
|
381
|
+
): boolean {
|
|
382
|
+
return decideOutputSummary(prompt, output, config, isError).shouldSummarize;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function buildSummaryPrompt(
|
|
386
|
+
prompt: string,
|
|
387
|
+
output: string,
|
|
388
|
+
originalUserPrompt?: string,
|
|
389
|
+
): string {
|
|
390
|
+
const languageContext = originalUserPrompt?.trim()
|
|
391
|
+
? [
|
|
392
|
+
i18n.t("languageContext"),
|
|
393
|
+
"<user-language-context>",
|
|
394
|
+
originalUserPrompt.trim(),
|
|
395
|
+
"</user-language-context>",
|
|
396
|
+
]
|
|
397
|
+
: [];
|
|
398
|
+
return [
|
|
399
|
+
i18n.t("system"),
|
|
400
|
+
i18n.t("data"),
|
|
401
|
+
i18n.t("preserve"),
|
|
402
|
+
i18n.t("languageMatch"),
|
|
403
|
+
i18n.t("exactRaw"),
|
|
404
|
+
i18n.t("onlyResult"),
|
|
405
|
+
"",
|
|
406
|
+
i18n.t("request"),
|
|
407
|
+
prompt,
|
|
408
|
+
...(languageContext.length > 0 ? ["", ...languageContext] : []),
|
|
409
|
+
"",
|
|
410
|
+
"<tool-output>",
|
|
411
|
+
output,
|
|
412
|
+
"</tool-output>",
|
|
413
|
+
].join("\n");
|
|
414
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Container, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { buildDistillAuditLines, createDistillAuditComponent, resolveDistillRenderConfig } from "./fallback-renderer.ts";
|
|
3
|
+
import { loadDistillConfig } from "./summary-utils.ts";
|
|
4
|
+
|
|
5
|
+
const TOOL_DISPLAY_API_KEY = Symbol.for("pi-tool-display.api.v1");
|
|
6
|
+
const PENDING_MIDDLEWARES_KEY = Symbol.for("pi-tool-display.pendingResultRenderMiddlewares.v1");
|
|
7
|
+
const DISTILL_MIDDLEWARE_ID = "pi-distill.result-renderer.v1";
|
|
8
|
+
const SUPPORTED_TOOLS = new Set(["bash", "read", "grep", "find"]);
|
|
9
|
+
|
|
10
|
+
type RenderTheme = {
|
|
11
|
+
fg(color: string, text: string): string;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type MiddlewareContext = {
|
|
16
|
+
toolName: string;
|
|
17
|
+
result: unknown;
|
|
18
|
+
options: { expanded?: boolean };
|
|
19
|
+
theme: RenderTheme;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ResultMiddleware = (context: MiddlewareContext, next: () => unknown) => unknown;
|
|
23
|
+
|
|
24
|
+
type MiddlewareRegistration = {
|
|
25
|
+
id: string;
|
|
26
|
+
toolName: string;
|
|
27
|
+
middleware: ResultMiddleware;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ToolDisplayApi = {
|
|
31
|
+
registerResultRenderMiddleware?(registration: MiddlewareRegistration): string;
|
|
32
|
+
unregisterResultRenderMiddleware?(id: string): boolean;
|
|
33
|
+
hasResultRenderMiddleware?(id: string): boolean;
|
|
34
|
+
isResultRenderPipelineActive?(toolName: string): boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type GlobalProtocol = typeof globalThis & {
|
|
38
|
+
[TOOL_DISPLAY_API_KEY]?: ToolDisplayApi;
|
|
39
|
+
[PENDING_MIDDLEWARES_KEY]?: MiddlewareRegistration[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function getApi(): ToolDisplayApi | undefined {
|
|
43
|
+
return (globalThis as GlobalProtocol)[TOOL_DISPLAY_API_KEY];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getDetails(result: unknown): Record<string, unknown> | undefined {
|
|
47
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
|
|
48
|
+
const details = (result as Record<string, unknown>).details;
|
|
49
|
+
return details && typeof details === "object" && !Array.isArray(details)
|
|
50
|
+
? details as Record<string, unknown>
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asComponent(value: unknown): Component | undefined {
|
|
55
|
+
return value && typeof value === "object" && typeof (value as Component).render === "function"
|
|
56
|
+
? value as Component
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const distillMiddleware: ResultMiddleware = (context, next) => {
|
|
61
|
+
if (!SUPPORTED_TOOLS.has(context.toolName)) return next();
|
|
62
|
+
const details = getDetails(context.result);
|
|
63
|
+
if (!details) return next();
|
|
64
|
+
const render = resolveDistillRenderConfig(details, loadDistillConfig().render);
|
|
65
|
+
const audit = buildDistillAuditLines(
|
|
66
|
+
context.toolName,
|
|
67
|
+
details,
|
|
68
|
+
context.options.expanded === true,
|
|
69
|
+
render,
|
|
70
|
+
);
|
|
71
|
+
if (!audit) return next();
|
|
72
|
+
|
|
73
|
+
const panel = createDistillAuditComponent(audit, context.theme);
|
|
74
|
+
const summarized = details.outputSummaryStatus === "summarized"
|
|
75
|
+
&& render.showResult
|
|
76
|
+
&& typeof details.summaryText === "string"
|
|
77
|
+
&& details.summaryText.trim().length > 0;
|
|
78
|
+
if (summarized) return panel;
|
|
79
|
+
|
|
80
|
+
const base = asComponent(next());
|
|
81
|
+
if (!base) return panel;
|
|
82
|
+
const container = new Container();
|
|
83
|
+
container.addChild(base);
|
|
84
|
+
container.addChild(panel);
|
|
85
|
+
return container;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function queueRegistration(registration: MiddlewareRegistration): void {
|
|
89
|
+
const globalProtocol = globalThis as GlobalProtocol;
|
|
90
|
+
const queue = Array.isArray(globalProtocol[PENDING_MIDDLEWARES_KEY])
|
|
91
|
+
? globalProtocol[PENDING_MIDDLEWARES_KEY]!
|
|
92
|
+
: [];
|
|
93
|
+
const index = queue.findIndex((entry) => entry?.id === registration.id);
|
|
94
|
+
if (index >= 0) queue[index] = registration;
|
|
95
|
+
else queue.push(registration);
|
|
96
|
+
globalProtocol[PENDING_MIDDLEWARES_KEY] = queue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function registerDistillToolDisplayMiddleware(): () => void {
|
|
100
|
+
const registration: MiddlewareRegistration = {
|
|
101
|
+
id: DISTILL_MIDDLEWARE_ID,
|
|
102
|
+
toolName: "*",
|
|
103
|
+
middleware: distillMiddleware,
|
|
104
|
+
};
|
|
105
|
+
const api = getApi();
|
|
106
|
+
if (typeof api?.registerResultRenderMiddleware === "function") {
|
|
107
|
+
api.registerResultRenderMiddleware(registration);
|
|
108
|
+
} else {
|
|
109
|
+
queueRegistration(registration);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
getApi()?.unregisterResultRenderMiddleware?.(DISTILL_MIDDLEWARE_ID);
|
|
114
|
+
const queue = (globalThis as GlobalProtocol)[PENDING_MIDDLEWARES_KEY];
|
|
115
|
+
if (!Array.isArray(queue)) return;
|
|
116
|
+
const index = queue.findIndex((entry) => entry?.id === DISTILL_MIDDLEWARE_ID);
|
|
117
|
+
if (index >= 0) queue.splice(index, 1);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isDistillToolDisplayMiddlewareActive(toolName: string): boolean {
|
|
122
|
+
const api = getApi();
|
|
123
|
+
return api?.hasResultRenderMiddleware?.(DISTILL_MIDDLEWARE_ID) === true
|
|
124
|
+
&& api.isResultRenderPipelineActive?.(toolName) === true;
|
|
125
|
+
}
|