@bli-cockpit/cli 0.2.264 → 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.
@@ -12,6 +12,11 @@
12
12
  import { isUtf8 } from "node:buffer";
13
13
  import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
14
14
  import { describeError } from "../health-detail.js";
15
+ import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
16
+ /** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
17
+ export function agentOfKind(kind) {
18
+ return kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
19
+ }
15
20
  /** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
16
21
  export function holdDecodedText(source) {
17
22
  const bytes = source.bytes;
@@ -27,12 +32,23 @@ export function holdDecodedText(source) {
27
32
  * BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
28
33
  * base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
29
34
  * The text is unchanged (the very same decoding) unless this sync has an image session AND the switch
30
- * is `describe_and_drop`; in `describe_and_keep` it always comes back untouched. Never throws: an
31
- * image step that breaks hands back the original text and says so.
35
+ * is `describe_and_drop`; in `describe_and_keep` it always comes back untouched. An image step
36
+ * that breaks hands back the original text and says so. The one throw: a streamed source (BLI-23317)
37
+ * whose file cannot be read to the end raises `TranscriptReadError` or `TranscriptTooLargeError`, which
38
+ * the caller turns into the `file_read_failed` and `file_too_large` skips.
32
39
  */
33
40
  export async function prepareTranscriptBody(collection, file) {
34
41
  const session = collection.budget.images;
35
- const agent = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
42
+ const agent = agentOfKind(file.kind);
43
+ if (file.source.stream) {
44
+ const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind });
45
+ if (streamed) {
46
+ file.source.readBytes = streamed.readBytes;
47
+ return { held: streamed.held, volatile: streamed.volatile, pendingImages: streamed.pendingImages };
48
+ }
49
+ file.source.bytes = await readTranscriptWhole(file.source.stream);
50
+ file.source.readBytes = file.source.bytes.byteLength;
51
+ }
36
52
  const untouched = (volatile, pendingImages) => ({ held: holdDecodedText(file.source), volatile, pendingImages });
37
53
  if (!session || !agent)
38
54
  return untouched(false, null);
@@ -46,14 +62,9 @@ export async function prepareTranscriptBody(collection, file) {
46
62
  // BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
47
63
  // this very file: an image still without a stored description (keep mode never replaces, so
48
64
  // any image counts) or a slim step that failed and should be retried.
49
- const imagesPending = session.mode === "describe_and_drop"
50
- ? prepared.imageCount > prepared.replacedCount
51
- : prepared.imageCount > 0;
52
- const slimFailed = session.mode === "describe_and_drop" && prepared.slim === null;
53
- const volatile = imagesPending || slimFailed;
54
65
  // BLI-22794: volatile ONLY because of images without a stored description (the slim step held
55
66
  // up): the bytes change only when one of these gains one, so the memo may keep the entry.
56
- const pendingImages = volatile && !slimFailed ? prepared.pendingImages : null;
67
+ const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
57
68
  if (prepared.changed) {
58
69
  // The old path encoded the new text and read it back; a text with no lone surrogate (slim output
59
70
  // re-escapes them) is the same either way, so the new text is the exact decoding of the new bytes.
@@ -22,7 +22,8 @@ import { describeError } from "../health-detail.js";
22
22
  import { DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, } from "../raw-evidence-staging.js";
23
23
  import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
24
24
  import { maskSecretsInHeldTranscript } from "./raw-evidence-sanitize-held.js";
25
- import { prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
25
+ import { agentOfKind, prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
26
+ import { TranscriptReadError, TranscriptTooLargeError } from "./transcript-text-stream.js";
26
27
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
27
28
  import { evidenceEntry } from "./raw-evidence-manifest.js";
28
29
  import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
@@ -57,16 +58,30 @@ export async function collectOneEvidenceFile(collection, options) {
57
58
  const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
58
59
  if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
59
60
  return true;
60
- const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
61
+ const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
61
62
  if (read.status === "read_failed")
62
63
  return skip("file_read_failed");
63
64
  if (read.status === "too_large") {
64
65
  markCapApplied(collection, options.kind, "max_file_bytes");
65
66
  return skip("file_too_large");
66
67
  }
67
- const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = await prepareTranscriptForUpload(collection, options, names, read.source);
68
+ let prepared;
69
+ try {
70
+ prepared = await prepareTranscriptForUpload(collection, options, names, read.source);
71
+ }
72
+ catch (error) {
73
+ // BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
74
+ if (error instanceof TranscriptReadError)
75
+ return skip("file_read_failed");
76
+ if (error instanceof TranscriptTooLargeError) {
77
+ markCapApplied(collection, options.kind, "max_file_bytes");
78
+ return skip("file_too_large");
79
+ }
80
+ throw error;
81
+ }
82
+ const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = prepared;
68
83
  await rememberPreparedTranscript(collection, options, names, {
69
- stamp, recipe, readBytes: read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
84
+ stamp, recipe, readBytes: read.source.readBytes ?? read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
70
85
  });
71
86
  if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
72
87
  collection.reused.push({
@@ -264,12 +279,19 @@ async function stageOneTranscript(collection, file) {
264
279
  }),
265
280
  }));
266
281
  }
282
+ /** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
283
+ function streamMinBytesFor(collection, kind) {
284
+ const session = collection.budget.images;
285
+ if (!session?.prepareTranscriptFile || !agentOfKind(kind))
286
+ return null;
287
+ return session.streamMinBytes ?? null;
288
+ }
267
289
  /**
268
290
  * Size is checked twice on purpose: once by `stat` so an oversized transcript
269
291
  * is never buffered at all, and once on the bytes actually read, because a live
270
292
  * session can grow between the two.
271
293
  */
272
- async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
294
+ async function readEvidenceFileWithinCap(filePath, maxFileBytes, streamMinBytes) {
273
295
  if (maxFileBytes) {
274
296
  let stat;
275
297
  try {
@@ -290,6 +312,14 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
290
312
  if (stat.size > maxFileBytes)
291
313
  return { status: "too_large" };
292
314
  }
315
+ if (streamMinBytes !== null) {
316
+ // BLI-23317: a big transcript is streamed by the prepare step, not buffered here. A stat that fails
317
+ // falls through to the read below, which reports the failure itself.
318
+ const size = await fs.stat(filePath).then((stat) => stat.size, () => null);
319
+ if (size !== null && size >= streamMinBytes) {
320
+ return { status: "ok", source: { bytes: null, stream: { filePath, maxFileBytes } }, byteLength: size };
321
+ }
322
+ }
293
323
  let bytes;
294
324
  try {
295
325
  bytes = await fs.readFile(filePath);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The transcript that is never read whole (BLI-23317): its path goes to the image session's two
3
+ * streaming passes, and what comes back is the one slimmed string the sanitizer takes.
4
+ *
5
+ * Three endings, each named:
6
+ * - a body: the text is the slim form of the file (valid UTF-8 was checked across every chunk, so
7
+ * `exactBytes` is null: the text encodes back to the bytes a clean file would upload);
8
+ * - null: the streamed path cannot give the whole-file answer (invalid UTF-8, a file that changed
9
+ * between the passes, an image step that broke), and the caller reads the file whole, which is
10
+ * what ran before this existed and still keeps the original bytes for the clean branch;
11
+ * - a throw of `TranscriptReadError` or `TranscriptTooLargeError`: the same outcomes a whole-file
12
+ * read gives (`file_read_failed`, `file_too_large`), never a shorter file.
13
+ */
14
+ import fs from "node:fs/promises";
15
+ import { describeError } from "../health-detail.js";
16
+ import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
17
+ const TAG = "[raw-evidence]";
18
+ /**
19
+ * BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from this very
20
+ * file: an image still without a stored description (keep mode never replaces, so any image counts) or
21
+ * a slim step that failed and should be retried. BLI-22794: volatile ONLY because of images without a
22
+ * stored description means the bytes change only when one of them gains one, so the memo may keep the entry.
23
+ */
24
+ export function volatilityOf(mode, prepared) {
25
+ const imagesPending = mode === "describe_and_drop"
26
+ ? prepared.imageCount > prepared.replacedCount
27
+ : prepared.imageCount > 0;
28
+ const slimFailed = mode === "describe_and_drop" && prepared.slim === null;
29
+ const volatile = imagesPending || slimFailed;
30
+ return { volatile, pendingImages: volatile && !slimFailed ? prepared.pendingImages : null };
31
+ }
32
+ export async function streamTranscriptBody(session, agent, file) {
33
+ if (!session?.prepareTranscriptFile || !agent) {
34
+ console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
35
+ return null;
36
+ }
37
+ try {
38
+ const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes });
39
+ const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
40
+ console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile }));
41
+ return { held: { text: prepared.text, exactBytes: null }, volatile, pendingImages, readBytes: prepared.readBytes };
42
+ }
43
+ catch (error) {
44
+ if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
45
+ throw error;
46
+ console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
47
+ return null;
48
+ }
49
+ }
50
+ /** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
51
+ export async function readTranscriptWhole(file) {
52
+ let bytes;
53
+ try {
54
+ bytes = await fs.readFile(file.filePath);
55
+ }
56
+ catch (error) {
57
+ console.error(TAG + " evidence file could not be read", JSON.stringify({ reason: "file_read_failed", stage: "read", ...describeError(error) }));
58
+ throw new TranscriptReadError("read", error);
59
+ }
60
+ if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
61
+ throw new TranscriptTooLargeError(file.maxFileBytes);
62
+ return bytes;
63
+ }
@@ -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.264");
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
+ }
@@ -8,7 +8,10 @@
8
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, slimPassOrNull, 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,16 +37,19 @@ 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 { swap, ...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
50
  // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
46
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 };
47
53
  const pass = slimPassOrNull(input.text, input.source, { sessionId: input.sessionId }, swap);
48
54
  if (pass)
49
55
  return { ...afterImages, text: pass.text, changed: pass.changed, slim: pass.stats };
@@ -51,8 +57,79 @@ export async function openImageDescriptions(options) {
51
57
  const slim = slimOrKeep(replaced.text, input.source, { sessionId: input.sessionId });
52
58
  return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
53
59
  },
60
+ prepareTranscriptFile: mode === "describe_and_drop" ? (input) => prepareFileInTwoPasses(run, input) : undefined,
54
61
  };
55
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
+ }
56
133
  function describeRoomLeft(run, sha256s) {
57
134
  if (run.sentThisRun >= run.maxPerRun)
58
135
  return false;
@@ -74,7 +151,7 @@ function markDescribed(run, sha256) {
74
151
  described.add(key);
75
152
  orderedKeys.push(key);
76
153
  }
77
- async function sendOne(run, image, text, input) {
154
+ async function sendOne(run, image, base64, input) {
78
155
  const { options, fetchImpl, settledThisRun, failedImages } = run;
79
156
  run.sentThisRun += 1;
80
157
  const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
@@ -82,7 +159,7 @@ async function sendOne(run, image, text, input) {
82
159
  sha256: image.sha256,
83
160
  media_type: image.mediaType,
84
161
  byte_size: image.byteSize,
85
- content_base64: base64Of(text, image),
162
+ content_base64: base64,
86
163
  source: input.source,
87
164
  session_id: input.sessionId.slice(0, 200),
88
165
  });
@@ -113,19 +190,15 @@ function rememberFailure(run, key, outcome) {
113
190
  run.stateDirty = true;
114
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) }));
115
192
  }
116
- async function describeAndReplace(run, input) {
193
+ async function describeAndReplace(run, input, finds) {
117
194
  const { mode, described, options, orderedKeys, failedImages } = run;
118
- const images = findEmbeddedImages(input.text);
119
- const swap = { images, replace: (image) => described.has(imageStateKey(image.sha256)) };
120
- if (images.length === 0)
121
- return { swap, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
122
- const unique = new Map();
123
- for (const image of images)
124
- if (!unique.has(image.sha256))
125
- 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 };
126
199
  const counts = { settled_earlier: 0, already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
127
200
  const toSend = selectImagesToDescribe(run, unique, counts);
128
- await sendPendingImages(run, toSend, counts, input);
201
+ await sendPendingImages(run, toSend, counts, input, finds.base64For);
129
202
  const failedReasons = countImageOutcomes(run, toSend, counts);
130
203
  if (counts.described_now + counts.known > 0 || run.stateDirty) {
131
204
  await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
@@ -133,24 +206,25 @@ async function describeAndReplace(run, input) {
133
206
  }
134
207
  const deferredOnly = counts.over_budget + counts.backing_off > 0
135
208
  && counts.described_now + counts.known + counts.failed + counts.too_large + counts.settled_earlier === 0;
136
- const replaced = mode === "describe_and_drop" ? countImageSwaps(swap) : { replacedCount: 0, charsSaved: 0 };
209
+ const replaced = mode === "describe_and_drop" ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
137
210
  const pendingImages = [...unique.keys()].filter((sha256) => !described.has(imageStateKey(sha256)));
138
211
  console.error(TAG + " transcript prepared", JSON.stringify({
139
212
  mode,
140
213
  source: input.source,
141
- images: images.length,
214
+ images: finds.total,
142
215
  unique: unique.size,
143
216
  ...counts,
144
217
  deferred_only: deferredOnly,
145
218
  failed_reasons: failedReasons,
146
219
  replaced: replaced.replacedCount,
147
220
  chars_saved: replaced.charsSaved,
148
- 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,
149
222
  }));
150
223
  return {
151
- swap,
224
+ replace,
225
+ finds,
152
226
  pendingImages,
153
- imageCount: images.length,
227
+ imageCount: finds.total,
154
228
  uniqueCount: unique.size,
155
229
  replacedCount: replaced.replacedCount,
156
230
  charsSaved: replaced.charsSaved,
@@ -197,7 +271,7 @@ function selectImagesToDescribe(run, unique, counts) {
197
271
  }
198
272
  return toSend;
199
273
  }
200
- async function sendPendingImages(run, toSend, counts, input) {
274
+ async function sendPendingImages(run, toSend, counts, input, base64For) {
201
275
  const { now, maxPerRun, maxRunMs } = run;
202
276
  const callStartedAt = now();
203
277
  let next = 0;
@@ -209,7 +283,7 @@ async function sendPendingImages(run, toSend, counts, input) {
209
283
  counts.over_budget += 1;
210
284
  continue;
211
285
  }
212
- await sendOne(run, image, input.text, input);
286
+ await sendOne(run, image, base64For(image), input);
213
287
  }
214
288
  };
215
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
+ }
@@ -170,7 +170,7 @@ export function slimTranscript(text, kind) {
170
170
  return { text: slim.text, stats: slim.stats };
171
171
  }
172
172
  const TAG = "[slim-transcript]";
173
- function logSlimmed(kind, sessionId, stats, startedAt) {
173
+ export function logSlimmed(kind, sessionId, stats, startedAt) {
174
174
  console.error(TAG + (stats.linesModified === 0 ? " nothing to strip" : " transcript slimmed"), JSON.stringify({
175
175
  source: kind,
176
176
  session_id: sessionId.slice(0, 80),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.264",
3
+ "version": "0.2.265",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {