pi-diet 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ProbabilityEngineer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # pi-diet
2
+
3
+ Compact oversized Pi tool results with transparent previews and spill files.
4
+
5
+ `pi-diet` keeps Pi sessions lean by filtering runaway tool outputs before they bloat model context and session JSONL files. Small results pass through unchanged. Large results are saved losslessly under `~/.pi/agent/pi-diet/spills/` and replaced with a compact preview, tail, omitted-size count, and spill path.
6
+
7
+ ## Install
8
+
9
+ From npm:
10
+
11
+ ```bash
12
+ pi install npm:pi-diet
13
+ ```
14
+
15
+ From GitHub:
16
+
17
+ ```bash
18
+ pi install git:github.com/ProbabilityEngineer/pi-diet
19
+ ```
20
+
21
+ For local development without installing:
22
+
23
+ ```bash
24
+ pi -e ./index.ts
25
+ ```
26
+
27
+ ## Status
28
+
29
+ Early MVP, but validated against a real Pi session for oversized bash output.
30
+
31
+ ## Behavior
32
+
33
+ - pass through small tool results unchanged
34
+ - compact oversized tool results automatically via the Pi `tool_result` hook
35
+ - spill full original output to disk before replacing model-visible content
36
+ - preserve transparent access to the full output via a spill-file path
37
+ - provide specialized previews for bash, read-image, noisy LSP JSON, and generic search-style output
38
+
39
+ ## Default thresholds
40
+
41
+ - `thresholdChars`: 64000
42
+ - `headChars`: 8000
43
+ - `tailChars`: 8000
44
+
45
+ ## Commands
46
+
47
+ - `/diet-pi status`
48
+ - `/diet-pi on`
49
+ - `/diet-pi off`
50
+
51
+ ## Example
52
+
53
+ Ask Pi to run a command with very large output. Instead of storing the whole result in model context, `pi-diet` will replace it with a compact marker and a spill path like:
54
+
55
+ ```text
56
+ [pi-diet: compacted oversized bash result]
57
+ Original size: 102955 chars
58
+ Full output: ~/.pi/agent/pi-diet/spills/...
59
+ ```
60
+
61
+ ## Rescue script
62
+
63
+ Rescue a single session file:
64
+
65
+ ```bash
66
+ node scripts/rescue-session.mjs ~/.pi/agent/sessions/.../session.jsonl --out /tmp/pi-diet-rescue
67
+ ```
68
+
69
+ Rescue every session file under a directory:
70
+
71
+ ```bash
72
+ node scripts/rescue-session.mjs ~/.pi/agent/sessions/some-project --out /tmp/pi-diet-rescue
73
+ ```
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ npm test
79
+ ```
80
+
81
+ ## Safety
82
+
83
+ `pi-diet` does not silently discard oversized tool results. It writes the full original result to a spill file first, then replaces the model-visible content with a compact preview.
package/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { ExtensionAPI, ToolResultEvent } from "@earendil-works/pi-coding-agent";
2
+ import { compactToolResult, DEFAULT_SETTINGS, type DietPiSettings, type ToolContentBlock } from "./src/diet.ts";
3
+
4
+ const STATUS_KEY = "pi-diet";
5
+
6
+ export default function dietPi(pi: ExtensionAPI) {
7
+ let settings: DietPiSettings = { ...DEFAULT_SETTINGS };
8
+
9
+ function statusText(): string {
10
+ return `pi-diet ${settings.enabled ? "on" : "off"} · threshold=${settings.thresholdChars} · head=${settings.headChars} · tail=${settings.tailChars}`;
11
+ }
12
+
13
+ function refreshStatus(ctx: { hasUI: boolean; ui: { setStatus: (key: string, text: string | undefined) => void } }) {
14
+ if (!ctx.hasUI) return;
15
+ ctx.ui.setStatus(STATUS_KEY, statusText());
16
+ }
17
+
18
+ pi.registerCommand("diet-pi", {
19
+ description: "Control pi-diet result compaction: status | on | off",
20
+ handler: async (args, ctx) => {
21
+ const action = args.trim().toLowerCase();
22
+ if (!action || action === "status") {
23
+ ctx.ui.notify(statusText(), "info");
24
+ refreshStatus(ctx);
25
+ return;
26
+ }
27
+ if (action === "on") {
28
+ settings = { ...settings, enabled: true };
29
+ ctx.ui.notify("pi-diet enabled", "info");
30
+ refreshStatus(ctx);
31
+ return;
32
+ }
33
+ if (action === "off") {
34
+ settings = { ...settings, enabled: false };
35
+ ctx.ui.notify("pi-diet disabled", "info");
36
+ refreshStatus(ctx);
37
+ return;
38
+ }
39
+ ctx.ui.notify("Usage: /diet-pi status|on|off", "warning");
40
+ },
41
+ });
42
+
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ refreshStatus(ctx);
45
+ });
46
+
47
+ pi.on("session_shutdown", async (_event, ctx) => {
48
+ if (!ctx.hasUI) return;
49
+ ctx.ui.setStatus(STATUS_KEY, undefined);
50
+ });
51
+
52
+ pi.on("tool_result", async (event: ToolResultEvent) => {
53
+ const patch = await compactToolResult({
54
+ toolName: event.toolName,
55
+ toolCallId: event.toolCallId,
56
+ input: event.input,
57
+ content: event.content as ToolContentBlock[],
58
+ details: event.details,
59
+ isError: event.isError,
60
+ settings,
61
+ });
62
+ return patch ?? undefined;
63
+ });
64
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "pi-diet",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Compact oversized Pi tool results with transparent previews and spill files.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "ProbabilityEngineer",
9
+ "homepage": "https://github.com/ProbabilityEngineer/pi-diet#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ProbabilityEngineer/pi-diet.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/ProbabilityEngineer/pi-diet/issues"
16
+ },
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi-extension",
20
+ "pi",
21
+ "tool-results",
22
+ "context",
23
+ "sessions"
24
+ ],
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "typebox": "*"
28
+ },
29
+ "devDependencies": {
30
+ "@earendil-works/pi-coding-agent": "^0.76.0",
31
+ "typescript": "^5.9.3",
32
+ "typebox": "latest"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./index.ts"
37
+ ]
38
+ },
39
+ "files": [
40
+ "index.ts",
41
+ "src",
42
+ "scripts",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "scripts": {
47
+ "test": "node --experimental-strip-types --test tests/*.test.ts"
48
+ }
49
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { compactToolResult, DEFAULT_SETTINGS } from "../src/diet.ts";
5
+
6
+ async function collectJsonlFiles(inputPath) {
7
+ const info = await stat(inputPath);
8
+ if (info.isFile()) return [inputPath];
9
+ if (!info.isDirectory()) return [];
10
+
11
+ const found = [];
12
+ async function walk(dir) {
13
+ const entries = await readdir(dir, { withFileTypes: true });
14
+ for (const entry of entries) {
15
+ const next = join(dir, entry.name);
16
+ if (entry.isDirectory()) await walk(next);
17
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) found.push(next);
18
+ }
19
+ }
20
+
21
+ await walk(inputPath);
22
+ return found.sort();
23
+ }
24
+
25
+ async function rescueFile(resolvedInput, outDir) {
26
+ const text = await readFile(resolvedInput, 'utf8');
27
+ const lines = text.split('\n');
28
+ let changed = 0;
29
+
30
+ const nextLines = await Promise.all(lines.map(async (line) => {
31
+ if (!line.trim()) return line;
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(line);
35
+ } catch {
36
+ return line;
37
+ }
38
+
39
+ const message = parsed?.message;
40
+ if (!message || message.role !== 'toolResult' || !Array.isArray(message.content)) {
41
+ return line;
42
+ }
43
+
44
+ const patch = await compactToolResult({
45
+ toolName: message.toolName ?? 'tool',
46
+ toolCallId: message.toolCallId ?? parsed.id ?? 'tool-call',
47
+ input: message.input ?? {},
48
+ content: message.content,
49
+ details: message.details,
50
+ isError: Boolean(message.isError),
51
+ settings: DEFAULT_SETTINGS,
52
+ });
53
+
54
+ if (!patch) return line;
55
+ changed += 1;
56
+ parsed.message = {
57
+ ...message,
58
+ content: patch.content,
59
+ details: patch.details,
60
+ isError: patch.isError ?? message.isError,
61
+ };
62
+ return JSON.stringify(parsed);
63
+ }));
64
+
65
+ await mkdir(outDir, { recursive: true });
66
+ const outPath = join(outDir, `${basename(resolvedInput, '.jsonl')}.rescued.jsonl`);
67
+ await writeFile(outPath, nextLines.join('\n'), 'utf8');
68
+ return { changed, outPath };
69
+ }
70
+
71
+ async function main() {
72
+ const [, , inputPath, ...args] = process.argv;
73
+ if (!inputPath) {
74
+ console.error('Usage: node scripts/rescue-session.mjs <session.jsonl|dir> [--out DIR]');
75
+ process.exit(1);
76
+ }
77
+
78
+ const outIndex = args.indexOf('--out');
79
+ const outRoot = outIndex >= 0 ? resolve(args[outIndex + 1]) : dirname(resolve(inputPath));
80
+ const resolvedInput = resolve(inputPath);
81
+ const files = await collectJsonlFiles(resolvedInput);
82
+ if (!files.length) {
83
+ console.error(`No .jsonl files found under ${resolvedInput}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ let totalChanged = 0;
88
+ for (const file of files) {
89
+ const relativeDir = files.length === 1 ? '' : dirname(relative(resolvedInput, file));
90
+ const outDir = join(outRoot, relativeDir);
91
+ const result = await rescueFile(file, outDir);
92
+ totalChanged += result.changed;
93
+ console.log(`file=${file} rescued=${result.changed} output=${result.outPath}`);
94
+ }
95
+ console.log(`summary files=${files.length} rescued=${totalChanged} out=${outRoot}`);
96
+ }
97
+
98
+ main().catch((error) => {
99
+ console.error(error instanceof Error ? error.message : String(error));
100
+ process.exit(1);
101
+ });
package/src/diet.ts ADDED
@@ -0,0 +1,325 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export type ToolContentBlock = {
7
+ type: string;
8
+ text?: string;
9
+ source?: { type?: string; mediaType?: string; data?: string };
10
+ [key: string]: unknown;
11
+ };
12
+
13
+ export type DietPiSettings = {
14
+ enabled: boolean;
15
+ thresholdChars: number;
16
+ headChars: number;
17
+ tailChars: number;
18
+ spillDir: string;
19
+ };
20
+
21
+ export type SpillRecord = {
22
+ toolName: string;
23
+ toolCallId: string;
24
+ input: unknown;
25
+ isError: boolean;
26
+ content: unknown;
27
+ details: unknown;
28
+ };
29
+
30
+ export type CompactionPatch = {
31
+ content: ToolContentBlock[];
32
+ details: unknown;
33
+ isError?: boolean;
34
+ };
35
+
36
+ export type AnalyzeResult = {
37
+ text: string;
38
+ charCount: number;
39
+ kind: "bash" | "image-read" | "lsp-json" | "searchish" | "generic";
40
+ metadataLines: string[];
41
+ previewText?: string;
42
+ };
43
+
44
+ export const DEFAULT_SETTINGS: DietPiSettings = {
45
+ enabled: true,
46
+ thresholdChars: 64_000,
47
+ headChars: 8_000,
48
+ tailChars: 8_000,
49
+ spillDir: join(resolveAgentDir(), "pi-diet", "spills"),
50
+ };
51
+
52
+ export function resolveAgentDir(): string {
53
+ try {
54
+ return getAgentDir();
55
+ } catch {
56
+ return join(homedir(), ".pi", "agent");
57
+ }
58
+ }
59
+
60
+ export function sanitizeForFileName(value: string): string {
61
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
62
+ }
63
+
64
+ export function timestampForFileName(date = new Date()): string {
65
+ return date.toISOString().replace(/[:]/g, "-").replace(/\.\d{3}Z$/, "Z");
66
+ }
67
+
68
+ export function renderContentBlocks(content: ToolContentBlock[]): string {
69
+ const lines: string[] = [];
70
+ for (const block of content) {
71
+ if (block.type === "text") {
72
+ lines.push(block.text ?? "");
73
+ continue;
74
+ }
75
+
76
+ if (block.type === "image") {
77
+ const mediaType = typeof block.source?.mediaType === "string" ? block.source.mediaType : "image/unknown";
78
+ const dataLength = typeof block.source?.data === "string" ? block.source.data.length : 0;
79
+ lines.push(`[image block ${mediaType}${dataLength ? ` data=${dataLength} chars` : ""}]`);
80
+ continue;
81
+ }
82
+
83
+ lines.push(`[${block.type} block] ${safeJson(block)}`);
84
+ }
85
+ return lines.join("\n");
86
+ }
87
+
88
+ export function safeJson(value: unknown): string {
89
+ try {
90
+ return JSON.stringify(value, null, 2);
91
+ } catch {
92
+ return String(value);
93
+ }
94
+ }
95
+
96
+ export function analyzeToolResult(toolName: string, content: ToolContentBlock[], details: unknown): AnalyzeResult {
97
+ const text = renderContentBlocks(content);
98
+ const detailsText = details === undefined ? "" : `\n\n--- details ---\n${safeJson(details)}`;
99
+ const fullText = `${text}${detailsText}`;
100
+
101
+ const lspPreview = looksLikeLspJson(fullText) ? extractLspSummary(fullText) : null;
102
+ if (lspPreview) {
103
+ return {
104
+ text: fullText,
105
+ charCount: fullText.length,
106
+ kind: "lsp-json",
107
+ metadataLines: [
108
+ `Approx locations/symbols: ${lspPreview.count}`,
109
+ ...(lspPreview.samples.length ? [`Sample locations: ${lspPreview.samples.join("; ")}`] : []),
110
+ ],
111
+ previewText: lspPreview.previewText,
112
+ };
113
+ }
114
+
115
+ if (toolName === "read" && hasImagePayload(content, text)) {
116
+ const imagePreview = extractImageSummary(content, fullText, details);
117
+ return {
118
+ text: fullText,
119
+ charCount: fullText.length,
120
+ kind: "image-read",
121
+ metadataLines: imagePreview.metadataLines,
122
+ previewText: imagePreview.previewText,
123
+ };
124
+ }
125
+
126
+ if (toolName === "bash") {
127
+ const bashPreview = extractBashSummary(fullText);
128
+ return {
129
+ text: fullText,
130
+ charCount: fullText.length,
131
+ kind: "bash",
132
+ metadataLines: bashPreview.metadataLines,
133
+ previewText: bashPreview.previewText,
134
+ };
135
+ }
136
+
137
+ if (toolName.includes("search") || toolName.includes("grep") || toolName.includes("references") || toolName.includes("symbols")) {
138
+ const searchPreview = extractSearchSummary(fullText);
139
+ return {
140
+ text: fullText,
141
+ charCount: fullText.length,
142
+ kind: "searchish",
143
+ metadataLines: searchPreview.metadataLines,
144
+ previewText: searchPreview.previewText,
145
+ };
146
+ }
147
+
148
+ return {
149
+ text: fullText,
150
+ charCount: fullText.length,
151
+ kind: "generic",
152
+ metadataLines: [],
153
+ };
154
+ }
155
+
156
+ export function buildPreview(analyze: AnalyzeResult, settings: DietPiSettings): string {
157
+ if (analyze.previewText) return analyze.previewText;
158
+
159
+ const head = analyze.text.slice(0, settings.headChars);
160
+ const tail = analyze.text.slice(-settings.tailChars);
161
+ if (analyze.text.length <= settings.headChars + settings.tailChars) return head;
162
+ return `--- head ---\n${head}\n\n--- tail ---\n${tail}`;
163
+ }
164
+
165
+ export function buildCompactedText(args: {
166
+ toolName: string;
167
+ toolCallId: string;
168
+ analyze: AnalyzeResult;
169
+ settings: DietPiSettings;
170
+ spillPath: string;
171
+ }): string {
172
+ const { toolName, toolCallId, analyze, settings, spillPath } = args;
173
+ const omitted = Math.max(0, analyze.charCount - Math.min(analyze.charCount, settings.headChars + settings.tailChars));
174
+ const metadata = analyze.metadataLines.length ? `${analyze.metadataLines.join("\n")}\n` : "";
175
+ return [
176
+ `[pi-diet: compacted oversized ${toolName} result]`,
177
+ `Tool call: ${toolCallId}`,
178
+ `Recognizer: ${analyze.kind}`,
179
+ `Original size: ${analyze.charCount} chars`,
180
+ `Preview: first ${settings.headChars} chars + last ${settings.tailChars} chars shown unless specialized`,
181
+ `Omitted: ${omitted} chars`,
182
+ `Full output: ${spillPath}`,
183
+ metadata.trimEnd(),
184
+ buildPreview(analyze, settings),
185
+ ].filter(Boolean).join("\n\n");
186
+ }
187
+
188
+ export async function writeSpillFile(record: SpillRecord, settings: DietPiSettings): Promise<string> {
189
+ const fileName = `${timestampForFileName()}-${sanitizeForFileName(record.toolName)}-${sanitizeForFileName(record.toolCallId)}.txt`;
190
+ const spillPath = join(settings.spillDir, fileName);
191
+ await mkdir(dirname(spillPath), { recursive: true });
192
+ await writeFile(spillPath, safeJson(record), "utf8");
193
+ return spillPath;
194
+ }
195
+
196
+ export async function compactToolResult(args: {
197
+ toolName: string;
198
+ toolCallId: string;
199
+ input: unknown;
200
+ content: ToolContentBlock[];
201
+ details: unknown;
202
+ isError: boolean;
203
+ settings: DietPiSettings;
204
+ }): Promise<CompactionPatch | null> {
205
+ const { toolName, toolCallId, input, content, details, isError, settings } = args;
206
+ if (!settings.enabled) return null;
207
+
208
+ const analyzed = analyzeToolResult(toolName, content, details);
209
+ if (analyzed.charCount <= settings.thresholdChars) return null;
210
+
211
+ const spillPath = await writeSpillFile({ toolName, toolCallId, input, content, details, isError }, settings);
212
+ const compactedText = buildCompactedText({ toolName, toolCallId, analyze: analyzed, settings, spillPath });
213
+
214
+ return {
215
+ content: [{ type: "text", text: compactedText }],
216
+ details: mergeDietDetails(details, {
217
+ compacted: true,
218
+ spillPath,
219
+ originalSizeChars: analyzed.charCount,
220
+ thresholdChars: settings.thresholdChars,
221
+ recognizer: analyzed.kind,
222
+ }),
223
+ isError,
224
+ };
225
+ }
226
+
227
+ export function mergeDietDetails(details: unknown, dietPi: Record<string, unknown>): unknown {
228
+ if (details && typeof details === "object" && !Array.isArray(details)) {
229
+ return { ...(details as Record<string, unknown>), dietPi };
230
+ }
231
+ return { originalDetails: details ?? null, dietPi };
232
+ }
233
+
234
+ export function countOccurrences(text: string, needle: string): number {
235
+ if (!needle) return 0;
236
+ let count = 0;
237
+ let index = 0;
238
+ while (true) {
239
+ index = text.indexOf(needle, index);
240
+ if (index === -1) return count;
241
+ count += 1;
242
+ index += needle.length;
243
+ }
244
+ }
245
+
246
+ export function looksLikeLspJson(text: string): boolean {
247
+ return countOccurrences(text, '"uri"') >= 3 && countOccurrences(text, '"range"') >= 3 && countOccurrences(text, '"line"') >= 3;
248
+ }
249
+
250
+ export function hasImagePayload(content: ToolContentBlock[], text: string): boolean {
251
+ if (content.some((block) => block.type === "image")) return true;
252
+ return /image\/(png|jpeg|jpg|gif|webp)/i.test(text) || /read image file/i.test(text);
253
+ }
254
+
255
+ export function extractLspSummary(text: string): { count: number; samples: string[]; previewText: string } | null {
256
+ const matches = Array.from(text.matchAll(/"uri"\s*:\s*"file:\/\/([^"\n]+)"[\s\S]{0,160}?"line"\s*:\s*(\d+)[\s\S]{0,80}?"character"\s*:\s*(\d+)/g));
257
+ if (!matches.length) return null;
258
+ const samples = matches.slice(0, 5).map((match) => `${basename(match[1])}:${Number(match[2]) + 1}:${Number(match[3]) + 1}`);
259
+ return {
260
+ count: countOccurrences(text, '"uri"'),
261
+ samples,
262
+ previewText: [
263
+ "--- compact LSP preview ---",
264
+ ...samples.map((sample, index) => `${index + 1}. ${sample}`),
265
+ matches.length > samples.length ? `... and more in spill file` : "",
266
+ ].filter(Boolean).join("\n"),
267
+ };
268
+ }
269
+
270
+ export function extractImageSummary(content: ToolContentBlock[], text: string, details: unknown): { metadataLines: string[]; previewText: string } {
271
+ const mediaTypes = content
272
+ .filter((block) => block.type === "image")
273
+ .map((block) => block.source?.mediaType)
274
+ .filter((value): value is string => typeof value === "string");
275
+ const metadataLines = [
276
+ ...(mediaTypes.length ? [`Image types: ${Array.from(new Set(mediaTypes)).join(", ")}`] : []),
277
+ ...(findDimensionHints(text).length ? [`Dimensions: ${findDimensionHints(text).join(", ")}`] : []),
278
+ ];
279
+ const detailsText = details === undefined ? "" : safeJson(details).slice(0, 500);
280
+ const textualHints = text.split(/\r?\n/).filter((line) => line.trim() && !/base64|^[A-Za-z0-9+/=]{60,}$/.test(line)).slice(0, 6);
281
+ return {
282
+ metadataLines,
283
+ previewText: [
284
+ "--- image read summary ---",
285
+ ...textualHints,
286
+ ...(detailsText ? ["", "--- details excerpt ---", detailsText] : []),
287
+ ].join("\n").trim(),
288
+ };
289
+ }
290
+
291
+ export function extractBashSummary(text: string): { metadataLines: string[]; previewText: string } {
292
+ const exitCode = text.match(/exit code:?\s*(\d+)/i)?.[1];
293
+ const stderrHint = text.match(/stderr:?\s*(.*)/i)?.[1];
294
+ const metadataLines = [
295
+ ...(exitCode ? [`Exit code: ${exitCode}`] : []),
296
+ ...(stderrHint ? [`stderr hint: ${stderrHint.slice(0, 160)}`] : []),
297
+ ];
298
+ const lines = text.split(/\r?\n/);
299
+ const head = lines.slice(0, 20).join("\n");
300
+ const tail = lines.slice(-20).join("\n");
301
+ return {
302
+ metadataLines,
303
+ previewText: `--- head ---\n${head}\n\n--- tail ---\n${tail}`,
304
+ };
305
+ }
306
+
307
+ export function extractSearchSummary(text: string): { metadataLines: string[]; previewText: string } {
308
+ const lines = text.split(/\r?\n/).filter((line) => line.trim());
309
+ const matches = lines.filter((line) => /:\d+(:\d+)?:/.test(line) || /file:\/\//.test(line));
310
+ const metadataLines = [
311
+ `Approx matches: ${matches.length || lines.length}`,
312
+ "Tip: refine the query if you need a smaller in-context result.",
313
+ ];
314
+ return {
315
+ metadataLines,
316
+ previewText: [
317
+ "--- first matches ---",
318
+ ...lines.slice(0, 20),
319
+ ].join("\n"),
320
+ };
321
+ }
322
+
323
+ export function findDimensionHints(text: string): string[] {
324
+ return Array.from(new Set(Array.from(text.matchAll(/\b(\d{2,5}x\d{2,5})\b/gi)).map((match) => match[1]))).slice(0, 5);
325
+ }