@bli-cockpit/cli 0.2.206 → 0.2.208

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.
@@ -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.206");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.208");
25
25
  return 0;
26
26
  }
27
27
 
@@ -1,4 +1,5 @@
1
1
  import { writeLine } from "./cli-io.js";
2
+ import { readDescribedImageKeys } from "../image-description-state.js";
2
3
  import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
3
4
  import { fetchTranscriptObjects } from "../evidence-transcripts-client.js";
4
5
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
@@ -28,13 +29,14 @@ export async function runSourcesCheck(command, io, now = new Date()) {
28
29
  codex: await readRawEvidenceCursor(paths),
29
30
  claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
30
31
  };
32
+ const describedKeys = new Set(await readDescribedImageKeys(paths.state_dir));
31
33
  const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
32
34
  for (const file of local.files) {
33
35
  const judged = await judgeSource({
34
36
  file,
35
37
  remotes: remote.files.get(file.key) ?? [],
36
38
  cursorEntry: file.kind === "claude_subagent" ? null : (cursors[file.source].sessions[file.session_id] ?? null),
37
- facts: fileFacts(file),
39
+ facts: fileFacts(file, undefined, { describedKeys }),
38
40
  }).catch((error) => {
39
41
  report.local.unreadable += 1;
40
42
  console.error("[collector sources] a transcript could not be judged; left out of the answer", JSON.stringify({ reason: "file_unreadable", kind: file.kind, byte_size: file.byte_size, error_name: error?.name ?? "Error" }));
@@ -30,6 +30,8 @@ import path from "node:path";
30
30
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, REDACTION_STUB_SHA256, } from "@bli-cockpit/telemetry-core";
31
31
  import { sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
32
32
  import { isSecretLikePath } from "../adapters/raw-evidence-keys.js";
33
+ import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
34
+ import { uploadFormOfTranscript } from "../image-description-session.js";
33
35
  export async function judgeSource(input) {
34
36
  const { file, remotes, cursorEntry } = input;
35
37
  if (remotes.length === 0)
@@ -79,7 +81,11 @@ export async function judgeSource(input) {
79
81
  * upload does (`raw-evidence-transcript-file.ts`): same sanitizer, same
80
82
  * secret-like-name rule, same byte cap.
81
83
  */
82
- export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
84
+ export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
85
+ /** Tests only set `mode`. Production reads the one switch. `describedKeys` is the collector's described-image state. */
86
+ upload = {}) {
87
+ const mode = upload.mode ?? IMAGE_HANDLING_MODE;
88
+ const describedKeys = upload.describedKeys ?? new Set();
83
89
  let bytes = null;
84
90
  const read = () => {
85
91
  bytes ??= file.byte_size > maxBytes ? Promise.resolve(null) : fs.readFile(file.file_path);
@@ -96,9 +102,11 @@ export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
96
102
  sanitized ??= read().then((buffer) => {
97
103
  if (!buffer)
98
104
  return null;
105
+ // Compare against what the uploader sends: in drop mode that is the slim form, not the disk bytes.
106
+ const uploadText = uploadFormOfTranscript(buffer.toString("utf8"), file.source === "codex" ? "codex" : "claude_code", mode, describedKeys);
99
107
  const result = sanitizeTextEvidenceForUpload({
100
- text: buffer.toString("utf8"),
101
- originalBytes: buffer,
108
+ text: uploadText,
109
+ originalBytes: uploadText === buffer.toString("utf8") ? buffer : Buffer.from(uploadText, "utf8"),
102
110
  redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
103
111
  secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
104
112
  });
@@ -22,8 +22,8 @@
22
22
  */
23
23
  import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
24
24
  import { postImageToDescribeDoor } from "./image-describe-client.js";
25
- import { slimOrKeep } from "./slim/slim-transcript.js";
26
- import { imageStateKey, readDescribedImageKeys, writeDescribedImageKeys, } from "./image-description-state.js";
25
+ import { slimOrKeep, slimTranscript } from "./slim/slim-transcript.js";
26
+ import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
27
27
  /** New images sent to the door in one sync (all worktrees together). */
28
28
  export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
29
29
  /** After this long in one sync, remaining images wait for the next tick. */
@@ -35,6 +35,8 @@ export async function openImageDescriptions(options) {
35
35
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
36
36
  const described = new Set(await readDescribedImageKeys(options.stateDir));
37
37
  const orderedKeys = [...described];
38
+ const failedImages = await readFailedImages(options.stateDir);
39
+ let stateDirty = false;
38
40
  const settledThisRun = new Map();
39
41
  const now = options.now ?? Date.now;
40
42
  const startedAt = now();
@@ -59,12 +61,33 @@ export async function openImageDescriptions(options) {
59
61
  source: input.source,
60
62
  session_id: input.sessionId.slice(0, 200),
61
63
  });
64
+ const key = imageStateKey(image.sha256);
62
65
  if (outcome.ok) {
63
66
  markDescribed(image.sha256);
64
67
  settledThisRun.set(image.sha256, outcome.status);
68
+ if (failedImages[key]) {
69
+ delete failedImages[key];
70
+ stateDirty = true;
71
+ }
65
72
  return;
66
73
  }
67
74
  settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
75
+ rememberFailure(key, outcome);
76
+ }
77
+ /**
78
+ * BLI-16979: the door answered about THIS image and could not describe it (a 4xx such as
79
+ * `describer_rejected_image`, or a 5xx). Wait before sending the same bytes again, instead of
80
+ * re-sending them every sync. A transport error, a 401 and a 429 say nothing about the image, so
81
+ * they are not remembered.
82
+ */
83
+ function rememberFailure(key, outcome) {
84
+ if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
85
+ return;
86
+ const attempts = (failedImages[key]?.attempts ?? 0) + 1;
87
+ const delay = imageRetryDelayMs(attempts, outcome.permanent);
88
+ failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
89
+ stateDirty = true;
90
+ 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) }));
68
91
  }
69
92
  async function describeAndReplace(input) {
70
93
  const images = findEmbeddedImages(input.text);
@@ -74,7 +97,7 @@ export async function openImageDescriptions(options) {
74
97
  for (const image of images)
75
98
  if (!unique.has(image.sha256))
76
99
  unique.set(image.sha256, image);
77
- const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0 };
100
+ const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
78
101
  const failedReasons = {};
79
102
  const toSend = [];
80
103
  for (const image of unique.values()) {
@@ -90,6 +113,11 @@ export async function openImageDescriptions(options) {
90
113
  counts.too_large += 1;
91
114
  continue;
92
115
  }
116
+ const earlierFailure = failedImages[imageStateKey(image.sha256)];
117
+ if (earlierFailure && earlierFailure.retry_after > now()) {
118
+ counts.backing_off += 1;
119
+ continue;
120
+ }
93
121
  toSend.push(image);
94
122
  }
95
123
  let next = 0;
@@ -116,8 +144,10 @@ export async function openImageDescriptions(options) {
116
144
  failedReasons[reason] = (failedReasons[reason] ?? 0) + 1;
117
145
  }
118
146
  }
119
- if (counts.described_now + counts.known > 0)
120
- await writeDescribedImageKeys(options.stateDir, orderedKeys);
147
+ if (counts.described_now + counts.known > 0 || stateDirty) {
148
+ await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
149
+ stateDirty = false;
150
+ }
121
151
  const replaced = mode === "describe_and_drop"
122
152
  ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
123
153
  : { text: input.text, replacedCount: 0, charsSaved: 0 };
@@ -151,6 +181,19 @@ export async function openImageDescriptions(options) {
151
181
  },
152
182
  };
153
183
  }
184
+ /**
185
+ * What the uploader sends for this transcript text, worked out offline (BLI-17013). Keep mode: the
186
+ * text as it is. Drop mode: every image whose key is in `describedKeys` becomes its marker, then
187
+ * the slim rules run, the same two steps as `prepareTranscript` above, with no describe-door call.
188
+ * `cockpit sources check` uses it so a complete upload is judged against the bytes actually uploaded.
189
+ */
190
+ export function uploadFormOfTranscript(text, source, mode, describedKeys) {
191
+ if (mode !== "describe_and_drop")
192
+ return text;
193
+ const images = findEmbeddedImages(text);
194
+ const replaced = replaceImagesWithMarkers(text, images, (image) => describedKeys.has(imageStateKey(image.sha256)));
195
+ return slimTranscript(replaced.text, source).text;
196
+ }
154
197
  /**
155
198
  * The image step for one build of an envelope. When a sync-wide budget is shared across worktrees
156
199
  * the session lives on it (one count budget and one state file for the whole sync); otherwise this
@@ -35,10 +35,54 @@ export async function readDescribedImageKeys(stateDir) {
35
35
  return [];
36
36
  }
37
37
  }
38
- export async function writeDescribedImageKeys(stateDir, keys) {
38
+ /** Oldest failures leave first past this many. */
39
+ export const IMAGE_FAILED_STATE_MAX_KEYS = 5_000;
40
+ const HOUR_MS = 3_600_000;
41
+ /** A refusal that repeats for the same bytes (a 4xx): a week, then a month. */
42
+ const PERMANENT_BACKOFF_MS = [7 * 24 * HOUR_MS, 30 * 24 * HOUR_MS];
43
+ /** A failure that may pass (the door or the model was down): 1 h, 6 h, 24 h, then 72 h. */
44
+ const TRANSIENT_BACKOFF_MS = [HOUR_MS, 6 * HOUR_MS, 24 * HOUR_MS, 72 * HOUR_MS];
45
+ /** How long to leave an image alone after its Nth failed attempt. */
46
+ export function imageRetryDelayMs(attempts, permanent) {
47
+ const steps = permanent ? PERMANENT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
48
+ return steps[Math.min(Math.max(attempts, 1), steps.length) - 1];
49
+ }
50
+ /** The failures on record, key to record. A missing or unreadable file is an empty map, said out loud when unreadable. */
51
+ export async function readFailedImages(stateDir) {
52
+ try {
53
+ const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
54
+ if (!raw?.failed || typeof raw.failed !== "object" || Array.isArray(raw.failed))
55
+ return {};
56
+ const kept = {};
57
+ for (const [key, value] of Object.entries(raw.failed)) {
58
+ const record = value;
59
+ if (key.length !== KEY_CHARS || !record)
60
+ continue;
61
+ if (typeof record.reason !== "string" || typeof record.attempts !== "number" || typeof record.retry_after !== "number")
62
+ continue;
63
+ kept[key] = { reason: record.reason, attempts: record.attempts, retry_after: record.retry_after };
64
+ }
65
+ return kept;
66
+ }
67
+ catch (error) {
68
+ if (!isMissingFileFailure(error)) {
69
+ console.error("[image-descriptions] failed-image list unreadable, starting from an empty list", JSON.stringify({ reason: "image_failed_state_unreadable" }));
70
+ }
71
+ return {};
72
+ }
73
+ }
74
+ export async function writeImageDescriptionState(stateDir, state) {
75
+ const keys = state.described;
39
76
  const kept = keys.length > IMAGE_DESCRIPTION_STATE_MAX_KEYS ? keys.slice(keys.length - IMAGE_DESCRIPTION_STATE_MAX_KEYS) : keys;
77
+ const failedEntries = Object.entries(state.failed).sort((a, b) => a[1].retry_after - b[1].retry_after);
78
+ const failed = Object.fromEntries(failedEntries.slice(Math.max(0, failedEntries.length - IMAGE_FAILED_STATE_MAX_KEYS)));
40
79
  await writeJsonFile(imageDescriptionStatePath(stateDir), {
41
80
  schema_version: "cockpit-image-descriptions.v1",
42
81
  described: kept,
82
+ failed,
43
83
  });
84
+ }
85
+ /** Writes the described list and keeps whatever failures are on record. */
86
+ export async function writeDescribedImageKeys(stateDir, keys) {
87
+ await writeImageDescriptionState(stateDir, { described: keys, failed: await readFailedImages(stateDir) });
44
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.206",
3
+ "version": "0.2.208",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {