@bli-cockpit/cli 0.2.264 → 0.2.266

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
+ }
@@ -126,7 +126,8 @@ export async function readRegisteredDarwinRuntimePaths(homeDir) {
126
126
  const command = decodeXmlEntities(plist);
127
127
  return {
128
128
  node_executable: /node_bin='([^']*)'/u.exec(command)?.[1] ?? null,
129
- cli_entry_point: /"\$node_bin" '([^']*)' sync /u.exec(command)?.[1] ?? null,
129
+ cli_entry_point: /"\$node_bin"(?: --optimize-for-size)? '([^']*)' sync /u.exec(command)?.[1] ?? null,
130
+ optimize_for_size: /"\$node_bin" '[^']*' sync /u.test(command) ? false : /"\$node_bin" --optimize-for-size '[^']*' sync /u.test(command) ? true : null,
130
131
  };
131
132
  }
132
133
  /**
@@ -222,12 +223,15 @@ function watchPathsBlock(watchPaths) {
222
223
  " </array>",
223
224
  ];
224
225
  }
226
+ /** The V8 flag the scheduled sync runs node with (BLI-23317). */
227
+ export const NODE_OPTIMIZE_FOR_SIZE_FLAG = "--optimize-for-size";
225
228
  function renderDarwinSyncCommand(options) {
229
+ const nodeFlag = options.optimizeForSize === false ? "" : ` ${NODE_OPTIMIZE_FOR_SIZE_FLAG}`;
226
230
  const dashboardArg = isDefaultDashboardUrl(options.dashboardUrl)
227
231
  ? ""
228
232
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
229
233
  const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
230
- const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
234
+ const commands = options.workDirs.map((root) => `"$node_bin"${nodeFlag} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
231
235
  return [
232
236
  "exit_code=0",
233
237
  ...darwinNodeResolutionPreamble(options.nodeExecutable),
@@ -278,14 +282,16 @@ function darwinAgentRegistrationProblems(plist, expected) {
278
282
  if (!plist.includes(` <key>ThrottleInterval</key>\n <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`)) {
279
283
  problems.push(`minimum gap between ticks is not ${DARWIN_MIN_SECONDS_BETWEEN_TICKS} seconds`);
280
284
  }
281
- const expectedCommand = renderDarwinSyncCommand({
285
+ // BLI-23317: the form with --optimize-for-size is what is written; the form without it is still accepted.
286
+ const matchesCurrentRuntime = [true, false].some((optimizeForSize) => plist.includes(programArgumentsBlock(renderDarwinSyncCommand({
282
287
  discoveryArgs: expected.discoveryArgs,
283
288
  workDirs: expected.workDirs,
284
289
  dashboardUrl: expected.dashboardUrl,
285
290
  nodeExecutable: expected.nodeExecutable,
286
291
  cliEntryPoint: expected.cliEntryPoint,
287
- });
288
- if (!plist.includes(programArgumentsBlock(expectedCommand).join("\n"))) {
292
+ optimizeForSize,
293
+ })).join("\n")));
294
+ if (!matchesCurrentRuntime) {
289
295
  problems.push("command does not match the current roots, dashboard URL, or Tower runtime");
290
296
  }
291
297
  return problems;
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "./autostart.js";
4
+ import { autostartStatus, installAutostartAgent, registeredLauncherUpgradeProblems, registeredRuntimePathProblems, } from "./autostart.js";
5
5
  import { detectSecondCockpitInstall, secondInstallReason, } from "./second-install.js";
6
6
  import { isAutostartOptedOut } from "./autostart-opt-out.js";
7
7
  export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
@@ -52,6 +52,14 @@ async function readAutostartProblem(paths, options, platform) {
52
52
  platform,
53
53
  })
54
54
  : [];
55
+ // BLI-23317: an install that still runs node without --optimize-for-size works, so it is not "broken";
56
+ // it is rewritten once here, on the normal cadence, and logged as its own reason.
57
+ const upgradeProblems = status.status === "loaded"
58
+ ? await registeredLauncherUpgradeProblems({ homeDir: options.homeDir, exec: options.exec, platform })
59
+ : [];
60
+ if (upgradeProblems.length > 0) {
61
+ console.error("[autostart-self-heal] registration runs node without --optimize-for-size; rewriting it", JSON.stringify({ reason: "registration_predates_optimize_for_size", platform }));
62
+ }
55
63
  const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
56
64
  // BLI-21564: the reminder job is repaired with the sync agent. A missing or broken reminder job is a
57
65
  // problem of its own (an existing install that predates it gets it created here), and the opt-out
@@ -59,7 +67,7 @@ async function readAutostartProblem(paths, options, platform) {
59
67
  const remindProblem = status.status === "loaded" && status.remind && status.remind.status !== "loaded" && status.remind.status !== "unsupported"
60
68
  ? [`reminder job ${status.remind.status === "absent" ? "absent" : status.remind.message ?? "not loaded"}`]
61
69
  : [];
62
- if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && remindProblem.length === 0) {
70
+ if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && upgradeProblems.length === 0 && remindProblem.length === 0) {
63
71
  // Healthy is the steady state and stays silent. A marker left by an
64
72
  // earlier attempt is spent: that repair worked, so the next break starts
65
73
  // fresh instead of waiting out a retry window.
@@ -70,6 +78,7 @@ async function readAutostartProblem(paths, options, platform) {
70
78
  ...(absent ? ["registration absent"] : []),
71
79
  ...(status.message ? [status.message] : []),
72
80
  ...runtimeProblems,
81
+ ...upgradeProblems,
73
82
  ...remindProblem,
74
83
  ]
75
84
  .join("; ")
@@ -110,6 +110,9 @@ export async function readRegisteredWindowsRuntimePaths(homeDir) {
110
110
  return {
111
111
  node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
112
112
  cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
113
+ optimize_for_size: /& \$nodeExecutable --optimize-for-size \$cliEntryPoint sync /u.test(script)
114
+ ? true
115
+ : /& \$nodeExecutable \$cliEntryPoint sync /u.test(script) ? false : null,
113
116
  };
114
117
  }
115
118
  /**
@@ -125,15 +128,22 @@ export async function syncScriptProblem(options) {
125
128
  if (!options.repoRoots || options.repoRoots.length === 0)
126
129
  return null;
127
130
  const settings = resolveAutostartSettings(options, "win32");
128
- const expected = `${UTF8_BOM}${renderWindowsSyncScript({
131
+ const onDisk = await readFile(scriptPath, "utf8").catch(() => null);
132
+ const expectedFor = async (optimizeForSize) => `${UTF8_BOM}${renderWindowsSyncScript({
129
133
  discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
130
134
  workDirs: settings.work_dirs,
131
135
  dashboardUrl: settings.dashboard_url,
132
136
  nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
133
137
  cliEntryPoint: settings.cli_entry_point,
134
138
  syncLogPath: windowsSyncLogPath(options.homeDir),
139
+ optimizeForSize,
135
140
  })}`;
136
- const difference = renderedFileDifference(await readFile(scriptPath, "utf8").catch(() => null), expected);
141
+ // BLI-23317: the script with --optimize-for-size is what is written; the older one without it is still accepted.
142
+ const difference = renderedFileDifference(onDisk, await expectedFor(true)) === null
143
+ ? null
144
+ : renderedFileDifference(onDisk, await expectedFor(false)) === null
145
+ ? null
146
+ : renderedFileDifference(onDisk, await expectedFor(true));
137
147
  return difference
138
148
  ? `sync script does not match the current roots or Tower runtime (${describeDifference(difference)})`
139
149
  : null;
@@ -271,13 +281,14 @@ export function renderWindowsLauncherScript(options) {
271
281
  ].join("\r\n");
272
282
  }
273
283
  function renderWindowsSyncScript(options) {
284
+ const nodeFlag = options.optimizeForSize === false ? "" : " --optimize-for-size";
274
285
  const dashboardArgs = isDefaultDashboardUrl(options.dashboardUrl)
275
286
  ? ""
276
287
  : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
277
288
  const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
278
289
  const commands = options.workDirs.flatMap((root) => [
279
290
  "try {",
280
- ` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
291
+ ` & $nodeExecutable${nodeFlag} $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
281
292
  " if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
282
293
  "} catch {",
283
294
  " Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
package/dist/autostart.js CHANGED
@@ -75,7 +75,20 @@ export async function readRegisteredRuntimePaths(options) {
75
75
  if (platform === "win32") {
76
76
  return readRegisteredWindowsRuntimePaths(options.homeDir);
77
77
  }
78
- return { node_executable: null, cli_entry_point: null };
78
+ return { node_executable: null, cli_entry_point: null, optimize_for_size: null };
79
+ }
80
+ /**
81
+ * BLI-23317: a registration written before `--optimize-for-size` still RUNS, so it is not a broken
82
+ * registration and `registeredRuntimePathProblems` stays silent about it. The scheduled tick's
83
+ * self-heal asks this separately so the existing fleet is rewritten to the new form on its normal
84
+ * cadence instead of waiting for a hand-run `cockpit doctor`.
85
+ */
86
+ export async function registeredLauncherUpgradeProblems(options) {
87
+ const platform = options.platform ?? process.platform;
88
+ if (platform !== (options.hostPlatform ?? process.platform))
89
+ return [];
90
+ const registered = await readRegisteredRuntimePaths(options);
91
+ return registered.optimize_for_size === false ? ["registration predates --optimize-for-size"] : [];
79
92
  }
80
93
  /**
81
94
  * Named problems with the registered runtime, or an empty list. Path-free
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // BLI-23317: first import, so the V8 flag is set before the heavy modules below load.
3
+ import "./optimize-for-size-entry.js";
2
4
  import { runCockpitCli } from "./commands/public-root.js";
3
5
  import {
4
6
  EXIT_DIED_MID_RUN,
@@ -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.266");
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,30 @@ 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 };
137
- const pendingImages = [...unique.keys()].filter((sha256) => !described.has(imageStateKey(sha256)));
209
+ const replaced = mode === "describe_and_drop" ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
210
+ // BLI-23317: an image over the describe limit is never sent, so it can never gain a description and
211
+ // never changes the output. Listing it made the describer look like it still had room, and the
212
+ // transcript was rebuilt on every tick (rebuilt_describer_has_room). Counted in `too_large` above.
213
+ const pendingImages = [...unique.values()]
214
+ .filter((image) => !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
215
+ .map((image) => image.sha256);
138
216
  console.error(TAG + " transcript prepared", JSON.stringify({
139
217
  mode,
140
218
  source: input.source,
141
- images: images.length,
219
+ images: finds.total,
142
220
  unique: unique.size,
143
221
  ...counts,
144
222
  deferred_only: deferredOnly,
145
223
  failed_reasons: failedReasons,
146
224
  replaced: replaced.replacedCount,
147
225
  chars_saved: replaced.charsSaved,
148
- kept_without_description: mode === "describe_and_drop" ? images.length - replaced.replacedCount : 0,
226
+ kept_without_description: mode === "describe_and_drop" ? finds.total - replaced.replacedCount : 0,
149
227
  }));
150
228
  return {
151
- swap,
229
+ replace,
230
+ finds,
152
231
  pendingImages,
153
- imageCount: images.length,
232
+ imageCount: finds.total,
154
233
  uniqueCount: unique.size,
155
234
  replacedCount: replaced.replacedCount,
156
235
  charsSaved: replaced.charsSaved,
@@ -197,7 +276,7 @@ function selectImagesToDescribe(run, unique, counts) {
197
276
  }
198
277
  return toSend;
199
278
  }
200
- async function sendPendingImages(run, toSend, counts, input) {
279
+ async function sendPendingImages(run, toSend, counts, input, base64For) {
201
280
  const { now, maxPerRun, maxRunMs } = run;
202
281
  const callStartedAt = now();
203
282
  let next = 0;
@@ -209,7 +288,7 @@ async function sendPendingImages(run, toSend, counts, input) {
209
288
  counts.over_budget += 1;
210
289
  continue;
211
290
  }
212
- await sendOne(run, image, input.text, input);
291
+ await sendOne(run, image, base64For(image), input);
213
292
  }
214
293
  };
215
294
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
@@ -0,0 +1,3 @@
1
+ import { applyOptimizeForSize } from "./optimize-for-size.js";
2
+ // Side-effect module: imported FIRST by the CLI entry so the flag is set before the heavy imports load (BLI-23317).
3
+ applyOptimizeForSize();
@@ -0,0 +1,25 @@
1
+ import v8 from "node:v8";
2
+ /**
3
+ * BLI-23317: run V8 with `--optimize-for-size`. Measured 15-30% lower peak memory with no slowdown on
4
+ * Node 20-26 (one 200 MB transcript 848 -> 721 MB; CLI floor 113 -> 94 MB). `NODE_OPTIONS` rejects the
5
+ * flag, so the scheduled launchers pass it on the node command line and a hand-run `cockpit` applies it
6
+ * here, as the first statement of the entry (`optimize-for-size-entry.ts`, imported before anything heavy).
7
+ *
8
+ * Only a refusal is logged: this runs at the start of every command a person types, and a stderr line
9
+ * on each success would be noise in their terminal. A refusal is logged and ignored, never fatal: the flag is a memory saving, not a requirement.
10
+ */
11
+ export const OPTIMIZE_FOR_SIZE_FLAG = "--optimize-for-size";
12
+ export function applyOptimizeForSize(options = {}) {
13
+ const execArgv = options.execArgv ?? process.execArgv;
14
+ if (execArgv.includes(OPTIMIZE_FOR_SIZE_FLAG)) {
15
+ return "already_set_by_launcher";
16
+ }
17
+ try {
18
+ (options.setFlags ?? v8.setFlagsFromString)(OPTIMIZE_FOR_SIZE_FLAG);
19
+ return "applied";
20
+ }
21
+ catch (error) {
22
+ console.error("[optimize-for-size] could not apply the flag; continuing without it", JSON.stringify({ reason: "set_flags_refused", error: error instanceof Error ? error.name : "unknown" }));
23
+ return "refused";
24
+ }
25
+ }
@@ -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.266",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {