@leing2021/super-pi 0.14.2 → 0.15.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,7 @@ 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"
18
17
 
19
18
  const artifactHelperParams = Type.Object({
20
19
  repoRoot: Type.String({ description: "Repository root where workflow artifacts should be created" }),
@@ -171,36 +170,6 @@ const patternExtractorParams = Type.Object({
171
170
  categories: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()), { description: "Category name to keyword mapping" })),
172
171
  })
173
172
 
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
173
  export default function ceCoreExtension(pi: ExtensionAPI) {
205
174
  const artifactHelper = createArtifactHelperTool()
206
175
  const askUserQuestion = createAskUserQuestionTool()
@@ -537,68 +506,46 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
537
506
  },
538
507
  })
539
508
 
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++
547
- }
548
- if (footerTui) footerTui.requestRender()
549
- })
509
+ // Bash output smart filter reduces context waste from verbose command output
510
+ pi.on("tool_result", async (event, _ctx) => {
511
+ if (event.toolName !== "bash") return undefined
550
512
 
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
- }
513
+ // Extract command from input
514
+ const command = (event.input as any)?.command ?? ""
515
+ if (!command) return undefined
516
+
517
+ // Extract text content from tool result
518
+ const textBlocks = (event.content as Array<any>)?.filter((b: any) => b.type === "text") ?? []
519
+ if (textBlocks.length === 0) return undefined
520
+
521
+ const output = textBlocks.map((b: any) => b.text).join("")
522
+ const fullOutputPath = (event.details as any)?.fullOutputPath
523
+
524
+ const result = filterBashOutput({
525
+ command,
526
+ output,
527
+ isError: event.isError ?? false,
528
+ fullOutputPath,
570
529
  })
571
- })
572
530
 
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
- },
531
+ if (!result.filtered) return undefined
532
+
533
+ // Replace content with filtered version
534
+ return {
535
+ content: [{ type: "text", text: result.output }],
536
+ details: {
537
+ ...event.details,
538
+ bashFilter: {
539
+ strategy: result.strategy,
540
+ originalBytes: result.originalBytes,
541
+ filteredBytes: result.filteredBytes,
542
+ },
543
+ },
544
+ }
599
545
  })
600
546
  }
601
547
 
548
+
602
549
  export { createArtifactHelperTool } from "./tools/artifact-helper"
603
550
  export { createAskUserQuestionTool } from "./tools/ask-user-question"
604
551
  export { createSubagentTool } from "./tools/subagent"
@@ -619,3 +566,4 @@ export {
619
566
  getRunArtifactPath,
620
567
  } from "./utils/artifact-paths"
621
568
  export { normalizeSlug } from "./utils/name-utils"
569
+ export { filterBashOutput } from "./tools/bash-output-filter"
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leing2021/super-pi",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Pi-native Compound Engineering package for iterative development workflows",