@bli-cockpit/cli 0.2.267 → 0.2.269
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/pinned-transcript.js +143 -0
- package/dist/adapters/raw-evidence-budget-admission.js +19 -6
- package/dist/adapters/raw-evidence-manifest.js +6 -3
- package/dist/adapters/raw-evidence-sanitize-held.js +17 -9
- package/dist/adapters/raw-evidence-snapshot-supersede.js +55 -5
- package/dist/adapters/raw-evidence-stage-file.js +43 -0
- package/dist/adapters/raw-evidence-transcript-body.js +8 -1
- package/dist/adapters/raw-evidence-transcript-file.js +56 -73
- package/dist/adapters/raw-evidence-transcript-stage.js +84 -0
- package/dist/adapters/raw-evidence-transcript-stream.js +3 -3
- package/dist/adapters/staged-output.js +128 -0
- package/dist/adapters/transcript-text-stream.js +9 -91
- package/dist/commands/public-root.js +1 -1
- package/dist/image-byte-scan.js +185 -0
- package/dist/image-census-stream.js +33 -23
- package/dist/image-description-send.js +80 -0
- package/dist/image-description-session.js +47 -104
- package/dist/slim/claude-rules.js +19 -1
- package/dist/slim/codex-rules.js +19 -1
- package/dist/slim/slim-stream.js +84 -41
- package/dist/slim/slim-support.js +22 -0
- package/dist/slim/slim-transcript.js +1 -1
- package/dist/staging-gate.js +36 -16
- package/package.json +4 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { postImageToDescribeDoor } from "./image-describe-client.js";
|
|
2
|
+
import { imageRetryDelayMs, imageStateKey } from "./image-description-state.js";
|
|
3
|
+
const TAG = "[image-descriptions]";
|
|
4
|
+
const CONCURRENCY = 3;
|
|
5
|
+
function markDescribed(run, sha256) {
|
|
6
|
+
const { described, orderedKeys } = run;
|
|
7
|
+
const key = imageStateKey(sha256);
|
|
8
|
+
if (described.has(key))
|
|
9
|
+
return;
|
|
10
|
+
described.add(key);
|
|
11
|
+
orderedKeys.push(key);
|
|
12
|
+
}
|
|
13
|
+
async function sendOne(run, image, payload, input) {
|
|
14
|
+
const { options, fetchImpl, settledThisRun, failedImages } = run;
|
|
15
|
+
// Counted before the first await: the budget check that let this send through ran in the same tick.
|
|
16
|
+
run.sentThisRun += 1;
|
|
17
|
+
const base64 = await payload;
|
|
18
|
+
const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
|
|
19
|
+
schema_version: "image-describe.v1",
|
|
20
|
+
sha256: image.sha256,
|
|
21
|
+
media_type: image.mediaType,
|
|
22
|
+
byte_size: image.byteSize,
|
|
23
|
+
content_base64: base64,
|
|
24
|
+
source: input.source,
|
|
25
|
+
session_id: input.sessionId.slice(0, 200),
|
|
26
|
+
});
|
|
27
|
+
const key = imageStateKey(image.sha256);
|
|
28
|
+
if (outcome.ok) {
|
|
29
|
+
markDescribed(run, image.sha256);
|
|
30
|
+
settledThisRun.set(image.sha256, outcome.status);
|
|
31
|
+
if (failedImages[key]) {
|
|
32
|
+
delete failedImages[key];
|
|
33
|
+
run.stateDirty = true;
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
|
|
38
|
+
rememberFailure(run, key, outcome);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Remember image-specific HTTP failures for backoff. Transport failures, 401
|
|
42
|
+
* and 429 say nothing about the image and must not be remembered.
|
|
43
|
+
*/
|
|
44
|
+
function rememberFailure(run, key, outcome) {
|
|
45
|
+
const { failedImages, now } = run;
|
|
46
|
+
if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
|
|
47
|
+
return;
|
|
48
|
+
const attempts = (failedImages[key]?.attempts ?? 0) + 1;
|
|
49
|
+
const delay = imageRetryDelayMs(attempts, outcome.permanent);
|
|
50
|
+
failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
|
|
51
|
+
run.stateDirty = true;
|
|
52
|
+
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) }));
|
|
53
|
+
}
|
|
54
|
+
export async function sendPendingImages(run, toSend, counts, input, base64For) {
|
|
55
|
+
const { now, maxPerRun, maxRunMs } = run;
|
|
56
|
+
const callStartedAt = now();
|
|
57
|
+
let next = 0;
|
|
58
|
+
let aborted = false;
|
|
59
|
+
const worker = async () => {
|
|
60
|
+
while (next < toSend.length && !aborted) {
|
|
61
|
+
const image = toSend[next++];
|
|
62
|
+
const timeUp = run.describingMs + (now() - callStartedAt) > maxRunMs;
|
|
63
|
+
if (run.sentThisRun >= maxPerRun || timeUp) {
|
|
64
|
+
counts.over_budget += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
// The payload is read from the source only now, for this one image (BLI-23317).
|
|
69
|
+
await sendOne(run, image, base64For(image), input);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// A source that changed under the passes ends the whole prepare: the other workers send nothing more.
|
|
73
|
+
aborted = true;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
|
|
79
|
+
run.describingMs += now() - callStartedAt;
|
|
80
|
+
}
|
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
* Describe each distinct image once. Keep images without stored descriptions;
|
|
4
4
|
* only describe_and_drop replaces described images and then runs the slim rules.
|
|
5
5
|
* Failures never fail the transcript. Logs contain metadata, never image bytes
|
|
6
|
-
* or descriptions.
|
|
6
|
+
* or descriptions. The sends themselves (three at a time, budgets, backoff) are in
|
|
7
|
+
* `image-description-send.ts`.
|
|
7
8
|
*/
|
|
8
9
|
import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, formatImageMarker, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
|
|
9
|
-
import {
|
|
10
|
+
import { sendPendingImages } from "./image-description-send.js";
|
|
10
11
|
import { resolveImageHandlingMode } from "./image-mode-state.js";
|
|
11
12
|
import { censusOfLines } from "./image-census-stream.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { sha256OfBase64Run } from "./image-byte-scan.js";
|
|
14
|
+
import { openPinnedTranscript } from "./adapters/pinned-transcript.js";
|
|
15
|
+
import { openStagedOutput, readStagedText } from "./adapters/staged-output.js";
|
|
16
|
+
import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
|
|
17
|
+
import { LineBytesSink, slimLineStream } from "./slim/slim-stream.js";
|
|
14
18
|
import { logSlimmed, slimOrKeep, slimPassOrNull, slimTranscript } from "./slim/slim-transcript.js";
|
|
15
|
-
import { imageStateKey,
|
|
19
|
+
import { imageStateKey, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
|
|
16
20
|
/** New images sent to the door in one sync (all worktrees together); the count never refills within a tick. */
|
|
17
21
|
export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
|
|
18
22
|
/**
|
|
@@ -22,7 +26,6 @@ export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
|
|
|
22
26
|
* spent the budget on that work, so almost nothing was described (BLI-22789: 1 of 1,874, then 10).
|
|
23
27
|
*/
|
|
24
28
|
export const IMAGE_DESCRIBE_MAX_RUN_MS = 120_000;
|
|
25
|
-
const CONCURRENCY = 3;
|
|
26
29
|
const TAG = "[image-descriptions]";
|
|
27
30
|
export async function openImageDescriptions(options) {
|
|
28
31
|
const mode = options.mode ?? IMAGE_HANDLING_MODE;
|
|
@@ -76,15 +79,18 @@ function findsOfText(text) {
|
|
|
76
79
|
swapped: (replace) => countImageSwaps({ images, replace }),
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
|
-
function findsOfCensus(census) {
|
|
82
|
+
function findsOfCensus(census, source) {
|
|
80
83
|
return {
|
|
81
84
|
total: census.total,
|
|
82
85
|
unique: census.unique,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// The one payload about to be sent is read from its offsets in the pinned file and re-hashed: bytes that
|
|
87
|
+
// no longer decode to the image the census saw mean the file changed under us, and a request carrying
|
|
88
|
+
// them would be refused by the door and remembered as a failure of the IMAGE.
|
|
89
|
+
base64For: async (image) => {
|
|
90
|
+
const payload = await source.read(image.base64Start, image.base64End - image.base64Start);
|
|
91
|
+
if (sha256OfBase64Run(payload, 0, payload.length) !== image.sha256)
|
|
92
|
+
throw new TranscriptNotStreamable("file_changed_during_pass");
|
|
93
|
+
return payload.toString("latin1");
|
|
88
94
|
},
|
|
89
95
|
swapped: (replace) => {
|
|
90
96
|
let replacedCount = 0;
|
|
@@ -102,33 +108,35 @@ function findsOfCensus(census) {
|
|
|
102
108
|
}
|
|
103
109
|
async function prepareFileInTwoPasses(run, input) {
|
|
104
110
|
const startedAt = Date.now();
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
throw
|
|
130
|
-
|
|
131
|
-
|
|
111
|
+
// The file is opened once and its length pinned: the census offsets, the describe payload reads and the swap
|
|
112
|
+
// pass all read these bytes, and a source that stopped being them is set aside (`verifyUnchanged`).
|
|
113
|
+
const source = await openPinnedTranscript(input.filePath, { maxFileBytes: input.maxFileBytes });
|
|
114
|
+
let writer;
|
|
115
|
+
try {
|
|
116
|
+
// Pass one: discover. Nothing of the text is kept; only where each image is and what it is.
|
|
117
|
+
const census = await censusOfLines(source.lines());
|
|
118
|
+
// Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
|
|
119
|
+
const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census, source));
|
|
120
|
+
// Pass two: swap and slim over exactly the bytes pass one saw, writing the result to staging as it goes.
|
|
121
|
+
writer = input.stageDir ? await openStagedOutput(input.stageDir) : undefined;
|
|
122
|
+
const memory = writer ? undefined : new LineBytesSink(source.length);
|
|
123
|
+
const slim = await slimLineStream(source.lines(), input.source, census.occurrences, replace, writer ?? memory);
|
|
124
|
+
if (slim.replacedCount !== afterImages.replacedCount)
|
|
125
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
126
|
+
await source.verifyUnchanged();
|
|
127
|
+
const staged = await writer?.finish();
|
|
128
|
+
// The sanitizer reads the whole text: from the staged file when one was written, else from the sink's buffer.
|
|
129
|
+
const text = staged ? await readStagedText(staged) : memory.text();
|
|
130
|
+
logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
|
|
131
|
+
return { ...afterImages, text, changed: slim.changed, slim: slim.stats, readBytes: source.length, ...(staged ? { staged } : {}) };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
await writer?.abandon();
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await source.close();
|
|
139
|
+
}
|
|
132
140
|
}
|
|
133
141
|
function describeRoomLeft(run, sha256s) {
|
|
134
142
|
if (run.sentThisRun >= run.maxPerRun)
|
|
@@ -143,53 +151,6 @@ function describeRoomLeft(run, sha256s) {
|
|
|
143
151
|
return !(failure && failure.retry_after > run.now());
|
|
144
152
|
});
|
|
145
153
|
}
|
|
146
|
-
function markDescribed(run, sha256) {
|
|
147
|
-
const { described, orderedKeys } = run;
|
|
148
|
-
const key = imageStateKey(sha256);
|
|
149
|
-
if (described.has(key))
|
|
150
|
-
return;
|
|
151
|
-
described.add(key);
|
|
152
|
-
orderedKeys.push(key);
|
|
153
|
-
}
|
|
154
|
-
async function sendOne(run, image, base64, input) {
|
|
155
|
-
const { options, fetchImpl, settledThisRun, failedImages } = run;
|
|
156
|
-
run.sentThisRun += 1;
|
|
157
|
-
const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
|
|
158
|
-
schema_version: "image-describe.v1",
|
|
159
|
-
sha256: image.sha256,
|
|
160
|
-
media_type: image.mediaType,
|
|
161
|
-
byte_size: image.byteSize,
|
|
162
|
-
content_base64: base64,
|
|
163
|
-
source: input.source,
|
|
164
|
-
session_id: input.sessionId.slice(0, 200),
|
|
165
|
-
});
|
|
166
|
-
const key = imageStateKey(image.sha256);
|
|
167
|
-
if (outcome.ok) {
|
|
168
|
-
markDescribed(run, image.sha256);
|
|
169
|
-
settledThisRun.set(image.sha256, outcome.status);
|
|
170
|
-
if (failedImages[key]) {
|
|
171
|
-
delete failedImages[key];
|
|
172
|
-
run.stateDirty = true;
|
|
173
|
-
}
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
|
|
177
|
-
rememberFailure(run, key, outcome);
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Remember image-specific HTTP failures for backoff. Transport failures, 401
|
|
181
|
-
* and 429 say nothing about the image and must not be remembered.
|
|
182
|
-
*/
|
|
183
|
-
function rememberFailure(run, key, outcome) {
|
|
184
|
-
const { failedImages, now } = run;
|
|
185
|
-
if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
|
|
186
|
-
return;
|
|
187
|
-
const attempts = (failedImages[key]?.attempts ?? 0) + 1;
|
|
188
|
-
const delay = imageRetryDelayMs(attempts, outcome.permanent);
|
|
189
|
-
failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
|
|
190
|
-
run.stateDirty = true;
|
|
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) }));
|
|
192
|
-
}
|
|
193
154
|
async function describeAndReplace(run, input, finds) {
|
|
194
155
|
const { mode, described, options, orderedKeys, failedImages } = run;
|
|
195
156
|
const replace = (image) => described.has(imageStateKey(image.sha256));
|
|
@@ -276,24 +237,6 @@ function selectImagesToDescribe(run, unique, counts) {
|
|
|
276
237
|
}
|
|
277
238
|
return toSend;
|
|
278
239
|
}
|
|
279
|
-
async function sendPendingImages(run, toSend, counts, input, base64For) {
|
|
280
|
-
const { now, maxPerRun, maxRunMs } = run;
|
|
281
|
-
const callStartedAt = now();
|
|
282
|
-
let next = 0;
|
|
283
|
-
const worker = async () => {
|
|
284
|
-
while (next < toSend.length) {
|
|
285
|
-
const image = toSend[next++];
|
|
286
|
-
const timeUp = run.describingMs + (now() - callStartedAt) > maxRunMs;
|
|
287
|
-
if (run.sentThisRun >= maxPerRun || timeUp) {
|
|
288
|
-
counts.over_budget += 1;
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
await sendOne(run, image, base64For(image), input);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
|
|
295
|
-
run.describingMs += now() - callStartedAt;
|
|
296
|
-
}
|
|
297
240
|
function countImageOutcomes(run, toSend, counts) {
|
|
298
241
|
const { settledThisRun } = run;
|
|
299
242
|
const failedReasons = {};
|
|
@@ -14,17 +14,19 @@
|
|
|
14
14
|
* every assistant line, holding no content.
|
|
15
15
|
* - `toolUseResult` fields that repeat the tool_result or the tool input (claude-tool-results.ts).
|
|
16
16
|
* - Attachment bookkeeping (claude-attachments.ts).
|
|
17
|
+
* - A thinking block's encrypted `signature`, replaced by a marker with its length (BLI-23334).
|
|
17
18
|
*
|
|
18
19
|
* Unknown shapes pass through untouched.
|
|
19
20
|
*/
|
|
20
21
|
import { slimAttachmentLine, slimStopHookSummary } from "./claude-attachments.js";
|
|
21
22
|
import { rememberToolInputs, slimToolUseResult, } from "./claude-tool-results.js";
|
|
22
|
-
import { isJsonObject, stripKey } from "./slim-support.js";
|
|
23
|
+
import { dropCiphertext, isJsonObject, stripKey } from "./slim-support.js";
|
|
23
24
|
export function newClaudeSlimState() {
|
|
24
25
|
return { toolInputs: new Map() };
|
|
25
26
|
}
|
|
26
27
|
export function slimClaudeRecord(record, state, seen, edits) {
|
|
27
28
|
stripKey(record, "wireToolInputs", "wireToolInputs", edits);
|
|
29
|
+
dropThinkingSignatures(record["message"], edits);
|
|
28
30
|
switch (record["type"]) {
|
|
29
31
|
case "assistant": {
|
|
30
32
|
rememberToolInputs(record, state.toolInputs);
|
|
@@ -45,4 +47,20 @@ export function slimClaudeRecord(record, state, seen, edits) {
|
|
|
45
47
|
default:
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* BLI-23334: a thinking block's `signature` is a base64 blob Anthropic signs the block with; it
|
|
53
|
+
* compresses to about 75 percent and nothing in Tower reads it. The block, its `thinking` text and
|
|
54
|
+
* every other key stay. Assistant lines and `api-request-blob` lines carry the same blocks, so the
|
|
55
|
+
* rule keys on `message.content[]`, not on the line type. `redacted_thinking.data` is not touched:
|
|
56
|
+
* that block has no readable text, its data is all it is.
|
|
57
|
+
*/
|
|
58
|
+
function dropThinkingSignatures(message, edits) {
|
|
59
|
+
if (!isJsonObject(message) || !Array.isArray(message["content"]))
|
|
60
|
+
return;
|
|
61
|
+
for (const block of message["content"]) {
|
|
62
|
+
if (isJsonObject(block) && block["type"] === "thinking") {
|
|
63
|
+
dropCiphertext(block, "signature", "claude.thinking.signature", edits);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
48
66
|
}
|
package/dist/slim/codex-rules.js
CHANGED
|
@@ -8,7 +8,20 @@
|
|
|
8
8
|
* Guardian history and task_complete messages deduplicate only against earlier
|
|
9
9
|
* response items; a copy appearing first is retained.
|
|
10
10
|
*/
|
|
11
|
-
import { SLIM_COPY_MIN_CHARS, canonicalText, contentHash, isJsonObject, isSlimRef, slimRefFor, stripKey, } from "./slim-support.js";
|
|
11
|
+
import { SLIM_COPY_MIN_CHARS, canonicalText, contentHash, dropCiphertext, isJsonObject, isSlimRef, slimRefFor, stripKey, } from "./slim-support.js";
|
|
12
|
+
/**
|
|
13
|
+
* BLI-23334: `encrypted_content` on a reasoning or compaction item is ciphertext only OpenAI can
|
|
14
|
+
* read (it compresses to about 75 percent). It becomes a marker with its length; the item and its
|
|
15
|
+
* readable `summary` stay. Exactly two places carry it: a `response_item` payload of those types,
|
|
16
|
+
* and the same item types inside a `compacted` line's `replacement_history`. No other key and no
|
|
17
|
+
* other item type is touched, so nothing outside this contract can lose a field.
|
|
18
|
+
*/
|
|
19
|
+
const CIPHERTEXT_ITEM_TYPES = new Set(["reasoning", "compaction"]);
|
|
20
|
+
function dropItemCiphertext(item, edits) {
|
|
21
|
+
if (isJsonObject(item) && typeof item["type"] === "string" && CIPHERTEXT_ITEM_TYPES.has(item["type"])) {
|
|
22
|
+
dropCiphertext(item, "encrypted_content", "codex.encrypted_content", edits);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
12
25
|
/** Strings from `response_item` lines remembered for comparison. Past this, new strings are not added. */
|
|
13
26
|
const MAX_REMEMBERED_STRINGS = 300_000;
|
|
14
27
|
/** Item types whose event copy is a pure repeat of a `response_item`. */
|
|
@@ -23,6 +36,7 @@ export function slimCodexRecord(record, state, seen, edits) {
|
|
|
23
36
|
switch (record["type"]) {
|
|
24
37
|
case "response_item":
|
|
25
38
|
rememberLongStrings(payload, state, 0);
|
|
39
|
+
dropItemCiphertext(payload, edits);
|
|
26
40
|
stripKey(payload, "internal_chat_message_metadata_passthrough", "codex.response_item.passthrough", edits);
|
|
27
41
|
if (payload["type"] === "message" && payload["role"] === "developer") {
|
|
28
42
|
seen.dedupeKey(payload, "content", "codex.developer_message.repeat", edits);
|
|
@@ -39,6 +53,10 @@ export function slimCodexRecord(record, state, seen, edits) {
|
|
|
39
53
|
return;
|
|
40
54
|
}
|
|
41
55
|
case "compacted": {
|
|
56
|
+
const history = payload["replacement_history"];
|
|
57
|
+
if (Array.isArray(history))
|
|
58
|
+
for (const item of history)
|
|
59
|
+
dropItemCiphertext(item, edits);
|
|
42
60
|
// guardian_history re-saves earlier turns; replacement_history is kept whole (part of it is found nowhere else).
|
|
43
61
|
const guardian = payload["guardian_history"];
|
|
44
62
|
if (Array.isArray(guardian))
|
package/dist/slim/slim-stream.js
CHANGED
|
@@ -1,29 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pass two of the streamed transcript: image swap and slimming over lines as they arrive
|
|
2
|
+
* Pass two of the streamed transcript: image swap and slimming over byte lines as they arrive
|
|
3
|
+
* (BLI-23317).
|
|
3
4
|
*
|
|
4
5
|
* 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
|
|
6
|
-
* and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on
|
|
7
|
-
* joined input; `raw-evidence-transcript-stream.test.ts` and the frozen goldens hold it to that.
|
|
6
|
+
* slim rules), with the lines coming from a pinned file instead of a decoded string, so the original
|
|
7
|
+
* text and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on
|
|
8
|
+
* the joined input; `raw-evidence-transcript-stream.test.ts` and the frozen goldens hold it to that.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Stage D: the images were found and hashed ONCE, by the census, and arrive here as occurrences with
|
|
11
|
+
* absolute byte offsets. A line with no chosen occurrence is decoded as it is; a line with one is
|
|
12
|
+
* rebuilt from the bytes around it and the marker's bytes, so a described image's base64 is never
|
|
13
|
+
* decoded to a string, let alone matched by a regex. A line that keeps an image (undescribed, too large)
|
|
14
|
+
* is decoded whole, as before.
|
|
15
|
+
*
|
|
16
|
+
* Each slimmed line goes to a `LineSink` and nowhere else: the staging file (`staged-output.ts`, hashing
|
|
17
|
+
* as it writes) when the gate allows one, else `LineBytesSink` below, one byte buffer in memory. No
|
|
18
|
+
* array of line strings is kept: with a non-Latin-1 character in most lines, the strings are two bytes a
|
|
19
|
+
* character, so they would cost as much as the text they build. The caller takes the text from the sink
|
|
20
|
+
* once the pass is over, for the sanitizer, which still reads the whole text: redaction is file-global
|
|
21
|
+
* (the first 200 ranges, absolute offsets, the residual fallback) and is not changed here.
|
|
15
22
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
23
|
+
import { formatImageMarker } from "@bli-cockpit/telemetry-core";
|
|
24
|
+
import { TextDecoder } from "node:util";
|
|
25
|
+
import { TranscriptNotStreamable } from "../adapters/transcript-text-stream.js";
|
|
18
26
|
import { createSlimmer } from "./slim-transcript.js";
|
|
19
27
|
const NEWLINE = 0x0a;
|
|
20
|
-
/**
|
|
21
|
-
|
|
28
|
+
/**
|
|
29
|
+
* UTF-8 bytes of the lines so far, in a buffer that starts at the input's size (pages never written are
|
|
30
|
+
* never resident) and grows only if the output outgrows it. The sink when nothing is staged to disk.
|
|
31
|
+
*/
|
|
32
|
+
export class LineBytesSink {
|
|
22
33
|
buffer;
|
|
23
34
|
length = 0;
|
|
24
35
|
constructor(capacity) {
|
|
25
36
|
this.buffer = Buffer.allocUnsafe(Math.max(capacity, 1024));
|
|
26
37
|
}
|
|
38
|
+
async write(text) {
|
|
39
|
+
if (text === "\n") {
|
|
40
|
+
this.reserve(1);
|
|
41
|
+
this.buffer[this.length++] = NEWLINE;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const size = Buffer.byteLength(text);
|
|
45
|
+
this.reserve(size);
|
|
46
|
+
this.length += this.buffer.write(text, this.length, size, "utf8");
|
|
47
|
+
}
|
|
27
48
|
reserve(extra) {
|
|
28
49
|
const needed = this.length + extra;
|
|
29
50
|
if (needed <= this.buffer.length)
|
|
@@ -32,46 +53,68 @@ class LineBytes {
|
|
|
32
53
|
this.buffer.copy(grown, 0, 0, this.length);
|
|
33
54
|
this.buffer = grown;
|
|
34
55
|
}
|
|
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
56
|
text() {
|
|
45
57
|
return this.buffer.toString("utf8", 0, this.length);
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
|
-
export async function slimLineStream(lines, kind, replace,
|
|
60
|
+
export async function slimLineStream(lines, kind, occurrences, replace, sink) {
|
|
49
61
|
const slimmer = createSlimmer(kind);
|
|
50
|
-
|
|
62
|
+
// One decoder for the pass: fatal on invalid UTF-8, BOM kept as a character like `Buffer.toString`.
|
|
63
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
64
|
+
const decode = (bytes) => {
|
|
65
|
+
try {
|
|
66
|
+
return decoder.decode(bytes);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new TranscriptNotStreamable("invalid_utf8");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
let next = 0;
|
|
51
73
|
let replacedCount = 0;
|
|
52
74
|
let charsSaved = 0;
|
|
53
75
|
let changed = false;
|
|
54
76
|
let first = true;
|
|
55
|
-
for await (const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
for await (const { start, bytes } of lines) {
|
|
78
|
+
const end = start + bytes.length;
|
|
79
|
+
// Occurrences sit inside one line; one that starts before this line was missed by the census's cut.
|
|
80
|
+
if (next < occurrences.length && occurrences[next].replaceStart < start)
|
|
81
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
82
|
+
const chosen = [];
|
|
83
|
+
for (; next < occurrences.length && occurrences[next].replaceStart < end; next += 1) {
|
|
84
|
+
const image = occurrences[next];
|
|
85
|
+
if (image.replaceEnd > end)
|
|
86
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
87
|
+
if (replace(image))
|
|
88
|
+
chosen.push(image);
|
|
89
|
+
}
|
|
90
|
+
let line;
|
|
91
|
+
if (chosen.length === 0) {
|
|
92
|
+
line = decode(bytes);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const pieces = [];
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
for (const image of chosen) {
|
|
98
|
+
const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
|
|
99
|
+
pieces.push(bytes.subarray(cursor, image.replaceStart - start), Buffer.from(marker, "utf8"));
|
|
100
|
+
cursor = image.replaceEnd - start;
|
|
101
|
+
replacedCount += 1;
|
|
102
|
+
charsSaved += image.replaceEnd - image.replaceStart - marker.length;
|
|
66
103
|
}
|
|
104
|
+
pieces.push(bytes.subarray(cursor));
|
|
105
|
+
line = decode(Buffer.concat(pieces));
|
|
106
|
+
changed = true;
|
|
67
107
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
108
|
+
const slimmed = slimmer.slimLine(line);
|
|
109
|
+
// An unswapped line is its own decoding, so it is changed exactly when the slim rules rewrote it.
|
|
110
|
+
if (chosen.length === 0 && slimmed !== line)
|
|
70
111
|
changed = true;
|
|
71
112
|
if (!first)
|
|
72
|
-
|
|
113
|
+
await sink.write("\n");
|
|
73
114
|
first = false;
|
|
74
|
-
|
|
115
|
+
await sink.write(slimmed);
|
|
75
116
|
}
|
|
76
|
-
|
|
117
|
+
if (next < occurrences.length)
|
|
118
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
119
|
+
return { stats: slimmer.stats(), replacedCount, charsSaved, changed };
|
|
77
120
|
}
|
|
@@ -58,6 +58,28 @@ export function stripKey(holder, key, rule, edits) {
|
|
|
58
58
|
delete holder[key];
|
|
59
59
|
return true;
|
|
60
60
|
}
|
|
61
|
+
/** What an encrypted value becomes (BLI-23334): `[encrypted signature dropped, 1624 chars]`. Idempotent, prefix-stable. */
|
|
62
|
+
const CIPHERTEXT_MARKER_START = "[encrypted signature dropped, ";
|
|
63
|
+
export function ciphertextMarker(chars) {
|
|
64
|
+
return `${CIPHERTEXT_MARKER_START}${chars} chars]`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Replace `holder[key]`, an opaque encrypted string nothing downstream can read (a Claude thinking
|
|
68
|
+
* `signature`, a Codex `encrypted_content`), with a marker that keeps its length. The surrounding
|
|
69
|
+
* block and its readable text are never touched. Shorter than its own marker, or already a marker,
|
|
70
|
+
* means no change.
|
|
71
|
+
*/
|
|
72
|
+
export function dropCiphertext(holder, key, rule, edits) {
|
|
73
|
+
const value = holder[key];
|
|
74
|
+
if (typeof value !== "string" || value.startsWith(CIPHERTEXT_MARKER_START))
|
|
75
|
+
return false;
|
|
76
|
+
const marker = ciphertextMarker(value.length);
|
|
77
|
+
if (value.length <= marker.length)
|
|
78
|
+
return false;
|
|
79
|
+
holder[key] = marker;
|
|
80
|
+
edits.strip(rule, value.length - marker.length);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
61
83
|
/**
|
|
62
84
|
* The one copy per transcript. The first time a block is seen it stays in the line; every time
|
|
63
85
|
* after, the block is replaced by `{"slim_ref":"sha256:<16 hex>"}` naming the earlier copy. The
|
|
@@ -36,7 +36,7 @@ import { LineEdits, SeenBlocks, isJsonObject } from "./slim-support.js";
|
|
|
36
36
|
* once, which is intended: the stored bytes are no longer what the new rules would produce.
|
|
37
37
|
* `slim-rules-version.test.ts` fails when a rule changes without this moving.
|
|
38
38
|
*/
|
|
39
|
-
export const SLIM_RULES_VERSION = "slim-rules.
|
|
39
|
+
export const SLIM_RULES_VERSION = "slim-rules.v2";
|
|
40
40
|
/** The text an image marker starts with; a line carrying one is a modified line (an image became a marker). */
|
|
41
41
|
const IMAGE_MARKER_START = "[image sha256:";
|
|
42
42
|
export function createSlimmer(kind, rulesForTests) {
|
package/dist/staging-gate.js
CHANGED
|
@@ -172,13 +172,10 @@ export async function openStagingAllowance(stateDir, env, deps = {}) {
|
|
|
172
172
|
return { snapshot, admitted_bytes: 0, deferred: {} };
|
|
173
173
|
}
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* costs nothing and must be passed as `alreadyStaged` so it is never refused.
|
|
175
|
+
* The gate's decision for a file of this size, with nothing charged, counted or logged: the one rule
|
|
176
|
+
* `admitToStaging` applies and `roomToStageUpTo` asks about beforehand.
|
|
178
177
|
*/
|
|
179
|
-
|
|
180
|
-
if (options.alreadyStaged)
|
|
181
|
-
return null;
|
|
178
|
+
function decideAdmission(allowance, byteLength) {
|
|
182
179
|
const { snapshot } = allowance;
|
|
183
180
|
const payloadCap = Math.floor(snapshot.cap_bytes * (1 - STAGING_MANIFEST_RESERVE_FRACTION));
|
|
184
181
|
const spent = allowance.admitted_bytes;
|
|
@@ -189,16 +186,6 @@ export function admitToStaging(allowance, byteLength, options = {}) {
|
|
|
189
186
|
snapshot.floor_bytes !== null &&
|
|
190
187
|
freeNow - byteLength < snapshot.floor_bytes;
|
|
191
188
|
const smallForFree = freeNow !== null && freeNow > 0 && byteLength <= freeNow * LOW_DISK_SMALL_FILE_FRACTION;
|
|
192
|
-
if (belowFloor && smallForFree) {
|
|
193
|
-
console.error("[staging-gate] low-disk floor waived, file is under 1% of free space", JSON.stringify({
|
|
194
|
-
event: STAGING_DEFERRED,
|
|
195
|
-
reason: "low_disk_waived_small_file",
|
|
196
|
-
kind: options.kind ?? null,
|
|
197
|
-
file_bytes: byteLength,
|
|
198
|
-
free_bytes: freeNow,
|
|
199
|
-
floor_bytes: snapshot.floor_bytes,
|
|
200
|
-
}));
|
|
201
|
-
}
|
|
202
189
|
if (belowFloor && !smallForFree) {
|
|
203
190
|
reason = "low_disk";
|
|
204
191
|
}
|
|
@@ -209,6 +196,39 @@ export function admitToStaging(allowance, byteLength, options = {}) {
|
|
|
209
196
|
(stagedNow > 0 && stagedNow + byteLength > payloadCap)) {
|
|
210
197
|
reason = "staging_at_cap";
|
|
211
198
|
}
|
|
199
|
+
return { reason, waivedSmallFile: belowFloor && smallForFree, freeNow };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Would the gate admit a file of up to this size right now? A read-only question (BLI-23317 stage D): the
|
|
203
|
+
* streamed transcript pass writes its output to staging while it is made, BEFORE the real size is known
|
|
204
|
+
* and the real admission runs, so it asks first with the source file's size as the bound. A "no" means no
|
|
205
|
+
* byte is written ahead of the gate; the ordinary path then refuses the file exactly as it always did.
|
|
206
|
+
*/
|
|
207
|
+
export function roomToStageUpTo(allowance, byteLength) {
|
|
208
|
+
if (!allowance)
|
|
209
|
+
return null;
|
|
210
|
+
return decideAdmission(allowance, byteLength).reason;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* May a file of this size be copied into staging now? Returns the reason it may
|
|
214
|
+
* not, or null after charging the allowance. A copy that already exists on disk
|
|
215
|
+
* costs nothing and must be passed as `alreadyStaged` so it is never refused.
|
|
216
|
+
*/
|
|
217
|
+
export function admitToStaging(allowance, byteLength, options = {}) {
|
|
218
|
+
if (options.alreadyStaged)
|
|
219
|
+
return null;
|
|
220
|
+
const { snapshot } = allowance;
|
|
221
|
+
const { reason, waivedSmallFile, freeNow } = decideAdmission(allowance, byteLength);
|
|
222
|
+
if (waivedSmallFile) {
|
|
223
|
+
console.error("[staging-gate] low-disk floor waived, file is under 1% of free space", JSON.stringify({
|
|
224
|
+
event: STAGING_DEFERRED,
|
|
225
|
+
reason: "low_disk_waived_small_file",
|
|
226
|
+
kind: options.kind ?? null,
|
|
227
|
+
file_bytes: byteLength,
|
|
228
|
+
free_bytes: freeNow,
|
|
229
|
+
floor_bytes: snapshot.floor_bytes,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
212
232
|
if (!reason) {
|
|
213
233
|
allowance.admitted_bytes += byteLength;
|
|
214
234
|
return null;
|