@bli-cockpit/cli 0.2.266 → 0.2.268

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,84 @@
1
+ /**
2
+ * Put an accepted transcript's bytes on disk and add its manifest entry (split out of
3
+ * `raw-evidence-transcript-file.ts`, BLI-23317 stage D).
4
+ *
5
+ * The mask step's answer comes in two shapes (`MaskedBody`): bytes in memory, or (a clean streamed
6
+ * transcript) a file already on disk and hashed. Both publish the same object under the same name with
7
+ * the same manifest entry; the second never holds the bytes.
8
+ */
9
+ import path from "node:path";
10
+ import { shortHash } from "./raw-evidence-keys.js";
11
+ import { evidenceEntry } from "./raw-evidence-manifest.js";
12
+ import { describeSnapshot, describeStagedSnapshot, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
13
+ import { stageEvidenceBytes } from "./raw-evidence-collection-state.js";
14
+ import { stageEvidenceFile } from "./raw-evidence-stage-file.js";
15
+ import { discardStagedOutput } from "./staged-output.js";
16
+ export function byteLengthOf(body) {
17
+ return "staged" in body ? body.staged.byteLength : body.bytes.byteLength;
18
+ }
19
+ /** A staged partial that will not be published (content already durable, budget deferral) is deleted, never left behind. */
20
+ export async function dropStagedBody(body, reason) {
21
+ if ("staged" in body)
22
+ await discardStagedOutput(body.staged, reason);
23
+ }
24
+ /** Put an accepted transcript's bytes on disk and add its manifest entry. */
25
+ export async function stageOneTranscript(collection, file) {
26
+ collection.index.value += 1;
27
+ const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(file.filePath)}-${file.names.packedFileName}`);
28
+ const prior = collection.priorSnapshots.get(file.names.sourceKey);
29
+ // BLI-11088: remember this snapshot's shape, and claim the last one when these bytes rebuild it. A
30
+ // streamed clean file (BLI-23317 stage D) is read once from its partial, before it is published.
31
+ let content;
32
+ let snapshot;
33
+ let supersedes;
34
+ let staged;
35
+ if ("staged" in file.sanitized) {
36
+ const partial = file.sanitized.staged;
37
+ content = { sha256: partial.sha256, byteLength: partial.byteLength };
38
+ try {
39
+ const described = await describeStagedSnapshot({
40
+ filePath: partial.path, byteLength: partial.byteLength, contentHash: file.contentHash, kind: file.kind, prior, claim: file.stubClaim === null,
41
+ });
42
+ snapshot = described.snapshot;
43
+ supersedes = file.stubClaim ?? described.supersedes;
44
+ }
45
+ catch (error) {
46
+ await discardStagedOutput(partial, "snapshot_read_failed");
47
+ throw error;
48
+ }
49
+ staged = await stageEvidenceFile(collection, { staged: partial, fileName: path.basename(relativePath), kind: file.kind });
50
+ }
51
+ else {
52
+ content = file.sanitized.bytes;
53
+ snapshot = describeSnapshot(file.sanitized.bytes);
54
+ supersedes = file.stubClaim ?? supersedeClaimFor({ bytes: file.sanitized.bytes, contentHash: file.contentHash, kind: file.kind, prior });
55
+ staged = await stageEvidenceBytes(collection, {
56
+ contentHash: file.contentHash,
57
+ bytes: file.sanitized.bytes,
58
+ fileName: path.basename(relativePath),
59
+ kind: file.kind,
60
+ sourceKey: file.names.sourceKey,
61
+ });
62
+ }
63
+ collection.entries.push(evidenceEntry({
64
+ context: collection.context,
65
+ kind: file.kind,
66
+ packId: collection.packId,
67
+ localPath: staged.local_path,
68
+ relativePath,
69
+ mediaType: file.mediaType,
70
+ redactedSummary: file.sanitized.status === "redacted"
71
+ ? `${file.redactedSummary} Secret-like values were deterministically redacted before upload.`
72
+ : file.redactedSummary,
73
+ // Both branches carry a record now (BLI-3277), so "was anything
74
+ // replaced?" is the status, never the presence of `redaction`.
75
+ redaction: file.sanitized.redaction,
76
+ bytes: content,
77
+ codexSessionId: file.sessionId,
78
+ contentAddress: file.contentAddress(file.contentHash.slice(0, 16)),
79
+ stagedInPack: staged.staged_in_pack,
80
+ sourceKey: file.names.sourceKey,
81
+ snapshot,
82
+ supersedes,
83
+ }));
84
+ }
@@ -35,10 +35,10 @@ export async function streamTranscriptBody(session, agent, file) {
35
35
  return null;
36
36
  }
37
37
  try {
38
- const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes });
38
+ const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir });
39
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 };
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, staged_bytes: prepared.staged?.byteLength ?? null }));
41
+ return { held: { text: prepared.text, exactBytes: null, ...(prepared.staged ? { staged: prepared.staged } : {}) }, volatile, pendingImages, readBytes: prepared.readBytes };
42
42
  }
43
43
  catch (error) {
44
44
  if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The slimmed transcript, written to staging as it is made (BLI-23317, stage D).
3
+ *
4
+ * Before this, the streamed pass wrote every slimmed line into one buffer sized to the INPUT, decoded
5
+ * it to the string the sanitizer takes, and the clean outcome then encoded that string AGAIN into the
6
+ * bytes it staged. Now each slimmed line is encoded once, hashed and appended to a `.partial` file in
7
+ * the pack's `files` directory as it arrives. When the sanitizer decides the file is clean, those
8
+ * bytes ARE the upload: the partial is renamed into place (or dropped when the content is already
9
+ * durable or the budget defers it), and nothing re-encodes or re-hashes the file. Any other outcome
10
+ * (a secret was masked) discards the partial and builds its bytes from the sanitizer's text, as before.
11
+ *
12
+ * Bytes written are exactly `Buffer.from(text, "utf8")` of the text the sanitizer reads: slim output
13
+ * re-escapes lone surrogates, so encoding never changes a character. The sha256 is over the bytes in
14
+ * file order, and `byteLength` is their count.
15
+ */
16
+ import { createHash, randomBytes } from "node:crypto";
17
+ import fsp from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { describeError } from "../health-detail.js";
20
+ const TAG = "[staged-output]";
21
+ /** Bytes gathered before a write: big enough to keep writes few, small enough to stay a rounding error. */
22
+ const FLUSH_BYTES = 1024 * 1024;
23
+ export async function openStagedOutput(directory) {
24
+ const file = path.join(directory, `.stream-${process.pid}-${randomBytes(6).toString("hex")}.partial`);
25
+ let handle;
26
+ try {
27
+ await fsp.mkdir(directory, { recursive: true });
28
+ handle = await fsp.open(file, "wx", 0o600);
29
+ }
30
+ catch (error) {
31
+ console.error(TAG + " staging file could not be created, nothing was written", JSON.stringify({ reason: "staging_write_failed", stage: "open", ...describeError(error) }));
32
+ throw error;
33
+ }
34
+ const hash = createHash("sha256");
35
+ let pieces = [];
36
+ let pending = 0;
37
+ let byteLength = 0;
38
+ let open = true;
39
+ async function flush() {
40
+ if (pending === 0)
41
+ return;
42
+ const bytes = pieces.length === 1 ? pieces[0] : Buffer.concat(pieces, pending);
43
+ pieces = [];
44
+ pending = 0;
45
+ hash.update(bytes);
46
+ try {
47
+ for (let done = 0; done < bytes.length;) {
48
+ const { bytesWritten } = await handle.write(bytes, done, bytes.length - done);
49
+ done += bytesWritten;
50
+ }
51
+ }
52
+ catch (error) {
53
+ console.error(TAG + " staging write failed, the partial is dropped", JSON.stringify({ reason: "staging_write_failed", stage: "write", byte_size: byteLength, ...describeError(error) }));
54
+ await abandon();
55
+ throw error;
56
+ }
57
+ }
58
+ async function abandon() {
59
+ if (open) {
60
+ open = false;
61
+ await handle.close().catch(() => undefined);
62
+ }
63
+ await fsp.rm(file, { force: true }).catch(() => undefined);
64
+ }
65
+ return {
66
+ async write(text) {
67
+ if (text.length === 0)
68
+ return;
69
+ const bytes = Buffer.from(text, "utf8");
70
+ pieces.push(bytes);
71
+ pending += bytes.length;
72
+ byteLength += bytes.length;
73
+ if (pending >= FLUSH_BYTES)
74
+ await flush();
75
+ },
76
+ async finish() {
77
+ await flush();
78
+ open = false;
79
+ try {
80
+ await handle.close();
81
+ }
82
+ catch (error) {
83
+ console.error(TAG + " staging file did not close cleanly, the partial is dropped", JSON.stringify({ reason: "staging_write_failed", stage: "close", ...describeError(error) }));
84
+ await fsp.rm(file, { force: true }).catch(() => undefined);
85
+ throw error;
86
+ }
87
+ return { path: file, sha256: hash.digest("hex"), byteLength };
88
+ },
89
+ abandon,
90
+ };
91
+ }
92
+ /**
93
+ * The text of a finished staged file, for the sanitizer, which reads the whole text: the file is read
94
+ * into one buffer and decoded once. Both exist together for that moment (as the byte buffer and the text
95
+ * did before), and the buffer is detached and freed on the spot instead of waiting for a collection.
96
+ * `Buffer.toString("utf8")` keeps a BOM as a character, the same decoding the in-memory sink gives.
97
+ */
98
+ export async function readStagedText(staged) {
99
+ if (staged.byteLength === 0)
100
+ return "";
101
+ const handle = await fsp.open(staged.path, "r");
102
+ try {
103
+ // Never the shared pool: the backing store may be detached below.
104
+ const bytes = Buffer.allocUnsafeSlow(staged.byteLength);
105
+ for (let done = 0; done < bytes.length;) {
106
+ const { bytesRead } = await handle.read(bytes, done, bytes.length - done, done);
107
+ if (bytesRead === 0)
108
+ throw new Error("staged_file_shorter_than_written");
109
+ done += bytesRead;
110
+ }
111
+ const text = bytes.toString("utf8");
112
+ bytes.buffer.transfer?.(0);
113
+ return text;
114
+ }
115
+ finally {
116
+ await handle.close().catch(() => undefined);
117
+ }
118
+ }
119
+ /** Delete a staged partial that will not be published. A file already gone is fine; anything else is logged. */
120
+ export async function discardStagedOutput(staged, reason) {
121
+ try {
122
+ await fsp.rm(staged.path, { force: true });
123
+ console.error(TAG + " staged partial discarded", JSON.stringify({ reason, byte_size: staged.byteLength }));
124
+ }
125
+ catch (error) {
126
+ console.error(TAG + " staged partial could not be deleted", JSON.stringify({ reason: "staged_partial_delete_failed", why: reason, byte_size: staged.byteLength, ...describeError(error) }));
127
+ }
128
+ }
@@ -1,26 +1,16 @@
1
1
  /**
2
- * A transcript's lines as text, straight from disk, for the pass that never holds the whole file (BLI-23317).
2
+ * The named endings of reading a transcript as a stream (BLI-23317).
3
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.
4
+ * The reader itself is `pinned-transcript.ts` (stage D: one descriptor, a pinned length, byte lines).
5
+ * What lives here is the vocabulary the callers map to skips and fallbacks:
9
6
  *
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).
7
+ * - `TranscriptReadError`: a read that failed is never a shorter file; the caller maps it to the same
8
+ * `file_read_failed` skip a whole-file read failure gets;
9
+ * - `TranscriptTooLargeError`: the file is over this pass's cap (`file_too_large`);
10
+ * - `TranscriptNotStreamable`: the streamed path cannot give the whole-file answer for this file (bytes
11
+ * that are not valid UTF-8, a file that changed between the passes, an image step that broke), so the
12
+ * caller reads the file whole, which keeps the original bytes for the clean branch.
17
13
  */
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
14
  /** 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
15
  export class TranscriptReadError extends Error {
26
16
  stage;
@@ -49,76 +39,4 @@ export class TranscriptNotStreamable extends Error {
49
39
  this.name = "TranscriptNotStreamable";
50
40
  this.reason = reason;
51
41
  }
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
42
  }
@@ -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.266");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.268");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Find the embedded images of one transcript line without ever building the base64 as a JS string
3
+ * (BLI-23317, stage D).
4
+ *
5
+ * `findEmbeddedImages` (telemetry-core/transcript-images.ts) is the RULE and stays the test oracle: it
6
+ * feeds `evidenceRulesFingerprint`, so its regex and its results do not change. This module is the same
7
+ * rule run on the line's BYTES, which is what the streamed prepare path calls:
8
+ *
9
+ * - a whole-string payload: a base64 run that starts right after a `"`, starts with one of the four
10
+ * image starts (`iVBOR`, `/9j/`, `R0lGO`, `UklGR`), is at least 495 characters past that start
11
+ * (so 499 for a JPEG, 500 for the others, exactly as the regex counts) and is closed by `"` or `\"`;
12
+ * - a data URL: `data:image/<[a-z0-9.+-]+>;base64,` and then a run of at least 500 characters, no closing
13
+ * quote needed. The whole URL is the replaced range.
14
+ *
15
+ * A match is accepted only when the decoded bytes sniff as PNG, JPEG, GIF or WebP; either way the scan
16
+ * resumes after the run, as `matchAll` does. All matched characters are ASCII, so a byte offset here is
17
+ * the character offset there on ASCII text, and the replaced ranges are the same everywhere else.
18
+ *
19
+ * Decoding is incremental: the run is decoded in blocks of 48 Ki characters (a multiple of 4, so each
20
+ * block decodes on its own) straight into a sha256, with the first block sniffed. At no point does a
21
+ * string of the run, or a Buffer of the whole decoded image, exist. The one exception is a run whose
22
+ * `=` characters are not plain trailing padding: Node's decoder rules for those are version specific,
23
+ * so such a run takes the whole-run decode the oracle takes (never seen in real transcripts).
24
+ *
25
+ * `byteSize` is the length Node's `Buffer.from(run, "base64")` gives, because every block, including a
26
+ * short last one, is decoded by that very function.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { IMAGE_MIN_BASE64_CHARS, sniffImageMediaType, } from "@bli-cockpit/telemetry-core";
30
+ const QUOTE = 0x22;
31
+ const BACKSLASH = 0x5c;
32
+ const EQUALS = 0x3d;
33
+ /** Characters per decoded block. A multiple of 4: each block is a whole number of base64 quanta. */
34
+ const BLOCK_CHARS = 48 * 1024;
35
+ const BASE64 = new Uint8Array(256);
36
+ for (const range of ["AZ", "az", "09"]) {
37
+ for (let code = range.charCodeAt(0); code <= range.charCodeAt(1); code += 1)
38
+ BASE64[code] = 1;
39
+ }
40
+ for (const char of "+/=")
41
+ BASE64[char.charCodeAt(0)] = 1;
42
+ const DATA_URL_LOWER = new Uint8Array(256);
43
+ for (const range of ["az", "09"]) {
44
+ for (let code = range.charCodeAt(0); code <= range.charCodeAt(1); code += 1)
45
+ DATA_URL_LOWER[code] = 1;
46
+ }
47
+ for (const char of ".+-")
48
+ DATA_URL_LOWER[char.charCodeAt(0)] = 1;
49
+ /** The four image starts of a whole-string payload, and the data URL anchor. */
50
+ const STARTS = ["iVBOR", "/9j/", "R0lGO", "UklGR"].map((text) => Buffer.from(text, "latin1"));
51
+ const DATA_URL = Buffer.from("data:image/", "latin1");
52
+ const BASE64_TAG = Buffer.from(";base64,", "latin1");
53
+ /** Is there any chance this line holds an image? Same anchors as the string check, on bytes. */
54
+ export function bytesMayHoldImage(line) {
55
+ return line.includes(DATA_URL) || STARTS.some((start) => line.includes(start));
56
+ }
57
+ /** The end (exclusive) of the base64-alphabet run that starts at `from`. */
58
+ function runEnd(line, from) {
59
+ let at = from;
60
+ while (at < line.length && BASE64[line[at]] === 1)
61
+ at += 1;
62
+ return at;
63
+ }
64
+ /**
65
+ * sha256, size and type of the image `line[from, to)` encodes, or null when it is not a PNG, JPEG, GIF
66
+ * or WebP. Blocks of the run are decoded one at a time.
67
+ */
68
+ export function digestBase64Run(line, from, to) {
69
+ const length = to - from;
70
+ const inRun = line.subarray(from, to).indexOf(EQUALS);
71
+ const firstPad = inRun === -1 ? -1 : from + inRun;
72
+ const padCount = firstPad === -1 ? 0 : to - firstPad;
73
+ const plainPadding = padCount === 0
74
+ || (padCount <= 2 && length % 4 === 0 && onlyPaddingFrom(line, firstPad, to));
75
+ if (!plainPadding) {
76
+ const bytes = Buffer.from(line.toString("latin1", from, to), "base64");
77
+ const mediaType = sniffImageMediaType(bytes);
78
+ return mediaType ? { sha256: createHash("sha256").update(bytes).digest("hex"), mediaType, byteSize: bytes.byteLength } : null;
79
+ }
80
+ const hash = createHash("sha256");
81
+ let byteSize = 0;
82
+ let mediaType = null;
83
+ for (let at = from; at < to; at += BLOCK_CHARS) {
84
+ const block = Buffer.from(line.toString("latin1", at, Math.min(to, at + BLOCK_CHARS)), "base64");
85
+ if (at === from) {
86
+ mediaType = sniffImageMediaType(block);
87
+ if (!mediaType)
88
+ return null;
89
+ }
90
+ hash.update(block);
91
+ byteSize += block.byteLength;
92
+ }
93
+ return mediaType ? { sha256: hash.digest("hex"), mediaType, byteSize } : null;
94
+ }
95
+ /** True when everything from `firstPad` to `to` is `=` (padding at the very end of the run, nothing after it). */
96
+ function onlyPaddingFrom(line, firstPad, to) {
97
+ for (let at = firstPad; at < to; at += 1)
98
+ if (line[at] !== EQUALS)
99
+ return false;
100
+ return true;
101
+ }
102
+ /** The sha256 of an image's decoded bytes, or null when `line[from, to)` does not decode to an image (used to re-check a payload before it is sent). */
103
+ export function sha256OfBase64Run(line, from, to) {
104
+ return digestBase64Run(line, from, to)?.sha256 ?? null;
105
+ }
106
+ /**
107
+ * The next index at or after `from` where `anchor` sits right after a `"`, or -1. `current` is the answer
108
+ * of the last call: -2 not looked for yet, -1 none left, else still good while it is at or after `from`.
109
+ */
110
+ function nextQuotedStart(line, anchor, from, current) {
111
+ if (current === -1 || current >= from)
112
+ return current;
113
+ let at = line.indexOf(anchor, from);
114
+ while (at !== -1 && !(at > 0 && line[at - 1] === QUOTE))
115
+ at = line.indexOf(anchor, at + 1);
116
+ return at;
117
+ }
118
+ /**
119
+ * Every embedded image in `line` (one physical line, no `\n` inside), in order, with byte offsets inside
120
+ * the line. Equal to `findEmbeddedImages` on the decoded text in everything but the unit of the offsets.
121
+ */
122
+ export function scanImagesInLine(line) {
123
+ const found = [];
124
+ if (line.length < IMAGE_MIN_BASE64_CHARS - 1)
125
+ return found;
126
+ // For each whole-string start, the next quoted occurrence (-2: not looked for yet, -1: none left).
127
+ const nextStart = STARTS.map(() => -2);
128
+ let nextData = -2;
129
+ let position = 0;
130
+ while (position < line.length) {
131
+ let candidate = -1;
132
+ let kind = -1;
133
+ for (let index = 0; index < STARTS.length; index += 1) {
134
+ nextStart[index] = nextQuotedStart(line, STARTS[index], position, nextStart[index]);
135
+ const at = nextStart[index];
136
+ if (at !== -1 && (candidate === -1 || at < candidate)) {
137
+ candidate = at;
138
+ kind = index;
139
+ }
140
+ }
141
+ if (nextData !== -1) {
142
+ nextData = nextData >= position ? nextData : line.indexOf(DATA_URL, position);
143
+ if (nextData !== -1 && (candidate === -1 || nextData < candidate)) {
144
+ candidate = nextData;
145
+ kind = STARTS.length;
146
+ }
147
+ }
148
+ if (candidate === -1)
149
+ break;
150
+ if (kind < STARTS.length) {
151
+ const startLength = STARTS[kind].length;
152
+ const end = runEnd(line, candidate);
153
+ const closed = end < line.length && (line[end] === QUOTE || (line[end] === BACKSLASH && line[end + 1] === QUOTE));
154
+ if (!closed || end - candidate < startLength + (IMAGE_MIN_BASE64_CHARS - 5)) {
155
+ position = candidate + 1;
156
+ continue;
157
+ }
158
+ position = end;
159
+ const digest = digestBase64Run(line, candidate, end);
160
+ if (digest)
161
+ found.push({ replaceStart: candidate, replaceEnd: end, base64Start: candidate, base64End: end, ...digest });
162
+ continue;
163
+ }
164
+ // data:image/<type>;base64,<run of at least 500>
165
+ let cursor = candidate + DATA_URL.length;
166
+ const typeStart = cursor;
167
+ while (cursor < line.length && DATA_URL_LOWER[line[cursor]] === 1)
168
+ cursor += 1;
169
+ if (cursor === typeStart || line.compare(BASE64_TAG, 0, BASE64_TAG.length, cursor, cursor + BASE64_TAG.length) !== 0) {
170
+ position = candidate + 1;
171
+ continue;
172
+ }
173
+ const base64Start = cursor + BASE64_TAG.length;
174
+ const end = runEnd(line, base64Start);
175
+ if (end - base64Start < IMAGE_MIN_BASE64_CHARS) {
176
+ position = candidate + 1;
177
+ continue;
178
+ }
179
+ position = end;
180
+ const digest = digestBase64Run(line, base64Start, end);
181
+ if (digest)
182
+ found.push({ replaceStart: candidate, replaceEnd: end, base64Start, base64End: end, ...digest });
183
+ }
184
+ return found;
185
+ }
@@ -1,36 +1,46 @@
1
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).
2
+ * Pass one of the streamed transcript: find every embedded image line by line, on bytes, and keep only
3
+ * WHERE it is and what it is, never its payload (BLI-23317, stage D).
4
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).
5
+ * Each occurrence is an `EmbeddedImage` whose offsets are absolute byte offsets in the pinned file, so the
6
+ * swap pass needs no second scan and no second hash, and the describe step reads the one payload it is
7
+ * about to send straight from those offsets (`readPayload`) instead of a census holding every candidate's
8
+ * base64. What outlives a line is small: one record per OCCURRENCE (a few hundred bytes), a tally per
9
+ * distinct image of how often it occurs and how many characters swapping each occurrence saves, and the
10
+ * first occurrence of each distinct image.
11
+ *
12
+ * The finder is `image-byte-scan.ts`, the byte form of `findEmbeddedImages`; a line holds the same
13
+ * images either way because an image's characters never include "\n". Every line is also checked to be
14
+ * valid UTF-8, so a file the text path cannot decode is set aside here, before any image is sent.
11
15
  */
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))
16
+ import { isUtf8 } from "node:buffer";
17
+ import { formatImageMarker } from "@bli-cockpit/telemetry-core";
18
+ import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
19
+ import { bytesMayHoldImage, scanImagesInLine } from "./image-byte-scan.js";
20
+ export async function censusOfLines(lines) {
21
+ const census = { total: 0, occurrences: [], unique: new Map(), tally: new Map() };
22
+ for await (const { start, bytes } of lines) {
23
+ if (!isUtf8(bytes))
24
+ throw new TranscriptNotStreamable("invalid_utf8");
25
+ if (!bytesMayHoldImage(bytes))
21
26
  continue;
22
- for (const image of findEmbeddedImages(line)) {
27
+ for (const found of scanImagesInLine(bytes)) {
28
+ const image = {
29
+ ...found,
30
+ replaceStart: found.replaceStart + start,
31
+ replaceEnd: found.replaceEnd + start,
32
+ base64Start: found.base64Start + start,
33
+ base64End: found.base64End + start,
34
+ };
23
35
  census.total += 1;
36
+ census.occurrences.push(image);
24
37
  const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
25
38
  const tally = census.tally.get(image.sha256) ?? { count: 0, savedChars: 0 };
26
39
  tally.count += 1;
27
40
  tally.savedChars += image.replaceEnd - image.replaceStart - marker.length;
28
41
  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"));
42
+ if (!census.unique.has(image.sha256))
43
+ census.unique.set(image.sha256, image);
34
44
  }
35
45
  }
36
46
  return census;