@bli-cockpit/cli 0.2.263 → 0.2.264

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.
@@ -1,4 +1,5 @@
1
1
  import { SECRET_FILE_SEGMENT_PATTERN } from "@bli-cockpit/telemetry-core";
2
+ import { linesOfFile } from "./transcript-line-stream.js";
2
3
  import crypto from "node:crypto";
3
4
  import fs from "node:fs/promises";
4
5
  import path from "node:path";
@@ -14,13 +15,12 @@ export async function collectAgentImageEvidenceFromJsonlFile(options) {
14
15
  skipped: [{ label: "transcript", reason: "file_too_large" }],
15
16
  };
16
17
  }
17
- const content = await fs.readFile(options.filePath, "utf8").catch(() => null);
18
- if (content === null)
19
- return { images: [], skipped: [] };
20
18
  const images = [];
21
19
  const skipped = [];
22
20
  let lineIndex = 0;
23
- for (const line of content.split("\n")) {
21
+ // BLI-23317: lines stream off the disk (the same lines `readFile` + `split("\n")` gave), so a 200 MB
22
+ // transcript is never held as bytes, string and lines at once; only the longest line is in memory.
23
+ for await (const line of linesOfFile(options.filePath)) {
24
24
  if (!line.trim())
25
25
  continue;
26
26
  lineIndex += 1;
@@ -29,6 +29,7 @@ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core"
29
29
  import crypto from "node:crypto";
30
30
  import fs from "node:fs/promises";
31
31
  import path from "node:path";
32
+ import { describeError } from "../health-detail.js";
32
33
  import { DELIVERY_BACKOFF_BYPASS_REASON, deliveryBackoffApplies, heldSourceKeys, readRawEvidenceStagingState, resolveStagedObject, } from "../raw-evidence-staging.js";
33
34
  import { openStagingAllowance, } from "../staging-gate.js";
34
35
  import { chmodPrivate } from "./raw-evidence-pack-store.js";
@@ -201,8 +202,19 @@ export async function stageEvidenceBytes(collection, options) {
201
202
  return { local_path: existing.local_path, staged_in_pack: false };
202
203
  }
203
204
  const destination = path.join(collection.filesDir, options.fileName);
204
- await fs.writeFile(destination, options.bytes, { mode: 0o600 });
205
- await chmodPrivate(destination, 0o600);
205
+ // BLI-23317: written beside the destination and renamed in, so a crash or a full disk mid-write
206
+ // leaves a `.partial` file and never a truncated object under the name the manifest points at.
207
+ const partial = `${destination}.partial-${process.pid}`;
208
+ try {
209
+ await fs.writeFile(partial, options.bytes, { mode: 0o600 });
210
+ await chmodPrivate(partial, 0o600);
211
+ await fs.rename(partial, destination);
212
+ }
213
+ catch (error) {
214
+ await fs.rm(partial, { force: true }).catch(() => undefined);
215
+ console.error("[raw-evidence] staging write failed, nothing was published", JSON.stringify({ reason: "staging_write_failed", kind: options.kind, byte_size: options.bytes.byteLength, ...describeError(error) }));
216
+ throw error;
217
+ }
206
218
  collection.stagedNewCount += 1;
207
219
  return { local_path: destination, staged_in_pack: true };
208
220
  }
@@ -0,0 +1,191 @@
1
+ import { containsSecretLikeContent, redactSecretLikeContent, scannedCleanRedactionMetadata, } from "@bli-cockpit/telemetry-core";
2
+ import { createHash } from "node:crypto";
3
+ import { WHOLE_FILE_MASK_LINE, fallbackRedactionMetadata, logRedactionCrash, maskSecretBearingLines, } from "./raw-evidence-sanitize.js";
4
+ /** Code units per encode window: 4 Mi units encode to at most 12 MiB, and the window is garbage right after. */
5
+ const WINDOW_UNITS = 4 * 1024 * 1024;
6
+ /**
7
+ * The sha256 and byte length of `Buffer.from(text, "utf8")`, without that buffer: the text is encoded
8
+ * a window at a time. A window never ends between the halves of a surrogate pair, so the bytes fed to
9
+ * the hash are the bytes a whole-string encode produces (a lone surrogate encodes the same either way).
10
+ */
11
+ export function factsOfText(text, windowUnits = WINDOW_UNITS) {
12
+ const hash = createHash("sha256");
13
+ let byteLength = 0;
14
+ for (let at = 0; at < text.length;) {
15
+ let end = Math.min(text.length, at + windowUnits);
16
+ if (end < text.length && isHighSurrogate(text.charCodeAt(end - 1)) && isLowSurrogate(text.charCodeAt(end)))
17
+ end += 1;
18
+ const chunk = Buffer.from(text.slice(at, end), "utf8");
19
+ hash.update(chunk);
20
+ byteLength += chunk.byteLength;
21
+ at = end;
22
+ }
23
+ return { sha256: hash.digest("hex"), byteLength };
24
+ }
25
+ function isHighSurrogate(unit) {
26
+ return unit >= 0xd800 && unit <= 0xdbff;
27
+ }
28
+ function isLowSurrogate(unit) {
29
+ return unit >= 0xdc00 && unit <= 0xdfff;
30
+ }
31
+ /**
32
+ * Same decision table as `sanitizeTextEvidenceForUpload`. The holder's text is cleared before the
33
+ * bytes of the answer are built, so the answer is the only large thing left alive when this returns.
34
+ */
35
+ export function sanitizeHeldTextForUpload(held, options) {
36
+ // No local in this frame points at the text: only `held` does, so clearing it frees the transcript.
37
+ let facts;
38
+ const scan = {
39
+ textLength: held.text.length,
40
+ facts: () => (facts ??= held.exactBytes ? { sha256: sha256Of(held.exactBytes), byteLength: held.exactBytes.byteLength } : factsOfText(held.text)),
41
+ exactBytes: held.exactBytes,
42
+ redactedFields: options.redactedFields,
43
+ secretLikeFileName: options.secretLikeFileName === true,
44
+ };
45
+ try {
46
+ const found = scanHeldText(held, options.redactedFields, options.redact ?? redactSecretLikeContent);
47
+ if (scan.secretLikeFileName)
48
+ return maskNamedFile(held, scan, found.attempt);
49
+ if (found.secretLikeContent) {
50
+ // This branch needs the redactor's receipt, never its text; dropping the rest lets the rope of
51
+ // slices (which points into the transcript) go with the transcript.
52
+ const metadata = found.attempt.metadata;
53
+ found.attempt = { redacted: false, text: "", metadata: null };
54
+ return maskSecretBearingLinesOnly(held, scan, metadata);
55
+ }
56
+ if (found.attempt.redacted)
57
+ return keepDeterministicRedaction(held, scan, found.attempt);
58
+ const bytes = held.exactBytes ?? Buffer.from(held.text, "utf8");
59
+ held.text = null;
60
+ return { status: "clean", bytes, redaction: scannedCleanRedactionMetadata(bytes) };
61
+ }
62
+ catch (error) {
63
+ // Reads the facts while the text is still held; a crash inside them lands on the same stub.
64
+ const stubFacts = safeFacts(scan);
65
+ held.text = null;
66
+ logRedactionCrash(error, stubFacts?.byteLength ?? 0, options.redactedFields.length);
67
+ return stubForCrashedRedaction(scan, stubFacts);
68
+ }
69
+ }
70
+ /** The content guard's verdict and the redactor's answer, in one frame that ends before any bytes are built. */
71
+ function scanHeldText(held, redactedFields, redact) {
72
+ // BLI-11094: inside the caller's try. The content guard runs regexes over the whole file.
73
+ const secretLikeContent = containsSecretLikeContent(held.text);
74
+ const attempt = redact(held.text, { appliedBy: "local_collector", redactedFields });
75
+ return { secretLikeContent, attempt };
76
+ }
77
+ function safeFacts(scan) {
78
+ try {
79
+ return scan.facts();
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ function sha256Of(bytes) {
86
+ return createHash("sha256").update(bytes).digest("hex");
87
+ }
88
+ function withFacts(metadata, original, sanitizedBytes) {
89
+ if (!metadata)
90
+ throw new Error("redacted evidence is missing redaction metadata");
91
+ return {
92
+ ...metadata,
93
+ original_content_hash_sha256: original.sha256,
94
+ sanitized_content_hash_sha256: sha256Of(sanitizedBytes),
95
+ original_byte_size: original.byteLength,
96
+ sanitized_byte_size: sanitizedBytes.byteLength,
97
+ };
98
+ }
99
+ /** The name itself is the signal: whatever the redactor left is re-checked, and a still-secret result becomes one marker line. */
100
+ function maskNamedFile(held, scan, attempt) {
101
+ const original = scan.facts();
102
+ const sanitizedText = attempt.redacted ? attempt.text : held.text;
103
+ const safeText = containsSecretLikeContent(sanitizedText) ? WHOLE_FILE_MASK_LINE : sanitizedText;
104
+ const metadata = attempt.metadata ?? fallbackRedactionMetadata({
105
+ ruleId: "secret_like_file_name",
106
+ originalLength: scan.textLength,
107
+ redactedFields: scan.redactedFields,
108
+ fullContentRedacted: false,
109
+ });
110
+ held.text = null;
111
+ const sanitizedBytes = Buffer.from(safeText, "utf8");
112
+ return {
113
+ status: "redacted",
114
+ bytes: sanitizedBytes,
115
+ redaction: withFacts(metadata, original, sanitizedBytes),
116
+ completenessLabel: "secret_like_name_masked",
117
+ };
118
+ }
119
+ /** Only the lines that matched are replaced; every other line uploads intact. */
120
+ function maskSecretBearingLinesOnly(held, scan, metadata) {
121
+ const original = scan.facts();
122
+ const redaction = metadata ?? fallbackRedactionMetadata({
123
+ ruleId: "secret_like_content_guard",
124
+ originalLength: scan.textLength,
125
+ redactedFields: scan.redactedFields,
126
+ });
127
+ let maskedText = maskSecretBearingLines(held.text, redaction);
128
+ held.text = null;
129
+ if (containsSecretLikeContent(maskedText))
130
+ maskedText = WHOLE_FILE_MASK_LINE;
131
+ const sanitizedBytes = Buffer.from(maskedText, "utf8");
132
+ return {
133
+ status: "redacted",
134
+ bytes: sanitizedBytes,
135
+ redaction: withFacts(redaction, original, sanitizedBytes),
136
+ completenessLabel: "secret_content_masked",
137
+ };
138
+ }
139
+ /** The redactor found and replaced something the content guard did not flag. */
140
+ function keepDeterministicRedaction(held, scan, attempt) {
141
+ const original = scan.facts();
142
+ const metadata = attempt.metadata ?? fallbackRedactionMetadata({
143
+ ruleId: "secret_redaction_failed",
144
+ originalLength: scan.textLength,
145
+ redactedFields: scan.redactedFields,
146
+ });
147
+ held.text = null;
148
+ const sanitizedBytes = Buffer.from(attempt.text, "utf8");
149
+ return {
150
+ status: "redacted",
151
+ bytes: sanitizedBytes,
152
+ redaction: withFacts(metadata, original, sanitizedBytes),
153
+ completenessLabel: "secret_content_masked",
154
+ };
155
+ }
156
+ function stubForCrashedRedaction(scan, facts) {
157
+ const sanitizedBytes = Buffer.from("[REDACTED_LINE:redaction_crashed_stubbed]\n", "utf8");
158
+ return {
159
+ status: "redacted",
160
+ bytes: sanitizedBytes,
161
+ redaction: withFacts(fallbackRedactionMetadata({ ruleId: "redaction_crashed_stubbed", originalLength: scan.textLength, redactedFields: scan.redactedFields }),
162
+ // The old path would have thrown here (hashing the original) only if the original were unreadable; it never is.
163
+ facts ?? { sha256: sha256Of(Buffer.alloc(0)), byteLength: 0 }, sanitizedBytes),
164
+ completenessLabel: "redaction_crashed_stubbed",
165
+ };
166
+ }
167
+ /**
168
+ * Mask secret-like values in a transcript and record that it happened (see `maskSecretsInTranscript`
169
+ * in raw-evidence-sanitize.ts, whose answer this one matches), from text the caller holds once.
170
+ */
171
+ export function maskSecretsInHeldTranscript(collection, held, options) {
172
+ const sanitized = sanitizeHeldTextForUpload(held, {
173
+ redactedFields: [`${options.kind}.body`],
174
+ secretLikeFileName: options.secretLikeFileName,
175
+ });
176
+ if (sanitized.status === "redacted") {
177
+ collection.redacted.push({
178
+ kind: options.kind,
179
+ label: options.evidenceLabel,
180
+ redaction: sanitized.redaction,
181
+ completenessLabel: sanitized.completenessLabel,
182
+ });
183
+ console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
184
+ kind: options.kind,
185
+ mode: sanitized.completenessLabel,
186
+ original_bytes: sanitized.redaction.original_byte_size,
187
+ uploaded_bytes: sanitized.bytes.byteLength,
188
+ }));
189
+ }
190
+ return sanitized;
191
+ }
@@ -64,7 +64,7 @@ export function sanitizeTextEvidenceForUpload(options) {
64
64
  });
65
65
  }
66
66
  }
67
- function logRedactionCrash(error, byteSize, redactedFieldCount) {
67
+ export function logRedactionCrash(error, byteSize, redactedFieldCount) {
68
68
  // The redactor itself crashed. The stub below is the safe answer — a
69
69
  // placeholder goes up instead of unredacted bytes — but it is also
70
70
  // indistinguishable downstream from a file that was legitimately masked,
@@ -97,7 +97,7 @@ function maskFileWithSecretLikeName(input) {
97
97
  redaction: withRedactionContentMetadata(input.redactionResult.metadata ??
98
98
  fallbackRedactionMetadata({
99
99
  ruleId: "secret_like_file_name",
100
- originalText: input.text,
100
+ originalLength: input.text.length,
101
101
  redactedFields: input.redactedFields,
102
102
  fullContentRedacted: false,
103
103
  }), { originalBytes: input.originalBytes, sanitizedBytes }),
@@ -109,7 +109,7 @@ function maskSecretBearingLinesOnly(input) {
109
109
  const redaction = input.redactionResult.metadata ??
110
110
  fallbackRedactionMetadata({
111
111
  ruleId: "secret_like_content_guard",
112
- originalText: input.text,
112
+ originalLength: input.text.length,
113
113
  redactedFields: input.redactedFields,
114
114
  });
115
115
  let maskedText = maskSecretBearingLines(input.text, redaction);
@@ -136,7 +136,7 @@ function keepDeterministicRedaction(input) {
136
136
  redaction: withRedactionContentMetadata(input.redactionResult.metadata ??
137
137
  fallbackRedactionMetadata({
138
138
  ruleId: "secret_redaction_failed",
139
- originalText: input.text,
139
+ originalLength: input.text.length,
140
140
  redactedFields: input.redactedFields,
141
141
  }), { originalBytes: input.originalBytes, sanitizedBytes }),
142
142
  completenessLabel: "secret_content_masked",
@@ -149,7 +149,7 @@ function stubForCrashedRedaction(input) {
149
149
  bytes: sanitizedBytes,
150
150
  redaction: withRedactionContentMetadata(fallbackRedactionMetadata({
151
151
  ruleId: "redaction_crashed_stubbed",
152
- originalText: input.text,
152
+ originalLength: input.text.length,
153
153
  redactedFields: input.redactedFields,
154
154
  }), { originalBytes: input.originalBytes, sanitizedBytes }),
155
155
  completenessLabel: "redaction_crashed_stubbed",
@@ -174,7 +174,7 @@ function stubForCrashedRedaction(input) {
174
174
  * offsets are UTF-16 and file-absolute, and a line takes the rule of the FIRST
175
175
  * range in array order that overlaps it.
176
176
  */
177
- function maskSecretBearingLines(text, redaction) {
177
+ export function maskSecretBearingLines(text, redaction) {
178
178
  if (redaction.redacted_ranges.length === 0) {
179
179
  return "[REDACTED_LINE:secret_like_content_guard]\n";
180
180
  }
@@ -240,7 +240,7 @@ function lineEndingOf(text, start, end) {
240
240
  return "\n";
241
241
  return "";
242
242
  }
243
- function fallbackRedactionMetadata(options) {
243
+ export function fallbackRedactionMetadata(options) {
244
244
  const fullContentRedacted = options.fullContentRedacted !== false;
245
245
  return {
246
246
  schema_version: "raw-evidence-redaction.v1",
@@ -252,17 +252,17 @@ function fallbackRedactionMetadata(options) {
252
252
  rule_id: options.ruleId,
253
253
  match_count: 1,
254
254
  redacted_char_count: fullContentRedacted
255
- ? options.originalText.length
255
+ ? options.originalLength
256
256
  : 0,
257
257
  },
258
258
  ],
259
259
  secret_like_match_count: 1,
260
260
  redacted_fields: options.redactedFields,
261
- redacted_ranges: fullContentRedacted && options.originalText.length > 0
261
+ redacted_ranges: fullContentRedacted && options.originalLength > 0
262
262
  ? [
263
263
  {
264
264
  start: 0,
265
- end: options.originalText.length,
265
+ end: options.originalLength,
266
266
  rule_id: options.ruleId,
267
267
  },
268
268
  ]
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The transcript text that the mask step reads, and the image and slim step that produce it
3
+ * (BLI-16977 for the steps, BLI-23317 for how few copies of a 200 MB file they leave behind).
4
+ *
5
+ * The file's bytes are decoded ONCE and handed on in a holder (`HeldText`); the bytes themselves
6
+ * are let go the moment the text exists, because for valid UTF-8 the text encodes back to exactly
7
+ * those bytes (`Buffer.from(text)` is the original, so nothing needs the buffer to stay). A file
8
+ * that is not valid UTF-8 keeps its bytes in the holder: the decoding replaced characters, so the
9
+ * bytes cannot be rebuilt, and the old path uploaded the original bytes when nothing changed.
10
+ * The order stays image replacement, slim, redact.
11
+ */
12
+ import { isUtf8 } from "node:buffer";
13
+ import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
14
+ import { describeError } from "../health-detail.js";
15
+ /** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
16
+ export function holdDecodedText(source) {
17
+ const bytes = source.bytes;
18
+ const text = bytes.toString("utf8");
19
+ const lossless = isUtf8(bytes);
20
+ source.bytes = null;
21
+ if (!lossless) {
22
+ console.error("[raw-evidence] transcript is not valid UTF-8; keeping its bytes beside the text so an unchanged file uploads as it is", JSON.stringify({ reason: "invalid_utf8_bytes_kept", byte_size: bytes.byteLength }));
23
+ }
24
+ return { text, exactBytes: lossless ? null : bytes };
25
+ }
26
+ /**
27
+ * BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
28
+ * base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
29
+ * 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.
32
+ */
33
+ export async function prepareTranscriptBody(collection, file) {
34
+ const session = collection.budget.images;
35
+ const agent = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
36
+ const untouched = (volatile, pendingImages) => ({ held: holdDecodedText(file.source), volatile, pendingImages });
37
+ if (!session || !agent)
38
+ return untouched(false, null);
39
+ // Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
40
+ if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.source.bytes))
41
+ return untouched(false, null);
42
+ let held;
43
+ try {
44
+ held = holdDecodedText(file.source);
45
+ const prepared = await session.prepareTranscript({ text: held.text, source: agent, sessionId: file.sessionId });
46
+ // BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
47
+ // this very file: an image still without a stored description (keep mode never replaces, so
48
+ // 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
+ // BLI-22794: volatile ONLY because of images without a stored description (the slim step held
55
+ // 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;
57
+ if (prepared.changed) {
58
+ // The old path encoded the new text and read it back; a text with no lone surrogate (slim output
59
+ // re-escapes them) is the same either way, so the new text is the exact decoding of the new bytes.
60
+ held.text = prepared.text;
61
+ held.exactBytes = null;
62
+ }
63
+ return { held, volatile, pendingImages };
64
+ }
65
+ catch (error) {
66
+ console.error("[raw-evidence] image or slim step failed, uploading the transcript as it was", JSON.stringify({ reason: "image_step_failed", kind: file.kind, ...describeError(error) }));
67
+ return { held: held ?? holdDecodedText(file.source), volatile: true, pendingImages: null };
68
+ }
69
+ }
@@ -21,8 +21,8 @@ import path from "node:path";
21
21
  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
- import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
25
- import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
24
+ import { maskSecretsInHeldTranscript } from "./raw-evidence-sanitize-held.js";
25
+ import { prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
26
26
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
27
27
  import { evidenceEntry } from "./raw-evidence-manifest.js";
28
28
  import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
@@ -64,9 +64,9 @@ export async function collectOneEvidenceFile(collection, options) {
64
64
  markCapApplied(collection, options.kind, "max_file_bytes");
65
65
  return skip("file_too_large");
66
66
  }
67
- const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = await prepareTranscriptForUpload(collection, options, names, read.bytes);
67
+ const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = await prepareTranscriptForUpload(collection, options, names, read.source);
68
68
  await rememberPreparedTranscript(collection, options, names, {
69
- stamp, recipe, readBytes: read.bytes.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
69
+ stamp, recipe, readBytes: read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
70
70
  });
71
71
  if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
72
72
  collection.reused.push({
@@ -95,17 +95,14 @@ function recordEvidenceSkip(collection, kind, label, reason) {
95
95
  collection.skipped.push({ kind, label, reason });
96
96
  return false;
97
97
  }
98
- async function prepareTranscriptForUpload(collection, options, names, bytes) {
98
+ async function prepareTranscriptForUpload(collection, options, names, source) {
99
99
  // BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
100
100
  // base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
101
- const images = await prepareEmbeddedImages(collection, {
102
- bytes,
103
- kind: options.kind,
104
- sessionId: options.sessionId,
105
- });
101
+ // BLI-23317: the text is decoded once and travels in a holder the mask step clears, so the file's
102
+ // bytes, the decoded text and the masked text are never all alive together.
103
+ const body = await prepareTranscriptBody(collection, { source, kind: options.kind, sessionId: options.sessionId });
106
104
  const redactedBefore = collection.redacted.length;
107
- const sanitized = maskSecretsInTranscript(collection, {
108
- raw: images.bytes,
105
+ const sanitized = maskSecretsInHeldTranscript(collection, body.held, {
109
106
  kind: options.kind,
110
107
  evidenceLabel: names.evidenceLabel,
111
108
  secretLikeFileName: names.secretLikeFileName,
@@ -122,47 +119,7 @@ async function prepareTranscriptForUpload(collection, options, names, bytes) {
122
119
  byteSize: sanitized.bytes.byteLength,
123
120
  alreadyDurable: collection.skipContentHashes.has(contentHash),
124
121
  });
125
- return { sanitized, contentHash, stubClaim, volatile: images.volatile, pendingImages: images.pendingImages, redactedEntry };
126
- }
127
- /**
128
- * The transcript bytes after the image step. Unchanged (the very same buffer) unless this sync has
129
- * an image session AND the switch is `describe_and_drop` (images become markers where a description
130
- * is stored, then the slim rules run, see src/slim/). In `describe_and_keep` the bytes always come
131
- * back untouched. Never throws: an image step that breaks hands back the original bytes and says so.
132
- */
133
- async function prepareEmbeddedImages(collection, file) {
134
- const session = collection.budget.images;
135
- const source = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
136
- if (!session || !source)
137
- return { bytes: file.bytes, volatile: false, pendingImages: null };
138
- // Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
139
- if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.bytes))
140
- return { bytes: file.bytes, volatile: false, pendingImages: null };
141
- try {
142
- const prepared = await session.prepareTranscript({
143
- text: file.bytes.toString("utf8"),
144
- source,
145
- sessionId: file.sessionId,
146
- });
147
- // BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
148
- // this very file: an image still without a stored description (keep mode never replaces, so
149
- // any image counts) or a slim step that failed and should be retried.
150
- const imagesPending = session.mode === "describe_and_drop"
151
- ? prepared.imageCount > prepared.replacedCount
152
- : prepared.imageCount > 0;
153
- const slimFailed = session.mode === "describe_and_drop" && prepared.slim === null;
154
- const volatile = imagesPending || slimFailed;
155
- // BLI-22794: volatile ONLY because of images without a stored description (the slim step held
156
- // up): the bytes change only when one of these gains one, so the memo may keep the entry.
157
- const pendingImages = volatile && !slimFailed ? prepared.pendingImages : null;
158
- if (!prepared.changed)
159
- return { bytes: file.bytes, volatile, pendingImages };
160
- return { bytes: Buffer.from(prepared.text, "utf8"), volatile, pendingImages };
161
- }
162
- catch (error) {
163
- console.error("[raw-evidence] image or slim step failed, uploading the transcript as it was", JSON.stringify({ reason: "image_step_failed", kind: file.kind, ...describeError(error) }));
164
- return { bytes: file.bytes, volatile: true, pendingImages: null };
165
- }
122
+ return { sanitized, contentHash, stubClaim, volatile: body.volatile, pendingImages: body.pendingImages, redactedEntry };
166
123
  }
167
124
  /**
168
125
  * BLI-3936: the answer for a transcript whose file has not changed since a tick that found its
@@ -348,5 +305,5 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
348
305
  if (maxFileBytes && bytes.byteLength > maxFileBytes) {
349
306
  return { status: "too_large" };
350
307
  }
351
- return { status: "ok", bytes };
308
+ return { status: "ok", source: { bytes }, byteLength: bytes.byteLength };
352
309
  }
@@ -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
+ }
@@ -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.264");
25
25
  return 0;
26
26
  }
27
27
 
@@ -5,10 +5,10 @@
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 { slimOrKeep, slimPassOrNull, slimTranscript } from "./slim/slim-transcript.js";
12
12
  import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
13
13
  /** New images sent to the door in one sync (all worktrees together); the count never refills within a tick. */
14
14
  export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
@@ -39,10 +39,16 @@ export async function openImageDescriptions(options) {
39
39
  isDescribed: (sha256) => described.has(imageStateKey(sha256)),
40
40
  hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
41
41
  async prepareTranscript(input) {
42
- const afterImages = await describeAndReplace(run, input);
42
+ const { swap, ...afterImages } = await describeAndReplace(run, input);
43
43
  if (mode !== "describe_and_drop")
44
44
  return { ...afterImages, text: input.text, changed: false, slim: null };
45
- const slim = slimOrKeep(afterImages.text, input.source, { sessionId: input.sessionId });
45
+ // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
46
+ // intermediate copy). A pass that cannot run falls back to the two-step order, which is the same bytes.
47
+ const pass = slimPassOrNull(input.text, input.source, { sessionId: input.sessionId }, swap);
48
+ if (pass)
49
+ return { ...afterImages, text: pass.text, changed: pass.changed, slim: pass.stats };
50
+ const replaced = replaceImagesWithMarkers(input.text, swap.images, swap.replace);
51
+ const slim = slimOrKeep(replaced.text, input.source, { sessionId: input.sessionId });
46
52
  return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
47
53
  },
48
54
  };
@@ -110,8 +116,9 @@ function rememberFailure(run, key, outcome) {
110
116
  async function describeAndReplace(run, input) {
111
117
  const { mode, described, options, orderedKeys, failedImages } = run;
112
118
  const images = findEmbeddedImages(input.text);
119
+ const swap = { images, replace: (image) => described.has(imageStateKey(image.sha256)) };
113
120
  if (images.length === 0)
114
- return { text: input.text, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
121
+ return { swap, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
115
122
  const unique = new Map();
116
123
  for (const image of images)
117
124
  if (!unique.has(image.sha256))
@@ -126,9 +133,7 @@ async function describeAndReplace(run, input) {
126
133
  }
127
134
  const deferredOnly = counts.over_budget + counts.backing_off > 0
128
135
  && 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 };
136
+ const replaced = mode === "describe_and_drop" ? countImageSwaps(swap) : { replacedCount: 0, charsSaved: 0 };
132
137
  const pendingImages = [...unique.keys()].filter((sha256) => !described.has(imageStateKey(sha256)));
133
138
  console.error(TAG + " transcript prepared", JSON.stringify({
134
139
  mode,
@@ -143,7 +148,7 @@ async function describeAndReplace(run, input) {
143
148
  kept_without_description: mode === "describe_and_drop" ? images.length - replaced.replacedCount : 0,
144
149
  }));
145
150
  return {
146
- text: replaced.text,
151
+ swap,
147
152
  pendingImages,
148
153
  imageCount: images.length,
149
154
  uniqueCount: unique.size,
@@ -152,6 +157,19 @@ async function describeAndReplace(run, input) {
152
157
  deferredOnly,
153
158
  };
154
159
  }
160
+ /** What `replaceImagesWithMarkers` would report for this swap, without building the text it would make. */
161
+ function countImageSwaps(swap) {
162
+ let replacedCount = 0;
163
+ let charsSaved = 0;
164
+ for (const image of swap.images) {
165
+ if (!swap.replace(image))
166
+ continue;
167
+ const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
168
+ replacedCount += 1;
169
+ charsSaved += image.replaceEnd - image.replaceStart - marker.length;
170
+ }
171
+ return { replacedCount, charsSaved };
172
+ }
155
173
  function selectImagesToDescribe(run, unique, counts) {
156
174
  const { described, settledThisRun, failedImages, now } = run;
157
175
  const toSend = [];
@@ -22,6 +22,7 @@
22
22
  * What each rule keeps and drops: claude-rules.ts and codex-rules.ts. The table for people:
23
23
  * docs/architecture/image-descriptions.md.
24
24
  */
25
+ import { replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
25
26
  import { newClaudeSlimState, slimClaudeRecord } from "./claude-rules.js";
26
27
  import { newCodexSlimState, slimCodexRecord } from "./codex-rules.js";
27
28
  import { LineEdits, SeenBlocks, isJsonObject } from "./slim-support.js";
@@ -103,17 +104,89 @@ function slimOneLine(line, applyRules, stats) {
103
104
  stats.linesModified += 1;
104
105
  return JSON.stringify(record) + (line.endsWith("\r") ? "\r" : "");
105
106
  }
106
- /** The slim form of a whole transcript's text, line for line. */
107
- export function slimTranscript(text, kind) {
107
+ /**
108
+ * The slim form of a whole transcript's text, line for line, with the image swap folded into the
109
+ * same pass (BLI-23317). Output is byte-identical to `replaceImagesWithMarkers` followed by the old
110
+ * split/map/join: lines are found by `indexOf`, never `split`; a line no rule touched stays part of
111
+ * a slice of the input and is not copied on its own, so the only new allocation is the one joined
112
+ * result (and only when some line changed). An image sits inside one line (its characters exclude
113
+ * "\n"), so it is swapped inside that line before the rules read it, as the two-step order did.
114
+ */
115
+ export function slimPass(text, kind, swap) {
108
116
  const slimmer = createSlimmer(kind);
109
- const lines = text.split("\n");
110
- const out = new Array(lines.length);
111
- for (let index = 0; index < lines.length; index += 1)
112
- out[index] = slimmer.slimLine(lines[index]);
117
+ const pieces = [];
118
+ let emitted = 0;
119
+ let nextImage = 0;
120
+ let replacedCount = 0;
121
+ let charsSaved = 0;
122
+ for (let position = 0;;) {
123
+ const newline = text.indexOf("\n", position);
124
+ const end = newline === -1 ? text.length : newline;
125
+ let line = text.slice(position, end);
126
+ let swapped = false;
127
+ if (swap) {
128
+ const local = [];
129
+ while (nextImage < swap.images.length && swap.images[nextImage].replaceStart < end) {
130
+ const image = swap.images[nextImage];
131
+ nextImage += 1;
132
+ if (image.replaceStart < position || image.replaceEnd > end)
133
+ throw new Error("image_spans_lines");
134
+ local.push({
135
+ ...image,
136
+ replaceStart: image.replaceStart - position,
137
+ replaceEnd: image.replaceEnd - position,
138
+ base64Start: image.base64Start - position,
139
+ base64End: image.base64End - position,
140
+ });
141
+ }
142
+ if (local.length > 0) {
143
+ const replaced = replaceImagesWithMarkers(line, local, swap.replace);
144
+ if (replaced.replacedCount > 0) {
145
+ line = replaced.text;
146
+ swapped = true;
147
+ replacedCount += replaced.replacedCount;
148
+ charsSaved += replaced.charsSaved;
149
+ }
150
+ }
151
+ }
152
+ const out = slimmer.slimLine(line);
153
+ if (swapped || out !== line) {
154
+ pieces.push(text.slice(emitted, position), out);
155
+ emitted = end;
156
+ }
157
+ if (newline === -1)
158
+ break;
159
+ position = newline + 1;
160
+ }
113
161
  const stats = slimmer.stats();
114
- return { text: stats.linesModified === 0 ? text : out.join("\n"), stats };
162
+ if (pieces.length === 0)
163
+ return { text, stats, replacedCount, charsSaved, changed: false };
164
+ pieces.push(text.slice(emitted));
165
+ return { text: pieces.join(""), stats, replacedCount, charsSaved, changed: true };
166
+ }
167
+ /** The slim form of a whole transcript's text, line for line. */
168
+ export function slimTranscript(text, kind) {
169
+ const slim = slimPass(text, kind);
170
+ return { text: slim.text, stats: slim.stats };
115
171
  }
116
172
  const TAG = "[slim-transcript]";
173
+ function logSlimmed(kind, sessionId, stats, startedAt) {
174
+ console.error(TAG + (stats.linesModified === 0 ? " nothing to strip" : " transcript slimmed"), JSON.stringify({
175
+ source: kind,
176
+ session_id: sessionId.slice(0, 80),
177
+ lines: stats.lines,
178
+ lines_modified: stats.linesModified,
179
+ lines_failed: stats.linesFailed,
180
+ bytes_before: stats.bytesBefore,
181
+ bytes_after: stats.bytesAfter,
182
+ chars_by_rule: stats.charsByRule,
183
+ lines_by_rule: stats.linesByRule,
184
+ elapsed_ms: Date.now() - startedAt,
185
+ }));
186
+ }
187
+ function logSlimFailed(kind, error, bytes) {
188
+ console.error(TAG + " failed, uploading the transcript as it was", JSON.stringify({ reason: "slim_failed", source: kind, error_name: error instanceof Error ? error.name : typeof error, bytes }));
189
+ }
117
190
  /**
118
191
  * `slimTranscript` that can never fail a transcript: any exception returns the text it was given and
119
192
  * logs the reason. Metadata only: counts, sizes, rule names. Never a line, never a field value.
@@ -122,23 +195,27 @@ export function slimOrKeep(text, kind, label) {
122
195
  const startedAt = Date.now();
123
196
  try {
124
197
  const slim = slimTranscript(text, kind);
125
- const stats = slim.stats;
126
- console.error(TAG + (stats.linesModified === 0 ? " nothing to strip" : " transcript slimmed"), JSON.stringify({
127
- source: kind,
128
- session_id: label.sessionId.slice(0, 80),
129
- lines: stats.lines,
130
- lines_modified: stats.linesModified,
131
- lines_failed: stats.linesFailed,
132
- bytes_before: stats.bytesBefore,
133
- bytes_after: stats.bytesAfter,
134
- chars_by_rule: stats.charsByRule,
135
- lines_by_rule: stats.linesByRule,
136
- elapsed_ms: Date.now() - startedAt,
137
- }));
138
- return { text: slim.text, stats };
198
+ logSlimmed(kind, label.sessionId, slim.stats, startedAt);
199
+ return { text: slim.text, stats: slim.stats };
139
200
  }
140
201
  catch (error) {
141
- console.error(TAG + " failed, uploading the transcript as it was", JSON.stringify({ reason: "slim_failed", source: kind, error_name: error instanceof Error ? error.name : typeof error, bytes: typeof text === "string" ? text.length : 0 }));
202
+ logSlimFailed(kind, error, typeof text === "string" ? text.length : 0);
142
203
  return { text, stats: null };
143
204
  }
205
+ }
206
+ /**
207
+ * `slimPass` that can never fail a transcript: a pass that throws (an image that would span lines,
208
+ * an exhausted string) logs the reason and returns null, and the caller takes the two-step path.
209
+ */
210
+ export function slimPassOrNull(text, kind, label, swap) {
211
+ const startedAt = Date.now();
212
+ try {
213
+ const slim = slimPass(text, kind, swap);
214
+ logSlimmed(kind, label.sessionId, slim.stats, startedAt);
215
+ return slim;
216
+ }
217
+ catch (error) {
218
+ logSlimFailed(kind, error, text.length);
219
+ return null;
220
+ }
144
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.263",
3
+ "version": "0.2.264",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {