@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.
- package/CHANGELOG.md +53 -0
- package/package.json +35 -27
- package/src/async/index.ts +1 -0
- package/src/async/job-manager.ts +341 -0
- package/src/cli/file-processor.ts +3 -3
- package/src/cli/list-models.ts +3 -17
- package/src/cli/stats-cli.ts +3 -22
- package/src/cli/web-search-cli.ts +8 -16
- package/src/commit/agentic/agent.ts +6 -9
- package/src/commit/agentic/index.ts +44 -50
- package/src/commit/agentic/state.ts +0 -9
- package/src/commit/agentic/tools/propose-commit.ts +1 -30
- package/src/commit/agentic/tools/schemas.ts +31 -0
- package/src/commit/agentic/tools/split-commit.ts +1 -30
- package/src/commit/agentic/validation.ts +1 -18
- package/src/commit/analysis/conventional.ts +3 -50
- package/src/commit/analysis/summary.ts +2 -13
- package/src/commit/changelog/detect.ts +4 -1
- package/src/commit/changelog/generate.ts +2 -25
- package/src/commit/changelog/index.ts +1 -2
- package/src/commit/cli.ts +4 -12
- package/src/commit/map-reduce/reduce-phase.ts +2 -43
- package/src/commit/pipeline.ts +7 -15
- package/src/commit/utils.ts +44 -0
- package/src/config/prompt-templates.ts +1 -81
- package/src/config/settings-schema.ts +20 -1
- package/src/config.ts +2 -3
- package/src/debug/index.ts +1 -6
- package/src/debug/system-info.ts +2 -6
- package/src/discovery/builtin.ts +5 -9
- package/src/discovery/helpers.ts +0 -26
- package/src/discovery/ssh.ts +1 -8
- package/src/exa/company.ts +8 -39
- package/src/exa/factory.ts +64 -0
- package/src/exa/index.ts +0 -16
- package/src/exa/linkedin.ts +8 -39
- package/src/exa/mcp-client.ts +0 -64
- package/src/exa/researcher.ts +17 -59
- package/src/exa/search.ts +30 -154
- package/src/extensibility/custom-tools/loader.ts +3 -41
- package/src/extensibility/extensions/loader.ts +2 -9
- package/src/extensibility/hooks/loader.ts +3 -20
- package/src/extensibility/hooks/runner.ts +3 -19
- package/src/extensibility/plugins/installer.ts +2 -1
- package/src/extensibility/plugins/loader.ts +29 -117
- package/src/extensibility/skills.ts +2 -89
- package/src/extensibility/slash-commands.ts +1 -63
- package/src/extensibility/utils.ts +38 -0
- package/src/index.ts +9 -25
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/jobs-protocol.ts +118 -0
- package/src/ipy/kernel.ts +2 -0
- package/src/lsp/config.ts +1 -5
- package/src/lsp/lspmux.ts +0 -17
- package/src/lsp/utils.ts +2 -24
- package/src/main.ts +16 -24
- package/src/mcp/client.ts +1 -46
- package/src/mcp/render.ts +8 -1
- package/src/mcp/tool-cache.ts +1 -5
- package/src/mcp/transports/http.ts +2 -7
- package/src/mcp/transports/stdio.ts +2 -7
- package/src/modes/components/bash-execution.ts +2 -16
- package/src/modes/components/extensions/inspector-panel.ts +8 -18
- package/src/modes/components/footer.ts +10 -50
- package/src/modes/components/model-selector.ts +2 -21
- package/src/modes/components/python-execution.ts +2 -16
- package/src/modes/components/settings-selector.ts +1 -10
- package/src/modes/components/status-line/segments.ts +8 -25
- package/src/modes/components/status-line.ts +14 -31
- package/src/modes/components/tool-execution.ts +8 -2
- package/src/modes/controllers/command-controller.ts +71 -30
- package/src/modes/controllers/event-controller.ts +34 -4
- package/src/modes/controllers/mcp-command-controller.ts +3 -34
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/controllers/ssh-command-controller.ts +3 -34
- package/src/modes/interactive-mode.ts +6 -2
- package/src/modes/rpc/rpc-client.ts +1 -5
- package/src/modes/shared.ts +73 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +26 -2
- package/src/patch/hashline.ts +6 -286
- package/src/patch/index.ts +6 -57
- package/src/patch/normalize.ts +22 -65
- package/src/patch/shared.ts +16 -16
- package/src/prompts/system/custom-system-prompt.md +0 -10
- package/src/prompts/system/system-prompt.md +69 -89
- package/src/prompts/tools/async-result.md +5 -0
- package/src/prompts/tools/bash.md +5 -0
- package/src/prompts/tools/cancel-job.md +7 -0
- package/src/prompts/tools/hashline.md +0 -16
- package/src/prompts/tools/poll-jobs.md +7 -0
- package/src/prompts/tools/task.md +4 -0
- package/src/sdk.ts +70 -6
- package/src/session/agent-session.ts +43 -6
- package/src/session/agent-storage.ts +69 -278
- package/src/session/auth-storage.ts +14 -1430
- package/src/session/session-manager.ts +69 -5
- package/src/session/session-storage.ts +1 -5
- package/src/session/streaming-output.ts +637 -76
- package/src/slash-commands/builtin-registry.ts +8 -0
- package/src/ssh/connection-manager.ts +4 -12
- package/src/ssh/sshfs-mount.ts +3 -7
- package/src/ssh/utils.ts +8 -0
- package/src/system-prompt.ts +24 -90
- package/src/task/executor.ts +11 -1
- package/src/task/index.ts +258 -13
- package/src/task/parallel.ts +32 -0
- package/src/task/render.ts +15 -7
- package/src/task/types.ts +5 -0
- package/src/tools/ask.ts +4 -7
- package/src/tools/bash-interactive.ts +4 -5
- package/src/tools/bash.ts +125 -41
- package/src/tools/cancel-job.ts +93 -0
- package/src/tools/fetch.ts +7 -27
- package/src/tools/find.ts +3 -3
- package/src/tools/gemini-image.ts +15 -14
- package/src/tools/grep.ts +3 -3
- package/src/tools/index.ts +13 -29
- package/src/tools/json-tree.ts +12 -1
- package/src/tools/jtd-to-json-schema.ts +10 -74
- package/src/tools/jtd-to-typescript.ts +10 -72
- package/src/tools/jtd-utils.ts +102 -0
- package/src/tools/notebook.ts +4 -9
- package/src/tools/output-meta.ts +52 -26
- package/src/tools/path-utils.ts +13 -7
- package/src/tools/poll-jobs.ts +178 -0
- package/src/tools/python.ts +32 -35
- package/src/tools/read.ts +61 -82
- package/src/tools/render-utils.ts +8 -159
- package/src/tools/ssh.ts +7 -20
- package/src/tools/submit-result.ts +1 -1
- package/src/tools/tool-errors.ts +0 -30
- package/src/tools/tool-result.ts +1 -2
- package/src/tools/write.ts +8 -10
- package/src/tui/code-cell.ts +8 -3
- package/src/tui/status-line.ts +4 -4
- package/src/tui/types.ts +0 -1
- package/src/tui/utils.ts +1 -14
- package/src/utils/command-args.ts +76 -0
- package/src/utils/file-mentions.ts +15 -19
- package/src/utils/frontmatter.ts +5 -10
- package/src/utils/shell-snapshot.ts +0 -11
- package/src/utils/title-generator.ts +0 -12
- package/src/web/scrapers/artifacthub.ts +7 -16
- package/src/web/scrapers/arxiv.ts +3 -8
- package/src/web/scrapers/aur.ts +8 -22
- package/src/web/scrapers/biorxiv.ts +5 -14
- package/src/web/scrapers/bluesky.ts +13 -36
- package/src/web/scrapers/brew.ts +5 -10
- package/src/web/scrapers/cheatsh.ts +2 -12
- package/src/web/scrapers/chocolatey.ts +63 -26
- package/src/web/scrapers/choosealicense.ts +3 -18
- package/src/web/scrapers/cisa-kev.ts +4 -18
- package/src/web/scrapers/clojars.ts +6 -33
- package/src/web/scrapers/coingecko.ts +25 -33
- package/src/web/scrapers/crates-io.ts +7 -26
- package/src/web/scrapers/crossref.ts +4 -18
- package/src/web/scrapers/devto.ts +11 -41
- package/src/web/scrapers/discogs.ts +7 -10
- package/src/web/scrapers/discourse.ts +6 -31
- package/src/web/scrapers/dockerhub.ts +12 -35
- package/src/web/scrapers/fdroid.ts +8 -33
- package/src/web/scrapers/firefox-addons.ts +10 -34
- package/src/web/scrapers/flathub.ts +7 -24
- package/src/web/scrapers/github-gist.ts +2 -12
- package/src/web/scrapers/github.ts +9 -47
- package/src/web/scrapers/gitlab.ts +130 -185
- package/src/web/scrapers/go-pkg.ts +12 -22
- package/src/web/scrapers/hackage.ts +88 -43
- package/src/web/scrapers/hackernews.ts +25 -45
- package/src/web/scrapers/hex.ts +19 -36
- package/src/web/scrapers/huggingface.ts +26 -91
- package/src/web/scrapers/iacr.ts +3 -8
- package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
- package/src/web/scrapers/lemmy.ts +5 -23
- package/src/web/scrapers/lobsters.ts +16 -28
- package/src/web/scrapers/mastodon.ts +24 -43
- package/src/web/scrapers/maven.ts +6 -21
- package/src/web/scrapers/mdn.ts +7 -11
- package/src/web/scrapers/metacpan.ts +9 -41
- package/src/web/scrapers/musicbrainz.ts +4 -28
- package/src/web/scrapers/npm.ts +8 -25
- package/src/web/scrapers/nuget.ts +14 -37
- package/src/web/scrapers/nvd.ts +6 -28
- package/src/web/scrapers/ollama.ts +7 -34
- package/src/web/scrapers/open-vsx.ts +5 -19
- package/src/web/scrapers/opencorporates.ts +30 -14
- package/src/web/scrapers/openlibrary.ts +49 -33
- package/src/web/scrapers/orcid.ts +4 -18
- package/src/web/scrapers/osv.ts +7 -24
- package/src/web/scrapers/packagist.ts +9 -24
- package/src/web/scrapers/pub-dev.ts +7 -50
- package/src/web/scrapers/pubmed.ts +54 -21
- package/src/web/scrapers/pypi.ts +8 -26
- package/src/web/scrapers/rawg.ts +11 -19
- package/src/web/scrapers/readthedocs.ts +4 -9
- package/src/web/scrapers/reddit.ts +5 -15
- package/src/web/scrapers/repology.ts +8 -20
- package/src/web/scrapers/rfc.ts +5 -14
- package/src/web/scrapers/rubygems.ts +6 -21
- package/src/web/scrapers/searchcode.ts +8 -36
- package/src/web/scrapers/sec-edgar.ts +4 -18
- package/src/web/scrapers/semantic-scholar.ts +15 -35
- package/src/web/scrapers/snapcraft.ts +5 -19
- package/src/web/scrapers/sourcegraph.ts +5 -43
- package/src/web/scrapers/spdx.ts +4 -18
- package/src/web/scrapers/spotify.ts +4 -23
- package/src/web/scrapers/stackoverflow.ts +8 -13
- package/src/web/scrapers/terraform.ts +9 -37
- package/src/web/scrapers/tldr.ts +3 -7
- package/src/web/scrapers/twitter.ts +3 -7
- package/src/web/scrapers/types.ts +105 -27
- package/src/web/scrapers/utils.ts +97 -103
- package/src/web/scrapers/vimeo.ts +7 -27
- package/src/web/scrapers/vscode-marketplace.ts +8 -17
- package/src/web/scrapers/w3c.ts +6 -14
- package/src/web/scrapers/wikidata.ts +5 -19
- package/src/web/scrapers/wikipedia.ts +2 -12
- package/src/web/scrapers/youtube.ts +5 -34
- package/src/web/search/index.ts +0 -9
- package/src/web/search/providers/anthropic.ts +3 -2
- package/src/web/search/providers/brave.ts +3 -18
- package/src/web/search/providers/exa.ts +1 -12
- package/src/web/search/providers/kimi.ts +5 -44
- package/src/web/search/providers/perplexity.ts +1 -12
- package/src/web/search/providers/synthetic.ts +3 -26
- package/src/web/search/providers/utils.ts +36 -0
- package/src/web/search/providers/zai.ts +9 -50
- package/src/web/search/types.ts +0 -28
- package/src/web/search/utils.ts +17 -0
- package/src/tools/output-utils.ts +0 -63
- package/src/tools/truncate.ts +0 -385
- package/src/web/search/auth.ts +0 -178
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
2
|
-
import {
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
return
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
83
|
-
|
|
573
|
+
async push(chunk: string): Promise<void> {
|
|
574
|
+
chunk = sanitizeText(chunk);
|
|
575
|
+
this.#onChunk?.(chunk);
|
|
84
576
|
|
|
85
|
-
const dataBytes = Buffer.byteLength(
|
|
577
|
+
const dataBytes = Buffer.byteLength(chunk, "utf-8");
|
|
86
578
|
this.#totalBytes += dataBytes;
|
|
87
|
-
|
|
579
|
+
|
|
580
|
+
if (chunk.length > 0) {
|
|
88
581
|
this.#sawData = true;
|
|
89
|
-
this.#totalLines += countNewlines(
|
|
582
|
+
this.#totalLines += countNewlines(chunk);
|
|
90
583
|
}
|
|
91
584
|
|
|
92
|
-
const
|
|
93
|
-
const
|
|
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
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
593
|
+
|
|
594
|
+
if (!willOverflow) {
|
|
595
|
+
this.#buffer += chunk;
|
|
596
|
+
this.#bufferBytes += dataBytes;
|
|
597
|
+
return;
|
|
108
598
|
}
|
|
109
|
-
}
|
|
110
599
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|