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 +21 -0
- package/README.md +83 -0
- package/index.ts +64 -0
- package/package.json +49 -0
- package/scripts/rescue-session.mjs +101 -0
- package/src/diet.ts +325 -0
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
|
+
}
|