@oh-my-pi/pi-coding-agent 12.18.3 → 12.19.2

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 (233) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +35 -27
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +341 -0
  5. package/src/cli/file-processor.ts +3 -3
  6. package/src/cli/list-models.ts +3 -17
  7. package/src/cli/stats-cli.ts +3 -22
  8. package/src/cli/web-search-cli.ts +8 -16
  9. package/src/commit/agentic/agent.ts +6 -9
  10. package/src/commit/agentic/index.ts +44 -50
  11. package/src/commit/agentic/state.ts +0 -9
  12. package/src/commit/agentic/tools/propose-commit.ts +1 -30
  13. package/src/commit/agentic/tools/schemas.ts +31 -0
  14. package/src/commit/agentic/tools/split-commit.ts +1 -30
  15. package/src/commit/agentic/validation.ts +1 -18
  16. package/src/commit/analysis/conventional.ts +3 -50
  17. package/src/commit/analysis/summary.ts +2 -13
  18. package/src/commit/changelog/detect.ts +4 -1
  19. package/src/commit/changelog/generate.ts +2 -25
  20. package/src/commit/changelog/index.ts +1 -2
  21. package/src/commit/cli.ts +4 -12
  22. package/src/commit/map-reduce/reduce-phase.ts +2 -43
  23. package/src/commit/pipeline.ts +7 -15
  24. package/src/commit/utils.ts +44 -0
  25. package/src/config/prompt-templates.ts +1 -81
  26. package/src/config/settings-schema.ts +20 -1
  27. package/src/config.ts +2 -3
  28. package/src/debug/index.ts +1 -6
  29. package/src/debug/system-info.ts +2 -6
  30. package/src/discovery/builtin.ts +5 -9
  31. package/src/discovery/helpers.ts +0 -26
  32. package/src/discovery/ssh.ts +1 -8
  33. package/src/exa/company.ts +8 -39
  34. package/src/exa/factory.ts +64 -0
  35. package/src/exa/index.ts +0 -16
  36. package/src/exa/linkedin.ts +8 -39
  37. package/src/exa/mcp-client.ts +0 -64
  38. package/src/exa/researcher.ts +17 -59
  39. package/src/exa/search.ts +30 -154
  40. package/src/extensibility/custom-tools/loader.ts +3 -41
  41. package/src/extensibility/extensions/loader.ts +2 -9
  42. package/src/extensibility/hooks/loader.ts +3 -20
  43. package/src/extensibility/hooks/runner.ts +3 -19
  44. package/src/extensibility/plugins/installer.ts +2 -1
  45. package/src/extensibility/plugins/loader.ts +29 -117
  46. package/src/extensibility/skills.ts +2 -89
  47. package/src/extensibility/slash-commands.ts +1 -63
  48. package/src/extensibility/utils.ts +38 -0
  49. package/src/index.ts +9 -25
  50. package/src/internal-urls/index.ts +1 -0
  51. package/src/internal-urls/jobs-protocol.ts +118 -0
  52. package/src/ipy/kernel.ts +2 -0
  53. package/src/lsp/config.ts +1 -5
  54. package/src/lsp/lspmux.ts +0 -17
  55. package/src/lsp/utils.ts +2 -24
  56. package/src/main.ts +16 -24
  57. package/src/mcp/client.ts +1 -46
  58. package/src/mcp/render.ts +8 -1
  59. package/src/mcp/tool-cache.ts +1 -5
  60. package/src/mcp/transports/http.ts +2 -7
  61. package/src/mcp/transports/stdio.ts +2 -7
  62. package/src/modes/components/bash-execution.ts +2 -16
  63. package/src/modes/components/extensions/inspector-panel.ts +8 -18
  64. package/src/modes/components/footer.ts +10 -50
  65. package/src/modes/components/model-selector.ts +2 -21
  66. package/src/modes/components/python-execution.ts +2 -16
  67. package/src/modes/components/settings-selector.ts +1 -10
  68. package/src/modes/components/status-line/segments.ts +8 -25
  69. package/src/modes/components/status-line.ts +14 -31
  70. package/src/modes/components/tool-execution.ts +8 -2
  71. package/src/modes/controllers/command-controller.ts +71 -30
  72. package/src/modes/controllers/event-controller.ts +34 -4
  73. package/src/modes/controllers/mcp-command-controller.ts +3 -34
  74. package/src/modes/controllers/selector-controller.ts +2 -2
  75. package/src/modes/controllers/ssh-command-controller.ts +3 -34
  76. package/src/modes/interactive-mode.ts +6 -2
  77. package/src/modes/rpc/rpc-client.ts +1 -5
  78. package/src/modes/shared.ts +73 -0
  79. package/src/modes/types.ts +1 -0
  80. package/src/modes/utils/ui-helpers.ts +26 -2
  81. package/src/patch/hashline.ts +6 -286
  82. package/src/patch/index.ts +6 -57
  83. package/src/patch/normalize.ts +22 -65
  84. package/src/patch/shared.ts +16 -16
  85. package/src/prompts/system/custom-system-prompt.md +0 -10
  86. package/src/prompts/system/system-prompt.md +69 -89
  87. package/src/prompts/tools/async-result.md +5 -0
  88. package/src/prompts/tools/bash.md +5 -0
  89. package/src/prompts/tools/cancel-job.md +7 -0
  90. package/src/prompts/tools/hashline.md +0 -16
  91. package/src/prompts/tools/poll-jobs.md +7 -0
  92. package/src/prompts/tools/task.md +4 -0
  93. package/src/sdk.ts +70 -6
  94. package/src/session/agent-session.ts +43 -6
  95. package/src/session/agent-storage.ts +69 -278
  96. package/src/session/auth-storage.ts +14 -1430
  97. package/src/session/session-manager.ts +69 -5
  98. package/src/session/session-storage.ts +1 -5
  99. package/src/session/streaming-output.ts +637 -76
  100. package/src/slash-commands/builtin-registry.ts +8 -0
  101. package/src/ssh/connection-manager.ts +4 -12
  102. package/src/ssh/sshfs-mount.ts +3 -7
  103. package/src/ssh/utils.ts +8 -0
  104. package/src/system-prompt.ts +24 -90
  105. package/src/task/executor.ts +11 -1
  106. package/src/task/index.ts +258 -13
  107. package/src/task/parallel.ts +32 -0
  108. package/src/task/render.ts +15 -7
  109. package/src/task/types.ts +5 -0
  110. package/src/tools/ask.ts +4 -7
  111. package/src/tools/bash-interactive.ts +4 -5
  112. package/src/tools/bash.ts +125 -41
  113. package/src/tools/cancel-job.ts +93 -0
  114. package/src/tools/fetch.ts +7 -27
  115. package/src/tools/find.ts +3 -3
  116. package/src/tools/gemini-image.ts +15 -14
  117. package/src/tools/grep.ts +3 -3
  118. package/src/tools/index.ts +13 -29
  119. package/src/tools/json-tree.ts +12 -1
  120. package/src/tools/jtd-to-json-schema.ts +10 -74
  121. package/src/tools/jtd-to-typescript.ts +10 -72
  122. package/src/tools/jtd-utils.ts +102 -0
  123. package/src/tools/notebook.ts +4 -9
  124. package/src/tools/output-meta.ts +52 -26
  125. package/src/tools/path-utils.ts +13 -7
  126. package/src/tools/poll-jobs.ts +178 -0
  127. package/src/tools/python.ts +32 -35
  128. package/src/tools/read.ts +61 -82
  129. package/src/tools/render-utils.ts +8 -159
  130. package/src/tools/ssh.ts +7 -20
  131. package/src/tools/submit-result.ts +1 -1
  132. package/src/tools/tool-errors.ts +0 -30
  133. package/src/tools/tool-result.ts +1 -2
  134. package/src/tools/write.ts +8 -10
  135. package/src/tui/code-cell.ts +8 -3
  136. package/src/tui/status-line.ts +4 -4
  137. package/src/tui/types.ts +0 -1
  138. package/src/tui/utils.ts +1 -14
  139. package/src/utils/command-args.ts +76 -0
  140. package/src/utils/file-mentions.ts +15 -19
  141. package/src/utils/frontmatter.ts +5 -10
  142. package/src/utils/shell-snapshot.ts +0 -11
  143. package/src/utils/title-generator.ts +0 -12
  144. package/src/web/scrapers/artifacthub.ts +7 -16
  145. package/src/web/scrapers/arxiv.ts +3 -8
  146. package/src/web/scrapers/aur.ts +8 -22
  147. package/src/web/scrapers/biorxiv.ts +5 -14
  148. package/src/web/scrapers/bluesky.ts +13 -36
  149. package/src/web/scrapers/brew.ts +5 -10
  150. package/src/web/scrapers/cheatsh.ts +2 -12
  151. package/src/web/scrapers/chocolatey.ts +63 -26
  152. package/src/web/scrapers/choosealicense.ts +3 -18
  153. package/src/web/scrapers/cisa-kev.ts +4 -18
  154. package/src/web/scrapers/clojars.ts +6 -33
  155. package/src/web/scrapers/coingecko.ts +25 -33
  156. package/src/web/scrapers/crates-io.ts +7 -26
  157. package/src/web/scrapers/crossref.ts +4 -18
  158. package/src/web/scrapers/devto.ts +11 -41
  159. package/src/web/scrapers/discogs.ts +7 -10
  160. package/src/web/scrapers/discourse.ts +6 -31
  161. package/src/web/scrapers/dockerhub.ts +12 -35
  162. package/src/web/scrapers/fdroid.ts +8 -33
  163. package/src/web/scrapers/firefox-addons.ts +10 -34
  164. package/src/web/scrapers/flathub.ts +7 -24
  165. package/src/web/scrapers/github-gist.ts +2 -12
  166. package/src/web/scrapers/github.ts +9 -47
  167. package/src/web/scrapers/gitlab.ts +130 -185
  168. package/src/web/scrapers/go-pkg.ts +12 -22
  169. package/src/web/scrapers/hackage.ts +88 -43
  170. package/src/web/scrapers/hackernews.ts +25 -45
  171. package/src/web/scrapers/hex.ts +19 -36
  172. package/src/web/scrapers/huggingface.ts +26 -91
  173. package/src/web/scrapers/iacr.ts +3 -8
  174. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  175. package/src/web/scrapers/lemmy.ts +5 -23
  176. package/src/web/scrapers/lobsters.ts +16 -28
  177. package/src/web/scrapers/mastodon.ts +24 -43
  178. package/src/web/scrapers/maven.ts +6 -21
  179. package/src/web/scrapers/mdn.ts +7 -11
  180. package/src/web/scrapers/metacpan.ts +9 -41
  181. package/src/web/scrapers/musicbrainz.ts +4 -28
  182. package/src/web/scrapers/npm.ts +8 -25
  183. package/src/web/scrapers/nuget.ts +14 -37
  184. package/src/web/scrapers/nvd.ts +6 -28
  185. package/src/web/scrapers/ollama.ts +7 -34
  186. package/src/web/scrapers/open-vsx.ts +5 -19
  187. package/src/web/scrapers/opencorporates.ts +30 -14
  188. package/src/web/scrapers/openlibrary.ts +49 -33
  189. package/src/web/scrapers/orcid.ts +4 -18
  190. package/src/web/scrapers/osv.ts +7 -24
  191. package/src/web/scrapers/packagist.ts +9 -24
  192. package/src/web/scrapers/pub-dev.ts +7 -50
  193. package/src/web/scrapers/pubmed.ts +54 -21
  194. package/src/web/scrapers/pypi.ts +8 -26
  195. package/src/web/scrapers/rawg.ts +11 -19
  196. package/src/web/scrapers/readthedocs.ts +4 -9
  197. package/src/web/scrapers/reddit.ts +5 -15
  198. package/src/web/scrapers/repology.ts +8 -20
  199. package/src/web/scrapers/rfc.ts +5 -14
  200. package/src/web/scrapers/rubygems.ts +6 -21
  201. package/src/web/scrapers/searchcode.ts +8 -36
  202. package/src/web/scrapers/sec-edgar.ts +4 -18
  203. package/src/web/scrapers/semantic-scholar.ts +15 -35
  204. package/src/web/scrapers/snapcraft.ts +5 -19
  205. package/src/web/scrapers/sourcegraph.ts +5 -43
  206. package/src/web/scrapers/spdx.ts +4 -18
  207. package/src/web/scrapers/spotify.ts +4 -23
  208. package/src/web/scrapers/stackoverflow.ts +8 -13
  209. package/src/web/scrapers/terraform.ts +9 -37
  210. package/src/web/scrapers/tldr.ts +3 -7
  211. package/src/web/scrapers/twitter.ts +3 -7
  212. package/src/web/scrapers/types.ts +105 -27
  213. package/src/web/scrapers/utils.ts +97 -103
  214. package/src/web/scrapers/vimeo.ts +7 -27
  215. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  216. package/src/web/scrapers/w3c.ts +6 -14
  217. package/src/web/scrapers/wikidata.ts +5 -19
  218. package/src/web/scrapers/wikipedia.ts +2 -12
  219. package/src/web/scrapers/youtube.ts +5 -34
  220. package/src/web/search/index.ts +0 -9
  221. package/src/web/search/providers/anthropic.ts +3 -2
  222. package/src/web/search/providers/brave.ts +3 -18
  223. package/src/web/search/providers/exa.ts +1 -12
  224. package/src/web/search/providers/kimi.ts +5 -44
  225. package/src/web/search/providers/perplexity.ts +1 -12
  226. package/src/web/search/providers/synthetic.ts +3 -26
  227. package/src/web/search/providers/utils.ts +36 -0
  228. package/src/web/search/providers/zai.ts +9 -50
  229. package/src/web/search/types.ts +0 -28
  230. package/src/web/search/utils.ts +17 -0
  231. package/src/tools/output-utils.ts +0 -63
  232. package/src/tools/truncate.ts +0 -385
  233. package/src/web/search/auth.ts +0 -178
@@ -1,5 +1,19 @@
1
1
  import { sanitizeText } from "@oh-my-pi/pi-natives";
2
- import { DEFAULT_MAX_BYTES } from "../tools/truncate";
2
+ import { formatBytes } from "../tools/render-utils";
3
+
4
+ // =============================================================================
5
+ // Constants
6
+ // =============================================================================
7
+
8
+ export const DEFAULT_MAX_LINES = 3000;
9
+ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
10
+ export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
11
+
12
+ const NL = "\n";
13
+
14
+ // =============================================================================
15
+ // Interfaces
16
+ // =============================================================================
3
17
 
4
18
  export interface OutputSummary {
5
19
  output: string;
@@ -19,52 +33,530 @@ export interface OutputSinkOptions {
19
33
  onChunk?: (chunk: string) => void;
20
34
  }
21
35
 
36
+ export interface TruncationResult {
37
+ content: string;
38
+ truncated: boolean;
39
+ truncatedBy: "lines" | "bytes" | null;
40
+ totalLines: number;
41
+ totalBytes: number;
42
+ outputLines: number;
43
+ outputBytes: number;
44
+ lastLinePartial: boolean;
45
+ firstLineExceedsLimit: boolean;
46
+ maxLines: number;
47
+ maxBytes: number;
48
+ }
49
+
50
+ export interface TruncationOptions {
51
+ /** Maximum number of lines (default: 3000) */
52
+ maxLines?: number;
53
+ /** Maximum number of bytes (default: 50KB) */
54
+ maxBytes?: number;
55
+ }
56
+
57
+ /** Result from byte-level truncation helpers. */
58
+ export interface ByteTruncationResult {
59
+ text: string;
60
+ bytes: number;
61
+ }
62
+
63
+ export interface TailTruncationNoticeOptions {
64
+ fullOutputPath?: string;
65
+ originalContent?: string;
66
+ suffix?: string;
67
+ }
68
+
69
+ export interface HeadTruncationNoticeOptions {
70
+ startLine?: number;
71
+ totalFileLines?: number;
72
+ }
73
+
74
+ // =============================================================================
75
+ // Internal low-level helpers
76
+ // =============================================================================
77
+
78
+ /** Count newline characters via native substring search. */
22
79
  function countNewlines(text: string): number {
23
80
  let count = 0;
24
- for (let i = 0; i < text.length; i++) {
25
- if (text.charCodeAt(i) === 10) count += 1;
81
+ let pos = text.indexOf(NL);
82
+ while (pos !== -1) {
83
+ count++;
84
+ pos = text.indexOf(NL, pos + 1);
26
85
  }
27
86
  return count;
28
87
  }
29
88
 
30
- function countLines(text: string): number {
31
- if (text.length === 0) return 0;
32
- return countNewlines(text) + 1;
89
+ /** Zero-copy view of a Uint8Array as a Buffer (copies only if already a Buffer). */
90
+ function asBuffer(data: Uint8Array): Buffer {
91
+ return Buffer.isBuffer(data) ? (data as Buffer) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
92
+ }
93
+
94
+ /** Advance past UTF-8 continuation bytes (10xxxxxx) to a leading byte. */
95
+ function findUtf8BoundaryForward(buf: Buffer, pos: number): number {
96
+ let i = Math.max(0, pos);
97
+ while (i < buf.length && (buf[i] & 0xc0) === 0x80) i++;
98
+ return i;
99
+ }
100
+
101
+ /** Retreat past UTF-8 continuation bytes to land on a leading byte. */
102
+ function findUtf8BoundaryBackward(buf: Buffer, cut: number): number {
103
+ let i = Math.min(buf.length, Math.max(0, cut));
104
+ // If the cut is at end-of-buffer, it's already a valid boundary.
105
+ if (i >= buf.length) return buf.length;
106
+ while (i > 0 && (buf[i] & 0xc0) === 0x80) i--;
107
+ return i;
108
+ }
109
+
110
+ // =============================================================================
111
+ // Byte-level truncation (windowed encoding)
112
+ // =============================================================================
113
+
114
+ function truncateBytesWindowed(
115
+ data: string | Uint8Array,
116
+ maxBytesRaw: number,
117
+ mode: "head" | "tail",
118
+ ): ByteTruncationResult {
119
+ const maxBytes = maxBytesRaw;
120
+ if (maxBytes === 0) return { text: "", bytes: 0 };
121
+
122
+ // --------------------------
123
+ // String path (windowed)
124
+ // --------------------------
125
+ if (typeof data === "string") {
126
+ // Fast non-truncation check only when it *might* fit.
127
+ if (data.length <= maxBytes) {
128
+ const len = Buffer.byteLength(data, "utf-8");
129
+ if (len <= maxBytes) return { text: data, bytes: len };
130
+ // else: multibyte-heavy string; fall through to truncation using full string as window.
131
+ }
132
+
133
+ const window =
134
+ mode === "head"
135
+ ? data.substring(0, Math.min(data.length, maxBytes))
136
+ : data.substring(Math.max(0, data.length - maxBytes));
137
+
138
+ const buf = Buffer.from(window, "utf-8");
139
+
140
+ if (mode === "head") {
141
+ const end = findUtf8BoundaryBackward(buf, maxBytes);
142
+ if (end <= 0) return { text: "", bytes: 0 };
143
+ const slice = buf.subarray(0, end);
144
+ return { text: slice.toString("utf-8"), bytes: slice.length };
145
+ } else {
146
+ const startAt = Math.max(0, buf.length - maxBytes);
147
+ const start = findUtf8BoundaryForward(buf, startAt);
148
+ const slice = buf.subarray(start);
149
+ return { text: slice.toString("utf-8"), bytes: slice.length };
150
+ }
151
+ }
152
+
153
+ // --------------------------
154
+ // Uint8Array / Buffer path
155
+ // --------------------------
156
+ const buf = asBuffer(data);
157
+ if (buf.length <= maxBytes) return { text: buf.toString("utf-8"), bytes: buf.length };
158
+
159
+ if (mode === "head") {
160
+ const end = findUtf8BoundaryBackward(buf, maxBytes);
161
+ if (end <= 0) return { text: "", bytes: 0 };
162
+ const slice = buf.subarray(0, end);
163
+ return { text: slice.toString("utf-8"), bytes: slice.length };
164
+ } else {
165
+ const startAt = buf.length - maxBytes;
166
+ const start = findUtf8BoundaryForward(buf, startAt);
167
+ const slice = buf.subarray(start);
168
+ return { text: slice.toString("utf-8"), bytes: slice.length };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Truncate a string/buffer to fit within a byte limit, keeping the tail.
174
+ * Handles multi-byte UTF-8 boundaries correctly.
175
+ */
176
+ export function truncateTailBytes(data: string | Uint8Array, maxBytes: number): ByteTruncationResult {
177
+ return truncateBytesWindowed(data, maxBytes, "tail");
178
+ }
179
+
180
+ /**
181
+ * Truncate a string/buffer to fit within a byte limit, keeping the head.
182
+ * Handles multi-byte UTF-8 boundaries correctly.
183
+ */
184
+ export function truncateHeadBytes(data: string | Uint8Array, maxBytes: number): ByteTruncationResult {
185
+ return truncateBytesWindowed(data, maxBytes, "head");
186
+ }
187
+
188
+ // =============================================================================
189
+ // Line-level utilities
190
+ // =============================================================================
191
+
192
+ /**
193
+ * Truncate a single line to max characters, appending '…' if truncated.
194
+ */
195
+ export function truncateLine(
196
+ line: string,
197
+ maxChars: number = DEFAULT_MAX_COLUMN,
198
+ ): { text: string; wasTruncated: boolean } {
199
+ if (line.length <= maxChars) return { text: line, wasTruncated: false };
200
+ return { text: `${line.slice(0, maxChars)}…`, wasTruncated: true };
201
+ }
202
+
203
+ // =============================================================================
204
+ // Content truncation (line + byte aware, no full Buffer allocation)
205
+ // =============================================================================
206
+
207
+ /** Shared helper to build a no-truncation result. */
208
+ function noTruncResult(
209
+ content: string,
210
+ totalLines: number,
211
+ totalBytes: number,
212
+ maxLines: number,
213
+ maxBytes: number,
214
+ ): TruncationResult {
215
+ return {
216
+ content,
217
+ truncated: false,
218
+ truncatedBy: null,
219
+ totalLines,
220
+ totalBytes,
221
+ outputLines: totalLines,
222
+ outputBytes: totalBytes,
223
+ lastLinePartial: false,
224
+ firstLineExceedsLimit: false,
225
+ maxLines,
226
+ maxBytes,
227
+ };
33
228
  }
34
229
 
35
- function truncateStringToBytesFromEnd(text: string, maxBytes: number): { text: string; bytes: number } {
36
- const buf = Buffer.from(text, "utf-8");
37
- if (buf.length <= maxBytes) {
38
- return { text, bytes: buf.length };
230
+ /**
231
+ * Truncate content from the head (keep first N lines/bytes).
232
+ * Never returns partial lines. If the first line exceeds the byte limit,
233
+ * returns empty content with firstLineExceedsLimit=true.
234
+ *
235
+ * This implementation avoids Buffer.from(content) for the whole input.
236
+ * It only computes UTF-8 byteLength for candidate lines that can still fit.
237
+ */
238
+ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
239
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
240
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
241
+
242
+ const totalBytes = Buffer.byteLength(content, "utf-8");
243
+ const totalLines = countNewlines(content) + 1;
244
+
245
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
246
+ return noTruncResult(content, totalLines, totalBytes, maxLines, maxBytes);
39
247
  }
40
248
 
41
- let start = buf.length - maxBytes;
42
- while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
43
- start++;
249
+ let includedLines = 0;
250
+ let bytesUsed = 0;
251
+ let cutIndex = 0; // char index where we cut (exclusive)
252
+ let cursor = 0;
253
+
254
+ let truncatedBy: "lines" | "bytes" = "lines";
255
+
256
+ while (includedLines < maxLines) {
257
+ const nl = content.indexOf(NL, cursor);
258
+ const lineEnd = nl === -1 ? content.length : nl;
259
+
260
+ const sepBytes = includedLines > 0 ? 1 : 0;
261
+ const remaining = maxBytes - bytesUsed - sepBytes;
262
+
263
+ // No room even for separators / bytes.
264
+ if (remaining < 0) {
265
+ truncatedBy = "bytes";
266
+ break;
267
+ }
268
+
269
+ // Fast reject huge lines without slicing/encoding:
270
+ // UTF-8 bytes >= UTF-16 code units, so if code units exceed remaining, bytes must exceed too.
271
+ const lineCodeUnits = lineEnd - cursor;
272
+ if (lineCodeUnits > remaining) {
273
+ truncatedBy = "bytes";
274
+ if (includedLines === 0) {
275
+ return {
276
+ content: "",
277
+ truncated: true,
278
+ truncatedBy: "bytes",
279
+ totalLines,
280
+ totalBytes,
281
+ outputLines: 0,
282
+ outputBytes: 0,
283
+ lastLinePartial: false,
284
+ firstLineExceedsLimit: true,
285
+ maxLines,
286
+ maxBytes,
287
+ };
288
+ }
289
+ break;
290
+ }
291
+
292
+ // Small slice (bounded by remaining <= maxBytes) for exact UTF-8 byte count.
293
+ const lineText = content.slice(cursor, lineEnd);
294
+ const lineBytes = Buffer.byteLength(lineText, "utf-8");
295
+
296
+ if (lineBytes > remaining) {
297
+ truncatedBy = "bytes";
298
+ if (includedLines === 0) {
299
+ return {
300
+ content: "",
301
+ truncated: true,
302
+ truncatedBy: "bytes",
303
+ totalLines,
304
+ totalBytes,
305
+ outputLines: 0,
306
+ outputBytes: 0,
307
+ lastLinePartial: false,
308
+ firstLineExceedsLimit: true,
309
+ maxLines,
310
+ maxBytes,
311
+ };
312
+ }
313
+ break;
314
+ }
315
+
316
+ // Include the line (join semantics: no trailing newline after the last included line).
317
+ bytesUsed += sepBytes + lineBytes;
318
+ includedLines++;
319
+
320
+ cutIndex = nl === -1 ? content.length : nl; // exclude the newline after the last included line
321
+ if (nl === -1) break;
322
+ cursor = nl + 1;
44
323
  }
45
324
 
46
- const sliced = buf.subarray(start).toString("utf-8");
47
- return { text: sliced, bytes: Buffer.byteLength(sliced, "utf-8") };
325
+ if (includedLines >= maxLines && bytesUsed <= maxBytes) truncatedBy = "lines";
326
+
327
+ return {
328
+ content: content.slice(0, cutIndex),
329
+ truncated: true,
330
+ truncatedBy,
331
+ totalLines,
332
+ totalBytes,
333
+ outputLines: includedLines,
334
+ outputBytes: bytesUsed,
335
+ lastLinePartial: false,
336
+ firstLineExceedsLimit: false,
337
+ maxLines,
338
+ maxBytes,
339
+ };
48
340
  }
49
341
 
50
342
  /**
51
- * Line-buffered output sink with file spill support.
343
+ * Truncate content from the tail (keep last N lines/bytes).
344
+ * May return a partial first line if the last line exceeds the byte limit.
52
345
  *
53
- * Uses a single string buffer with line position tracking.
54
- * When memory limit exceeded, spills ~half to file in one batch operation.
346
+ * Also avoids Buffer.from(content) for the whole input.
55
347
  */
348
+ export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
349
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
350
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
351
+
352
+ const totalBytes = Buffer.byteLength(content, "utf-8");
353
+ const totalLines = countNewlines(content) + 1;
354
+
355
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
356
+ return noTruncResult(content, totalLines, totalBytes, maxLines, maxBytes);
357
+ }
358
+
359
+ let includedLines = 0;
360
+ let bytesUsed = 0;
361
+ let startIndex = content.length; // char index where output starts
362
+ let end = content.length; // char index where current line ends (exclusive)
363
+
364
+ let truncatedBy: "lines" | "bytes" = "lines";
365
+
366
+ while (includedLines < maxLines) {
367
+ const nl = content.lastIndexOf(NL, end - 1);
368
+ const lineStart = nl === -1 ? 0 : nl + 1;
369
+
370
+ const sepBytes = includedLines > 0 ? 1 : 0;
371
+ const remaining = maxBytes - bytesUsed - sepBytes;
372
+
373
+ if (remaining < 0) {
374
+ truncatedBy = "bytes";
375
+ break;
376
+ }
377
+
378
+ const lineCodeUnits = end - lineStart;
379
+
380
+ // Fast reject huge line without slicing/encoding.
381
+ if (lineCodeUnits > remaining) {
382
+ truncatedBy = "bytes";
383
+ if (includedLines === 0) {
384
+ // Window the line substring to avoid materializing a giant string.
385
+ const windowStart = Math.max(lineStart, end - maxBytes);
386
+ const window = content.substring(windowStart, end);
387
+ const tail = truncateTailBytes(window, maxBytes);
388
+ return {
389
+ content: tail.text,
390
+ truncated: true,
391
+ truncatedBy: "bytes",
392
+ totalLines,
393
+ totalBytes,
394
+ outputLines: 1,
395
+ outputBytes: tail.bytes,
396
+ lastLinePartial: true,
397
+ firstLineExceedsLimit: false,
398
+ maxLines,
399
+ maxBytes,
400
+ };
401
+ }
402
+ break;
403
+ }
404
+
405
+ const lineText = content.slice(lineStart, end);
406
+ const lineBytes = Buffer.byteLength(lineText, "utf-8");
407
+
408
+ if (lineBytes > remaining) {
409
+ truncatedBy = "bytes";
410
+ if (includedLines === 0) {
411
+ const tail = truncateTailBytes(lineText, maxBytes);
412
+ return {
413
+ content: tail.text,
414
+ truncated: true,
415
+ truncatedBy: "bytes",
416
+ totalLines,
417
+ totalBytes,
418
+ outputLines: 1,
419
+ outputBytes: tail.bytes,
420
+ lastLinePartial: true,
421
+ firstLineExceedsLimit: false,
422
+ maxLines,
423
+ maxBytes,
424
+ };
425
+ }
426
+ break;
427
+ }
428
+
429
+ bytesUsed += sepBytes + lineBytes;
430
+ includedLines++;
431
+ startIndex = lineStart;
432
+
433
+ if (nl === -1) break;
434
+ end = nl; // exclude the newline itself; it'll be accounted as sepBytes in the next iteration
435
+ }
436
+
437
+ if (includedLines >= maxLines && bytesUsed <= maxBytes) truncatedBy = "lines";
438
+
439
+ return {
440
+ content: content.slice(startIndex),
441
+ truncated: true,
442
+ truncatedBy,
443
+ totalLines,
444
+ totalBytes,
445
+ outputLines: includedLines,
446
+ outputBytes: bytesUsed,
447
+ lastLinePartial: false,
448
+ firstLineExceedsLimit: false,
449
+ maxLines,
450
+ maxBytes,
451
+ };
452
+ }
453
+
454
+ // =============================================================================
455
+ // TailBuffer — ring-style tail buffer with lazy joining
456
+ // =============================================================================
457
+
458
+ const MAX_PENDING = 10;
459
+
460
+ export class TailBuffer {
461
+ #pending: string[] = [];
462
+ #pos = 0; // byte count of the currently-held tail (after trims)
463
+
464
+ constructor(readonly maxBytes: number) {}
465
+
466
+ append(text: string): void {
467
+ if (!text) return;
468
+
469
+ const max = this.maxBytes;
470
+ if (max === 0) {
471
+ this.#pending.length = 0;
472
+ this.#pos = 0;
473
+ return;
474
+ }
475
+
476
+ const n = Buffer.byteLength(text, "utf-8");
477
+
478
+ // If the incoming chunk alone is >= budget, it fully dominates the tail.
479
+ if (n >= max) {
480
+ const { text: t, bytes } = truncateTailBytes(text, max);
481
+ this.#pending[0] = t;
482
+ this.#pending.length = 1;
483
+ this.#pos = bytes;
484
+ return;
485
+ }
486
+
487
+ this.#pos += n;
488
+
489
+ if (this.#pending.length === 0) {
490
+ this.#pending[0] = text;
491
+ this.#pending.length = 1;
492
+ } else {
493
+ this.#pending.push(text);
494
+ if (this.#pending.length > MAX_PENDING) this.#compact();
495
+ }
496
+
497
+ // Trim when we exceed 2× budget to amortize cost.
498
+ if (this.#pos > max * 2) this.#trimTo(max);
499
+ }
500
+
501
+ text(): string {
502
+ const max = this.maxBytes;
503
+ this.#trimTo(max);
504
+ return this.#flush();
505
+ }
506
+
507
+ bytes(): number {
508
+ const max = this.maxBytes;
509
+ this.#trimTo(max);
510
+ return this.#pos;
511
+ }
512
+
513
+ // -- private ---------------------------------------------------------------
514
+
515
+ #compact(): void {
516
+ this.#pending[0] = this.#pending.join("");
517
+ this.#pending.length = 1;
518
+ }
519
+
520
+ #flush(): string {
521
+ if (this.#pending.length === 0) return "";
522
+ if (this.#pending.length > 1) this.#compact();
523
+ return this.#pending[0];
524
+ }
525
+
526
+ #trimTo(max: number): void {
527
+ if (max === 0) {
528
+ this.#pending.length = 0;
529
+ this.#pos = 0;
530
+ return;
531
+ }
532
+ if (this.#pos <= max) return;
533
+
534
+ const joined = this.#flush();
535
+ const { text, bytes } = truncateTailBytes(joined, max);
536
+ this.#pos = bytes;
537
+ this.#pending[0] = text;
538
+ this.#pending.length = 1;
539
+ }
540
+ }
541
+
542
+ // =============================================================================
543
+ // OutputSink — line-buffered output with file spill support
544
+ // =============================================================================
545
+
56
546
  export class OutputSink {
57
547
  #buffer = "";
58
548
  #bufferBytes = 0;
59
- #totalLines = 0;
549
+ #totalLines = 0; // newline count
60
550
  #totalBytes = 0;
61
551
  #sawData = false;
62
552
  #truncated = false;
553
+
63
554
  #file?: {
64
555
  path: string;
65
556
  artifactId?: string;
66
557
  sink: Bun.FileSink;
67
558
  };
559
+
68
560
  readonly #artifactPath?: string;
69
561
  readonly #artifactId?: string;
70
562
  readonly #spillThreshold: number;
@@ -72,66 +564,58 @@ export class OutputSink {
72
564
 
73
565
  constructor(options?: OutputSinkOptions) {
74
566
  const { artifactPath, artifactId, spillThreshold = DEFAULT_MAX_BYTES, onChunk } = options ?? {};
75
-
76
567
  this.#artifactPath = artifactPath;
77
568
  this.#artifactId = artifactId;
78
569
  this.#spillThreshold = spillThreshold;
79
570
  this.#onChunk = onChunk;
80
571
  }
81
572
 
82
- async #pushSanitized(data: string): Promise<void> {
83
- this.#onChunk?.(data);
573
+ async push(chunk: string): Promise<void> {
574
+ chunk = sanitizeText(chunk);
575
+ this.#onChunk?.(chunk);
84
576
 
85
- const dataBytes = Buffer.byteLength(data, "utf-8");
577
+ const dataBytes = Buffer.byteLength(chunk, "utf-8");
86
578
  this.#totalBytes += dataBytes;
87
- if (data.length > 0) {
579
+
580
+ if (chunk.length > 0) {
88
581
  this.#sawData = true;
89
- this.#totalLines += countNewlines(data);
582
+ this.#totalLines += countNewlines(chunk);
90
583
  }
91
584
 
92
- const bufferOverflow = this.#bufferBytes + dataBytes > this.#spillThreshold;
93
- const overflow = this.#file || bufferOverflow;
94
- const sink = overflow ? await this.#fileSink() : null;
95
-
96
- this.#buffer += data;
97
- this.#bufferBytes += dataBytes;
98
- await sink?.write(data);
585
+ const threshold = this.#spillThreshold;
586
+ const willOverflow = this.#bufferBytes + dataBytes > threshold;
99
587
 
100
- if (bufferOverflow) {
101
- this.#truncated = true;
102
- const trimmed = truncateStringToBytesFromEnd(this.#buffer, this.#spillThreshold);
103
- this.#buffer = trimmed.text;
104
- this.#bufferBytes = trimmed.bytes;
588
+ // Write to file if already spilling or about to overflow
589
+ if (this.#file != null || willOverflow) {
590
+ const sink = await this.#ensureFileSink();
591
+ await sink?.write(chunk);
105
592
  }
106
- if (this.#file) {
107
- this.#truncated = true;
593
+
594
+ if (!willOverflow) {
595
+ this.#buffer += chunk;
596
+ this.#bufferBytes += dataBytes;
597
+ return;
108
598
  }
109
- }
110
599
 
111
- async #fileSink(): Promise<Bun.FileSink | null> {
112
- if (!this.#artifactPath) return null;
113
- if (!this.#file) {
114
- try {
115
- this.#file = {
116
- path: this.#artifactPath,
117
- artifactId: this.#artifactId,
118
- sink: Bun.file(this.#artifactPath).writer(),
119
- };
120
- await this.#file.sink.write(this.#buffer);
121
- } catch {
122
- try {
123
- await this.#file?.sink?.end();
124
- } catch {}
125
- this.#file = undefined;
126
- return null;
127
- }
600
+ // Overflow: keep only a tail window in memory.
601
+ this.#truncated = true;
602
+
603
+ // Avoid creating a giant intermediate string when chunk alone dominates.
604
+ if (dataBytes >= threshold) {
605
+ const { text, bytes } = truncateTailBytes(chunk, threshold);
606
+ this.#buffer = text;
607
+ this.#bufferBytes = bytes;
608
+ } else {
609
+ // Intermediate size is bounded (<= threshold + dataBytes), safe to concat.
610
+ this.#buffer += chunk;
611
+ this.#bufferBytes += dataBytes;
612
+
613
+ const { text, bytes } = truncateTailBytes(this.#buffer, threshold);
614
+ this.#buffer = text;
615
+ this.#bufferBytes = bytes;
128
616
  }
129
- return this.#file.sink;
130
- }
131
617
 
132
- async push(chunk: string): Promise<void> {
133
- chunk = sanitizeText(chunk);
134
- await this.#pushSanitized(chunk);
618
+ if (this.#file) this.#truncated = true;
135
619
  }
136
620
 
137
621
  createInput(): WritableStream<Uint8Array | string> {
@@ -139,14 +623,9 @@ export class OutputSink {
139
623
  const finalize = async () => {
140
624
  await this.push(dec.decode());
141
625
  };
142
-
143
626
  return new WritableStream({
144
627
  write: async chunk => {
145
- if (typeof chunk === "string") {
146
- await this.push(chunk);
147
- } else {
148
- await this.push(dec.decode(chunk, { stream: true }));
149
- }
628
+ await this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
150
629
  },
151
630
  close: finalize,
152
631
  abort: finalize,
@@ -155,23 +634,105 @@ export class OutputSink {
155
634
 
156
635
  async dump(notice?: string): Promise<OutputSummary> {
157
636
  const noticeLine = notice ? `[${notice}]\n` : "";
158
- const outputLines = countLines(this.#buffer);
159
- const outputBytes = this.#bufferBytes;
637
+ const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
160
638
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
161
- const totalBytes = this.#totalBytes;
162
639
 
163
- if (this.#file) {
164
- await this.#file.sink.end();
165
- }
640
+ if (this.#file) await this.#file.sink.end();
166
641
 
167
642
  return {
168
643
  output: `${noticeLine}${this.#buffer}`,
169
644
  truncated: this.#truncated,
170
645
  totalLines,
171
- totalBytes,
646
+ totalBytes: this.#totalBytes,
172
647
  outputLines,
173
- outputBytes,
648
+ outputBytes: this.#bufferBytes,
174
649
  artifactId: this.#file?.artifactId,
175
650
  };
176
651
  }
652
+
653
+ // -- private ---------------------------------------------------------------
654
+
655
+ async #ensureFileSink(): Promise<Bun.FileSink | null> {
656
+ if (!this.#artifactPath) return null;
657
+ if (this.#file) return this.#file.sink;
658
+
659
+ try {
660
+ const sink = Bun.file(this.#artifactPath).writer();
661
+ this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
662
+
663
+ // Flush existing buffer to file BEFORE it gets trimmed further.
664
+ if (this.#buffer.length > 0) {
665
+ await sink.write(this.#buffer);
666
+ }
667
+ return sink;
668
+ } catch {
669
+ try {
670
+ await this.#file?.sink?.end();
671
+ } catch {
672
+ /* ignore */
673
+ }
674
+ this.#file = undefined;
675
+ return null;
676
+ }
677
+ }
678
+ }
679
+
680
+ // =============================================================================
681
+ // Truncation notice formatting
682
+ // =============================================================================
683
+
684
+ /**
685
+ * Format a truncation notice for tail-truncated output (bash, python, ssh).
686
+ * Returns empty string if not truncated.
687
+ */
688
+ export function formatTailTruncationNotice(
689
+ truncation: TruncationResult,
690
+ options: TailTruncationNoticeOptions = {},
691
+ ): string {
692
+ if (!truncation.truncated) return "";
693
+
694
+ const { fullOutputPath, originalContent, suffix = "" } = options;
695
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
696
+ const endLine = truncation.totalLines;
697
+ const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
698
+
699
+ let notice: string;
700
+ if (truncation.lastLinePartial) {
701
+ let lastLineSizePart = "";
702
+ if (originalContent) {
703
+ const lastNl = originalContent.lastIndexOf(NL);
704
+ const lastLine = lastNl === -1 ? originalContent : originalContent.substring(lastNl + 1);
705
+ lastLineSizePart = ` (line is ${formatBytes(Buffer.byteLength(lastLine, "utf-8"))})`;
706
+ }
707
+ notice = `[Showing last ${formatBytes(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
708
+ } else if (truncation.truncatedBy === "lines") {
709
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
710
+ } else {
711
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatBytes(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`;
712
+ }
713
+
714
+ return `\n\n${notice}`;
715
+ }
716
+
717
+ /**
718
+ * Format a truncation notice for head-truncated output (read tool).
719
+ * Returns empty string if not truncated.
720
+ */
721
+ export function formatHeadTruncationNotice(
722
+ truncation: TruncationResult,
723
+ options: HeadTruncationNoticeOptions = {},
724
+ ): string {
725
+ if (!truncation.truncated) return "";
726
+
727
+ const startLineDisplay = options.startLine ?? 1;
728
+ const totalFileLines = options.totalFileLines ?? truncation.totalLines;
729
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
730
+ const nextOffset = endLineDisplay + 1;
731
+
732
+ const notice =
733
+ truncation.truncatedBy === "lines"
734
+ ? `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`
735
+ : `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatBytes(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`;
736
+
737
+ return `\n\n${notice}`;
177
738
  }