@bli-cockpit/cli 0.2.204 → 0.2.206

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.
@@ -39,6 +39,7 @@ export async function runLocalSourceCollectors(options) {
39
39
  byteBudget: options.rawEvidenceByteBudget,
40
40
  objectBudget: options.rawEvidenceObjectBudget,
41
41
  budget: options.rawEvidenceBudget,
42
+ images: options.rawEvidenceImages,
42
43
  deliveryMode: options.rawEvidenceDeliveryMode,
43
44
  })
44
45
  : {
@@ -59,6 +59,8 @@ export async function openCollection(context, options, places) {
59
59
  remainingBytes: byteBudget,
60
60
  remainingObjects: objectBudget,
61
61
  };
62
+ if (options.images)
63
+ budget.images ??= options.images;
62
64
  const stagingAllowance = (budget.staging ??= await openStagingAllowance(options.stateDir, options.env ?? process.env, { statfs: options.statfs }));
63
65
  return {
64
66
  context,
@@ -17,6 +17,7 @@ import { describeError } from "../health-detail.js";
17
17
  import { DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, } from "../raw-evidence-staging.js";
18
18
  import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
19
19
  import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
20
+ import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
20
21
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
21
22
  import { evidenceEntry } from "./raw-evidence-manifest.js";
22
23
  import { describeSnapshot, stubReplacementClaim, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
@@ -57,8 +58,15 @@ export async function collectOneEvidenceFile(collection, options) {
57
58
  markCapApplied(collection, options.kind, "max_file_bytes");
58
59
  return skip("file_too_large");
59
60
  }
61
+ // BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
62
+ // base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
63
+ const imagesPrepared = await prepareEmbeddedImages(collection, {
64
+ bytes: read.bytes,
65
+ kind: options.kind,
66
+ sessionId: options.sessionId,
67
+ });
60
68
  const sanitized = maskSecretsInTranscript(collection, {
61
- raw: read.bytes,
69
+ raw: imagesPrepared,
62
70
  kind: options.kind,
63
71
  evidenceLabel: names.evidenceLabel,
64
72
  secretLikeFileName: names.secretLikeFileName,
@@ -96,6 +104,35 @@ export async function collectOneEvidenceFile(collection, options) {
96
104
  });
97
105
  return true;
98
106
  }
107
+ /**
108
+ * The transcript bytes after the image step. Unchanged (the very same buffer) unless this sync has
109
+ * an image session AND the switch is `describe_and_drop` (images become markers where a description
110
+ * is stored, then the slim rules run, see src/slim/). In `describe_and_keep` the bytes always come
111
+ * back untouched. Never throws: an image step that breaks hands back the original bytes and says so.
112
+ */
113
+ async function prepareEmbeddedImages(collection, file) {
114
+ const session = collection.budget.images;
115
+ const source = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
116
+ if (!session || !source)
117
+ return file.bytes;
118
+ // Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
119
+ if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.bytes))
120
+ return file.bytes;
121
+ try {
122
+ const prepared = await session.prepareTranscript({
123
+ text: file.bytes.toString("utf8"),
124
+ source,
125
+ sessionId: file.sessionId,
126
+ });
127
+ if (!prepared.changed)
128
+ return file.bytes;
129
+ return Buffer.from(prepared.text, "utf8");
130
+ }
131
+ catch (error) {
132
+ 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) }));
133
+ return file.bytes;
134
+ }
135
+ }
99
136
  function nameEvidenceFile(options) {
100
137
  const fileName = path.basename(options.filePath);
101
138
  const secretLikeFileName = isSecretLikePath(fileName);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `cockpit image` (BLI-16977): describe one image by hand, or look a description up.
3
+ *
4
+ * Plain words: Tower replaces the base64 pictures inside collected transcripts with a short
5
+ * written description, kept against the image's sha256. The collector sends each picture once.
6
+ * `describe` is that same step for one file you point at; `show` finds a stored description from
7
+ * the sha256 or the 16-character prefix a transcript marker carries.
8
+ *
9
+ * Two verbs over the one `/api/ambient/images` door (POST describes, GET reads) on this machine's
10
+ * device token, the same door the MCP twins (`image_describe`, `image_show`) use. `--json` carries
11
+ * the door's body on stdout and nothing else. Every refusal names its reason: a file that is too
12
+ * large or not an image is refused here, before anything is sent.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { readFile, stat } from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { IMAGE_DESCRIBE_MAX_BYTES, formatImageMarker, imageSizeLabel, sniffImageMediaType, } from "@bli-cockpit/telemetry-core";
18
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
19
+ import { writeLine } from "./cli-io.js";
20
+ const TAG = "[image cli]";
21
+ const READ_DEADLINE_MS = 30_000;
22
+ /** The door runs a vision model inside the request. */
23
+ const DESCRIBE_DEADLINE_MS = 55_000;
24
+ const DOOR_PATH = "/api/ambient/images";
25
+ export async function runImage(command, io) {
26
+ const prepared = command.action === "describe" ? await readImageFile(command.file ?? "") : null;
27
+ if (prepared && !prepared.ok)
28
+ return refuse(io, command.json, prepared.reason, prepared.detail);
29
+ let door;
30
+ try {
31
+ door = await openAgentDoor("image", command, io);
32
+ }
33
+ catch (error) {
34
+ const detail = error instanceof Error ? error.message : "this machine is not paired with Tower";
35
+ return refuse(io, command.json, "not_paired", detail);
36
+ }
37
+ if (prepared?.ok)
38
+ return describeImage(command, door, prepared.request);
39
+ return showImage(command, door);
40
+ }
41
+ /** Reads the file and refuses, by name, anything the door would refuse. Nothing is sent from here. */
42
+ async function readImageFile(typed) {
43
+ const file = path.resolve(typed);
44
+ let size;
45
+ try {
46
+ const info = await stat(file);
47
+ if (!info.isFile())
48
+ return { ok: false, reason: "not_a_file", detail: `${typed} is not a file. Point at one image file.` };
49
+ size = info.size;
50
+ }
51
+ catch {
52
+ return { ok: false, reason: "file_unreadable", detail: `Could not read ${typed}. Check the path. Nothing was sent.` };
53
+ }
54
+ if (size > IMAGE_DESCRIBE_MAX_BYTES) {
55
+ return { ok: false, reason: "image_too_large", detail: `That image is ${imageSizeLabel(size)}; the describe door takes at most ${imageSizeLabel(IMAGE_DESCRIBE_MAX_BYTES)}. Nothing was sent.` };
56
+ }
57
+ let bytes;
58
+ try {
59
+ bytes = await readFile(file);
60
+ }
61
+ catch {
62
+ return { ok: false, reason: "file_unreadable", detail: `Could not read ${typed}. Check the path and permissions. Nothing was sent.` };
63
+ }
64
+ const mediaType = sniffImageMediaType(bytes);
65
+ if (!mediaType)
66
+ return { ok: false, reason: "not_an_image", detail: "That file is not a PNG, JPEG, GIF or WebP image (judged by its first bytes, not its name). Nothing was sent." };
67
+ if (bytes.length > IMAGE_DESCRIBE_MAX_BYTES) {
68
+ return { ok: false, reason: "image_too_large", detail: `That image is ${imageSizeLabel(bytes.length)}; the describe door takes at most ${imageSizeLabel(IMAGE_DESCRIBE_MAX_BYTES)}. Nothing was sent.` };
69
+ }
70
+ const request = {
71
+ schema_version: "image-describe.v1",
72
+ sha256: createHash("sha256").update(bytes).digest("hex"),
73
+ media_type: mediaType,
74
+ byte_size: bytes.length,
75
+ content_base64: Buffer.from(bytes).toString("base64"),
76
+ source: "manual",
77
+ };
78
+ return { ok: true, request };
79
+ }
80
+ async function describeImage(command, door, request) {
81
+ const body = command.session ? { ...request, session_id: command.session } : request;
82
+ const answer = await askAgentDoor(door, { path: DOOR_PATH, method: "POST", label: "image describe", timeoutMs: DESCRIBE_DEADLINE_MS, body });
83
+ if (!answer.ok)
84
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
85
+ const result = answer.body;
86
+ writeLine(door.io.stderr, `${TAG} describe answered ${JSON.stringify({ status: result.status, sha256: request.sha256.slice(0, 16), media_type: request.media_type, byte_size: request.byte_size, secrets_masked: result.description.secrets_masked })}`);
87
+ if (door.json)
88
+ return emitAgentDoor(door, result);
89
+ const out = door.io.stdout;
90
+ writeLine(out, result.status === "known" ? "Tower already had a description for this image:" : "Described:");
91
+ for (const line of descriptionLines(result.description))
92
+ writeLine(out, line);
93
+ writeLine(out, "");
94
+ writeLine(out, `Transcript marker: ${formatImageMarker({ sha256: request.sha256, mediaType: request.media_type, byteSize: request.byte_size })}`);
95
+ return 0;
96
+ }
97
+ async function showImage(command, door) {
98
+ const query = new URLSearchParams({ sha256: command.sha256 ?? "" });
99
+ const answer = await askAgentDoor(door, { path: `${DOOR_PATH}?${query.toString()}`, method: "GET", label: "image show", timeoutMs: READ_DEADLINE_MS });
100
+ if (!answer.ok)
101
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
102
+ const result = answer.body;
103
+ writeLine(door.io.stderr, `${TAG} show answered ${JSON.stringify({ sha256: (command.sha256 ?? "").slice(0, 16), returned: result.descriptions.length })}`);
104
+ if (door.json)
105
+ return emitAgentDoor(door, result);
106
+ if (result.descriptions.length === 0) {
107
+ writeLine(door.io.stdout, "No description stored for that image yet.");
108
+ return 0;
109
+ }
110
+ result.descriptions.forEach((description, index) => {
111
+ if (index > 0)
112
+ writeLine(door.io.stdout, "");
113
+ for (const line of descriptionLines(description))
114
+ writeLine(door.io.stdout, line);
115
+ });
116
+ return 0;
117
+ }
118
+ function descriptionLines(description) {
119
+ const lines = [
120
+ description.description,
121
+ "",
122
+ `Image: ${description.media_type.replace("image/", "")}, ${imageSizeLabel(description.byte_size)}, sha256 ${description.sha256}`,
123
+ `Described by ${description.model} on ${description.created_at}.`,
124
+ ];
125
+ if (description.secrets_masked)
126
+ lines.push("Something that looked like a secret was masked in this description.");
127
+ return lines;
128
+ }
129
+ /** A refusal before or without a door: the reason on stderr, and under `--json` on stdout as `{ ok: false, error, detail }`. */
130
+ function refuse(io, json, reason, detail) {
131
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
132
+ if (json)
133
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
134
+ else
135
+ writeLine(io.stderr, detail);
136
+ return 1;
137
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `cockpit image` argument parsing (BLI-16977).
3
+ *
4
+ * cockpit image describe <file> [--session <id>] [--json]
5
+ * cockpit image show <sha256-or-prefix> [--json]
6
+ *
7
+ * Tower swaps the base64 pictures inside collected transcripts for a short written description.
8
+ * `describe` sends one image file through that same door by hand; `show` looks a description up by
9
+ * the sha256 (or the 16-character prefix a marker in a transcript carries).
10
+ */
11
+ import { ImageHashQuerySchema } from "@bli-cockpit/telemetry-core";
12
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
13
+ const ONE_VERB = "image takes one verb: describe or show. Example: `cockpit image describe screenshot.png` or `cockpit image show ab12cd34ef567890`.";
14
+ const SESSION_ID_MAX_CHARS = 200;
15
+ export function parseImageArgs(args) {
16
+ const values = parseNamedArgs(args, {
17
+ allowedFlags: ["--session", "--home", "--dashboard-url", "--json"],
18
+ valueFlags: ["--session", "--home", "--dashboard-url"],
19
+ });
20
+ const [verb, ...rest] = values.positionals;
21
+ if (verb !== "describe" && verb !== "show")
22
+ throw new Error(ONE_VERB);
23
+ if (rest.length !== 1) {
24
+ throw new Error(verb === "describe" ? "`cockpit image describe` takes exactly one image file." : "`cockpit image show` takes exactly one sha256 (or its first 12 or more characters).");
25
+ }
26
+ const session = optionalNonEmpty(values.flags.get("--session"));
27
+ if (session !== undefined && verb !== "describe")
28
+ throw new Error("--session does not belong to `cockpit image show`.");
29
+ if (session !== undefined && session.length > SESSION_ID_MAX_CHARS)
30
+ throw new Error(`--session is at most ${SESSION_ID_MAX_CHARS} characters.`);
31
+ const common = {
32
+ kind: "image",
33
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
34
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
35
+ json: values.booleans.has("--json"),
36
+ };
37
+ if (verb === "describe")
38
+ return { ...common, action: "describe", file: rest[0], ...(session ? { session } : {}) };
39
+ return { ...common, action: "show", sha256: readHashQuery(rest[0]) };
40
+ }
41
+ /** A marker is pasted whole as often as not (`sha256:ab12…`), so accept that spelling and trim it. */
42
+ function readHashQuery(typed) {
43
+ const cleaned = typed.trim().replace(/^sha256:/i, "").replace(/…$|\.\.\.$/, "").toLowerCase();
44
+ const checked = ImageHashQuerySchema.safeParse(cleaned);
45
+ if (!checked.success)
46
+ throw new Error("An image hash is 12 to 64 hex characters, like ab12cd34ef567890 (the 16 characters inside a transcript's [image sha256:…] marker).");
47
+ return checked.data;
48
+ }
@@ -43,6 +43,7 @@ export { parseUsageArgs } from "./local-args-tower-usage.js";
43
43
  export { parseLogsArgs } from "./local-args-tower-logs.js";
44
44
  export { parseBrainArgs } from "./local-args-tower-brain.js";
45
45
  export { parseApprovalsArgs } from "./local-args-tower-approvals.js";
46
+ export { parseImageArgs } from "./local-args-tower-image.js";
46
47
  export { parseCareersArgs } from './local-args-tower-careers.js';
47
48
  export { parseRenameArgs } from './local-args-tower-rename.js';
48
49
  export { parseGlossaryArgs } from "./glossary.js";
@@ -19,7 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
19
19
  import { parseRootsArgs } from "./roots.js";
20
20
  import { SURFACE_ALIASES } from "./local-help.js";
21
21
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
22
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseApprovalsArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
22
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseApprovalsArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
23
23
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
24
24
  // `local-auth.ts` import it from here — so it stays exported from this address
25
25
  // even though it now lives next door. The same goes for the four names the
@@ -146,6 +146,8 @@ export function parseLocalArgs(argv) {
146
146
  return parseBrainArgs(argv.slice(1));
147
147
  case "approvals":
148
148
  return parseApprovalsArgs(argv.slice(1));
149
+ case "image":
150
+ return parseImageArgs(argv.slice(1));
149
151
  default:
150
152
  throw new Error(`Unknown local command: ${command ?? ""}`);
151
153
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `cockpit image` command shape (BLI-16977), beside `local-command-shapes-approvals.ts` for the
3
+ * same reason: `local-command-shapes.ts` sits at the readability ceiling.
4
+ */
5
+ export {};
@@ -89,6 +89,20 @@ export const TOWER_OPS_COMMAND_HELP = [
89
89
  "approvals_edit, approvals_reject, approvals_rules, approvals_rules_create, approvals_rules_revoke.",
90
90
  ],
91
91
  ],
92
+ [
93
+ "image",
94
+ [
95
+ "Usage: cockpit image describe <file> [--session <id>] [--dashboard-url <url>] [--json]",
96
+ " cockpit image show <sha256-or-prefix> [--dashboard-url <url>] [--json]",
97
+ "",
98
+ "Tower swaps the base64 pictures inside collected transcripts for a short written description kept against the image's sha256.",
99
+ " describe send one PNG, JPEG, GIF or WebP file (up to 3 MB) to the describe door; prints the description and the marker a transcript would carry",
100
+ " show look a stored description up by its sha256, or the first 12 to 64 characters of it (a marker carries 16)",
101
+ "A file that is too large or not an image is refused here by name, before anything is sent. An image Tower already described answers from storage.",
102
+ "--json writes the door's body to stdout and nothing else; a refusal is { ok: false, error, detail } and exits 1.",
103
+ "The bli-tower MCP server has the twins: image_describe, image_show.",
104
+ ],
105
+ ],
92
106
  [
93
107
  "github",
94
108
  [
@@ -87,6 +87,7 @@ export const rootCommandNames = new Set([
87
87
  "logs",
88
88
  "brain",
89
89
  "approvals",
90
+ "image",
90
91
  ...SURFACE_ALIASES.keys(),
91
92
  ]);
92
93
  /** Public names omit compatibility spellings while the router keeps accepting them. */
@@ -147,6 +148,7 @@ export function localCommandHelp(command) {
147
148
  " cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
148
149
  " cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
149
150
  " cockpit approvals [list|show <id>|create|approve <id> [--edit]|edit <id> [--approve]|reject <id>|rules [--active true|false]|rules create <card-id> [--any-audience] [--label <text>]|rules revoke <rule-id> [--reason <text>]] [--status <s>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
151
+ " cockpit image <describe <file> [--session <id>]|show <sha256-or-prefix>> [--dashboard-url <url>] [--json]",
150
152
  " cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
151
153
  " cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
152
154
  " cockpit usage session <id> [--from-window <n>] [--json]",
@@ -55,6 +55,7 @@ import { runUsage } from "./usage.js";
55
55
  import { runSpeed } from "./speed.js";
56
56
  import { runLogs } from "./logs.js";
57
57
  import { runApprovals } from "./approvals.js";
58
+ import { runImage } from "./image.js";
58
59
  import { runBrain } from "./brain.js";
59
60
  import { parseLocalArgs } from "./local-args.js";
60
61
  // `./local.js` is the published entry point for this command surface: the
@@ -233,14 +234,11 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
233
234
  return await runMsg(command, io);
234
235
  case "issue":
235
236
  return await runIssue(command, io);
236
- case "cal":
237
- return runCal(command, io);
237
+ case "cal": return runCal(command, io);
238
238
  case "mail":
239
239
  return await runMail(command, io);
240
- case "project":
241
- return await runProject(command, io);
242
- case "search":
243
- return await runSearch(command, io);
240
+ case "project": return await runProject(command, io);
241
+ case "search": return await runSearch(command, io);
244
242
  case "github":
245
243
  return await runGithub(command, io);
246
244
  case "release":
@@ -253,6 +251,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
253
251
  case "logs": return await runLogs(command, io);
254
252
  case "brain": return await runBrain(command, io);
255
253
  case "approvals": return await runApprovals(command, io);
254
+ case "image": return await runImage(command, io);
256
255
  }
257
256
  }
258
257
  catch (error) {
@@ -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.204");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.206");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The collector's one call to the image describe door (BLI-16977).
3
+ *
4
+ * POST /api/ambient/images takes one distinct image and answers with its stored description. This
5
+ * file sends it and reads the answer; it decides nothing about WHICH images to send (that is
6
+ * `image-description-session.ts`). A refusal keeps the server's own reason label, so a failure in
7
+ * the log names its cause instead of "failed".
8
+ *
9
+ * Metadata only leaves this module: hashes, sizes, status codes and reason labels. Never image
10
+ * bytes, never a description.
11
+ */
12
+ import { ImageDescribeResponseSchema, } from "@bli-cockpit/telemetry-core";
13
+ import { describeError } from "./health-detail.js";
14
+ import { safeFailureDetail } from "./evidence-upload-transport.js";
15
+ import { noteSyncProgress } from "./sync-lock.js";
16
+ const REQUEST_TIMEOUT_MS = 60_000;
17
+ const MAX_ATTEMPTS = 2;
18
+ const RETRY_DELAY_MS = 500;
19
+ export async function postImageToDescribeDoor(options, request) {
20
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
21
+ let last = { ok: false, reason: "transport_error", httpStatus: 0, permanent: false };
22
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
23
+ try {
24
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/images`, {
25
+ method: "POST",
26
+ headers: { Authorization: `Bearer ${options.deviceToken}`, "Content-Type": "application/json" },
27
+ body: JSON.stringify(request),
28
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
29
+ });
30
+ const body = await readJson(response);
31
+ noteSyncProgress();
32
+ if (response.ok) {
33
+ const parsed = ImageDescribeResponseSchema.safeParse(body);
34
+ if (parsed.success)
35
+ return { ok: true, status: parsed.data.status };
36
+ return { ok: false, reason: "describe_response_unreadable", httpStatus: response.status, permanent: false };
37
+ }
38
+ const reason = safeFailureDetail(body) ?? `http_${response.status}`;
39
+ last = { ok: false, reason, httpStatus: response.status, permanent: response.status >= 400 && response.status < 500 && response.status !== 429 && response.status !== 401 };
40
+ if (last.permanent || response.status === 401)
41
+ return last;
42
+ }
43
+ catch (error) {
44
+ console.error("[image-describe] request failed before a status came back", JSON.stringify({ reason: "transport_error", attempt, max_attempts: MAX_ATTEMPTS, sha256_prefix: request.sha256.slice(0, 16), ...describeError(error) }));
45
+ last = { ok: false, reason: "transport_error", httpStatus: 0, permanent: false };
46
+ }
47
+ if (attempt < MAX_ATTEMPTS)
48
+ await sleep(RETRY_DELAY_MS * attempt);
49
+ }
50
+ return last;
51
+ }
52
+ async function readJson(response) {
53
+ const text = await response.text();
54
+ if (!text)
55
+ return {};
56
+ try {
57
+ return JSON.parse(text);
58
+ }
59
+ catch {
60
+ return {};
61
+ }
62
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * One sync's handling of the images inside transcripts (BLI-16977).
3
+ *
4
+ * `prepareTranscript` takes a transcript's text just before it is masked and staged and does four
5
+ * things, in this order:
6
+ *
7
+ * 1. FIND every embedded image (`transcript-images.ts` in telemetry-core) and group them by sha256.
8
+ * 2. DESCRIBE each distinct image that this machine has not already sent: one POST per image to
9
+ * the describe door, a few at a time, inside a per-sync count and time budget. The state file
10
+ * remembers what was sent, so a screenshot that appears in 40 turns, or in 3 sessions, is sent
11
+ * once. A failure never fails the transcript.
12
+ * 3. REPLACE, only when the one switch (`IMAGE_HANDLING_MODE` in telemetry-core) says
13
+ * `describe_and_drop`: every image whose description is stored becomes its short marker. An
14
+ * image with no stored description is KEPT. In `describe_and_keep` (the default) the text
15
+ * comes back byte for byte as it went in.
16
+ * 4. SLIM, only in `describe_and_drop`: the slim transcript (src/slim/): the duplicate copies and
17
+ * the bookkeeping go, every line stays. In `describe_and_keep` the slim form is not computed
18
+ * and not uploaded.
19
+ *
20
+ * Metadata only in the logs: counts, sizes, hashes, reason labels. Never image bytes, never a
21
+ * description.
22
+ */
23
+ import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
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";
27
+ /** New images sent to the door in one sync (all worktrees together). */
28
+ export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
29
+ /** After this long in one sync, remaining images wait for the next tick. */
30
+ export const IMAGE_DESCRIBE_MAX_RUN_MS = 120_000;
31
+ const CONCURRENCY = 3;
32
+ const TAG = "[image-descriptions]";
33
+ export async function openImageDescriptions(options) {
34
+ const mode = options.mode ?? IMAGE_HANDLING_MODE;
35
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
36
+ const described = new Set(await readDescribedImageKeys(options.stateDir));
37
+ const orderedKeys = [...described];
38
+ const settledThisRun = new Map();
39
+ const now = options.now ?? Date.now;
40
+ const startedAt = now();
41
+ const maxPerRun = options.maxPerRun ?? IMAGE_DESCRIBE_MAX_PER_RUN;
42
+ const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
43
+ let sentThisRun = 0;
44
+ const markDescribed = (sha256) => {
45
+ const key = imageStateKey(sha256);
46
+ if (described.has(key))
47
+ return;
48
+ described.add(key);
49
+ orderedKeys.push(key);
50
+ };
51
+ async function sendOne(image, text, input) {
52
+ sentThisRun += 1;
53
+ const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
54
+ schema_version: "image-describe.v1",
55
+ sha256: image.sha256,
56
+ media_type: image.mediaType,
57
+ byte_size: image.byteSize,
58
+ content_base64: base64Of(text, image),
59
+ source: input.source,
60
+ session_id: input.sessionId.slice(0, 200),
61
+ });
62
+ if (outcome.ok) {
63
+ markDescribed(image.sha256);
64
+ settledThisRun.set(image.sha256, outcome.status);
65
+ return;
66
+ }
67
+ settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
68
+ }
69
+ async function describeAndReplace(input) {
70
+ const images = findEmbeddedImages(input.text);
71
+ if (images.length === 0)
72
+ return { text: input.text, imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0 };
73
+ const unique = new Map();
74
+ for (const image of images)
75
+ if (!unique.has(image.sha256))
76
+ unique.set(image.sha256, image);
77
+ const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0 };
78
+ const failedReasons = {};
79
+ const toSend = [];
80
+ for (const image of unique.values()) {
81
+ if (described.has(imageStateKey(image.sha256))) {
82
+ counts.already += 1;
83
+ continue;
84
+ }
85
+ const earlier = settledThisRun.get(image.sha256);
86
+ if (earlier)
87
+ continue;
88
+ if (image.byteSize > IMAGE_DESCRIBE_MAX_BYTES) {
89
+ settledThisRun.set(image.sha256, "too_large");
90
+ counts.too_large += 1;
91
+ continue;
92
+ }
93
+ toSend.push(image);
94
+ }
95
+ let next = 0;
96
+ const worker = async () => {
97
+ while (next < toSend.length) {
98
+ const image = toSend[next++];
99
+ if (sentThisRun >= maxPerRun || now() - startedAt > maxRunMs) {
100
+ counts.over_budget += 1;
101
+ continue;
102
+ }
103
+ await sendOne(image, input.text, input);
104
+ }
105
+ };
106
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
107
+ for (const image of toSend) {
108
+ const settled = settledThisRun.get(image.sha256);
109
+ if (settled === "described")
110
+ counts.described_now += 1;
111
+ else if (settled === "known")
112
+ counts.known += 1;
113
+ else if (settled?.startsWith("failed:")) {
114
+ counts.failed += 1;
115
+ const reason = settled.slice("failed:".length);
116
+ failedReasons[reason] = (failedReasons[reason] ?? 0) + 1;
117
+ }
118
+ }
119
+ if (counts.described_now + counts.known > 0)
120
+ await writeDescribedImageKeys(options.stateDir, orderedKeys);
121
+ const replaced = mode === "describe_and_drop"
122
+ ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
123
+ : { text: input.text, replacedCount: 0, charsSaved: 0 };
124
+ console.error(TAG + " transcript prepared", JSON.stringify({
125
+ mode,
126
+ source: input.source,
127
+ images: images.length,
128
+ unique: unique.size,
129
+ ...counts,
130
+ failed_reasons: failedReasons,
131
+ replaced: replaced.replacedCount,
132
+ chars_saved: replaced.charsSaved,
133
+ kept_without_description: mode === "describe_and_drop" ? images.length - replaced.replacedCount : 0,
134
+ }));
135
+ return {
136
+ text: replaced.text,
137
+ imageCount: images.length,
138
+ uniqueCount: unique.size,
139
+ replacedCount: replaced.replacedCount,
140
+ charsSaved: replaced.charsSaved,
141
+ };
142
+ }
143
+ return {
144
+ mode,
145
+ async prepareTranscript(input) {
146
+ const afterImages = await describeAndReplace(input);
147
+ if (mode !== "describe_and_drop")
148
+ return { ...afterImages, text: input.text, changed: false, slim: null };
149
+ const slim = slimOrKeep(afterImages.text, input.source, { sessionId: input.sessionId });
150
+ return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
151
+ },
152
+ };
153
+ }
154
+ /**
155
+ * The image step for one build of an envelope. When a sync-wide budget is shared across worktrees
156
+ * the session lives on it (one count budget and one state file for the whole sync); otherwise this
157
+ * build gets its own. A failure to open it is logged by name and the transcripts simply go up as
158
+ * they are.
159
+ */
160
+ export async function openImageStepForSync(options) {
161
+ if (options.budget?.images)
162
+ return options.budget.images;
163
+ try {
164
+ const session = await openImageDescriptions({
165
+ stateDir: options.stateDir,
166
+ dashboardUrl: options.dashboardUrl,
167
+ deviceToken: options.deviceToken,
168
+ fetchImpl: options.fetchImpl,
169
+ });
170
+ if (options.budget)
171
+ options.budget.images = session;
172
+ return session;
173
+ }
174
+ catch (error) {
175
+ console.error(TAG + " could not open, transcripts upload with their images", JSON.stringify({ reason: "image_step_unavailable", error_name: error instanceof Error ? error.name : typeof error }));
176
+ return undefined;
177
+ }
178
+ }