@wingsbutterfly/dsh-rtk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/README.zh.md +211 -0
  5. package/THIRD_PARTY_NOTICES.md +51 -0
  6. package/docs/assets/how-it-works.svg +47 -0
  7. package/docs/verification.md +279 -0
  8. package/lib/command.d.ts +41 -0
  9. package/lib/command.d.ts.map +1 -0
  10. package/lib/command.js +137 -0
  11. package/lib/command.js.map +1 -0
  12. package/lib/compact/build.d.ts +15 -0
  13. package/lib/compact/build.d.ts.map +1 -0
  14. package/lib/compact/build.js +140 -0
  15. package/lib/compact/build.js.map +1 -0
  16. package/lib/compact/detect.d.ts +22 -0
  17. package/lib/compact/detect.d.ts.map +1 -0
  18. package/lib/compact/detect.js +54 -0
  19. package/lib/compact/detect.js.map +1 -0
  20. package/lib/compact/dsh-result.d.ts +49 -0
  21. package/lib/compact/dsh-result.d.ts.map +1 -0
  22. package/lib/compact/dsh-result.js +84 -0
  23. package/lib/compact/dsh-result.js.map +1 -0
  24. package/lib/compact/git.d.ts +21 -0
  25. package/lib/compact/git.d.ts.map +1 -0
  26. package/lib/compact/git.js +197 -0
  27. package/lib/compact/git.js.map +1 -0
  28. package/lib/compact/index.d.ts +39 -0
  29. package/lib/compact/index.d.ts.map +1 -0
  30. package/lib/compact/index.js +236 -0
  31. package/lib/compact/index.js.map +1 -0
  32. package/lib/compact/linter.d.ts +12 -0
  33. package/lib/compact/linter.d.ts.map +1 -0
  34. package/lib/compact/linter.js +118 -0
  35. package/lib/compact/linter.js.map +1 -0
  36. package/lib/compact/search.d.ts +16 -0
  37. package/lib/compact/search.d.ts.map +1 -0
  38. package/lib/compact/search.js +67 -0
  39. package/lib/compact/search.js.map +1 -0
  40. package/lib/compact/source.d.ts +22 -0
  41. package/lib/compact/source.d.ts.map +1 -0
  42. package/lib/compact/source.js +224 -0
  43. package/lib/compact/source.js.map +1 -0
  44. package/lib/compact/test-output.d.ts +12 -0
  45. package/lib/compact/test-output.d.ts.map +1 -0
  46. package/lib/compact/test-output.js +168 -0
  47. package/lib/compact/test-output.js.map +1 -0
  48. package/lib/compact/text.d.ts +22 -0
  49. package/lib/compact/text.d.ts.map +1 -0
  50. package/lib/compact/text.js +87 -0
  51. package/lib/compact/text.js.map +1 -0
  52. package/lib/config.d.ts +243 -0
  53. package/lib/config.d.ts.map +1 -0
  54. package/lib/config.js +183 -0
  55. package/lib/config.js.map +1 -0
  56. package/lib/index.d.ts +46 -0
  57. package/lib/index.d.ts.map +1 -0
  58. package/lib/index.js +405 -0
  59. package/lib/index.js.map +1 -0
  60. package/lib/metrics.d.ts +35 -0
  61. package/lib/metrics.d.ts.map +1 -0
  62. package/lib/metrics.js +51 -0
  63. package/lib/metrics.js.map +1 -0
  64. package/lib/rtk-executable.d.ts +51 -0
  65. package/lib/rtk-executable.d.ts.map +1 -0
  66. package/lib/rtk-executable.js +75 -0
  67. package/lib/rtk-executable.js.map +1 -0
  68. package/lib/rtk-rewrite.d.ts +88 -0
  69. package/lib/rtk-rewrite.d.ts.map +1 -0
  70. package/lib/rtk-rewrite.js +150 -0
  71. package/lib/rtk-rewrite.js.map +1 -0
  72. package/lib/runtime-guard.d.ts +31 -0
  73. package/lib/runtime-guard.d.ts.map +1 -0
  74. package/lib/runtime-guard.js +32 -0
  75. package/lib/runtime-guard.js.map +1 -0
  76. package/package.json +82 -0
  77. package/scripts/link-dsh.mjs +135 -0
  78. package/src/command.ts +174 -0
  79. package/src/compact/build.ts +154 -0
  80. package/src/compact/detect.ts +54 -0
  81. package/src/compact/dsh-result.ts +99 -0
  82. package/src/compact/git.ts +209 -0
  83. package/src/compact/index.ts +284 -0
  84. package/src/compact/linter.ts +126 -0
  85. package/src/compact/search.ts +73 -0
  86. package/src/compact/source.ts +244 -0
  87. package/src/compact/test-output.ts +184 -0
  88. package/src/compact/text.ts +86 -0
  89. package/src/config.ts +263 -0
  90. package/src/index.ts +431 -0
  91. package/src/metrics.ts +84 -0
  92. package/src/rtk-executable.ts +117 -0
  93. package/src/rtk-rewrite.ts +179 -0
  94. package/src/runtime-guard.ts +44 -0
package/src/command.ts ADDED
@@ -0,0 +1,174 @@
1
+ import type { RtkConfig } from './config.js'
2
+ import type { MetricsSummary } from './metrics.js'
3
+ import type { RtkRuntimeStatus } from './runtime-guard.js'
4
+
5
+ /** Everything the `/rtk` command needs from the plugin. */
6
+ export interface RtkCommandController {
7
+ getConfig(): RtkConfig
8
+ /** Replace the user layer wholesale; `{}` restores composition defaults. */
9
+ resetConfig(): Promise<void>
10
+ getRuntimeStatus(): RtkRuntimeStatus
11
+ refreshRuntimeStatus(): Promise<RtkRuntimeStatus>
12
+ getMetrics(): MetricsSummary
13
+ clearMetrics(): void
14
+ /** Where the user layer of the configuration lives, when it is persisted. */
15
+ configLocation(): string
16
+ }
17
+
18
+ /** The command result shape the harness normalizes. */
19
+ export type RtkCommandResult = { kind: 'success'; text?: string } | { kind: 'error'; text: string }
20
+
21
+ /**
22
+ * Official install routes, taken from the project's own README.
23
+ *
24
+ * `cargo install rtk` is called out because crates.io carries an unrelated
25
+ * crate of the same name; only the `--git` form installs this tool.
26
+ */
27
+ const INSTALL_HINT = [
28
+ 'Install rtk with one of:',
29
+ ' brew install rtk # macOS / Linux (Homebrew, recommended)',
30
+ ' winget install rtk-ai.rtk # Windows',
31
+ ' curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh',
32
+ ' cargo install --git https://github.com/rtk-ai/rtk',
33
+ '',
34
+ 'Note: plain `cargo install rtk` installs an unrelated crate of the same name.',
35
+ 'Releases: https://github.com/rtk-ai/rtk/releases',
36
+ ].join('\n')
37
+
38
+ const HELP_TEXT = [
39
+ 'dsh-rtk — rewrite bash commands to rtk and compact tool output.',
40
+ '',
41
+ ' /rtk show configuration and runtime status',
42
+ ' /rtk show same as /rtk',
43
+ ' /rtk path where the configuration is stored',
44
+ ' /rtk verify check whether the rtk executable is usable (prints install options when it is not)',
45
+ ' /rtk stats compaction savings for this session',
46
+ ' /rtk clear-stats reset the savings counters',
47
+ ' /rtk reset restore configuration defaults',
48
+ ' /rtk help this text',
49
+ ].join('\n')
50
+
51
+ function yesNo(value: boolean): string {
52
+ return value ? 'on' : 'off'
53
+ }
54
+
55
+ function configReport(config: RtkConfig, status: RtkRuntimeStatus, location: string): string {
56
+ const compaction = config.outputCompaction
57
+ const lines = [
58
+ `dsh-rtk: ${yesNo(config.enabled)} mode: ${config.mode}`,
59
+ `rtk binary: ${status.rtkAvailable ? 'available' : 'unavailable'}${status.rtkExecutablePath ? ` (${status.rtkExecutablePath})` : ''}`,
60
+ `config: ${location}`,
61
+ '',
62
+ 'command rewriting',
63
+ ` guardWhenRtkMissing ${yesNo(config.guardWhenRtkMissing)}`,
64
+ ` showRewriteNotifications ${yesNo(config.showRewriteNotifications)}`,
65
+ ` rewriteTimeoutMs ${config.rewriteTimeoutMs}`,
66
+ '',
67
+ 'output compaction',
68
+ ` enabled ${yesNo(compaction.enabled)}`,
69
+ ` tools ${config.compactedTools.join(', ')}`,
70
+ ` stripAnsi ${yesNo(compaction.stripAnsi)}`,
71
+ ` readCompaction ${yesNo(compaction.readCompaction.enabled)}`,
72
+ ` sourceCodeFiltering ${compaction.sourceCodeFiltering}`,
73
+ ` filterBuildOutput ${yesNo(compaction.filterBuildOutput)}`,
74
+ ` aggregateTestOutput ${yesNo(compaction.aggregateTestOutput)}`,
75
+ ` compactGitOutput ${yesNo(compaction.compactGitOutput)}`,
76
+ ` aggregateLinterOutput ${yesNo(compaction.aggregateLinterOutput)}`,
77
+ ` groupSearchOutput ${yesNo(compaction.groupSearchOutput)}`,
78
+ ` smartTruncate ${yesNo(compaction.smartTruncate.enabled)} (max ${compaction.smartTruncate.maxLines} lines)`,
79
+ ` truncate ${yesNo(compaction.truncate.enabled)} (max ${compaction.truncate.maxChars} chars)`,
80
+ ]
81
+ if (status.lastError) lines.push('', `last rtk error: ${status.lastError}`)
82
+ return lines.join('\n')
83
+ }
84
+
85
+ function statsReport(summary: MetricsSummary): string {
86
+ if (summary.calls === 0) return 'dsh-rtk: no compaction recorded in this session yet.'
87
+
88
+ const lines = [
89
+ `compacted calls: ${summary.calls}`,
90
+ `characters: ${summary.originalChars} -> ${summary.compactedChars} (saved ${summary.savedChars}, ${summary.savedPercent}%)`,
91
+ ]
92
+
93
+ const tools = Object.entries(summary.byTool)
94
+ if (tools.length > 0) {
95
+ lines.push('', 'by tool:')
96
+ for (const [tool, totals] of tools.sort((left, right) => right[1].originalChars - left[1].originalChars)) {
97
+ const saved = Math.max(0, totals.originalChars - totals.compactedChars)
98
+ lines.push(` ${tool}: ${totals.calls} call(s), saved ${saved} chars`)
99
+ }
100
+ }
101
+
102
+ const techniques = Object.entries(summary.byTechnique)
103
+ if (techniques.length > 0) {
104
+ lines.push('', 'techniques:')
105
+ for (const [technique, count] of techniques.sort((left, right) => right[1] - left[1])) {
106
+ lines.push(` ${technique}: ${count}`)
107
+ }
108
+ }
109
+
110
+ return lines.join('\n')
111
+ }
112
+
113
+ /**
114
+ * Build the `/rtk` command definition.
115
+ *
116
+ * The harness has no interactive settings modal, so every subcommand returns
117
+ * text; the configuration itself is editable through the harness settings
118
+ * document, which this namespace is registered in.
119
+ *
120
+ * @param controller - accessors into the live plugin state.
121
+ * @returns the command definition to register.
122
+ */
123
+ export function createRtkCommand(controller: RtkCommandController): {
124
+ name: string
125
+ description: string
126
+ handler: (invocation: { rawInput: string }) => Promise<RtkCommandResult>
127
+ } {
128
+ return {
129
+ name: 'rtk',
130
+ description: 'RTK command rewriting and output compaction: status, verification, and savings.',
131
+ async handler(invocation) {
132
+ const subcommand = invocation.rawInput.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
133
+
134
+ switch (subcommand) {
135
+ case '':
136
+ case 'show':
137
+ return { kind: 'success', text: configReport(controller.getConfig(), controller.getRuntimeStatus(), controller.configLocation()) }
138
+
139
+ case 'path':
140
+ return { kind: 'success', text: controller.configLocation() }
141
+
142
+ case 'verify': {
143
+ const status = await controller.refreshRuntimeStatus()
144
+ if (status.rtkAvailable) {
145
+ const parts = [`rtk is available at ${status.rtkExecutablePath ?? status.rtkExecutableCommand ?? 'rtk'}`]
146
+ if (status.rtkExecutableResolutionWarning) parts.push(`note: ${status.rtkExecutableResolutionWarning}`)
147
+ return { kind: 'success', text: parts.join('\n') }
148
+ }
149
+ return {
150
+ kind: 'error',
151
+ text: `rtk is not usable: ${status.lastError ?? 'unknown error'}\n\n${INSTALL_HINT}`,
152
+ }
153
+ }
154
+
155
+ case 'stats':
156
+ return { kind: 'success', text: statsReport(controller.getMetrics()) }
157
+
158
+ case 'clear-stats':
159
+ controller.clearMetrics()
160
+ return { kind: 'success', text: 'dsh-rtk: savings counters cleared.' }
161
+
162
+ case 'reset':
163
+ await controller.resetConfig()
164
+ return { kind: 'success', text: 'dsh-rtk: configuration restored to defaults.' }
165
+
166
+ case 'help':
167
+ return { kind: 'success', text: HELP_TEXT }
168
+
169
+ default:
170
+ return { kind: 'error', text: `unknown subcommand "${subcommand}".\n\n${HELP_TEXT}` }
171
+ }
172
+ },
173
+ }
174
+ }
@@ -0,0 +1,154 @@
1
+ import { matchesCommandPatterns } from './detect.js'
2
+
3
+ interface BuildStats {
4
+ compiled: number
5
+ errors: string[][]
6
+ warnings: string[]
7
+ }
8
+
9
+ const BUILD_COMMAND_PATTERNS = [
10
+ /^cargo\s+(build|check)\b/,
11
+ /^bun\s+build\b/,
12
+ /^npm\s+run\s+build\b/,
13
+ /^yarn\s+build\b/,
14
+ /^pnpm\s+build\b/,
15
+ /^(?:npx\s+)?tsc\b/,
16
+ /^make\b/,
17
+ /^cmake\b/,
18
+ /^gradle\b/,
19
+ /^mvn\b/,
20
+ /^go\s+(build|install)\b/,
21
+ /^python\s+setup\.py\s+build\b/,
22
+ /^pip\s+install\b/,
23
+ ] as const
24
+
25
+ /** Progress chatter that carries no actionable information. */
26
+ const SKIP_PATTERNS = [
27
+ /^\s*Compiling\s+/,
28
+ /^\s*Checking\s+/,
29
+ /^\s*Downloading\s+/,
30
+ /^\s*Downloaded\s+/,
31
+ /^\s*Fetching\s+/,
32
+ /^\s*Fetched\s+/,
33
+ /^\s*Updating\s+/,
34
+ /^\s*Updated\s+/,
35
+ /^\s*Building\s+/,
36
+ /^\s*Generated\s+/,
37
+ /^\s*Creating\s+/,
38
+ /^\s*Running\s+/,
39
+ ]
40
+
41
+ const ERROR_START_PATTERNS = [
42
+ /^error\[/,
43
+ /^error:/,
44
+ /^\[ERROR\]/,
45
+ /^FAIL/,
46
+ // `tsc` and friends: `path(line,col): error TSxxxx: message`.
47
+ /^[^\s:][^:]*(?:\(\d+[,:]\d+\)|:\d+:\d+):\s*(?:fatal\s+)?error\b/,
48
+ // The `path:line:col: error: message` shape used by many linters and compilers.
49
+ /^[^\s:][^:]*:\d+:\s*(?:fatal\s+)?error\b/,
50
+ ]
51
+ const WARNING_PATTERNS = [/^warning:/, /^\[WARNING\]/, /^warn:/]
52
+ const COMPILE_PROGRESS_PATTERN = /^\s*(Compiling|Checking|Building)\s+/
53
+
54
+ const MAX_ERRORS = 5
55
+ const MAX_ERROR_LINES = 10
56
+ const BLANKS_TO_CLOSE_BLOCK = 2
57
+
58
+ function isSkipLine(line: string): boolean {
59
+ return SKIP_PATTERNS.some((pattern) => pattern.test(line))
60
+ }
61
+
62
+ /**
63
+ * Reduce compiler output to its errors and warnings.
64
+ *
65
+ * Errors keep their indented continuation lines (the source excerpt and the
66
+ * `-->` location) because that detail is what makes an error actionable;
67
+ * progress lines and everything else are dropped. A build with no diagnostics
68
+ * collapses to a one-line success marker, which is the common case and the
69
+ * largest saving.
70
+ *
71
+ * @param output - raw build output.
72
+ * @param command - the command that produced it; drives the applicability check.
73
+ * @returns the summary, or `null` when the command is not a build command.
74
+ */
75
+ export function filterBuildOutput(output: string, command: string | undefined | null): string | null {
76
+ if (!matchesCommandPatterns(command, BUILD_COMMAND_PATTERNS)) return null
77
+
78
+ const lines = output.split('\n')
79
+ const stats: BuildStats = { compiled: 0, errors: [], warnings: [] }
80
+
81
+ let inErrorBlock = false
82
+ let currentError: string[] = []
83
+ let blankCount = 0
84
+
85
+ for (const line of lines) {
86
+ if (COMPILE_PROGRESS_PATTERN.test(line)) {
87
+ stats.compiled++
88
+ continue
89
+ }
90
+ if (isSkipLine(line)) continue
91
+
92
+ if (ERROR_START_PATTERNS.some((pattern) => pattern.test(line))) {
93
+ if (inErrorBlock && currentError.length > 0) stats.errors.push([...currentError])
94
+ inErrorBlock = true
95
+ currentError = [line]
96
+ blankCount = 0
97
+ continue
98
+ }
99
+
100
+ if (WARNING_PATTERNS.some((pattern) => pattern.test(line))) {
101
+ stats.warnings.push(line)
102
+ continue
103
+ }
104
+
105
+ if (!inErrorBlock) continue
106
+
107
+ if (line.trim() === '') {
108
+ blankCount++
109
+ if (blankCount >= BLANKS_TO_CLOSE_BLOCK && currentError.length > 3) {
110
+ stats.errors.push([...currentError])
111
+ inErrorBlock = false
112
+ currentError = []
113
+ } else {
114
+ currentError.push(line)
115
+ }
116
+ continue
117
+ }
118
+
119
+ if (line.match(/^\s/) || line.match(/^-->/)) {
120
+ currentError.push(line)
121
+ blankCount = 0
122
+ continue
123
+ }
124
+
125
+ stats.errors.push([...currentError])
126
+ inErrorBlock = false
127
+ currentError = []
128
+ }
129
+
130
+ if (inErrorBlock && currentError.length > 0) stats.errors.push(currentError)
131
+
132
+ if (stats.errors.length === 0 && stats.warnings.length === 0) {
133
+ // Success may only be claimed on positive evidence that the build ran and
134
+ // was clean. With no diagnostics AND no progress lines the parser simply
135
+ // did not understand the output — and it cannot see an exit code, so
136
+ // guessing "success" would replace a real compiler failure with a
137
+ // reassuring lie. Declining to summarize leaves the text intact.
138
+ if (stats.compiled === 0) return null
139
+ return `[OK] Build successful (${stats.compiled} units compiled)`
140
+ }
141
+
142
+ const result: string[] = []
143
+ if (stats.errors.length > 0) {
144
+ result.push(`[ERROR] ${stats.errors.length} error(s):`)
145
+ for (const error of stats.errors.slice(0, MAX_ERRORS)) {
146
+ result.push(...error.slice(0, MAX_ERROR_LINES))
147
+ if (error.length > MAX_ERROR_LINES) result.push(' ...')
148
+ }
149
+ if (stats.errors.length > MAX_ERRORS) result.push(`... and ${stats.errors.length - MAX_ERRORS} more errors`)
150
+ }
151
+ if (stats.warnings.length > 0) result.push(`\n[WARN] ${stats.warnings.length} warning(s)`)
152
+
153
+ return result.join('\n')
154
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Command classification helpers.
3
+ *
4
+ * Every technique asks the same question first — "is this the kind of command
5
+ * whose output I know how to summarize?" — so the normalization lives here
6
+ * rather than being repeated (and drifting) per technique.
7
+ */
8
+
9
+ const ENV_PREFIX_PATTERN = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*/
10
+ const CHAIN_OPERATORS = ['&&', '||', ';', '|'] as const
11
+
12
+ function sliceFirstSegment(command: string): string {
13
+ let cutIndex = -1
14
+ for (const operator of CHAIN_OPERATORS) {
15
+ const index = command.indexOf(operator)
16
+ if (index === -1) continue
17
+ if (cutIndex === -1 || index < cutIndex) cutIndex = index
18
+ }
19
+ return cutIndex === -1 ? command : command.slice(0, cutIndex)
20
+ }
21
+
22
+ /**
23
+ * Reduce a command line to its first simple command, lower-cased.
24
+ *
25
+ * `FOO=1 git status | head` normalizes to `git status`: the assignment run is
26
+ * not the command, and the pipeline is not part of what the first command is.
27
+ * A pattern that matches the whole line would classify every prefixed or
28
+ * piped invocation as unknown and skip compaction entirely.
29
+ *
30
+ * @param command - raw command line, possibly multi-line.
31
+ * @returns the normalized first command, or `null` when there is none.
32
+ */
33
+ export function normalizeCommandForDetection(command: string | undefined | null): string | null {
34
+ if (typeof command !== 'string') return null
35
+
36
+ const firstNonEmptyLine = command
37
+ .split(/\r?\n/)
38
+ .map((line) => line.trim())
39
+ .find((line) => line.length > 0)
40
+ if (!firstNonEmptyLine) return null
41
+
42
+ const withoutEnvPrefix = firstNonEmptyLine.replace(ENV_PREFIX_PATTERN, '').trim()
43
+ if (!withoutEnvPrefix) return null
44
+
45
+ const firstSegment = sliceFirstSegment(withoutEnvPrefix).trim().toLowerCase()
46
+ return firstSegment || null
47
+ }
48
+
49
+ /** Whether the command's first simple command matches any pattern. */
50
+ export function matchesCommandPatterns(command: string | undefined | null, patterns: readonly RegExp[]): boolean {
51
+ const normalized = normalizeCommandForDetection(command)
52
+ if (!normalized) return false
53
+ return patterns.some((pattern) => pattern.test(normalized))
54
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Split and reassemble the harness `bash` result envelope.
3
+ *
4
+ * The bash tool renders a result as the stdout tail, an optional `[stderr]`
5
+ * section, and a run of trailing status markers (`[exit code: N]`,
6
+ * `[timed out after …]`, `[sandbox: …]`, …). Those markers are load-bearing:
7
+ * the model is told to check `[exit code: N]` on every call, and the Web UI
8
+ * parses that same line to draw its exit-status pill.
9
+ *
10
+ * Compaction rewrites the output body, so anything that rebuilds the text
11
+ * wholesale — the git, build, and test summarizers all do — would drop the
12
+ * markers with it. This module is the boundary that keeps that from happening:
13
+ * markers are lifted out before compaction and put back after, in their
14
+ * original order.
15
+ */
16
+
17
+ /** Markers the bash tool appends after the output body. */
18
+ const TRAILING_MARKER_PATTERNS = [
19
+ /^\[exit code: -?\d+\]$/,
20
+ /^\[killed by signal: .+\]$/,
21
+ /^\[timed out after \d+ms\]$/,
22
+ /^\[output truncated; full output: .+\]$/,
23
+ /^\[some output was dropped from memory; full output: .+\]$/,
24
+ /^\[sandbox: .+\]$/,
25
+ ] as const
26
+
27
+ const STDERR_HEADER = '[stderr]'
28
+ /** The bash tool's placeholder for a command that printed nothing at all. */
29
+ export const NO_OUTPUT_PLACEHOLDER = '(no output)'
30
+
31
+ function isTrailingMarker(line: string): boolean {
32
+ return TRAILING_MARKER_PATTERNS.some((pattern) => pattern.test(line))
33
+ }
34
+
35
+ /** A bash result decomposed into the parts compaction must treat differently. */
36
+ export interface BashResultParts {
37
+ /** True when the tool rendered its "printed nothing" placeholder. */
38
+ empty: boolean
39
+ /** stdout body, without the `[stderr]` section or trailing markers. */
40
+ stdout: string
41
+ /** stderr body when the result carried a `[stderr]` section. */
42
+ stderr?: string
43
+ /** Trailing status markers, in their original order. */
44
+ markers: string[]
45
+ }
46
+
47
+ /**
48
+ * Decompose a rendered bash result.
49
+ *
50
+ * Trailing markers are peeled from the end first, then the `[stderr]` section
51
+ * is split off. Only the first `[stderr]` header counts: if the command's own
52
+ * output contains that literal line, everything after it belongs to stderr,
53
+ * which is what the renderer produced.
54
+ *
55
+ * @param text - the rendered bash result.
56
+ * @returns the decomposed parts.
57
+ */
58
+ export function parseBashResult(text: string): BashResultParts {
59
+ if (text === NO_OUTPUT_PLACEHOLDER) {
60
+ return { empty: true, stdout: '', markers: [] }
61
+ }
62
+
63
+ const lines = text.split('\n')
64
+ const markers: string[] = []
65
+ while (lines.length > 0) {
66
+ const last = lines[lines.length - 1]
67
+ if (last === undefined || !isTrailingMarker(last)) break
68
+ markers.unshift(last)
69
+ lines.pop()
70
+ }
71
+
72
+ const headerIndex = lines.indexOf(STDERR_HEADER)
73
+ if (headerIndex === -1) {
74
+ return { empty: false, stdout: lines.join('\n'), markers }
75
+ }
76
+
77
+ const stdout = lines.slice(0, headerIndex).join('\n')
78
+ const stderr = lines.slice(headerIndex + 1).join('\n')
79
+ return { empty: false, stdout, stderr, markers }
80
+ }
81
+
82
+ /**
83
+ * Reassemble a bash result from its parts.
84
+ *
85
+ * The inverse of {@link parseBashResult}, so the model-visible contract — the
86
+ * marker lines the agent loop and the UI both depend on — survives a
87
+ * compaction pass untouched.
88
+ */
89
+ export function renderBashResult(parts: BashResultParts): string {
90
+ const sections: string[] = []
91
+ if (parts.empty) {
92
+ sections.push(NO_OUTPUT_PLACEHOLDER)
93
+ } else {
94
+ if (parts.stdout.length > 0) sections.push(parts.stdout)
95
+ if (parts.stderr !== undefined) sections.push(STDERR_HEADER, parts.stderr)
96
+ }
97
+ sections.push(...parts.markers)
98
+ return sections.join('\n')
99
+ }
@@ -0,0 +1,209 @@
1
+ import { matchesCommandPatterns, normalizeCommandForDetection } from './detect.js'
2
+
3
+ const GIT_COMMAND_PATTERNS = [/^git\s+(diff|status|log|show|stash)\b/] as const
4
+ const RAW_GIT_DIFF_PATTERN = /^diff --git /m
5
+ const RAW_GIT_STATUS_PATTERN = /^(?:## |(?:M|A|D|R|C|U|\?| )\S)/m
6
+
7
+ /**
8
+ * One line of `git status --porcelain` output.
9
+ *
10
+ * Covers the v1 branch header (`## main...origin/main`), the v2 headers
11
+ * (`# branch.head main`), the `??` untracked form, and the two-column status
12
+ * form. The human report contains none of these shapes.
13
+ */
14
+ const PORCELAIN_STATUS_LINE = /^(?:##?\s|\?\? .+|[ MADRCU?!]{2} .+)/
15
+
16
+ /** Share of non-blank lines that must parse as porcelain before summarizing. */
17
+ const PORCELAIN_LINE_MIN_RATIO = 0.8
18
+
19
+ /**
20
+ * Whether a body is `git status --porcelain` rather than the human report.
21
+ *
22
+ * The two are indistinguishable line by line — ` M path` is valid porcelain
23
+ * and also occurs inside the human report — but only porcelain may be sliced
24
+ * with fixed column offsets. Running the summarizer over the human text
25
+ * silently invents and drops entries, so the body must be *consistently*
26
+ * porcelain before any of it is interpreted.
27
+ */
28
+ function isPorcelainStatus(output: string): boolean {
29
+ const lines = output.split('\n').filter((line) => line.trim().length > 0)
30
+ if (lines.length === 0) return false
31
+ const matches = lines.filter((line) => PORCELAIN_STATUS_LINE.test(line)).length
32
+ return matches / lines.length >= PORCELAIN_LINE_MIN_RATIO
33
+ }
34
+
35
+ /** Whether the command is one of the git commands this module understands. */
36
+ export function isGitCommand(command: string | undefined | null): boolean {
37
+ return matchesCommandPatterns(command, GIT_COMMAND_PATTERNS)
38
+ }
39
+
40
+ /**
41
+ * Condense a unified diff to file headers, hunk headers, and a bounded sample
42
+ * of changed lines, with an added/removed count per file.
43
+ */
44
+ export function compactDiff(output: string, maxLines = 50): string {
45
+ const lines = output.split('\n')
46
+ const result: string[] = []
47
+ let currentFile = ''
48
+ let added = 0
49
+ let removed = 0
50
+ let inHunk = false
51
+ let hunkLines = 0
52
+ const maxHunkLines = 10
53
+
54
+ for (const line of lines) {
55
+ if (result.length >= maxLines) {
56
+ result.push('\n... (more changes truncated)')
57
+ break
58
+ }
59
+
60
+ if (line.startsWith('diff --git')) {
61
+ if (currentFile && (added > 0 || removed > 0)) result.push(` +${added} -${removed}`)
62
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/)
63
+ currentFile = match?.[2] ?? 'unknown'
64
+ result.push(`\n> ${currentFile}`)
65
+ added = 0
66
+ removed = 0
67
+ inHunk = false
68
+ continue
69
+ }
70
+
71
+ if (line.startsWith('@@')) {
72
+ inHunk = true
73
+ hunkLines = 0
74
+ result.push(` ${line.match(/@@ .+ @@/)?.[0] ?? '@@'}`)
75
+ continue
76
+ }
77
+
78
+ if (!inHunk) continue
79
+
80
+ if (line.startsWith('+') && !line.startsWith('+++')) {
81
+ added++
82
+ if (hunkLines < maxHunkLines) {
83
+ result.push(` ${line}`)
84
+ hunkLines++
85
+ }
86
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
87
+ removed++
88
+ if (hunkLines < maxHunkLines) {
89
+ result.push(` ${line}`)
90
+ hunkLines++
91
+ }
92
+ } else if (hunkLines < maxHunkLines && !line.startsWith('\\')) {
93
+ if (hunkLines > 0) {
94
+ result.push(` ${line}`)
95
+ hunkLines++
96
+ }
97
+ }
98
+
99
+ if (hunkLines === maxHunkLines) {
100
+ result.push(' ... (truncated)')
101
+ hunkLines++
102
+ }
103
+ }
104
+
105
+ if (currentFile && (added > 0 || removed > 0)) result.push(` +${added} -${removed}`)
106
+ return result.join('\n')
107
+ }
108
+
109
+ interface StatusStats {
110
+ staged: number
111
+ modified: number
112
+ untracked: number
113
+ conflicts: number
114
+ stagedFiles: string[]
115
+ modifiedFiles: string[]
116
+ untrackedFiles: string[]
117
+ }
118
+
119
+ /** Condense `git status` porcelain output into per-bucket counts and samples. */
120
+ export function compactStatus(output: string): string {
121
+ const lines = output.split('\n')
122
+ if (lines.length === 0 || (lines.length === 1 && lines[0]?.trim() === '')) return 'Clean working tree'
123
+
124
+ const stats: StatusStats = {
125
+ staged: 0,
126
+ modified: 0,
127
+ untracked: 0,
128
+ conflicts: 0,
129
+ stagedFiles: [],
130
+ modifiedFiles: [],
131
+ untrackedFiles: [],
132
+ }
133
+ let branchName = ''
134
+
135
+ for (const line of lines) {
136
+ if (line.startsWith('##')) {
137
+ const match = line.match(/## (.+)/)
138
+ if (match?.[1]) branchName = match[1].split('...')[0] ?? match[1]
139
+ continue
140
+ }
141
+ if (line.length < 3) continue
142
+
143
+ const status = line.slice(0, 2)
144
+ const filename = line.slice(3)
145
+ const indexStatus = status[0]
146
+ const worktreeStatus = status[1]
147
+
148
+ if (indexStatus !== undefined && ['M', 'A', 'D', 'R', 'C'].includes(indexStatus)) {
149
+ stats.staged++
150
+ stats.stagedFiles.push(filename)
151
+ }
152
+ if (indexStatus === 'U') stats.conflicts++
153
+ if (worktreeStatus !== undefined && ['M', 'D'].includes(worktreeStatus)) {
154
+ stats.modified++
155
+ stats.modifiedFiles.push(filename)
156
+ }
157
+ if (status === '??') {
158
+ stats.untracked++
159
+ stats.untrackedFiles.push(filename)
160
+ }
161
+ }
162
+
163
+ let result = `Branch: ${branchName}\n`
164
+ const append = (label: string, count: number, files: string[], shown: number): void => {
165
+ if (count === 0) return
166
+ result += `${label}: ${count} files\n`
167
+ for (const file of files.slice(0, shown)) result += ` ${file}\n`
168
+ if (count > shown) result += ` ... +${count - shown} more\n`
169
+ }
170
+ append('Staged', stats.staged, stats.stagedFiles, 5)
171
+ append('Modified', stats.modified, stats.modifiedFiles, 5)
172
+ append('Untracked', stats.untracked, stats.untrackedFiles, 3)
173
+ if (stats.conflicts > 0) result += `Conflicts: ${stats.conflicts} files\n`
174
+
175
+ return result.trim()
176
+ }
177
+
178
+ /** Keep the first `limit` log lines, capping each line's width. */
179
+ export function compactLog(output: string, limit = 20): string {
180
+ const lines = output.split('\n')
181
+ const result: string[] = []
182
+ for (const line of lines.slice(0, limit)) {
183
+ result.push(line.length > 80 ? `${line.slice(0, 77)}...` : line)
184
+ }
185
+ if (lines.length > limit) result.push(`... and ${lines.length - limit} more commits`)
186
+ return result.join('\n')
187
+ }
188
+
189
+ /**
190
+ * Compact a git command's output, or `null` when nothing applies.
191
+ *
192
+ * Each branch first checks that the output actually looks like raw git output.
193
+ * A command like `git diff --stat` inside a wrapper, or a script whose name
194
+ * merely starts with `git`, would otherwise be rewritten into a summary of
195
+ * text this module never parsed correctly.
196
+ */
197
+ export function compactGitOutput(output: string, command: string | undefined | null): string | null {
198
+ if (!isGitCommand(command)) return null
199
+ const normalized = normalizeCommandForDetection(command)
200
+ if (!normalized) return null
201
+
202
+ if (normalized.startsWith('git diff')) return RAW_GIT_DIFF_PATTERN.test(output) ? compactDiff(output) : null
203
+ if (normalized.startsWith('git status')) {
204
+ if (!isPorcelainStatus(output)) return null
205
+ return RAW_GIT_STATUS_PATTERN.test(output) ? compactStatus(output) : null
206
+ }
207
+ if (normalized.startsWith('git log')) return compactLog(output)
208
+ return null
209
+ }