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