@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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The slim transcript (BLI-16977): the transcript the collector uploads when
3
+ * `IMAGE_HANDLING_MODE` in telemetry-core is `describe_and_drop`. Images are already markers by the
4
+ * time this runs; this strips the duplicate copies and the bookkeeping that remain.
5
+ *
6
+ * The contract, in the order Edward and the skeptic pass set it:
7
+ *
8
+ * 1. ONE OUTPUT LINE PER INPUT LINE, same order. Tower counts lines and reads every line's
9
+ * timestamp, so a line is never dropped; its payload is. A blank line, a line that is not JSON,
10
+ * and a JSON value that is not an object are emitted as they came.
11
+ * 2. A LINE NO RULE TOUCHES IS EMITTED AS ITS ORIGINAL TEXT, never re-serialised. Only a modified
12
+ * line is re-written, and it gains a compact top-level `"_slim":["rule", ...]` naming what was
13
+ * stripped, so a reader can tell.
14
+ * 3. PREFIX STABLE. A line's slim form depends only on that line and the lines before it (no
15
+ * look-ahead, no global pass). So the slim form of a growing transcript extends the slim form
16
+ * of the shorter one byte for byte, and the BLI-11088 prefix supersede rule keeps working.
17
+ * The one honest exception is a last line still being written: a half-written JSON line is
18
+ * emitted as it is and becomes a slim line once it is whole.
19
+ * 4. NEVER FAIL A TRANSCRIPT. A line whose rules throw is emitted as it came and counted; any other
20
+ * failure hands back the text it was given (`slimOrKeep`) and says why.
21
+ *
22
+ * What each rule keeps and drops: claude-rules.ts and codex-rules.ts. The table for people:
23
+ * docs/architecture/image-descriptions.md.
24
+ */
25
+ import { newClaudeSlimState, slimClaudeRecord } from "./claude-rules.js";
26
+ import { newCodexSlimState, slimCodexRecord } from "./codex-rules.js";
27
+ import { LineEdits, SeenBlocks, isJsonObject } from "./slim-support.js";
28
+ /** The text an image marker starts with; a line carrying one is a modified line (an image became a marker). */
29
+ const IMAGE_MARKER_START = "[image sha256:";
30
+ export function createSlimmer(kind, rulesForTests) {
31
+ const seen = new SeenBlocks();
32
+ const claude = newClaudeSlimState();
33
+ const codex = newCodexSlimState();
34
+ const stats = {
35
+ lines: 0, linesModified: 0, linesFailed: 0, bytesBefore: 0, bytesAfter: 0, charsByRule: {}, linesByRule: {},
36
+ };
37
+ const applyRules = rulesForTests ?? ((record, edits) => {
38
+ if (kind === "claude_code")
39
+ slimClaudeRecord(record, claude, seen, edits);
40
+ else
41
+ slimCodexRecord(record, codex, seen, edits);
42
+ });
43
+ return {
44
+ slimLine(line) {
45
+ stats.lines += 1;
46
+ const before = Buffer.byteLength(line);
47
+ stats.bytesBefore += before;
48
+ let out = line;
49
+ try {
50
+ out = slimOneLine(line, applyRules, stats);
51
+ }
52
+ catch {
53
+ stats.linesFailed += 1;
54
+ out = line;
55
+ }
56
+ stats.bytesAfter += out === line ? before : Buffer.byteLength(out);
57
+ return out;
58
+ },
59
+ stats: () => stats,
60
+ };
61
+ }
62
+ function slimOneLine(line, applyRules, stats) {
63
+ const body = line.endsWith("\r") ? line.slice(0, -1) : line;
64
+ const first = body.trimStart().charCodeAt(0);
65
+ if (first !== 0x7b /* { */)
66
+ return line;
67
+ let record;
68
+ try {
69
+ record = JSON.parse(body);
70
+ }
71
+ catch {
72
+ return line;
73
+ }
74
+ if (!isJsonObject(record))
75
+ return line;
76
+ const edits = new LineEdits();
77
+ applyRules(record, edits);
78
+ const rules = edits.rules;
79
+ if (line.includes(IMAGE_MARKER_START))
80
+ rules.push("images");
81
+ const already = Array.isArray(record["_slim"]) ? record["_slim"] : [];
82
+ const fresh = rules.filter((rule) => !already.includes(rule));
83
+ if (fresh.length === 0)
84
+ return line;
85
+ record["_slim"] = [...already, ...fresh];
86
+ for (const [rule, chars] of edits.entries) {
87
+ stats.charsByRule[rule] = (stats.charsByRule[rule] ?? 0) + chars;
88
+ stats.linesByRule[rule] = (stats.linesByRule[rule] ?? 0) + 1;
89
+ }
90
+ if (fresh.includes("images"))
91
+ stats.linesByRule["images"] = (stats.linesByRule["images"] ?? 0) + 1;
92
+ stats.linesModified += 1;
93
+ return JSON.stringify(record) + (line.endsWith("\r") ? "\r" : "");
94
+ }
95
+ /** The slim form of a whole transcript's text, line for line. */
96
+ export function slimTranscript(text, kind) {
97
+ const slimmer = createSlimmer(kind);
98
+ const lines = text.split("\n");
99
+ const out = new Array(lines.length);
100
+ for (let index = 0; index < lines.length; index += 1)
101
+ out[index] = slimmer.slimLine(lines[index]);
102
+ const stats = slimmer.stats();
103
+ return { text: stats.linesModified === 0 ? text : out.join("\n"), stats };
104
+ }
105
+ const TAG = "[slim-transcript]";
106
+ /**
107
+ * `slimTranscript` that can never fail a transcript: any exception returns the text it was given and
108
+ * logs the reason. Metadata only: counts, sizes, rule names. Never a line, never a field value.
109
+ */
110
+ export function slimOrKeep(text, kind, label) {
111
+ const startedAt = Date.now();
112
+ try {
113
+ const slim = slimTranscript(text, kind);
114
+ const stats = slim.stats;
115
+ console.error(TAG + (stats.linesModified === 0 ? " nothing to strip" : " transcript slimmed"), JSON.stringify({
116
+ source: kind,
117
+ session_id: label.sessionId.slice(0, 80),
118
+ lines: stats.lines,
119
+ lines_modified: stats.linesModified,
120
+ lines_failed: stats.linesFailed,
121
+ bytes_before: stats.bytesBefore,
122
+ bytes_after: stats.bytesAfter,
123
+ chars_by_rule: stats.charsByRule,
124
+ lines_by_rule: stats.linesByRule,
125
+ elapsed_ms: Date.now() - startedAt,
126
+ }));
127
+ return { text: slim.text, stats };
128
+ }
129
+ catch (error) {
130
+ 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: typeof text === "string" ? text.length : 0 }));
131
+ return { text, stats: null };
132
+ }
133
+ }
@@ -43,6 +43,7 @@ export const STATE_DIR_RULES = [
43
43
  { name: "spool", match: "exact", class: "spool", bound: "upload queue; each entry is deleted once the ingest door accepts it; at most 20 pending uploads and 100 outbox entries (40 events each), oldest dropped first" },
44
44
  { name: "cursors", match: "exact", class: "state", bound: "raw-evidence ledger and reconcile answers capped at 5,000 rows each; backfill cursors one small file each" },
45
45
  { name: "raw-evidence-staging.json", match: "exact", class: "state", bound: "staged-object index and delivery attempts capped at 5,000 rows each" },
46
+ { name: "image-descriptions.json", match: "exact", class: "state", bound: "keys of images already sent to the describe door, capped at 150,000 keys (about 5 MB), oldest dropped first" },
46
47
  { name: "work-contexts", match: "exact", class: "state", bound: "one small file per worktree ever bound with `cockpit start`; overwritten, not pruned (a few KB each)" },
47
48
  { name: "active-work-context.json", match: "exact", class: "state", bound: "one file, overwritten" },
48
49
  { name: "root-work-scan.json", match: "exact", class: "state", bound: "one file, overwritten by each root scan" },
@@ -21,6 +21,7 @@ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorCo
21
21
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
22
22
  import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
23
23
  import { normalizeDashboardUrl } from "./upload-http.js";
24
+ import { openImageStepForSync } from "./image-description-session.js";
24
25
  import { describeError, isMissingFileFailure } from "./health-detail.js";
25
26
  import { makeSourceScanCompletedEvent, sanitizeRiskFlag, sanitizeSessionReference, sanitizeSourceScanResults, selectedTicketBindingCandidate, } from "./upload-envelope-event.js";
26
27
  export class LocalUploadBlockedError extends Error {
@@ -100,6 +101,15 @@ export async function buildLocalAmbientEnvelope(options = {}) {
100
101
  repoLabel,
101
102
  now,
102
103
  });
104
+ // BLI-16977: describe each distinct image in the transcripts once (and, only when the one switch
105
+ // in telemetry-core says drop, send a marker instead of the base64). Never throws.
106
+ const rawEvidenceImages = await openImageStepForSync({
107
+ budget: options.rawEvidenceBudget,
108
+ stateDir: paths.state_dir,
109
+ dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
110
+ deviceToken: sessionFile.device_token,
111
+ fetchImpl: options.fetch,
112
+ });
103
113
  const sourceCollection = await runLocalSourceCollectors({
104
114
  repoRoot,
105
115
  branch: uploadContext.branch,
@@ -129,6 +139,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
129
139
  rawEvidenceByteBudget: options.rawEvidenceByteBudget,
130
140
  rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
131
141
  rawEvidenceBudget: options.rawEvidenceBudget,
142
+ rawEvidenceImages,
132
143
  rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
133
144
  now,
134
145
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.204",
3
+ "version": "0.2.206",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.61",
31
- "@bli-cockpit/mcp": "0.1.116",
32
- "@bli-cockpit/telemetry-core": "0.1.70"
30
+ "@bli-cockpit/memory-mcp": "0.1.63",
31
+ "@bli-cockpit/mcp": "0.1.118",
32
+ "@bli-cockpit/telemetry-core": "0.1.72"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",