@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
- // Track tool calls for stats footer
541
- pi.on("tool_call", async (event, _ctx) => {
542
- if (!statsEnabled) return
543
- const name = event.toolName
544
- toolCounts.set(name, (toolCounts.get(name) || 0) + 1)
545
- if (name === "subagent" || name === "parallel_subagent") {
546
- subagentCount++
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
- // Enable custom footer on session start
552
- pi.on("session_start", async (_event, ctx) => {
553
- projectPath = process.cwd()
554
- statsEnabled = true
555
- toolCounts.clear()
556
- subagentCount = 0
557
- ctx.ui.setFooter((tui, theme, footerData) => {
558
- footerTui = tui
559
- const unsub = footerData.onBranchChange(() => tui.requestRender())
560
- return {
561
- dispose: () => {
562
- unsub()
563
- footerTui = null
564
- },
565
- invalidate() {},
566
- render(width: number): string[] {
567
- return renderStatsFooter(ctx, tui, theme, footerData, width)
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
- // /stats command to toggle footer
574
- pi.registerCommand("stats", {
575
- description: "Toggle stats footer (project path, tool usage, subagent count)",
576
- handler: async (_args, ctx) => {
577
- statsEnabled = !statsEnabled
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leing2021/super-pi",
3
- "version": "0.14.2",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Pi-native Compound Engineering package for iterative development workflows",