@leing2021/super-pi 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/extensions/ce-core/index.ts +103 -24
- package/extensions/ce-core/tools/async-mutex.ts +28 -0
- package/extensions/ce-core/tools/compaction-optimizer.ts +38 -0
- package/extensions/ce-core/tools/parallel-subagent.ts +49 -3
- package/extensions/ce-core/tools/read-output-filter.ts +506 -0
- package/extensions/ce-core/tools/subagent-depth-guard.ts +86 -0
- package/extensions/ce-core/tools/subagent.ts +58 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,14 +99,14 @@ Next time `02-plan` or `04-review` runs, a grep-first search strategy automatica
|
|
|
99
99
|
| `08-status` | Scan artifacts, report progress | `workflow_state`, `session_history` |
|
|
100
100
|
| `09-help` | Usage guide | — |
|
|
101
101
|
|
|
102
|
-
###
|
|
102
|
+
### 15 Tools (underlying capabilities)
|
|
103
103
|
|
|
104
104
|
| Tool | What it does |
|
|
105
105
|
|------|-------------|
|
|
106
106
|
| `task_splitter` | Union-Find algorithm analyzes file dependencies, auto-groups parallel-safe units |
|
|
107
107
|
| `session_checkpoint` | JSON-persisted checkpoints with save/load/fail/retry operations |
|
|
108
108
|
| `plan_diff` | Incremental plans: compare detects diffs, patch applies changes |
|
|
109
|
-
| `parallel_subagent` | `Promise.allSettled`-style parallel subagent execution |
|
|
109
|
+
| `parallel_subagent` | `Promise.allSettled`-style parallel subagent execution with context slimming |
|
|
110
110
|
| `review_router` | Auto-assign reviewer personas from diff metadata |
|
|
111
111
|
| `pattern_extractor` | Extract and categorize patterns from artifacts |
|
|
112
112
|
| `brainstorm_dialog` | Multi-round dialog state machine (start → refine × N → summarize) |
|
|
@@ -115,13 +115,15 @@ Next time `02-plan` or `04-review` runs, a grep-first search strategy automatica
|
|
|
115
115
|
| `worktree_manager` | Full git worktree lifecycle management |
|
|
116
116
|
| `artifact_helper` | Artifact path resolution and directory creation |
|
|
117
117
|
| `ask_user_question` | Structured user prompts (choices / free input) |
|
|
118
|
-
| `subagent` | Serial subagent chain |
|
|
118
|
+
| `subagent` | Serial subagent chain with depth guard and context control |
|
|
119
|
+
| `subagent-depth-guard` | Env-based recursion depth tracking (prevents runaway nesting) |
|
|
120
|
+
| `async-mutex` | Serializes `process.env` mutation for concurrency-safe child process spawning |
|
|
119
121
|
|
|
120
122
|
---
|
|
121
123
|
|
|
122
124
|
## Code Scale
|
|
123
125
|
|
|
124
|
-
~
|
|
126
|
+
~2500 lines of TypeScript implementing 15 tools, 22 Markdown reference files driving 9 skills' behavioral strategies, 162 tests covering all tool logic.
|
|
125
127
|
|
|
126
128
|
Not a heavy framework. Each tool has a single responsibility, each skill works independently, and together they form a complete workflow.
|
|
127
129
|
|
|
@@ -236,6 +238,22 @@ Not a fork. Not a wrapper. The methodologies are extracted and rebuilt with Pi's
|
|
|
236
238
|
|
|
237
239
|
## Changelog
|
|
238
240
|
|
|
241
|
+
### 0.17.0 — Subagent safety
|
|
242
|
+
- Recursion depth guard (`PI_SUBAGENT_DEPTH` / `PI_SUBAGENT_MAX_DEPTH`) prevents runaway nesting
|
|
243
|
+
- Async mutex for `process.env` concurrency safety during parallel subagent execution
|
|
244
|
+
- Context slimming: `inheritSkills` parameter, parallel workers default to slim context (`--no-skills`)
|
|
245
|
+
- Shared `createSubagentRunner` factory (deduped runner closures)
|
|
246
|
+
- 162 tests passing
|
|
247
|
+
|
|
248
|
+
### 0.16.0 — Context optimization
|
|
249
|
+
- Read output filter: structural compression for large code files, lock files, markdown
|
|
250
|
+
- Compaction optimizer: focused summary instructions for session compaction
|
|
251
|
+
- Bash output filter improvements
|
|
252
|
+
|
|
253
|
+
### 0.15.0 — Output filtering
|
|
254
|
+
- Bash output filter: smart truncation by command type (install, test, build)
|
|
255
|
+
- Read output filter: preserves structure while cutting verbosity
|
|
256
|
+
|
|
239
257
|
### 0.14.0 — Structured solution retrieval
|
|
240
258
|
- YAML frontmatter tagging + grep-first two-level search
|
|
241
259
|
- 95 tests passing
|
|
@@ -14,6 +14,53 @@ import { createPlanDiffTool } from "./tools/plan-diff"
|
|
|
14
14
|
import { createSessionHistoryTool } from "./tools/session-history"
|
|
15
15
|
import { createPatternExtractorTool } from "./tools/pattern-extractor"
|
|
16
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"
|
|
19
|
+
import { AsyncMutex } from "./tools/async-mutex"
|
|
20
|
+
import type { SubagentExecOptions, SubagentRunner } from "./tools/subagent"
|
|
21
|
+
|
|
22
|
+
const _subagentEnvMutex = new AsyncMutex()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a subagent runner that handles env injection with mutex protection
|
|
26
|
+
* for concurrency safety when multiple parallel subagents share the process.
|
|
27
|
+
*/
|
|
28
|
+
function createSubagentRunner(
|
|
29
|
+
pi: ExtensionAPI,
|
|
30
|
+
signal?: AbortSignal,
|
|
31
|
+
): SubagentRunner {
|
|
32
|
+
return async (prompt: string, options?: SubagentExecOptions) => {
|
|
33
|
+
const args = ["--no-session", ...(options?.extraFlags ?? []), "-p", prompt]
|
|
34
|
+
const release = await _subagentEnvMutex.acquire()
|
|
35
|
+
const savedEnv: Record<string, string | undefined> = {}
|
|
36
|
+
const extraEnv = options?.extraEnv ?? {}
|
|
37
|
+
for (const [key, value] of Object.entries(extraEnv)) {
|
|
38
|
+
savedEnv[key] = process.env[key]
|
|
39
|
+
process.env[key] = value
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const execResult = await pi.exec("pi", args, {
|
|
43
|
+
signal,
|
|
44
|
+
timeout: 10 * 60 * 1000,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (execResult.code !== 0) {
|
|
48
|
+
throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (execResult.stdout || "").trim()
|
|
52
|
+
} finally {
|
|
53
|
+
for (const [key, oldValue] of Object.entries(savedEnv)) {
|
|
54
|
+
if (oldValue === undefined) {
|
|
55
|
+
delete process.env[key]
|
|
56
|
+
} else {
|
|
57
|
+
process.env[key] = oldValue
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
release()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
17
64
|
|
|
18
65
|
const artifactHelperParams = Type.Object({
|
|
19
66
|
repoRoot: Type.String({ description: "Repository root where workflow artifacts should be created" }),
|
|
@@ -46,6 +93,7 @@ const subagentParams = Type.Object({
|
|
|
46
93
|
agent: Type.Optional(Type.String({ description: "Single subagent skill name" })),
|
|
47
94
|
task: Type.Optional(Type.String({ description: "Single subagent task" })),
|
|
48
95
|
chain: Type.Optional(Type.Array(subagentTaskSchema, { description: "Serial subagent chain with optional {previous} placeholder" })),
|
|
96
|
+
inheritSkills: Type.Optional(Type.Boolean({ description: "Whether the subagent should inherit skills. Default: true" })),
|
|
49
97
|
})
|
|
50
98
|
|
|
51
99
|
const workflowStateParams = Type.Object({
|
|
@@ -77,6 +125,7 @@ const parallelSubagentTaskSchema = Type.Object({
|
|
|
77
125
|
|
|
78
126
|
const parallelSubagentParams = Type.Object({
|
|
79
127
|
tasks: Type.Array(parallelSubagentTaskSchema, { description: "Array of independent tasks to run concurrently" }),
|
|
128
|
+
inheritSkills: Type.Optional(Type.Boolean({ description: "Whether subagents should inherit skills. Default: false" })),
|
|
80
129
|
})
|
|
81
130
|
|
|
82
131
|
const sessionCheckpointParams = Type.Object({
|
|
@@ -261,19 +310,9 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
|
|
|
261
310
|
agent: params.agent,
|
|
262
311
|
task: params.task,
|
|
263
312
|
chain: params.chain,
|
|
313
|
+
inheritSkills: params.inheritSkills,
|
|
264
314
|
},
|
|
265
|
-
|
|
266
|
-
const execResult = await pi.exec("pi", ["--no-session", "-p", prompt], {
|
|
267
|
-
signal,
|
|
268
|
-
timeout: 10 * 60 * 1000,
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
if (execResult.code !== 0) {
|
|
272
|
-
throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return (execResult.stdout || "").trim()
|
|
276
|
-
},
|
|
315
|
+
createSubagentRunner(pi, signal),
|
|
277
316
|
)
|
|
278
317
|
|
|
279
318
|
return {
|
|
@@ -361,19 +400,11 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
|
|
|
361
400
|
parameters: parallelSubagentParams,
|
|
362
401
|
async execute(_toolCallId, params, signal) {
|
|
363
402
|
const result = await parallelSubagent.execute(
|
|
364
|
-
{
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
signal,
|
|
368
|
-
timeout: 10 * 60 * 1000,
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
if (execResult.code !== 0) {
|
|
372
|
-
throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return (execResult.stdout || "").trim()
|
|
403
|
+
{
|
|
404
|
+
tasks: params.tasks,
|
|
405
|
+
inheritSkills: params.inheritSkills,
|
|
376
406
|
},
|
|
407
|
+
createSubagentRunner(pi, signal),
|
|
377
408
|
)
|
|
378
409
|
|
|
379
410
|
return {
|
|
@@ -543,12 +574,58 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
|
|
|
543
574
|
},
|
|
544
575
|
}
|
|
545
576
|
})
|
|
577
|
+
|
|
578
|
+
// Read output smart filter — reduces context waste from large file reads
|
|
579
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
580
|
+
if (event.toolName !== "read") return undefined
|
|
581
|
+
|
|
582
|
+
// Extract path from input
|
|
583
|
+
const path = (event.input as any)?.path ?? ""
|
|
584
|
+
if (!path) return undefined
|
|
585
|
+
|
|
586
|
+
// Extract text content from tool result
|
|
587
|
+
const textBlocks = (event.content as Array<any>)?.filter((b: any) => b.type === "text") ?? []
|
|
588
|
+
if (textBlocks.length === 0) return undefined
|
|
589
|
+
|
|
590
|
+
const output = textBlocks.map((b: any) => b.text).join("")
|
|
591
|
+
const isImage = (event.content as Array<any>)?.some((b: any) => b.type === "image") ?? false
|
|
592
|
+
|
|
593
|
+
const result = filterReadOutput({
|
|
594
|
+
path,
|
|
595
|
+
output,
|
|
596
|
+
isError: event.isError ?? false,
|
|
597
|
+
isImage,
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
if (!result.filtered) return undefined
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
content: [{ type: "text", text: result.output }],
|
|
604
|
+
details: {
|
|
605
|
+
...event.details,
|
|
606
|
+
readFilter: {
|
|
607
|
+
strategy: result.strategy,
|
|
608
|
+
originalBytes: result.originalBytes,
|
|
609
|
+
filteredBytes: result.filteredBytes,
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
// Compaction prompt optimizer — makes summaries more focused and useful
|
|
616
|
+
pi.on("session_before_compact", async (_event, _ctx) => {
|
|
617
|
+
return {
|
|
618
|
+
customInstructions: COMPACTION_FOCUS_INSTRUCTIONS,
|
|
619
|
+
}
|
|
620
|
+
})
|
|
546
621
|
}
|
|
547
622
|
|
|
548
623
|
|
|
549
624
|
export { createArtifactHelperTool } from "./tools/artifact-helper"
|
|
550
625
|
export { createAskUserQuestionTool } from "./tools/ask-user-question"
|
|
551
626
|
export { createSubagentTool } from "./tools/subagent"
|
|
627
|
+
export { checkSubagentDepth, getChildDepthEnv, DEFAULT_MAX_SUBAGENT_DEPTH } from "./tools/subagent-depth-guard"
|
|
628
|
+
export { AsyncMutex } from "./tools/async-mutex"
|
|
552
629
|
export { createWorkflowStateTool } from "./tools/workflow-state"
|
|
553
630
|
export { createWorktreeManagerTool } from "./tools/worktree-manager"
|
|
554
631
|
export { createReviewRouterTool } from "./tools/review-router"
|
|
@@ -567,3 +644,5 @@ export {
|
|
|
567
644
|
} from "./utils/artifact-paths"
|
|
568
645
|
export { normalizeSlug } from "./utils/name-utils"
|
|
569
646
|
export { filterBashOutput } from "./tools/bash-output-filter"
|
|
647
|
+
export { filterReadOutput } from "./tools/read-output-filter"
|
|
648
|
+
export { COMPACTION_FOCUS_INSTRUCTIONS, TURN_PREFIX_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async mutex for serializing critical sections that mutate shared state
|
|
3
|
+
* (e.g. process.env) across concurrent async operations.
|
|
4
|
+
*/
|
|
5
|
+
export class AsyncMutex {
|
|
6
|
+
private queue: (() => void)[] = []
|
|
7
|
+
private locked = false
|
|
8
|
+
|
|
9
|
+
async acquire(): Promise<() => void> {
|
|
10
|
+
return new Promise<() => void>((resolve) => {
|
|
11
|
+
const tryAcquire = () => {
|
|
12
|
+
if (!this.locked) {
|
|
13
|
+
this.locked = true
|
|
14
|
+
resolve(() => this.release())
|
|
15
|
+
} else {
|
|
16
|
+
this.queue.push(tryAcquire)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
tryAcquire()
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private release(): void {
|
|
24
|
+
this.locked = false
|
|
25
|
+
const next = this.queue.shift()
|
|
26
|
+
if (next) next()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -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.`
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel subagent tool — runs multiple skill-based subagent tasks concurrently.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Recursion depth guard (PI_SUBAGENT_DEPTH / PI_SUBAGENT_MAX_DEPTH)
|
|
6
|
+
* - Optional context slimming via --no-skills flag
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
checkSubagentDepth,
|
|
11
|
+
getChildDepthEnv,
|
|
12
|
+
} from "./subagent-depth-guard"
|
|
13
|
+
import type { SubagentExecOptions, SubagentRunner } from "./subagent"
|
|
14
|
+
|
|
1
15
|
export interface ParallelSubagentTask {
|
|
2
16
|
agent: string
|
|
3
17
|
task: string
|
|
@@ -5,6 +19,8 @@ export interface ParallelSubagentTask {
|
|
|
5
19
|
|
|
6
20
|
export interface ParallelSubagentInput {
|
|
7
21
|
tasks: ParallelSubagentTask[]
|
|
22
|
+
/** Whether subagents should inherit skills. Default: false for parallel */
|
|
23
|
+
inheritSkills?: boolean
|
|
8
24
|
}
|
|
9
25
|
|
|
10
26
|
export interface ParallelResultItem {
|
|
@@ -18,8 +34,6 @@ export interface ParallelSubagentResult {
|
|
|
18
34
|
outputs: ParallelResultItem[]
|
|
19
35
|
}
|
|
20
36
|
|
|
21
|
-
export type SubagentRunner = (prompt: string) => Promise<string>
|
|
22
|
-
|
|
23
37
|
function buildPrompt(agent: string, task: string): string {
|
|
24
38
|
return `/skill:${agent} ${task}`
|
|
25
39
|
}
|
|
@@ -28,12 +42,22 @@ export function createParallelSubagentTool() {
|
|
|
28
42
|
return {
|
|
29
43
|
name: "parallel_subagent",
|
|
30
44
|
async execute(input: ParallelSubagentInput, runner: SubagentRunner): Promise<ParallelSubagentResult> {
|
|
45
|
+
// Recursion depth guard
|
|
46
|
+
const depthCheck = checkSubagentDepth()
|
|
47
|
+
if (!depthCheck.allowed) {
|
|
48
|
+
throw new Error(depthCheck.reason!)
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
if (!input.tasks || input.tasks.length === 0) {
|
|
32
52
|
throw new Error("parallel_subagent requires at least one task")
|
|
33
53
|
}
|
|
34
54
|
|
|
55
|
+
// Default: parallel workers get a slim context (no inherited skills)
|
|
56
|
+
const inheritSkills = input.inheritSkills ?? false
|
|
57
|
+
const execOptions = buildParallelExecOptions(inheritSkills)
|
|
58
|
+
|
|
35
59
|
const promises = input.tasks.map(({ agent, task }) =>
|
|
36
|
-
runner(buildPrompt(agent, task)),
|
|
60
|
+
runner(buildPrompt(agent, task), execOptions),
|
|
37
61
|
)
|
|
38
62
|
|
|
39
63
|
const settled = await Promise.allSettled(promises)
|
|
@@ -52,3 +76,25 @@ export function createParallelSubagentTool() {
|
|
|
52
76
|
},
|
|
53
77
|
}
|
|
54
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build exec options for parallel subagent workers.
|
|
82
|
+
* Defaults to slim context (no inherited skills).
|
|
83
|
+
*/
|
|
84
|
+
function buildParallelExecOptions(inheritSkills: boolean): SubagentExecOptions {
|
|
85
|
+
const flags: string[] = []
|
|
86
|
+
const env: Record<string, string> = {}
|
|
87
|
+
|
|
88
|
+
// Context slimming
|
|
89
|
+
if (!inheritSkills) {
|
|
90
|
+
flags.push("--no-skills")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Recursion depth
|
|
94
|
+
Object.assign(env, getChildDepthEnv())
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
extraFlags: flags.length > 0 ? flags : undefined,
|
|
98
|
+
extraEnv: env,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent recursion depth guard.
|
|
3
|
+
*
|
|
4
|
+
* Prevents uncontrolled recursive subagent spawning by tracking depth
|
|
5
|
+
* via environment variables. Inspired by pi-subagents' depth control
|
|
6
|
+
* but implemented as a minimal, standalone module.
|
|
7
|
+
*
|
|
8
|
+
* Environment variables:
|
|
9
|
+
* PI_SUBAGENT_DEPTH — current nesting depth (default: 0)
|
|
10
|
+
* PI_SUBAGENT_MAX_DEPTH — maximum allowed depth (default: 2)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Default maximum subagent nesting depth */
|
|
14
|
+
export const DEFAULT_MAX_SUBAGENT_DEPTH = 2
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read the current subagent depth from environment.
|
|
18
|
+
* Returns 0 if unset or invalid.
|
|
19
|
+
*/
|
|
20
|
+
export function getCurrentDepth(): number {
|
|
21
|
+
const raw = process.env.PI_SUBAGENT_DEPTH
|
|
22
|
+
if (!raw) return 0
|
|
23
|
+
const depth = parseInt(raw, 10)
|
|
24
|
+
return Number.isNaN(depth) ? 0 : depth
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the maximum allowed depth from environment.
|
|
29
|
+
* Falls back to DEFAULT_MAX_SUBAGENT_DEPTH if unset or invalid.
|
|
30
|
+
*/
|
|
31
|
+
export function getMaxDepth(): number {
|
|
32
|
+
const raw = process.env.PI_SUBAGENT_MAX_DEPTH
|
|
33
|
+
if (!raw) return DEFAULT_MAX_SUBAGENT_DEPTH
|
|
34
|
+
const max = parseInt(raw, 10)
|
|
35
|
+
return Number.isNaN(max) || max < 0 ? DEFAULT_MAX_SUBAGENT_DEPTH : max
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check whether subagent execution is allowed at the current depth.
|
|
40
|
+
* Returns an object with:
|
|
41
|
+
* allowed — whether execution can proceed
|
|
42
|
+
* depth — the current depth
|
|
43
|
+
* max — the maximum depth
|
|
44
|
+
* reason — human-readable explanation if blocked
|
|
45
|
+
*/
|
|
46
|
+
export function checkSubagentDepth(): {
|
|
47
|
+
allowed: boolean
|
|
48
|
+
depth: number
|
|
49
|
+
max: number
|
|
50
|
+
reason?: string
|
|
51
|
+
} {
|
|
52
|
+
const depth = getCurrentDepth()
|
|
53
|
+
const max = getMaxDepth()
|
|
54
|
+
|
|
55
|
+
if (depth >= max) {
|
|
56
|
+
return {
|
|
57
|
+
allowed: false,
|
|
58
|
+
depth,
|
|
59
|
+
max,
|
|
60
|
+
reason: `Subagent recursion depth limit reached (depth=${depth}, max=${max}). ` +
|
|
61
|
+
`Set PI_SUBAGENT_MAX_DEPTH to increase the limit.`,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { allowed: true, depth, max }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build environment variables to pass to a child subagent process.
|
|
70
|
+
* Increments the depth counter so the child can guard its own spawns.
|
|
71
|
+
*
|
|
72
|
+
* @param overrides — optional overrides for max depth per-agent
|
|
73
|
+
*/
|
|
74
|
+
export function getChildDepthEnv(overrides?: {
|
|
75
|
+
maxDepth?: number
|
|
76
|
+
}): Record<string, string> {
|
|
77
|
+
const currentDepth = getCurrentDepth()
|
|
78
|
+
const childDepth = currentDepth + 1
|
|
79
|
+
let maxDepth = overrides?.maxDepth ?? getMaxDepth()
|
|
80
|
+
if (maxDepth < 0) maxDepth = DEFAULT_MAX_SUBAGENT_DEPTH
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
PI_SUBAGENT_DEPTH: String(childDepth),
|
|
84
|
+
PI_SUBAGENT_MAX_DEPTH: String(maxDepth),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent tool — runs a single skill-based subagent or a serial chain.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Recursion depth guard (PI_SUBAGENT_DEPTH / PI_SUBAGENT_MAX_DEPTH)
|
|
6
|
+
* - Optional context slimming via --no-skills flag
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
checkSubagentDepth,
|
|
11
|
+
getChildDepthEnv,
|
|
12
|
+
} from "./subagent-depth-guard"
|
|
13
|
+
|
|
1
14
|
export interface SubagentTask {
|
|
2
15
|
agent: string
|
|
3
16
|
task: string
|
|
@@ -7,6 +20,8 @@ export interface SubagentInput {
|
|
|
7
20
|
agent?: string
|
|
8
21
|
task?: string
|
|
9
22
|
chain?: SubagentTask[]
|
|
23
|
+
/** Whether the subagent should inherit skills. Default: true */
|
|
24
|
+
inheritSkills?: boolean
|
|
10
25
|
}
|
|
11
26
|
|
|
12
27
|
export interface SubagentResult {
|
|
@@ -14,12 +29,28 @@ export interface SubagentResult {
|
|
|
14
29
|
outputs: string[]
|
|
15
30
|
}
|
|
16
31
|
|
|
17
|
-
export
|
|
32
|
+
export interface SubagentExecOptions {
|
|
33
|
+
/** Extra CLI flags to pass to the child pi process */
|
|
34
|
+
extraFlags?: string[]
|
|
35
|
+
/** Environment variables to inject into the child process */
|
|
36
|
+
extraEnv?: Record<string, string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SubagentRunner = (
|
|
40
|
+
prompt: string,
|
|
41
|
+
options?: SubagentExecOptions,
|
|
42
|
+
) => Promise<string>
|
|
18
43
|
|
|
19
44
|
export function createSubagentTool() {
|
|
20
45
|
return {
|
|
21
46
|
name: "subagent",
|
|
22
47
|
async execute(input: SubagentInput, runner: SubagentRunner): Promise<SubagentResult> {
|
|
48
|
+
// Recursion depth guard
|
|
49
|
+
const depthCheck = checkSubagentDepth()
|
|
50
|
+
if (!depthCheck.allowed) {
|
|
51
|
+
throw new Error(depthCheck.reason!)
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
const hasSingle = Boolean(input.agent && input.task)
|
|
24
55
|
const hasChain = Boolean(input.chain && input.chain.length > 0)
|
|
25
56
|
|
|
@@ -27,9 +58,11 @@ export function createSubagentTool() {
|
|
|
27
58
|
throw new Error("Provide exactly one mode: single or chain")
|
|
28
59
|
}
|
|
29
60
|
|
|
61
|
+
const execOptions = buildExecOptions(input.inheritSkills)
|
|
62
|
+
|
|
30
63
|
if (hasSingle) {
|
|
31
64
|
const prompt = buildPrompt(input.agent!, input.task!)
|
|
32
|
-
const output = await runner(prompt)
|
|
65
|
+
const output = await runner(prompt, execOptions)
|
|
33
66
|
return {
|
|
34
67
|
mode: "single",
|
|
35
68
|
outputs: [output],
|
|
@@ -41,7 +74,7 @@ export function createSubagentTool() {
|
|
|
41
74
|
|
|
42
75
|
for (const task of input.chain ?? []) {
|
|
43
76
|
const prompt = buildPrompt(task.agent, task.task.replace(/\{previous\}/g, previous))
|
|
44
|
-
const output = await runner(prompt)
|
|
77
|
+
const output = await runner(prompt, execOptions)
|
|
45
78
|
outputs.push(output)
|
|
46
79
|
previous = output
|
|
47
80
|
}
|
|
@@ -54,6 +87,28 @@ export function createSubagentTool() {
|
|
|
54
87
|
}
|
|
55
88
|
}
|
|
56
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Build exec options based on subagent configuration.
|
|
92
|
+
* Controls context inheritance and recursion depth.
|
|
93
|
+
*/
|
|
94
|
+
function buildExecOptions(inheritSkills?: boolean): SubagentExecOptions {
|
|
95
|
+
const flags: string[] = []
|
|
96
|
+
const env: Record<string, string> = {}
|
|
97
|
+
|
|
98
|
+
// Context slimming: skip skills inheritance when explicitly disabled
|
|
99
|
+
if (inheritSkills === false) {
|
|
100
|
+
flags.push("--no-skills")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Recursion depth: pass incremented depth to child process
|
|
104
|
+
Object.assign(env, getChildDepthEnv())
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
extraFlags: flags.length > 0 ? flags : undefined,
|
|
108
|
+
extraEnv: env,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
function buildPrompt(agent: string, task: string): string {
|
|
58
113
|
return `/skill:${agent} ${task}`
|
|
59
114
|
}
|