@bli-cockpit/cli 0.2.263 → 0.2.265

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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A file's lines, one at a time, without ever holding the whole file (BLI-23317).
3
+ *
4
+ * `fs.readFile(path, "utf8")` followed by `.split("\n")` keeps the file's bytes, its decoded string and
5
+ * the array of lines alive together; for a 200 MB transcript that is the biggest allocation the image
6
+ * reader makes. This yields exactly the lines `split("\n")` would (the last one, possibly empty, is the
7
+ * text after the final newline), each decoded as UTF-8 with the same replacement of invalid sequences:
8
+ * a "\n" byte never occurs inside a multi-byte UTF-8 sequence, so decoding line by line equals decoding
9
+ * the whole file and cutting it. A line is held as bytes until its newline arrives, so the peak is the
10
+ * longest line, not the file.
11
+ *
12
+ * A file that cannot be opened or read yields no lines, as `readFile(...).catch(() => null)` did, and
13
+ * says why (a read that fails part way keeps the lines already yielded; the log names it).
14
+ */
15
+ import fs from "node:fs";
16
+ import { describeError } from "../health-detail.js";
17
+ const NEWLINE = 0x0a;
18
+ /** Read size per chunk. A long line spans many chunks; its parts wait as buffers until the line ends. */
19
+ const CHUNK_BYTES = 1024 * 1024;
20
+ export async function* linesOfFile(filePath) {
21
+ const stream = fs.createReadStream(filePath, { highWaterMark: CHUNK_BYTES });
22
+ let parts = [];
23
+ let lines = 0;
24
+ try {
25
+ for await (const chunk of stream) {
26
+ let start = 0;
27
+ for (let newline = chunk.indexOf(NEWLINE, start); newline !== -1; newline = chunk.indexOf(NEWLINE, start)) {
28
+ parts.push(chunk.subarray(start, newline));
29
+ const line = (parts.length === 1 ? parts[0] : Buffer.concat(parts)).toString("utf8");
30
+ parts = [];
31
+ start = newline + 1;
32
+ lines += 1;
33
+ yield line;
34
+ }
35
+ // A line that outlives its chunk must not pin the whole chunk, so its tail is copied.
36
+ if (start < chunk.length)
37
+ parts.push(Buffer.from(chunk.subarray(start)));
38
+ }
39
+ yield Buffer.concat(parts).toString("utf8");
40
+ }
41
+ catch (error) {
42
+ console.error("[transcript-line-stream] file could not be read; its lines stop here", JSON.stringify({ reason: "file_read_failed", lines_yielded: lines, ...describeError(error) }));
43
+ }
44
+ finally {
45
+ stream.destroy();
46
+ }
47
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A transcript's lines as text, straight from disk, for the pass that never holds the whole file (BLI-23317).
3
+ *
4
+ * Unlike `transcript-line-stream.ts` (the image reader's, which logs a failed read and ends the lines
5
+ * early), this one tells the two endings apart. A read that fails is a `TranscriptReadError`, never a
6
+ * shorter file: the caller maps it to the same `file_read_failed` skip a whole-file read failure gets.
7
+ * Text that is not valid UTF-8 is a `TranscriptNotStreamable`: the streamed path replaces nothing, so
8
+ * the caller falls back to the whole-file path, which keeps the original bytes for the clean branch.
9
+ *
10
+ * One `TextDecoder` (fatal, BOM kept, `stream: true`) lives across all chunks, so a character split
11
+ * between two chunks is valid input, and `decode()` at EOF rejects a file that ends mid-character.
12
+ * For valid UTF-8 it yields exactly the string `Buffer.toString("utf8")` makes, cut at "\n" like
13
+ * `split("\n")`: the last line is whatever follows the final newline, empty or not.
14
+ *
15
+ * A line is held as pieces until its newline arrives, so the peak is the longest line (a single very
16
+ * long line is still concatenated whole; that bound is not claimed to be small).
17
+ */
18
+ import fsp from "node:fs/promises";
19
+ import { TextDecoder } from "node:util";
20
+ import { describeError } from "../health-detail.js";
21
+ /** Bytes read per step, into one reused buffer. */
22
+ export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024;
23
+ const CHUNK_BYTES = TRANSCRIPT_CHUNK_BYTES;
24
+ /** The file could not be read to its end. Not EOF: whoever catches it must not treat the lines so far as the file. */
25
+ export class TranscriptReadError extends Error {
26
+ stage;
27
+ underlying;
28
+ constructor(stage, underlying) {
29
+ super(`transcript read failed at ${stage}`);
30
+ this.name = "TranscriptReadError";
31
+ this.stage = stage;
32
+ this.underlying = underlying;
33
+ }
34
+ }
35
+ /** The file is bigger than this pass's cap. */
36
+ export class TranscriptTooLargeError extends Error {
37
+ maxFileBytes;
38
+ constructor(maxFileBytes) {
39
+ super("transcript over the file cap");
40
+ this.name = "TranscriptTooLargeError";
41
+ this.maxFileBytes = maxFileBytes;
42
+ }
43
+ }
44
+ /** The streamed path cannot give the whole-file answer for this file; read it whole instead. `reason` is a label. */
45
+ export class TranscriptNotStreamable extends Error {
46
+ reason;
47
+ constructor(reason) {
48
+ super(`transcript not streamable: ${reason}`);
49
+ this.name = "TranscriptNotStreamable";
50
+ this.reason = reason;
51
+ }
52
+ }
53
+ /**
54
+ * The lines of `filePath`. `byteLimit` reads exactly that many bytes (the second pass reads the first
55
+ * pass's count, so a file that grew between the passes is not half-seen) and fails with
56
+ * `file_changed_during_pass` when fewer arrive. `stopAfter` is a ceiling only (the first pass, bounded
57
+ * by the file cap): fewer is fine.
58
+ */
59
+ export function openTranscriptLines(filePath, options = {}) {
60
+ let bytes = 0;
61
+ const { byteLimit } = options;
62
+ const readTo = byteLimit ?? options.stopAfter;
63
+ async function* lines() {
64
+ if (readTo === 0) {
65
+ yield "";
66
+ return;
67
+ }
68
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
69
+ let handle;
70
+ let held = [];
71
+ try {
72
+ handle = await fsp.open(filePath, "r");
73
+ // One buffer for the whole pass: the decoder copies out of it, so nothing waits on the collector to free a chunk.
74
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
75
+ for (let position = 0;;) {
76
+ const want = readTo === undefined ? CHUNK_BYTES : Math.min(CHUNK_BYTES, readTo - position);
77
+ if (want <= 0)
78
+ break;
79
+ const { bytesRead } = await handle.read(buffer, 0, want, position);
80
+ if (bytesRead === 0)
81
+ break;
82
+ position += bytesRead;
83
+ bytes += bytesRead;
84
+ const text = decodeOrThrow(decoder, buffer.subarray(0, bytesRead));
85
+ let start = 0;
86
+ for (let newline = text.indexOf("\n", start); newline !== -1; newline = text.indexOf("\n", start)) {
87
+ held.push(text.slice(start, newline));
88
+ const line = held.length === 1 ? held[0] : held.join("");
89
+ held = [];
90
+ start = newline + 1;
91
+ yield line;
92
+ }
93
+ if (start < text.length)
94
+ held.push(text.slice(start));
95
+ }
96
+ const tail = decodeOrThrow(decoder, undefined);
97
+ if (tail)
98
+ held.push(tail);
99
+ if (byteLimit !== undefined && bytes < byteLimit)
100
+ throw new TranscriptNotStreamable("file_changed_during_pass");
101
+ yield held.join("");
102
+ }
103
+ catch (error) {
104
+ if (error instanceof TranscriptNotStreamable)
105
+ throw error;
106
+ console.error("[transcript-text-stream] transcript could not be read to its end", JSON.stringify({ reason: "file_read_failed", stage: "stream", bytes_read: bytes, ...describeError(error) }));
107
+ throw new TranscriptReadError("stream", error);
108
+ }
109
+ finally {
110
+ await handle?.close().catch((error) => {
111
+ console.error("[transcript-text-stream] file handle did not close", JSON.stringify({ reason: "file_close_failed", ...describeError(error) }));
112
+ });
113
+ }
114
+ }
115
+ return { lines: lines(), bytesRead: () => bytes };
116
+ }
117
+ function decodeOrThrow(decoder, chunk) {
118
+ try {
119
+ return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode();
120
+ }
121
+ catch {
122
+ throw new TranscriptNotStreamable("invalid_utf8");
123
+ }
124
+ }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.263");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.265");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pass one of the streamed transcript: find every embedded image line by line and keep only what the
3
+ * describe step needs, never the text (BLI-23317).
4
+ *
5
+ * `findEmbeddedImages` over the whole text and over each line give the same images, because an image's
6
+ * characters never include "\n" and the finder's quote and data-URL anchors sit inside one line
7
+ * (`slimPass` already throws when one does not). So the census is the whole-text finder run per line,
8
+ * and what outlives a line is small: one record per distinct image, a tally per image of how often it
9
+ * occurs and how many characters swapping each occurrence saves, and the base64 of only those images
10
+ * the describe step may send (a copy, so no line or chunk stays alive behind it).
11
+ */
12
+ import { findEmbeddedImages, formatImageMarker, } from "@bli-cockpit/telemetry-core";
13
+ /** Could this line hold an embedded image? A superset of what the finder matches: its anchors are `base64` and the four image magics. */
14
+ export function lineMayHoldImage(line) {
15
+ return line.includes("base64") || line.includes("iVBOR") || line.includes("/9j/") || line.includes("R0lGO") || line.includes("UklGR");
16
+ }
17
+ export async function censusOfLines(lines, keepBase64) {
18
+ const census = { total: 0, unique: new Map(), tally: new Map(), base64: new Map() };
19
+ for await (const line of lines) {
20
+ if (!lineMayHoldImage(line))
21
+ continue;
22
+ for (const image of findEmbeddedImages(line)) {
23
+ census.total += 1;
24
+ const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
25
+ const tally = census.tally.get(image.sha256) ?? { count: 0, savedChars: 0 };
26
+ tally.count += 1;
27
+ tally.savedChars += image.replaceEnd - image.replaceStart - marker.length;
28
+ census.tally.set(image.sha256, tally);
29
+ if (census.unique.has(image.sha256))
30
+ continue;
31
+ census.unique.set(image.sha256, image);
32
+ if (keepBase64(image))
33
+ census.base64.set(image.sha256, Buffer.from(line.slice(image.base64Start, image.base64End), "latin1"));
34
+ }
35
+ }
36
+ return census;
37
+ }
@@ -5,10 +5,13 @@
5
5
  * Failures never fail the transcript. Logs contain metadata, never image bytes
6
6
  * or descriptions.
7
7
  */
8
- import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
8
+ import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, formatImageMarker, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
9
9
  import { postImageToDescribeDoor } from "./image-describe-client.js";
10
10
  import { resolveImageHandlingMode } from "./image-mode-state.js";
11
- import { slimOrKeep, slimTranscript } from "./slim/slim-transcript.js";
11
+ import { censusOfLines } from "./image-census-stream.js";
12
+ import { openTranscriptLines, TranscriptNotStreamable, TranscriptTooLargeError } from "./adapters/transcript-text-stream.js";
13
+ import { slimLineStream } from "./slim/slim-stream.js";
14
+ import { logSlimmed, slimOrKeep, slimPassOrNull, slimTranscript } from "./slim/slim-transcript.js";
12
15
  import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
13
16
  /** New images sent to the door in one sync (all worktrees together); the count never refills within a tick. */
14
17
  export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
@@ -34,19 +37,99 @@ export async function openImageDescriptions(options) {
34
37
  const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
35
38
  const sentThisRun = 0;
36
39
  const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, describingMs: 0, maxPerRun, maxRunMs, sentThisRun };
40
+ const streamMinBytes = options.streamMinBytes ?? STREAM_MIN_BYTES;
37
41
  return {
38
42
  mode,
43
+ streamMinBytes,
39
44
  isDescribed: (sha256) => described.has(imageStateKey(sha256)),
40
45
  hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
41
46
  async prepareTranscript(input) {
42
- const afterImages = await describeAndReplace(run, input);
47
+ const { replace, finds, ...afterImages } = await describeAndReplace(run, input, findsOfText(input.text));
43
48
  if (mode !== "describe_and_drop")
44
49
  return { ...afterImages, text: input.text, changed: false, slim: null };
45
- const slim = slimOrKeep(afterImages.text, input.source, { sessionId: input.sessionId });
50
+ // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
51
+ // intermediate copy). A pass that cannot run falls back to the two-step order, which is the same bytes.
52
+ const swap = { images: finds.images, replace };
53
+ const pass = slimPassOrNull(input.text, input.source, { sessionId: input.sessionId }, swap);
54
+ if (pass)
55
+ return { ...afterImages, text: pass.text, changed: pass.changed, slim: pass.stats };
56
+ const replaced = replaceImagesWithMarkers(input.text, swap.images, swap.replace);
57
+ const slim = slimOrKeep(replaced.text, input.source, { sessionId: input.sessionId });
46
58
  return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
47
59
  },
60
+ prepareTranscriptFile: mode === "describe_and_drop" ? (input) => prepareFileInTwoPasses(run, input) : undefined,
48
61
  };
49
62
  }
63
+ /** Files smaller than this are cheaper to read whole; the streamed path pays a second read. */
64
+ const STREAM_MIN_BYTES = 8 * 1024 * 1024;
65
+ function findsOfText(text) {
66
+ const images = findEmbeddedImages(text);
67
+ const unique = new Map();
68
+ for (const image of images)
69
+ if (!unique.has(image.sha256))
70
+ unique.set(image.sha256, image);
71
+ return {
72
+ total: images.length,
73
+ unique,
74
+ images,
75
+ base64For: (image) => base64Of(text, image),
76
+ swapped: (replace) => countImageSwaps({ images, replace }),
77
+ };
78
+ }
79
+ function findsOfCensus(census) {
80
+ return {
81
+ total: census.total,
82
+ unique: census.unique,
83
+ base64For: (image) => {
84
+ const kept = census.base64.get(image.sha256);
85
+ if (!kept)
86
+ throw new Error("image_base64_not_kept");
87
+ return kept.toString("latin1");
88
+ },
89
+ swapped: (replace) => {
90
+ let replacedCount = 0;
91
+ let charsSaved = 0;
92
+ for (const [sha256, image] of census.unique) {
93
+ if (!replace(image))
94
+ continue;
95
+ const tally = census.tally.get(sha256);
96
+ replacedCount += tally.count;
97
+ charsSaved += tally.savedChars;
98
+ }
99
+ return { replacedCount, charsSaved };
100
+ },
101
+ };
102
+ }
103
+ async function prepareFileInTwoPasses(run, input) {
104
+ const startedAt = Date.now();
105
+ // Pass one: discover. Nothing of the text is kept; only the images and, for those that may still be sent, their base64.
106
+ const room = Math.max(0, run.maxPerRun - run.sentThisRun);
107
+ let wanted = 0;
108
+ const first = openTranscriptLines(input.filePath, { stopAfter: input.maxFileBytes ? input.maxFileBytes + 1 : undefined });
109
+ const census = await censusOfLines(first.lines, (image) => {
110
+ // The same filters `selectImagesToDescribe` applies, so what is kept is what may be sent.
111
+ const key = imageStateKey(image.sha256);
112
+ const failure = run.failedImages[key];
113
+ if (wanted >= room || run.described.has(key) || run.settledThisRun.has(image.sha256) || image.byteSize > IMAGE_DESCRIBE_MAX_BYTES)
114
+ return false;
115
+ if (failure && failure.retry_after > run.now())
116
+ return false;
117
+ wanted += 1;
118
+ return true;
119
+ });
120
+ const readBytes = first.bytesRead();
121
+ if (input.maxFileBytes && readBytes > input.maxFileBytes)
122
+ throw new TranscriptTooLargeError(input.maxFileBytes);
123
+ // Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
124
+ const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census));
125
+ // Pass two: swap and slim over exactly the bytes pass one saw.
126
+ const second = openTranscriptLines(input.filePath, { byteLimit: readBytes });
127
+ const slim = await slimLineStream(second.lines, input.source, replace, readBytes);
128
+ if (slim.replacedCount !== afterImages.replacedCount)
129
+ throw new TranscriptNotStreamable("images_changed_between_passes");
130
+ logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
131
+ return { ...afterImages, text: slim.text, changed: slim.changed, slim: slim.stats, readBytes };
132
+ }
50
133
  function describeRoomLeft(run, sha256s) {
51
134
  if (run.sentThisRun >= run.maxPerRun)
52
135
  return false;
@@ -68,7 +151,7 @@ function markDescribed(run, sha256) {
68
151
  described.add(key);
69
152
  orderedKeys.push(key);
70
153
  }
71
- async function sendOne(run, image, text, input) {
154
+ async function sendOne(run, image, base64, input) {
72
155
  const { options, fetchImpl, settledThisRun, failedImages } = run;
73
156
  run.sentThisRun += 1;
74
157
  const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
@@ -76,7 +159,7 @@ async function sendOne(run, image, text, input) {
76
159
  sha256: image.sha256,
77
160
  media_type: image.mediaType,
78
161
  byte_size: image.byteSize,
79
- content_base64: base64Of(text, image),
162
+ content_base64: base64,
80
163
  source: input.source,
81
164
  session_id: input.sessionId.slice(0, 200),
82
165
  });
@@ -107,18 +190,15 @@ function rememberFailure(run, key, outcome) {
107
190
  run.stateDirty = true;
108
191
  console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
109
192
  }
110
- async function describeAndReplace(run, input) {
193
+ async function describeAndReplace(run, input, finds) {
111
194
  const { mode, described, options, orderedKeys, failedImages } = run;
112
- const images = findEmbeddedImages(input.text);
113
- if (images.length === 0)
114
- return { text: input.text, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
115
- const unique = new Map();
116
- for (const image of images)
117
- if (!unique.has(image.sha256))
118
- unique.set(image.sha256, image);
195
+ const replace = (image) => described.has(imageStateKey(image.sha256));
196
+ const { unique } = finds;
197
+ if (finds.total === 0)
198
+ return { replace, finds, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
119
199
  const counts = { settled_earlier: 0, already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
120
200
  const toSend = selectImagesToDescribe(run, unique, counts);
121
- await sendPendingImages(run, toSend, counts, input);
201
+ await sendPendingImages(run, toSend, counts, input, finds.base64For);
122
202
  const failedReasons = countImageOutcomes(run, toSend, counts);
123
203
  if (counts.described_now + counts.known > 0 || run.stateDirty) {
124
204
  await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
@@ -126,32 +206,44 @@ async function describeAndReplace(run, input) {
126
206
  }
127
207
  const deferredOnly = counts.over_budget + counts.backing_off > 0
128
208
  && counts.described_now + counts.known + counts.failed + counts.too_large + counts.settled_earlier === 0;
129
- const replaced = mode === "describe_and_drop"
130
- ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
131
- : { text: input.text, replacedCount: 0, charsSaved: 0 };
209
+ const replaced = mode === "describe_and_drop" ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
132
210
  const pendingImages = [...unique.keys()].filter((sha256) => !described.has(imageStateKey(sha256)));
133
211
  console.error(TAG + " transcript prepared", JSON.stringify({
134
212
  mode,
135
213
  source: input.source,
136
- images: images.length,
214
+ images: finds.total,
137
215
  unique: unique.size,
138
216
  ...counts,
139
217
  deferred_only: deferredOnly,
140
218
  failed_reasons: failedReasons,
141
219
  replaced: replaced.replacedCount,
142
220
  chars_saved: replaced.charsSaved,
143
- kept_without_description: mode === "describe_and_drop" ? images.length - replaced.replacedCount : 0,
221
+ kept_without_description: mode === "describe_and_drop" ? finds.total - replaced.replacedCount : 0,
144
222
  }));
145
223
  return {
146
- text: replaced.text,
224
+ replace,
225
+ finds,
147
226
  pendingImages,
148
- imageCount: images.length,
227
+ imageCount: finds.total,
149
228
  uniqueCount: unique.size,
150
229
  replacedCount: replaced.replacedCount,
151
230
  charsSaved: replaced.charsSaved,
152
231
  deferredOnly,
153
232
  };
154
233
  }
234
+ /** What `replaceImagesWithMarkers` would report for this swap, without building the text it would make. */
235
+ function countImageSwaps(swap) {
236
+ let replacedCount = 0;
237
+ let charsSaved = 0;
238
+ for (const image of swap.images) {
239
+ if (!swap.replace(image))
240
+ continue;
241
+ const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
242
+ replacedCount += 1;
243
+ charsSaved += image.replaceEnd - image.replaceStart - marker.length;
244
+ }
245
+ return { replacedCount, charsSaved };
246
+ }
155
247
  function selectImagesToDescribe(run, unique, counts) {
156
248
  const { described, settledThisRun, failedImages, now } = run;
157
249
  const toSend = [];
@@ -179,7 +271,7 @@ function selectImagesToDescribe(run, unique, counts) {
179
271
  }
180
272
  return toSend;
181
273
  }
182
- async function sendPendingImages(run, toSend, counts, input) {
274
+ async function sendPendingImages(run, toSend, counts, input, base64For) {
183
275
  const { now, maxPerRun, maxRunMs } = run;
184
276
  const callStartedAt = now();
185
277
  let next = 0;
@@ -191,7 +283,7 @@ async function sendPendingImages(run, toSend, counts, input) {
191
283
  counts.over_budget += 1;
192
284
  continue;
193
285
  }
194
- await sendOne(run, image, input.text, input);
286
+ await sendOne(run, image, base64For(image), input);
195
287
  }
196
288
  };
197
289
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Pass two of the streamed transcript: image swap and slimming over lines as they arrive (BLI-23317).
3
+ *
4
+ * The same per-line work as `slimPass` (swap the chosen images for markers inside the line, then the
5
+ * slim rules), with the lines coming from a stream instead of a decoded string, so the original text
6
+ * and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on the
7
+ * joined input; `raw-evidence-transcript-stream.test.ts` and the frozen goldens hold it to that.
8
+ *
9
+ * The result is ONE string, because the sanitizer after it takes one. Slimmed lines are written as
10
+ * UTF-8 into one byte buffer sized to the file (off the JS heap, one byte per ASCII character, no
11
+ * per-line garbage waiting on the collector) and decoded once at the end, so the last step holds the
12
+ * bytes beside the string they make, not a second copy of the string. Text that came from valid
13
+ * UTF-8 survives encode and decode unchanged; the slim rules re-escape lone surrogates, so none is
14
+ * ever written.
15
+ */
16
+ import { findEmbeddedImages, replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
17
+ import { lineMayHoldImage } from "../image-census-stream.js";
18
+ import { createSlimmer } from "./slim-transcript.js";
19
+ const NEWLINE = 0x0a;
20
+ /** UTF-8 bytes of the lines so far, in a buffer that starts at the input's size and grows only if the output outgrows it. */
21
+ class LineBytes {
22
+ buffer;
23
+ length = 0;
24
+ constructor(capacity) {
25
+ this.buffer = Buffer.allocUnsafe(Math.max(capacity, 1024));
26
+ }
27
+ reserve(extra) {
28
+ const needed = this.length + extra;
29
+ if (needed <= this.buffer.length)
30
+ return;
31
+ const grown = Buffer.allocUnsafe(Math.max(needed, Math.ceil(this.buffer.length * 1.25)));
32
+ this.buffer.copy(grown, 0, 0, this.length);
33
+ this.buffer = grown;
34
+ }
35
+ newline() {
36
+ this.reserve(1);
37
+ this.buffer[this.length++] = NEWLINE;
38
+ }
39
+ line(text) {
40
+ const size = Buffer.byteLength(text);
41
+ this.reserve(size);
42
+ this.length += this.buffer.write(text, this.length, size, "utf8");
43
+ }
44
+ text() {
45
+ return this.buffer.toString("utf8", 0, this.length);
46
+ }
47
+ }
48
+ export async function slimLineStream(lines, kind, replace, inputBytes) {
49
+ const slimmer = createSlimmer(kind);
50
+ const output = new LineBytes(inputBytes);
51
+ let replacedCount = 0;
52
+ let charsSaved = 0;
53
+ let changed = false;
54
+ let first = true;
55
+ for await (const original of lines) {
56
+ let line = original;
57
+ if (lineMayHoldImage(line)) {
58
+ const found = findEmbeddedImages(line);
59
+ if (found.length > 0) {
60
+ const replaced = replaceImagesWithMarkers(line, found, replace);
61
+ if (replaced.replacedCount > 0) {
62
+ line = replaced.text;
63
+ replacedCount += replaced.replacedCount;
64
+ charsSaved += replaced.charsSaved;
65
+ }
66
+ }
67
+ }
68
+ const out = slimmer.slimLine(line);
69
+ if (line !== original || out !== original)
70
+ changed = true;
71
+ if (!first)
72
+ output.newline();
73
+ first = false;
74
+ output.line(out);
75
+ }
76
+ return { text: output.text(), stats: slimmer.stats(), replacedCount, charsSaved, changed };
77
+ }