@leing2021/super-pi 0.14.2 → 0.16.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.
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { AssistantMessage } from "@mariozechner/pi-ai"
|
|
2
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
3
2
|
import { Type } from "@sinclair/typebox"
|
|
4
|
-
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"
|
|
5
3
|
import { createArtifactHelperTool, type ArtifactType } from "./tools/artifact-helper"
|
|
6
4
|
import { createAskUserQuestionTool } from "./tools/ask-user-question"
|
|
7
5
|
import { createSubagentTool } from "./tools/subagent"
|
|
@@ -15,6 +13,9 @@ import { createBrainstormDialogTool } from "./tools/brainstorm-dialog"
|
|
|
15
13
|
import { createPlanDiffTool } from "./tools/plan-diff"
|
|
16
14
|
import { createSessionHistoryTool } from "./tools/session-history"
|
|
17
15
|
import { createPatternExtractorTool } from "./tools/pattern-extractor"
|
|
16
|
+
import { filterBashOutput } from "./tools/bash-output-filter"
|
|
17
|
+
import { filterReadOutput } from "./tools/read-output-filter"
|
|
18
|
+
import { COMPACTION_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
|
|
18
19
|
|
|
19
20
|
const artifactHelperParams = Type.Object({
|
|
20
21
|
repoRoot: Type.String({ description: "Repository root where workflow artifacts should be created" }),
|
|
@@ -171,36 +172,6 @@ const patternExtractorParams = Type.Object({
|
|
|
171
172
|
categories: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()), { description: "Category name to keyword mapping" })),
|
|
172
173
|
})
|
|
173
174
|
|
|
174
|
-
// Session-scoped stats for custom footer
|
|
175
|
-
let statsEnabled = false
|
|
176
|
-
const toolCounts = new Map<string, number>()
|
|
177
|
-
let subagentCount = 0
|
|
178
|
-
let projectPath = ""
|
|
179
|
-
let footerTui: any = null
|
|
180
|
-
|
|
181
|
-
function renderStatsFooter(ctx: any, tui: any, theme: any, footerData: any, width: number): string[] {
|
|
182
|
-
const shortPath = projectPath
|
|
183
|
-
.replace(/^\/Users\/[^/]+/, "~")
|
|
184
|
-
.split("/")
|
|
185
|
-
.slice(-2)
|
|
186
|
-
.join("/")
|
|
187
|
-
|
|
188
|
-
const sorted = [...toolCounts.entries()].sort((a, b) => b[1] - a[1])
|
|
189
|
-
const total = sorted.reduce((sum, e) => sum + e[1], 0)
|
|
190
|
-
const top3 = sorted.slice(0, 3).map(([name, count]) => `${name}:${count}`).join(" ")
|
|
191
|
-
const toolsStr = total > 0 ? `tools(${total}) ${top3}` : ""
|
|
192
|
-
const subStr = subagentCount > 0 ? `sub:${subagentCount}` : ""
|
|
193
|
-
const branch = footerData.getGitBranch()
|
|
194
|
-
const branchStr = branch ? ` (${branch})` : ""
|
|
195
|
-
|
|
196
|
-
const left = theme.fg("dim", `${shortPath}${branchStr}`)
|
|
197
|
-
const rightParts = [subStr, toolsStr].filter(Boolean)
|
|
198
|
-
const right = rightParts.length > 0 ? theme.fg("dim", rightParts.join(" │ ")) : ""
|
|
199
|
-
|
|
200
|
-
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)))
|
|
201
|
-
return [truncateToWidth(left + pad + right, width)]
|
|
202
|
-
}
|
|
203
|
-
|
|
204
175
|
export default function ceCoreExtension(pi: ExtensionAPI) {
|
|
205
176
|
const artifactHelper = createArtifactHelperTool()
|
|
206
177
|
const askUserQuestion = createAskUserQuestionTool()
|
|
@@ -537,68 +508,90 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
|
|
|
537
508
|
},
|
|
538
509
|
})
|
|
539
510
|
|
|
540
|
-
//
|
|
541
|
-
pi.on("
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
511
|
+
// Bash output smart filter — reduces context waste from verbose command output
|
|
512
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
513
|
+
if (event.toolName !== "bash") return undefined
|
|
514
|
+
|
|
515
|
+
// Extract command from input
|
|
516
|
+
const command = (event.input as any)?.command ?? ""
|
|
517
|
+
if (!command) return undefined
|
|
518
|
+
|
|
519
|
+
// Extract text content from tool result
|
|
520
|
+
const textBlocks = (event.content as Array<any>)?.filter((b: any) => b.type === "text") ?? []
|
|
521
|
+
if (textBlocks.length === 0) return undefined
|
|
522
|
+
|
|
523
|
+
const output = textBlocks.map((b: any) => b.text).join("")
|
|
524
|
+
const fullOutputPath = (event.details as any)?.fullOutputPath
|
|
525
|
+
|
|
526
|
+
const result = filterBashOutput({
|
|
527
|
+
command,
|
|
528
|
+
output,
|
|
529
|
+
isError: event.isError ?? false,
|
|
530
|
+
fullOutputPath,
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
if (!result.filtered) return undefined
|
|
534
|
+
|
|
535
|
+
// Replace content with filtered version
|
|
536
|
+
return {
|
|
537
|
+
content: [{ type: "text", text: result.output }],
|
|
538
|
+
details: {
|
|
539
|
+
...event.details,
|
|
540
|
+
bashFilter: {
|
|
541
|
+
strategy: result.strategy,
|
|
542
|
+
originalBytes: result.originalBytes,
|
|
543
|
+
filteredBytes: result.filteredBytes,
|
|
544
|
+
},
|
|
545
|
+
},
|
|
547
546
|
}
|
|
548
|
-
if (footerTui) footerTui.requestRender()
|
|
549
547
|
})
|
|
550
548
|
|
|
551
|
-
//
|
|
552
|
-
pi.on("
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
549
|
+
// Read output smart filter — reduces context waste from large file reads
|
|
550
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
551
|
+
if (event.toolName !== "read") return undefined
|
|
552
|
+
|
|
553
|
+
// Extract path from input
|
|
554
|
+
const path = (event.input as any)?.path ?? ""
|
|
555
|
+
if (!path) return undefined
|
|
556
|
+
|
|
557
|
+
// Extract text content from tool result
|
|
558
|
+
const textBlocks = (event.content as Array<any>)?.filter((b: any) => b.type === "text") ?? []
|
|
559
|
+
if (textBlocks.length === 0) return undefined
|
|
560
|
+
|
|
561
|
+
const output = textBlocks.map((b: any) => b.text).join("")
|
|
562
|
+
const isImage = (event.content as Array<any>)?.some((b: any) => b.type === "image") ?? false
|
|
563
|
+
|
|
564
|
+
const result = filterReadOutput({
|
|
565
|
+
path,
|
|
566
|
+
output,
|
|
567
|
+
isError: event.isError ?? false,
|
|
568
|
+
isImage,
|
|
570
569
|
})
|
|
570
|
+
|
|
571
|
+
if (!result.filtered) return undefined
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
content: [{ type: "text", text: result.output }],
|
|
575
|
+
details: {
|
|
576
|
+
...event.details,
|
|
577
|
+
readFilter: {
|
|
578
|
+
strategy: result.strategy,
|
|
579
|
+
originalBytes: result.originalBytes,
|
|
580
|
+
filteredBytes: result.filteredBytes,
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
}
|
|
571
584
|
})
|
|
572
585
|
|
|
573
|
-
//
|
|
574
|
-
pi.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (statsEnabled) {
|
|
579
|
-
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
580
|
-
footerTui = tui
|
|
581
|
-
const unsub = footerData.onBranchChange(() => tui.requestRender())
|
|
582
|
-
return {
|
|
583
|
-
dispose: () => {
|
|
584
|
-
unsub()
|
|
585
|
-
footerTui = null
|
|
586
|
-
},
|
|
587
|
-
invalidate() {},
|
|
588
|
-
render(width: number): string[] {
|
|
589
|
-
return renderStatsFooter(ctx, tui, theme, footerData, width)
|
|
590
|
-
},
|
|
591
|
-
}
|
|
592
|
-
})
|
|
593
|
-
ctx.ui.notify("Stats footer enabled", "info")
|
|
594
|
-
} else {
|
|
595
|
-
ctx.ui.setFooter(undefined)
|
|
596
|
-
ctx.ui.notify("Default footer restored", "info")
|
|
597
|
-
}
|
|
598
|
-
},
|
|
586
|
+
// Compaction prompt optimizer — makes summaries more focused and useful
|
|
587
|
+
pi.on("session_before_compact", async (_event, _ctx) => {
|
|
588
|
+
return {
|
|
589
|
+
customInstructions: COMPACTION_FOCUS_INSTRUCTIONS,
|
|
590
|
+
}
|
|
599
591
|
})
|
|
600
592
|
}
|
|
601
593
|
|
|
594
|
+
|
|
602
595
|
export { createArtifactHelperTool } from "./tools/artifact-helper"
|
|
603
596
|
export { createAskUserQuestionTool } from "./tools/ask-user-question"
|
|
604
597
|
export { createSubagentTool } from "./tools/subagent"
|
|
@@ -619,3 +612,6 @@ export {
|
|
|
619
612
|
getRunArtifactPath,
|
|
620
613
|
} from "./utils/artifact-paths"
|
|
621
614
|
export { normalizeSlug } from "./utils/name-utils"
|
|
615
|
+
export { filterBashOutput } from "./tools/bash-output-filter"
|
|
616
|
+
export { filterReadOutput } from "./tools/read-output-filter"
|
|
617
|
+
export { COMPACTION_FOCUS_INSTRUCTIONS, TURN_PREFIX_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export interface BashOutputFilterInput {
|
|
8
|
+
/** The bash command that was run */
|
|
9
|
+
command: string
|
|
10
|
+
/** The raw output text */
|
|
11
|
+
output: string
|
|
12
|
+
/** Whether the command exited with an error */
|
|
13
|
+
isError: boolean
|
|
14
|
+
/** Original full output path (if truncated by pi) */
|
|
15
|
+
fullOutputPath?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BashOutputFilterResult {
|
|
19
|
+
/** Filtered output text */
|
|
20
|
+
output: string
|
|
21
|
+
/** Whether filtering was applied */
|
|
22
|
+
filtered: boolean
|
|
23
|
+
/** Original size in bytes */
|
|
24
|
+
originalBytes: number
|
|
25
|
+
/** Filtered size in bytes */
|
|
26
|
+
filteredBytes: number
|
|
27
|
+
/** The filter strategy that was applied */
|
|
28
|
+
strategy: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Command Classification
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
type CommandCategory = "install" | "test" | "build" | "search" | "git-diff" | "list" | "http" | "unknown"
|
|
36
|
+
|
|
37
|
+
interface CommandPattern {
|
|
38
|
+
pattern: RegExp
|
|
39
|
+
category: CommandCategory
|
|
40
|
+
/** Whether this is a "means" command (output is a side-effect) */
|
|
41
|
+
isMeans: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMMAND_PATTERNS: CommandPattern[] = [
|
|
45
|
+
// Install commands — output is a side-effect
|
|
46
|
+
{ pattern: /\b(npm|yarn|pnpm|bun)\s+(install|i|add|ci|update|upgrade)\b/, category: "install", isMeans: true },
|
|
47
|
+
{ pattern: /\b(pip|pip3|poetry|conda)\s+(install|add)\b/, category: "install", isMeans: true },
|
|
48
|
+
{ pattern: /\b(cargo)\s+(install|add|update)\b/, category: "install", isMeans: true },
|
|
49
|
+
{ pattern: /\b(bundle|gem)\s+(install)\b/, category: "install", isMeans: true },
|
|
50
|
+
{ pattern: /\b(go)\s+(get|install|mod\s+(tidy|download))\b/, category: "install", isMeans: true },
|
|
51
|
+
// Test commands — output is mostly noise (pass lines), failures are signal
|
|
52
|
+
{ pattern: /\b(npm|yarn|pnpm|bun)\s+(test|t|run\s+test)/, category: "test", isMeans: true },
|
|
53
|
+
{ pattern: /\b(vitest|jest|mocha|pytest|cargo\s+test|go\s+test|ruby|-Ilib)\b.*\b(test|spec)\b/, category: "test", isMeans: true },
|
|
54
|
+
{ pattern: /\b(jest|vitest|mocha|pytest|npx\s+(jest|vitest|mocha))\b/, category: "test", isMeans: true },
|
|
55
|
+
{ pattern: /\b(cargo)\s+test\b/, category: "test", isMeans: true },
|
|
56
|
+
{ pattern: /\b(go)\s+test\b/, category: "test", isMeans: true },
|
|
57
|
+
// Build/compile commands — warnings/errors are signal
|
|
58
|
+
{ pattern: /\b(tsc|typescript|tscl)\b/, category: "build", isMeans: true },
|
|
59
|
+
{ pattern: /\b(npm|yarn|pnpm|bun)\s+(run\s+)?(build|compile)\b/, category: "build", isMeans: true },
|
|
60
|
+
{ pattern: /\b(cargo)\s+(build|check|clippy)\b/, category: "build", isMeans: true },
|
|
61
|
+
{ pattern: /\b(make|cmake|gcc|g\+\+|clang)\b/, category: "build", isMeans: true },
|
|
62
|
+
{ pattern: /\b(dotnet)\s+(build|publish)\b/, category: "build", isMeans: true },
|
|
63
|
+
{ pattern: /\b(gradle|mvn|mvnw)\b/, category: "build", isMeans: true },
|
|
64
|
+
// Search commands — output IS the purpose, don't filter
|
|
65
|
+
{ pattern: /\b(grep|rg|ag|ack|git\s+grep)\b/, category: "search", isMeans: false },
|
|
66
|
+
{ pattern: /\b(rg|ripgrep)\b/, category: "search", isMeans: false },
|
|
67
|
+
// Git diff/log — output IS the purpose
|
|
68
|
+
{ pattern: /\bgit\s+(diff|log|show|range-diff)\b/, category: "git-diff", isMeans: false },
|
|
69
|
+
// File listing — moderate filtering ok
|
|
70
|
+
{ pattern: /\b(find|fd|ls\s+-R|tree|du|ncdu)\b/, category: "list", isMeans: false },
|
|
71
|
+
// HTTP requests — can be verbose
|
|
72
|
+
{ pattern: /\b(curl|wget|httpie|http\s+)\b/, category: "http", isMeans: false },
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
function classifyCommand(command: string): CommandPattern | null {
|
|
76
|
+
// Normalize command: remove leading whitespace, handle pipes
|
|
77
|
+
const cmd = command.trim()
|
|
78
|
+
|
|
79
|
+
// Check primary command (before any pipe)
|
|
80
|
+
const primaryCmd = cmd.split("|")[0].trim()
|
|
81
|
+
|
|
82
|
+
for (const pattern of COMMAND_PATTERNS) {
|
|
83
|
+
if (pattern.pattern.test(primaryCmd)) {
|
|
84
|
+
return pattern
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Output Filters
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Filter for install command output.
|
|
97
|
+
* Keep: error lines, warning lines, summary lines (added/removed/changed counts).
|
|
98
|
+
* Remove: progress bars, download lines, individual package install lines.
|
|
99
|
+
*/
|
|
100
|
+
function filterInstallOutput(output: string): string {
|
|
101
|
+
const lines = output.split("\n")
|
|
102
|
+
const kept: string[] = []
|
|
103
|
+
let removedCount = 0
|
|
104
|
+
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
// Always keep: empty lines (structure), errors, warnings, summary
|
|
107
|
+
if (
|
|
108
|
+
line.trim() === "" ||
|
|
109
|
+
/(error|ERR!|fatal|FAILED|failed|abort|timeout)/i.test(line) ||
|
|
110
|
+
/\b(warn|WARN|warning|deprecat)\b/i.test(line) ||
|
|
111
|
+
/\b(added|removed|changed|updated|audited)\s+\d+/i.test(line) ||
|
|
112
|
+
/\bpackages?\b.*\b(look|found|installed|removed)\b/i.test(line) ||
|
|
113
|
+
/\bvulnerabilities\b/i.test(line) ||
|
|
114
|
+
/^\s*$/.test(line) ||
|
|
115
|
+
line.startsWith("[") ||
|
|
116
|
+
/^\s*(\d+ packages?|up to date|already|nothing)/i.test(line)
|
|
117
|
+
) {
|
|
118
|
+
kept.push(line)
|
|
119
|
+
} else {
|
|
120
|
+
removedCount++
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Deduplicate consecutive empty lines
|
|
125
|
+
return deduplicateEmptyLines(kept.join("\n"))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Filter for test command output.
|
|
130
|
+
* Keep: FAIL, ERROR, summary, test suite headers.
|
|
131
|
+
* Remove: PASS lines (✓, ✔, PASS, ✓), progress dots.
|
|
132
|
+
*/
|
|
133
|
+
function filterTestOutput(output: string): string {
|
|
134
|
+
const lines = output.split("\n")
|
|
135
|
+
const kept: string[] = []
|
|
136
|
+
let removedCount = 0
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
// Always keep: errors, failures, summary, suite names
|
|
140
|
+
if (
|
|
141
|
+
line.trim() === "" ||
|
|
142
|
+
/\b(FAIL|FAILING|FAILED|ERROR|✕|✗|×|✘|broken)\b/i.test(line) ||
|
|
143
|
+
/\b(fail|failure|error|timeout|crash)\b.*\d/i.test(line) ||
|
|
144
|
+
/\b(tests?|suites?|files?|passed|failed|skipped|pending)\s*[:=]?\s*\d/i.test(line) ||
|
|
145
|
+
/\b(RUN|RUNS)\s/i.test(line) || // vitest running indicator
|
|
146
|
+
/^\s*(FAIL|PASS|ERROR|SKIP|TODO)\s+(?:\s|\[)/i.test(line) ||
|
|
147
|
+
/^[\s│┌┐└┘├┤┬┴┼─]+/.test(line) || // Box drawing (test summaries)
|
|
148
|
+
/\b(synopsis|assert|expect|received|expected)\b/i.test(line) ||
|
|
149
|
+
/\s+(→|at)\s+.*\.\w+\s*$/i.test(line) || // Stack traces
|
|
150
|
+
/\d+\.\d+\s*(s|ms)\s*$/.test(line.trim()) || // Timing lines
|
|
151
|
+
line.includes("Test Suites:") ||
|
|
152
|
+
line.includes("Tests:") ||
|
|
153
|
+
line.includes("Snapshots:") ||
|
|
154
|
+
line.includes("Time:")
|
|
155
|
+
) {
|
|
156
|
+
kept.push(line)
|
|
157
|
+
} else if (
|
|
158
|
+
// Specifically skip PASS lines
|
|
159
|
+
/^\s*✓|✔|PASS|✅|·/.test(line) ||
|
|
160
|
+
/^\s*√/.test(line) ||
|
|
161
|
+
/^\s*ok\s+\d+/.test(line) ||
|
|
162
|
+
/^\s*\.\s*$/.test(line.trim()) // Progress dots
|
|
163
|
+
) {
|
|
164
|
+
removedCount++
|
|
165
|
+
} else {
|
|
166
|
+
// Keep lines that don't match pass patterns (context lines)
|
|
167
|
+
kept.push(line)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return deduplicateEmptyLines(kept.join("\n"))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Filter for build/compile output.
|
|
176
|
+
* Keep: error lines, warning lines (deduplicated), summary.
|
|
177
|
+
* Remove: successful compilation lines, progress indicators.
|
|
178
|
+
*/
|
|
179
|
+
function filterBuildOutput(output: string): string {
|
|
180
|
+
const lines = output.split("\n")
|
|
181
|
+
const kept: string[] = []
|
|
182
|
+
const seenWarnings = new Set<string>()
|
|
183
|
+
let removedCount = 0
|
|
184
|
+
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
if (line.trim() === "") {
|
|
187
|
+
kept.push(line)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Always keep errors
|
|
192
|
+
if (/\b(error|Error:|fatal|FAILED|failed)\b/i.test(line)) {
|
|
193
|
+
kept.push(line)
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Deduplicate warnings (keep first occurrence)
|
|
198
|
+
if (/\b(warning|warn)\b/i.test(line)) {
|
|
199
|
+
const normalized = line.replace(/:\d+:\d+/g, ":N:N").trim()
|
|
200
|
+
if (!seenWarnings.has(normalized)) {
|
|
201
|
+
seenWarnings.add(normalized)
|
|
202
|
+
kept.push(line)
|
|
203
|
+
} else {
|
|
204
|
+
kept.push(` ... (${countByPattern(lines, normalized)} similar warnings omitted)`)
|
|
205
|
+
}
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Keep summary lines
|
|
210
|
+
if (
|
|
211
|
+
/\b(compiled|built|generated|error|warning)\s*\d/i.test(line) ||
|
|
212
|
+
/\b(Finished|Compiling|Building)\b/i.test(line) ||
|
|
213
|
+
/error\(s\)|warning\(s\)/i.test(line)
|
|
214
|
+
) {
|
|
215
|
+
kept.push(line)
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Skip progress/success lines
|
|
220
|
+
if (
|
|
221
|
+
/^\s*(Compiling|Building|Generating)\s/i.test(line) ||
|
|
222
|
+
/^\s*\[.*\]\s/.test(line) // [1/10] style progress
|
|
223
|
+
) {
|
|
224
|
+
removedCount++
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Keep everything else (might be important context)
|
|
229
|
+
kept.push(line)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return deduplicateEmptyLines(kept.join("\n"))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* General filter for any large output.
|
|
237
|
+
* Applies safe, universal compression:
|
|
238
|
+
* - Collapse consecutive empty lines
|
|
239
|
+
* - Remove ANSI escape sequences
|
|
240
|
+
* - Trim trailing whitespace
|
|
241
|
+
*/
|
|
242
|
+
function filterGenericOutput(output: string): string {
|
|
243
|
+
// Remove ANSI escape sequences
|
|
244
|
+
const cleaned = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
|
245
|
+
// Deduplicate empty lines
|
|
246
|
+
return deduplicateEmptyLines(cleaned)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============================================================================
|
|
250
|
+
// Helpers
|
|
251
|
+
// ============================================================================
|
|
252
|
+
|
|
253
|
+
function deduplicateEmptyLines(text: string): string {
|
|
254
|
+
return text.replace(/\n{3,}/g, "\n\n")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function countByPattern(lines: string[], normalizedPattern: string): number {
|
|
258
|
+
let count = 0
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
if (line.replace(/:\d+:\d+/g, ":N:N").trim() === normalizedPattern) {
|
|
261
|
+
count++
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return count
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatBytes(bytes: number): string {
|
|
268
|
+
if (bytes < 1024) return `${bytes}B`
|
|
269
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
270
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// Main Filter Function
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
/** Minimum output size (bytes) to trigger any filtering */
|
|
278
|
+
const MIN_FILTER_THRESHOLD = 2048 // 2KB
|
|
279
|
+
|
|
280
|
+
/** Maximum filtered output size (bytes) — aggressive cutoff for very large outputs */
|
|
281
|
+
const MAX_FILTERED_SIZE = 30720 // 30KB
|
|
282
|
+
|
|
283
|
+
export function filterBashOutput(input: BashOutputFilterInput): BashOutputFilterResult {
|
|
284
|
+
const { command, output, isError, fullOutputPath } = input
|
|
285
|
+
const originalBytes = Buffer.byteLength(output, "utf-8")
|
|
286
|
+
|
|
287
|
+
// Don't filter small outputs
|
|
288
|
+
if (originalBytes < MIN_FILTER_THRESHOLD) {
|
|
289
|
+
return {
|
|
290
|
+
output,
|
|
291
|
+
filtered: false,
|
|
292
|
+
originalBytes,
|
|
293
|
+
filteredBytes: originalBytes,
|
|
294
|
+
strategy: "none (below threshold)",
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Don't filter error outputs — they need to be complete
|
|
299
|
+
if (isError) {
|
|
300
|
+
return {
|
|
301
|
+
output,
|
|
302
|
+
filtered: false,
|
|
303
|
+
originalBytes,
|
|
304
|
+
filteredBytes: originalBytes,
|
|
305
|
+
strategy: "none (error output)",
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const matched = classifyCommand(command)
|
|
310
|
+
|
|
311
|
+
// If no pattern matched, apply generic filtering for large outputs
|
|
312
|
+
if (!matched) {
|
|
313
|
+
if (originalBytes > MAX_FILTERED_SIZE) {
|
|
314
|
+
const filtered = filterGenericOutput(output)
|
|
315
|
+
const filteredBytes = Buffer.byteLength(filtered, "utf-8")
|
|
316
|
+
if (filteredBytes < originalBytes * 0.9) {
|
|
317
|
+
return {
|
|
318
|
+
output: appendFilterNotice(filtered, originalBytes, filteredBytes, "generic", fullOutputPath),
|
|
319
|
+
filtered: true,
|
|
320
|
+
originalBytes,
|
|
321
|
+
filteredBytes,
|
|
322
|
+
strategy: "generic (large unknown command)",
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
output,
|
|
328
|
+
filtered: false,
|
|
329
|
+
originalBytes,
|
|
330
|
+
filteredBytes: originalBytes,
|
|
331
|
+
strategy: "none (unknown command, below aggressive threshold)",
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Don't filter "purpose" commands (search, git diff) unless extremely large
|
|
336
|
+
if (!matched.isMeans && originalBytes < MAX_FILTERED_SIZE) {
|
|
337
|
+
return {
|
|
338
|
+
output,
|
|
339
|
+
filtered: false,
|
|
340
|
+
originalBytes,
|
|
341
|
+
filteredBytes: originalBytes,
|
|
342
|
+
strategy: `none (${matched.category}: purpose command, within limit)`,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Apply category-specific filter
|
|
347
|
+
let filtered: string
|
|
348
|
+
let strategyName: string
|
|
349
|
+
|
|
350
|
+
switch (matched.category) {
|
|
351
|
+
case "install":
|
|
352
|
+
filtered = filterInstallOutput(output)
|
|
353
|
+
strategyName = "install (errors + summary only)"
|
|
354
|
+
break
|
|
355
|
+
case "test":
|
|
356
|
+
filtered = filterTestOutput(output)
|
|
357
|
+
strategyName = "test (failures + summary only)"
|
|
358
|
+
break
|
|
359
|
+
case "build":
|
|
360
|
+
filtered = filterBuildOutput(output)
|
|
361
|
+
strategyName = "build (errors + deduplicated warnings)"
|
|
362
|
+
break
|
|
363
|
+
default:
|
|
364
|
+
// Purpose commands that are very large get generic filtering
|
|
365
|
+
filtered = filterGenericOutput(output)
|
|
366
|
+
strategyName = `generic (${matched.category}: large output)`
|
|
367
|
+
break
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const filteredBytes = Buffer.byteLength(filtered, "utf-8")
|
|
371
|
+
|
|
372
|
+
// If filtering didn't help much (< 20% reduction), keep original
|
|
373
|
+
if (filteredBytes > originalBytes * 0.8) {
|
|
374
|
+
return {
|
|
375
|
+
output,
|
|
376
|
+
filtered: false,
|
|
377
|
+
originalBytes,
|
|
378
|
+
filteredBytes,
|
|
379
|
+
strategy: `none (${matched.category}: filtering insufficient, <20% reduction)`,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
output: appendFilterNotice(filtered, originalBytes, filteredBytes, strategyName, fullOutputPath),
|
|
385
|
+
filtered: true,
|
|
386
|
+
originalBytes,
|
|
387
|
+
filteredBytes,
|
|
388
|
+
strategy: strategyName,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function appendFilterNotice(
|
|
393
|
+
output: string,
|
|
394
|
+
originalBytes: number,
|
|
395
|
+
filteredBytes: number,
|
|
396
|
+
strategy: string,
|
|
397
|
+
fullOutputPath?: string,
|
|
398
|
+
): string {
|
|
399
|
+
const saved = formatBytes(originalBytes - filteredBytes)
|
|
400
|
+
const notice = `\n\n[Output filtered: ${formatBytes(originalBytes)} → ${formatBytes(filteredBytes)} (${saved} saved, strategy: ${strategy})${fullOutputPath ? `. Full output: ${fullOutputPath}` : ""}]`
|
|
401
|
+
return output + notice
|
|
402
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Compaction Prompt Optimizer
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Hooks into `session_before_compact` to inject custom instructions that make
|
|
6
|
+
// compaction summaries more focused and useful for coding agent context.
|
|
7
|
+
//
|
|
8
|
+
// This is a "prompt-only" optimization — it doesn't replace pi's compaction
|
|
9
|
+
// flow, just adds focus instructions to the summarization prompt.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Custom instructions appended to compaction summarization prompts.
|
|
13
|
+
*
|
|
14
|
+
* Goals:
|
|
15
|
+
* 1. Preserve exact technical identifiers (paths, names, error messages)
|
|
16
|
+
* 2. Be terse on reasoning process, verbose on concrete state changes
|
|
17
|
+
* 3. Summarize file reads by purpose rather than including code snippets
|
|
18
|
+
* 4. Keep Critical Context section detailed for continuation
|
|
19
|
+
*/
|
|
20
|
+
export const COMPACTION_FOCUS_INSTRUCTIONS = `Additional focus for this summary:
|
|
21
|
+
|
|
22
|
+
1. Preserve EXACT file paths, function names, class names, variable names, and error messages — never paraphrase these
|
|
23
|
+
2. For each code change, note: file path, function/class, what changed, and why
|
|
24
|
+
3. Summarize file reads by their purpose (e.g., "read auth.ts to understand JWT middleware flow") rather than including code snippets
|
|
25
|
+
4. Be concise on the agent's reasoning process; be verbose on concrete state changes and decisions
|
|
26
|
+
5. Keep the "Critical Context" section detailed — this is what the agent needs to continue working
|
|
27
|
+
6. If any tests were run, summarize results by: file, pass/fail count, and specific failure messages
|
|
28
|
+
7. Note any blocked items and their exact error state`
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Additional instructions for turn-prefix summaries (split turns).
|
|
32
|
+
* These are more concise since the turn suffix is retained.
|
|
33
|
+
*/
|
|
34
|
+
export const TURN_PREFIX_FOCUS_INSTRUCTIONS = `Focus the turn-prefix summary on:
|
|
35
|
+
- What the user originally asked for
|
|
36
|
+
- Key decisions made in the prefix
|
|
37
|
+
- Exact file paths and identifiers needed to understand the retained suffix
|
|
38
|
+
Skip reasoning details — only keep actionable context.`
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Read Output Filter
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Filters `read` tool output at the source (via tool_result hook) to reduce
|
|
6
|
+
// context waste from large file reads. Symmetric with bash-output-filter.ts.
|
|
7
|
+
//
|
|
8
|
+
// Strategies:
|
|
9
|
+
// 1. Lock / generated files → extreme compression (summary only)
|
|
10
|
+
// 2. package.json → keep scripts + dep summary, drop full version lists
|
|
11
|
+
// 3. Large code files → keep signatures/structure, collapse bodies
|
|
12
|
+
// 4. Large markdown → keep headings + first paragraph per section
|
|
13
|
+
// 5. Generic → head/tail truncation for very large files
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export interface ReadOutputFilterInput {
|
|
20
|
+
/** File path that was read */
|
|
21
|
+
path: string
|
|
22
|
+
/** The raw output text */
|
|
23
|
+
output: string
|
|
24
|
+
/** Whether the read resulted in an error */
|
|
25
|
+
isError?: boolean
|
|
26
|
+
/** Whether the file is an image (skip filtering) */
|
|
27
|
+
isImage?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReadOutputFilterResult {
|
|
31
|
+
/** Filtered output text */
|
|
32
|
+
output: string
|
|
33
|
+
/** Whether filtering was applied */
|
|
34
|
+
filtered: boolean
|
|
35
|
+
/** Original size in bytes */
|
|
36
|
+
originalBytes: number
|
|
37
|
+
/** Filtered size in bytes */
|
|
38
|
+
filteredBytes: number
|
|
39
|
+
/** The filter strategy that was applied */
|
|
40
|
+
strategy: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// File Classification
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
type FileCategory =
|
|
48
|
+
| "lock-file"
|
|
49
|
+
| "generated-file"
|
|
50
|
+
| "minified-file"
|
|
51
|
+
| "package-json"
|
|
52
|
+
| "code"
|
|
53
|
+
| "markdown"
|
|
54
|
+
| "config"
|
|
55
|
+
| "unknown"
|
|
56
|
+
|
|
57
|
+
interface FilePattern {
|
|
58
|
+
test: (path: string) => boolean
|
|
59
|
+
category: FileCategory
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const FILE_PATTERNS: FilePattern[] = [
|
|
63
|
+
// Lock files — extreme compression
|
|
64
|
+
{ test: (p) => /(^|\/)(package-lock\.json|yarn\.lock|bun\.lock|pnpm-lock\.yaml|Gemfile\.lock|Podfile\.lock|composer\.lock|Cargo\.lock)$/.test(p), category: "lock-file" },
|
|
65
|
+
// Minified / bundle files
|
|
66
|
+
{ test: (p) => /\.(min\.js|min\.css|bundle\.js|chunk\.js|vendor\.js)$/i.test(p), category: "minified-file" },
|
|
67
|
+
// Generated files
|
|
68
|
+
{ test: (p) => /\.(generated\.\w+|auto\.\w+|pb\.ts)$/i.test(p) || /(?:^|\/)(generated|auto-generated|__generated__)\//i.test(p), category: "generated-file" },
|
|
69
|
+
// package.json — smart filter
|
|
70
|
+
{ test: (p) => /(^|\/)package\.json$/.test(p), category: "package-json" },
|
|
71
|
+
// Markdown
|
|
72
|
+
{ test: (p) => /\.(md|mdx)$/i.test(p), category: "markdown" },
|
|
73
|
+
// Config files (JSON/YAML/TOML without lock)
|
|
74
|
+
{ test: (p) => /\.(jsonc?|ya?ml|toml|ini|conf|rc)$/i.test(p) && !/lock/i.test(p), category: "config" },
|
|
75
|
+
// Code files
|
|
76
|
+
{ test: (p) => /\.(ts|tsx|js|jsx|py|rs|go|java|kt|rb|c|cpp|h|hpp|cs|swift|zig|scala|clj)$/i.test(p), category: "code" },
|
|
77
|
+
// Code files (alternate extensions)
|
|
78
|
+
{ test: (p) => /\.(vue|svelte|astro|sol|dart|lua|r|pl|pm|sh|bash|zsh|fish|ps1)$/i.test(p), category: "code" },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
function classifyFile(path: string): FileCategory {
|
|
82
|
+
const normalized = path.replace(/\\/g, "/")
|
|
83
|
+
for (const pattern of FILE_PATTERNS) {
|
|
84
|
+
if (pattern.test(normalized)) return pattern.category
|
|
85
|
+
}
|
|
86
|
+
return "unknown"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Constants
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
/** Minimum output size (bytes) to trigger any filtering */
|
|
94
|
+
const MIN_FILTER_THRESHOLD = 2048 // 2KB
|
|
95
|
+
|
|
96
|
+
/** Maximum size for structural code compression trigger */
|
|
97
|
+
const CODE_COMPRESS_THRESHOLD = 8192 // 8KB
|
|
98
|
+
|
|
99
|
+
/** Maximum filtered output size (bytes) */
|
|
100
|
+
const MAX_FILTERED_SIZE = 30720 // 30KB
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Filter: Lock Files
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
function filterLockFile(output: string, path: string): string {
|
|
107
|
+
const bytes = Buffer.byteLength(output, "utf-8")
|
|
108
|
+
const lines = output.split("\n").length
|
|
109
|
+
|
|
110
|
+
// Try to extract name and lockfileVersion from JSON lock files
|
|
111
|
+
let summary = `Lock file: ${path.split("/").pop()}`
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const data = JSON.parse(output)
|
|
115
|
+
if (data.name) summary += `\nProject: ${data.name}`
|
|
116
|
+
if (data.lockfileVersion) summary += `\nLockfile version: ${data.lockfileVersion}`
|
|
117
|
+
if (data.packages) {
|
|
118
|
+
const pkgCount = Object.keys(data.packages).length
|
|
119
|
+
summary += `\nPackages: ${pkgCount}`
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Not JSON (yarn.lock, etc.) — count lines
|
|
123
|
+
summary += `\nLines: ${lines}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
summary += `\nOriginal size: ${formatBytes(bytes)}`
|
|
127
|
+
return summary
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Filter: Minified / Generated Files
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
function filterMinifiedFile(output: string, path: string): string {
|
|
135
|
+
const bytes = Buffer.byteLength(output, "utf-8")
|
|
136
|
+
const ext = path.split(".").pop() ?? ""
|
|
137
|
+
return [
|
|
138
|
+
`Minified ${ext} file: ${path.split("/").pop()}`,
|
|
139
|
+
`Size: ${formatBytes(bytes)} (${output.split("\n").length} lines)`,
|
|
140
|
+
`First 500 chars:`,
|
|
141
|
+
output.slice(0, 500),
|
|
142
|
+
"",
|
|
143
|
+
`[... ${formatBytes(bytes - 500)} omitted]`,
|
|
144
|
+
].join("\n")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function filterGeneratedFile(output: string, path: string): string {
|
|
148
|
+
const bytes = Buffer.byteLength(output, "utf-8")
|
|
149
|
+
const lines = output.split("\n")
|
|
150
|
+
|
|
151
|
+
// Keep first 30 lines + last 10 lines + count
|
|
152
|
+
if (lines.length <= 50) return output
|
|
153
|
+
|
|
154
|
+
const head = lines.slice(0, 30)
|
|
155
|
+
const tail = lines.slice(-10)
|
|
156
|
+
const omitted = lines.length - 40
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
...head,
|
|
160
|
+
"",
|
|
161
|
+
`[... ${omitted} lines omitted (generated file, ${formatBytes(bytes)})]`,
|
|
162
|
+
"",
|
|
163
|
+
...tail,
|
|
164
|
+
].join("\n")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// Filter: package.json
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
function filterPackageJson(output: string): string {
|
|
172
|
+
try {
|
|
173
|
+
const pkg = JSON.parse(output)
|
|
174
|
+
const sections: string[] = []
|
|
175
|
+
|
|
176
|
+
sections.push(`Name: ${pkg.name ?? "unknown"}`)
|
|
177
|
+
if (pkg.version) sections.push(`Version: ${pkg.version}`)
|
|
178
|
+
if (pkg.description) sections.push(`Description: ${pkg.description}`)
|
|
179
|
+
|
|
180
|
+
if (pkg.scripts && Object.keys(pkg.scripts).length > 0) {
|
|
181
|
+
sections.push("")
|
|
182
|
+
sections.push("Scripts:")
|
|
183
|
+
for (const [name, cmd] of Object.entries(pkg.scripts)) {
|
|
184
|
+
sections.push(` ${name}: ${cmd}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (pkg.dependencies) {
|
|
189
|
+
const depCount = Object.keys(pkg.dependencies).length
|
|
190
|
+
sections.push("")
|
|
191
|
+
sections.push(`Dependencies (${depCount}):`)
|
|
192
|
+
if (depCount <= 15) {
|
|
193
|
+
for (const [name, ver] of Object.entries(pkg.dependencies)) {
|
|
194
|
+
sections.push(` ${name}: ${ver}`)
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
// Show first 10 + summary
|
|
198
|
+
const entries = Object.entries(pkg.dependencies)
|
|
199
|
+
for (const [name, ver] of entries.slice(0, 10)) {
|
|
200
|
+
sections.push(` ${name}: ${ver}`)
|
|
201
|
+
}
|
|
202
|
+
sections.push(` ... and ${depCount - 10} more`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (pkg.devDependencies) {
|
|
207
|
+
const devCount = Object.keys(pkg.devDependencies).length
|
|
208
|
+
sections.push("")
|
|
209
|
+
sections.push(`DevDependencies (${devCount}):`)
|
|
210
|
+
if (devCount <= 10) {
|
|
211
|
+
for (const [name, ver] of Object.entries(pkg.devDependencies)) {
|
|
212
|
+
sections.push(` ${name}: ${ver}`)
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
const entries = Object.entries(pkg.devDependencies)
|
|
216
|
+
for (const [name, ver] of entries.slice(0, 5)) {
|
|
217
|
+
sections.push(` ${name}: ${ver}`)
|
|
218
|
+
}
|
|
219
|
+
sections.push(` ... and ${devCount - 5} more`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return sections.join("\n")
|
|
224
|
+
} catch {
|
|
225
|
+
// Not valid JSON — generic truncation
|
|
226
|
+
return truncateGeneric(output)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Filter: Large Code Files — Structural Compression
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Structural compression for code files.
|
|
236
|
+
* Keeps: imports, exports, type definitions, function/class signatures, comments with TODO/FIXME/HACK
|
|
237
|
+
* Collapses: function bodies > N lines, consecutive blank lines
|
|
238
|
+
*/
|
|
239
|
+
function filterCodeFile(output: string): string {
|
|
240
|
+
const lines = output.split("\n")
|
|
241
|
+
const kept: string[] = []
|
|
242
|
+
let bodyDepth = 0
|
|
243
|
+
let bodyLines = 0
|
|
244
|
+
let skippedInBody = 0
|
|
245
|
+
const MAX_BODY_LINES = 8 // keep first N lines of a body
|
|
246
|
+
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
const line = lines[i]
|
|
249
|
+
const trimmed = line.trim()
|
|
250
|
+
|
|
251
|
+
// Track brace depth for body detection
|
|
252
|
+
const opens = (line.match(/\{/g) || []).length
|
|
253
|
+
const closes = (line.match(/\}/g) || []).length
|
|
254
|
+
|
|
255
|
+
// Always keep: empty lines (structure), imports, exports, type/interface/class declarations
|
|
256
|
+
// comments with TODO/FIXME/HACK, decorators
|
|
257
|
+
if (
|
|
258
|
+
trimmed === "" ||
|
|
259
|
+
/^(import |export |from )/.test(trimmed) ||
|
|
260
|
+
/^(export )?(default |abstract |async |const |let |var |function |class |interface |type |enum )/.test(trimmed) ||
|
|
261
|
+
/^(\/\/|#|\/\*|\*)/.test(trimmed) ||
|
|
262
|
+
/\b(TODO|FIXME|HACK|XXX|WARN|BUG|NOTE)\b/i.test(trimmed) ||
|
|
263
|
+
/^@/.test(trimmed) || // decorators
|
|
264
|
+
/^}\s*$/.test(trimmed) // closing brace
|
|
265
|
+
) {
|
|
266
|
+
// If we were skipping body lines, add omission marker
|
|
267
|
+
if (skippedInBody > 0) {
|
|
268
|
+
kept.push(` [... ${skippedInBody} lines omitted]`)
|
|
269
|
+
skippedInBody = 0
|
|
270
|
+
}
|
|
271
|
+
kept.push(line)
|
|
272
|
+
bodyDepth += opens - closes
|
|
273
|
+
bodyLines = 0
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Inside a body (after opening brace)
|
|
278
|
+
bodyDepth += opens - closes
|
|
279
|
+
|
|
280
|
+
if (bodyDepth > 1 || (bodyDepth === 1 && bodyLines < MAX_BODY_LINES)) {
|
|
281
|
+
kept.push(line)
|
|
282
|
+
bodyLines++
|
|
283
|
+
} else if (bodyDepth === 1 && bodyLines >= MAX_BODY_LINES) {
|
|
284
|
+
// Skip body lines but track count
|
|
285
|
+
skippedInBody++
|
|
286
|
+
bodyLines++
|
|
287
|
+
} else {
|
|
288
|
+
// Top-level or unknown — keep it
|
|
289
|
+
kept.push(line)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Final omission marker
|
|
294
|
+
if (skippedInBody > 0) {
|
|
295
|
+
kept.push(` [... ${skippedInBody} lines omitted]`)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return deduplicateEmptyLines(kept.join("\n"))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ============================================================================
|
|
302
|
+
// Filter: Markdown
|
|
303
|
+
// ============================================================================
|
|
304
|
+
|
|
305
|
+
function filterMarkdown(output: string): string {
|
|
306
|
+
const lines = output.split("\n")
|
|
307
|
+
const kept: string[] = []
|
|
308
|
+
let inParagraph = false
|
|
309
|
+
let paragraphLineCount = 0
|
|
310
|
+
let inCodeBlock = false
|
|
311
|
+
|
|
312
|
+
for (const line of lines) {
|
|
313
|
+
const trimmed = line.trim()
|
|
314
|
+
|
|
315
|
+
// Track code block state
|
|
316
|
+
if (trimmed.startsWith("```")) {
|
|
317
|
+
inCodeBlock = !inCodeBlock
|
|
318
|
+
kept.push(line)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Keep all lines inside code blocks
|
|
323
|
+
if (inCodeBlock) {
|
|
324
|
+
kept.push(line)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Always keep headings
|
|
329
|
+
if (/^#{1,6}\s/.test(trimmed)) {
|
|
330
|
+
kept.push(line)
|
|
331
|
+
inParagraph = false
|
|
332
|
+
paragraphLineCount = 0
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Empty lines
|
|
337
|
+
if (trimmed === "") {
|
|
338
|
+
kept.push(line)
|
|
339
|
+
inParagraph = false
|
|
340
|
+
paragraphLineCount = 0
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Keep first paragraph line after heading, skip the rest
|
|
345
|
+
if (!inParagraph) {
|
|
346
|
+
kept.push(line)
|
|
347
|
+
inParagraph = true
|
|
348
|
+
paragraphLineCount = 1
|
|
349
|
+
} else {
|
|
350
|
+
paragraphLineCount++
|
|
351
|
+
// Skip subsequent paragraph lines
|
|
352
|
+
if (paragraphLineCount === 2) {
|
|
353
|
+
kept.push(` [... additional paragraph content omitted]`)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return deduplicateEmptyLines(kept.join("\n"))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ============================================================================
|
|
362
|
+
// Filter: Generic
|
|
363
|
+
// ============================================================================
|
|
364
|
+
|
|
365
|
+
function truncateGeneric(output: string): string {
|
|
366
|
+
const lines = output.split("\n")
|
|
367
|
+
const bytes = Buffer.byteLength(output, "utf-8")
|
|
368
|
+
|
|
369
|
+
if (lines.length <= 100) return output
|
|
370
|
+
|
|
371
|
+
const head = lines.slice(0, 50)
|
|
372
|
+
const tail = lines.slice(-10)
|
|
373
|
+
const omitted = lines.length - 60
|
|
374
|
+
|
|
375
|
+
return [
|
|
376
|
+
...head,
|
|
377
|
+
"",
|
|
378
|
+
`[... ${omitted} lines omitted (${formatBytes(bytes)})]`,
|
|
379
|
+
"",
|
|
380
|
+
...tail,
|
|
381
|
+
].join("\n")
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ============================================================================
|
|
385
|
+
// Helpers
|
|
386
|
+
// ============================================================================
|
|
387
|
+
|
|
388
|
+
function deduplicateEmptyLines(text: string): string {
|
|
389
|
+
return text.replace(/\n{3,}/g, "\n\n")
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function formatBytes(bytes: number): string {
|
|
393
|
+
if (bytes < 1024) return `${bytes}B`
|
|
394
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
395
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function appendFilterNotice(
|
|
399
|
+
output: string,
|
|
400
|
+
originalBytes: number,
|
|
401
|
+
filteredBytes: number,
|
|
402
|
+
strategy: string,
|
|
403
|
+
): string {
|
|
404
|
+
const saved = formatBytes(originalBytes - filteredBytes)
|
|
405
|
+
return `${output}\n\n[Read output filtered: ${formatBytes(originalBytes)} → ${formatBytes(filteredBytes)} (${saved} saved, strategy: ${strategy})]`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ============================================================================
|
|
409
|
+
// Main Filter Function
|
|
410
|
+
// ============================================================================
|
|
411
|
+
|
|
412
|
+
export function filterReadOutput(input: ReadOutputFilterInput): ReadOutputFilterResult {
|
|
413
|
+
const { path, output, isError, isImage } = input
|
|
414
|
+
const originalBytes = Buffer.byteLength(output, "utf-8")
|
|
415
|
+
|
|
416
|
+
// Don't filter images
|
|
417
|
+
if (isImage) {
|
|
418
|
+
return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (image)" }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Don't filter error outputs
|
|
422
|
+
if (isError) {
|
|
423
|
+
return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (error)" }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Don't filter small outputs
|
|
427
|
+
if (originalBytes < MIN_FILTER_THRESHOLD) {
|
|
428
|
+
return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (below threshold)" }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const category = classifyFile(path)
|
|
432
|
+
|
|
433
|
+
let filtered: string
|
|
434
|
+
let strategyName: string
|
|
435
|
+
|
|
436
|
+
switch (category) {
|
|
437
|
+
case "lock-file":
|
|
438
|
+
filtered = filterLockFile(output, path)
|
|
439
|
+
strategyName = "lock-file (summary only)"
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
case "minified-file":
|
|
443
|
+
filtered = filterMinifiedFile(output, path)
|
|
444
|
+
strategyName = "minified (head only)"
|
|
445
|
+
break
|
|
446
|
+
|
|
447
|
+
case "generated-file":
|
|
448
|
+
filtered = filterGeneratedFile(output, path)
|
|
449
|
+
strategyName = "generated (head + tail)"
|
|
450
|
+
break
|
|
451
|
+
|
|
452
|
+
case "package-json":
|
|
453
|
+
filtered = filterPackageJson(output)
|
|
454
|
+
strategyName = "package.json (scripts + dep summary)"
|
|
455
|
+
break
|
|
456
|
+
|
|
457
|
+
case "code":
|
|
458
|
+
if (originalBytes >= CODE_COMPRESS_THRESHOLD) {
|
|
459
|
+
filtered = filterCodeFile(output)
|
|
460
|
+
strategyName = "code (structural compression)"
|
|
461
|
+
} else {
|
|
462
|
+
return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (code file below threshold)" }
|
|
463
|
+
}
|
|
464
|
+
break
|
|
465
|
+
|
|
466
|
+
case "markdown":
|
|
467
|
+
filtered = filterMarkdown(output)
|
|
468
|
+
strategyName = "markdown (headings + first paragraph)"
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
case "config":
|
|
472
|
+
// Config files: only filter if very large
|
|
473
|
+
if (originalBytes > MAX_FILTERED_SIZE) {
|
|
474
|
+
filtered = truncateGeneric(output)
|
|
475
|
+
strategyName = "config (generic truncation)"
|
|
476
|
+
} else {
|
|
477
|
+
return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (config, within limit)" }
|
|
478
|
+
}
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
default:
|
|
482
|
+
// Unknown files: only filter if very large
|
|
483
|
+
if (originalBytes > MAX_FILTERED_SIZE) {
|
|
484
|
+
filtered = truncateGeneric(output)
|
|
485
|
+
strategyName = "generic (large unknown file)"
|
|
486
|
+
} else {
|
|
487
|
+
return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (unknown, within limit)" }
|
|
488
|
+
}
|
|
489
|
+
break
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const filteredBytes = Buffer.byteLength(filtered, "utf-8")
|
|
493
|
+
|
|
494
|
+
// If filtering didn't help much (< 20% reduction), keep original
|
|
495
|
+
if (filteredBytes > originalBytes * 0.8) {
|
|
496
|
+
return { output, filtered: false, originalBytes, filteredBytes, strategy: `none (${category}: filtering insufficient, <20% reduction)` }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
output: appendFilterNotice(filtered, originalBytes, filteredBytes, strategyName),
|
|
501
|
+
filtered: true,
|
|
502
|
+
originalBytes,
|
|
503
|
+
filteredBytes,
|
|
504
|
+
strategy: strategyName,
|
|
505
|
+
}
|
|
506
|
+
}
|