@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.
- 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 +69 -0
- package/dist/adapters/raw-evidence-transcript-file.js +11 -54
- package/dist/adapters/transcript-line-stream.js +47 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/image-description-session.js +27 -9
- package/dist/slim/slim-transcript.js +99 -22
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
261
|
+
redacted_ranges: fullContentRedacted && options.originalLength > 0
|
|
262
262
|
? [
|
|
263
263
|
{
|
|
264
264
|
start: 0,
|
|
265
|
-
end: options.
|
|
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 {
|
|
25
|
-
import {
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
}
|