@bli-cockpit/cli 0.2.249 → 0.2.251

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.
Files changed (32) hide show
  1. package/dist/adapters/raw-evidence-collection-state.js +2 -0
  2. package/dist/adapters/raw-evidence-prepare-memo.js +212 -0
  3. package/dist/adapters/raw-evidence-transcript-file.js +122 -9
  4. package/dist/adapters/raw-evidence.js +20 -0
  5. package/dist/autostart-darwin-plist.js +21 -5
  6. package/dist/autostart.js +1 -1
  7. package/dist/commands/backfill-live-window.js +41 -0
  8. package/dist/commands/backfill-report.js +11 -1
  9. package/dist/commands/backfill-scan.js +8 -4
  10. package/dist/commands/doctor-disk-words.js +1 -1
  11. package/dist/commands/local-args-tower-manager.js +28 -17
  12. package/dist/commands/local-args-tower-notes.js +1 -1
  13. package/dist/commands/local-args-tower-settings.js +24 -3
  14. package/dist/commands/local-help-commands-tower-ops.js +20 -2
  15. package/dist/commands/local-help-commands-tower.js +2 -2
  16. package/dist/commands/local-help-commands.js +1 -1
  17. package/dist/commands/local-help-project.js +1 -1
  18. package/dist/commands/local-help.js +1 -1
  19. package/dist/commands/manager-decisions.js +189 -0
  20. package/dist/commands/manager-hints.js +2 -0
  21. package/dist/commands/manager-limits.json +42 -1
  22. package/dist/commands/manager-meetings.js +184 -0
  23. package/dist/commands/manager-requests.js +15 -0
  24. package/dist/commands/manager.js +16 -2
  25. package/dist/commands/notes-accounts.js +1 -0
  26. package/dist/commands/public-root.js +1 -1
  27. package/dist/commands/settings.js +1 -1
  28. package/dist/evidence-upload-terminal.js +47 -5
  29. package/dist/image-description-session.js +20 -9
  30. package/dist/raw-evidence-staging.js +2 -1
  31. package/dist/upload-evidence-delivery-offer.js +3 -0
  32. package/package.json +2 -2
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `cockpit manager meetings | tie | decide-tie` (BLI-22192): meetings tied to Tower Manager clients, from the terminal.
3
+ *
4
+ * cockpit manager meetings [--client <client>] [--event <id> | --note <id>] [--page-size <n>] [--cursor <c>]
5
+ * cockpit manager tie <client> (--event <id> [--occurrence <timestamp>] | --note <id>) [--request-id <uuid>]
6
+ * cockpit manager decide-tie <tie-id> accept|dismiss|remove --version <v>
7
+ *
8
+ * A tie made here goes through the `/api/manager` door, which marks it as this person's agent's: it is a SUGGESTION until a
9
+ * person accepts or dismisses it (on the manager page, here, or with the MCP twins). A meeting is a calendar event (one
10
+ * occurrence of a recurring one with `--occurrence`) or a meeting note, by its Tower id (`cockpit cal` and `cockpit notes`
11
+ * print them). The door shows each tied meeting's title and time only when THIS caller can open that event or note; any
12
+ * other reads "a meeting you cannot open". One page at a time; the next page is the printed `--cursor`.
13
+ */
14
+ import { dim } from "./cli-io.js";
15
+ import { optionalNonEmpty } from "./local-arg-values.js";
16
+ import { managerTime } from "./manager-render-home.js";
17
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
19
+ const BASE = "/api/manager";
20
+ const enc = encodeURIComponent;
21
+ /** A copy of the server's `TOWER_MANAGER_MEETING_TIE_LIMITS.pageMax` (held to it by `manager-cli-mcp-rules.test.ts`). */
22
+ export const MEETING_TIE_PAGE_MAX = 200;
23
+ export const TIE_DECISIONS = ["accept", "dismiss", "remove"];
24
+ /** Where a tie stands, in words; held to the server's `TOWER_MANAGER_TIE_STATES` by `manager-enum-words.test.ts`. */
25
+ export const TIE_STATE = {
26
+ suggested: "suggested by an agent, waiting for a person",
27
+ confirmed: "confirmed",
28
+ dismissed: "dismissed (not suggested again)",
29
+ removed: "removed",
30
+ };
31
+ /** Which meeting `--event`/`--note` name: one of them, as an id. */
32
+ function readMeeting(values, verb, required) {
33
+ const event = optionalNonEmpty(values.flags.get("--event"));
34
+ const note = optionalNonEmpty(values.flags.get("--note"));
35
+ if (event && note)
36
+ throw new Error("--event and --note name two meetings. Give one.");
37
+ const raw = event ?? note;
38
+ if (!raw) {
39
+ if (required)
40
+ throw new Error(`\`cockpit manager ${verb}\` needs --event <id> (a calendar event: \`cockpit cal\` prints ids) or --note <id> (a meeting note: \`cockpit notes list\`).`);
41
+ return undefined;
42
+ }
43
+ if (!ID.test(raw))
44
+ throw new Error(`${event ? "--event" : "--note"} is the ${event ? "event's" : "note's"} Tower id, like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10.`);
45
+ return { meetingKind: event ? "event" : "note", meetingId: raw };
46
+ }
47
+ /** `decide-tie`'s tie id: a uuid, refused with where tie ids come from. */
48
+ export function readTieId(value) {
49
+ if (!ID.test(value))
50
+ throw new Error("A tie id looks like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10 (`cockpit manager meetings` prints them).");
51
+ return value;
52
+ }
53
+ /** `meetings`, `tie`, `decide-tie`: the flags and words each takes, into the command. */
54
+ export function readMeetingTieArgs(verb, values, rest, shape) {
55
+ if (verb === "meetings") {
56
+ const client = optionalNonEmpty(values.flags.get("--client"));
57
+ if (client)
58
+ shape.client = client;
59
+ const meeting = readMeeting(values, verb, false);
60
+ if (meeting)
61
+ Object.assign(shape, meeting);
62
+ const size = values.flags.get("--page-size");
63
+ if (size !== undefined) {
64
+ if (!/^\d{1,4}$/.test(size) || Number(size) < 1 || Number(size) > MEETING_TIE_PAGE_MAX)
65
+ throw new Error(`--page-size is a whole number from 1 to ${MEETING_TIE_PAGE_MAX}.`);
66
+ shape.pageSize = Number(size);
67
+ }
68
+ const cursor = optionalNonEmpty(values.flags.get("--cursor"));
69
+ if (cursor) {
70
+ if (!/^[A-Za-z0-9_-]{1,400}$/.test(cursor))
71
+ throw new Error("--cursor is a page's nextCursor exactly as `cockpit manager meetings --json` printed it, with the same --client, --event or --note.");
72
+ shape.cursor = cursor;
73
+ }
74
+ return;
75
+ }
76
+ if (verb === "tie") {
77
+ Object.assign(shape, readMeeting(values, verb, true));
78
+ const occurrence = values.flags.get("--occurrence");
79
+ if (occurrence !== undefined) {
80
+ if (shape.meetingKind !== "event")
81
+ throw new Error("--occurrence is one occurrence of a recurring calendar event: it goes with --event, not --note.");
82
+ if (!TIMESTAMP.test(occurrence) || Number.isNaN(Date.parse(occurrence)))
83
+ throw new Error("--occurrence is that occurrence's start with its offset, like 2026-10-20T09:00:00-07:00.");
84
+ shape.occurrenceStart = occurrence;
85
+ }
86
+ const requestId = values.flags.get("--request-id");
87
+ if (requestId !== undefined) {
88
+ if (!ID.test(requestId))
89
+ throw new Error("--request-id is the uuid an earlier `tie` printed.");
90
+ shape.requestId = requestId;
91
+ }
92
+ return;
93
+ }
94
+ const decision = rest[1];
95
+ if (!TIE_DECISIONS.includes(decision))
96
+ throw new Error("The last word of `cockpit manager decide-tie` is accept, dismiss or remove.");
97
+ shape.tieDecision = decision;
98
+ }
99
+ /** The request each verb sends: the page read, the tie, the decision. */
100
+ export function meetingTiePlan(command, clientId, newRequestId) {
101
+ if (command.action === "meetings") {
102
+ const query = new URLSearchParams();
103
+ // Only a client the person named (`--client`): with none, the read is every client's.
104
+ if (command.client && clientId)
105
+ query.set("clientId", clientId);
106
+ if (command.meetingKind && command.meetingId) {
107
+ query.set("meetingKind", command.meetingKind);
108
+ query.set("meetingId", command.meetingId);
109
+ }
110
+ if (command.cursor)
111
+ query.set("cursor", command.cursor);
112
+ if (command.pageSize !== undefined)
113
+ query.set("limit", String(command.pageSize));
114
+ const text = query.toString();
115
+ return { method: "GET", path: `${BASE}/meeting-ties${text ? `?${text}` : ""}`, logFields: pageLog };
116
+ }
117
+ if (command.action === "tie") {
118
+ const requestId = command.requestId ?? newRequestId();
119
+ const body = { meetingKind: command.meetingKind, meetingId: command.meetingId, requestId };
120
+ if (command.occurrenceStart)
121
+ body["occurrenceStart"] = command.occurrenceStart;
122
+ return { method: "POST", path: `${BASE}/clients/${enc(clientId ?? "")}/meeting-ties`, body, requestId, logFields: tieLog };
123
+ }
124
+ return {
125
+ method: "POST",
126
+ path: `${BASE}/meeting-ties/${enc(command.id ?? "")}/decision`,
127
+ body: { decision: command.tieDecision, expectedUpdatedAt: command.version },
128
+ logFields: tieLog,
129
+ };
130
+ }
131
+ function pageLog(answer) {
132
+ const page = answer && typeof answer === "object" ? answer : {};
133
+ return { returned: Array.isArray(page.ties) ? page.ties.length : null, total: typeof page.total === "number" ? page.total : null, more: typeof page.nextCursor === "string" };
134
+ }
135
+ function tieLog(answer) {
136
+ const tie = answer && typeof answer === "object" ? answer : {};
137
+ return { tie_id: tie.id ?? null, state: tie.state ?? null, ...(typeof tie.created === "boolean" ? { created: tie.created } : {}), ...(tie.unchanged === true ? { unchanged: true } : {}) };
138
+ }
139
+ /** "Weekly sync, Oct 20, 9:00 AM PDT" or, for a meeting this caller cannot open, never its title. */
140
+ function meetingWords(tie) {
141
+ const what = tie.meetingKind === "event" ? "calendar event" : "meeting note";
142
+ if (tie.meeting?.state !== "open")
143
+ return `a ${what} you cannot open`;
144
+ const when = tie.occurrenceStart ?? tie.meeting.start;
145
+ return `${tie.meeting.title}${when ? `, ${managerTime(when)}` : ""} (${what})`;
146
+ }
147
+ /** One tie on two lines: the meeting and its client, then where it stands, who tied it, and the ids a decision needs. */
148
+ export function tieLines(tie, color) {
149
+ const state = TIE_STATE[tie.state] ?? tie.state;
150
+ const who = tie.addedBy === "agent" ? `${tie.createdByName ?? "a teammate"}'s agent` : tie.createdByName ?? "a teammate";
151
+ return [
152
+ `${meetingWords(tie)} → ${tie.clientName}`,
153
+ ` ${state}; tied by ${who} ${managerTime(tie.createdAt)}${dim(` tie ${tie.id} version ${tie.updatedAt}${tie.meetingId ? ` ${tie.meetingKind} ${tie.meetingId}` : ""}${tie.occurrenceStart ? ` occurrence ${tie.occurrenceStart}` : ""}`, color)}`,
154
+ ];
155
+ }
156
+ export function meetingTiePageLines(page, command, color) {
157
+ const scope = command.client ? `tied to ${command.client}` : command.meetingId ? "of this meeting" : "across every client";
158
+ const head = `Meetings ${scope}: ${page.total}${page.ties.length < page.total ? ` (showing ${page.ties.length})` : ""}`;
159
+ if (page.ties.length === 0)
160
+ return [head, " No meetings are tied here yet. `cockpit manager tie <client> --event <id>` ties one."];
161
+ const lines = [head, ...page.ties.flatMap((tie) => tieLines(tie, color).map((line) => ` ${line}`))];
162
+ if (page.nextCursor) {
163
+ const again = [command.client ? `--client ${/\s/.test(command.client) ? `"${command.client}"` : command.client}` : "", command.meetingId ? `--${command.meetingKind} ${command.meetingId}` : ""].filter(Boolean).join(" ");
164
+ lines.push(`More: \`cockpit manager meetings ${again ? `${again} ` : ""}--cursor ${page.nextCursor}\` for the next page.`);
165
+ }
166
+ return lines;
167
+ }
168
+ /** What a tie or a decision did, in one sentence, then the tie. */
169
+ export function tieWrittenLines(command, tie, color) {
170
+ const sentence = command.action === "tie"
171
+ ? tie.created === false
172
+ ? tie.state === "confirmed" && tie.addedBy === "agent"
173
+ ? "Already tied (an agent's suggestion a person has confirmed); nothing new was written."
174
+ : "Already tied; nothing new was written."
175
+ : "Tied as a suggestion: a person accepts or dismisses it on the manager page, or with `cockpit manager decide-tie`."
176
+ : tie.unchanged === true
177
+ ? "No change; it already stood that way."
178
+ : command.tieDecision === "accept"
179
+ ? "Accepted: the tie is confirmed."
180
+ : command.tieDecision === "dismiss"
181
+ ? "Dismissed: it will not be suggested again."
182
+ : "Removed: the meeting is no longer tied to this client.";
183
+ return [sentence, ...tieLines(tie, color)];
184
+ }
@@ -1,3 +1,5 @@
1
+ import { meetingTiePlan } from "./manager-meetings.js";
2
+ import { decisionRequest } from "./manager-decisions.js";
1
3
  const BASE = "/api/manager";
2
4
  const enc = encodeURIComponent;
3
5
  export function planManagerRequest(command, clientId, newRequestId) {
@@ -77,6 +79,14 @@ export function planManagerRequest(command, clientId, newRequestId) {
77
79
  case "proposal": return write("POST", `${item}/proposal`, { ...seen, decision: command.decision, proposalId: command.proposalId });
78
80
  case "archive": return write("POST", `${item}/archive`, seen);
79
81
  case "unarchive": return write("POST", `${item}/unarchive`, seen);
82
+ // BLI-22192: meetings tied to clients (`manager-meetings.ts`).
83
+ case "meetings":
84
+ case "tie":
85
+ case "decide-tie": return meetingTiePlan(command, clientId, newRequestId);
86
+ // BLI-22187: decisions, their own kind (`manager-decisions.ts`).
87
+ case "decisions": return { ...decisionRequest(command, clientId, newRequestId), logFields: (a) => ({ returned: arrayLength(a, "items"), total: numberField(a, "total") }) };
88
+ case "add-decision":
89
+ case "decide": return { ...decisionRequest(command, clientId, newRequestId), logFields: decisionLog };
80
90
  }
81
91
  }
82
92
  function listQuery(command) {
@@ -110,6 +120,11 @@ function itemLog(table) {
110
120
  return { table, item_id: item?.id ?? null, review_status: item?.reviewStatus ?? null, ...(typeof body?.created === "boolean" ? { created: body.created } : {}) };
111
121
  };
112
122
  }
123
+ /** A decision write's log line: its id, status and whether it wrote, never its words. */
124
+ function decisionLog(answer) {
125
+ const body = answer && typeof answer === "object" ? answer : undefined;
126
+ return { decision_id: body?.item?.id ?? null, status: body?.item?.status ?? null, ...(typeof body?.created === "boolean" ? { created: body.created } : {}), ...(body?.unchanged === true ? { unchanged: true } : {}) };
127
+ }
113
128
  /** Home's log line: which parts it holds and their sizes, never their words. */
114
129
  function homeLog(answer) {
115
130
  const home = answer && typeof answer === "object" ? answer : {};
@@ -5,7 +5,7 @@
5
5
  * them, adds and edits them, ticks them off, links an action to the meeting it came from, and
6
6
  * decides the AI's suggestions, the same things the manager page does.
7
7
  *
8
- * Nineteen verbs (`home` and `summary`, BLI-5698: a client's Home and its "Where we are" card), each one `/api/manager` door on this machine's device token, the same door the MCP
8
+ * Twenty-five verbs (`meetings`, `tie` and `decide-tie`, BLI-22192: meetings tied to clients, `manager-meetings.ts`; `decisions`, `add-decision` and `decide`, BLI-22187: a client's decisions, `manager-decisions.ts`; `home` and `summary`, BLI-5698: a client's Home and its "Where we are" card), each one `/api/manager` door on this machine's device token, the same door the MCP
9
9
  * twins (`manager_*`) use; the door passes Railway's answer through unchanged. Every write on an
10
10
  * existing item carries `--version`, the item's `updatedAt` as `list`/`show` print it: a stale one is
11
11
  * refused with the door's own sentence and nothing is written. `approve`, `dismiss` and `proposal`
@@ -20,8 +20,10 @@ import { savedTextLines, clientDetailLines, clientLines, itemDetailLines, itemRo
20
20
  import { homeLines, summaryWrittenLines } from "./manager-render-home.js";
21
21
  import { parseManagerArgs } from "./local-args-tower-manager.js";
22
22
  import { READ_ONLY_HINTS, RESEEN_REFUSALS, itemHints, summaryHints } from "./manager-hints.js";
23
+ import { decisionListLines, decisionNextStep, decisionWriteLines } from "./manager-decisions.js";
23
24
  import { readFieldsText } from "./manager-fields-file.js";
24
25
  import { planManagerRequest } from "./manager-requests.js";
26
+ import { meetingTiePageLines, tieWrittenLines } from "./manager-meetings.js";
25
27
  const TAG = "[manager cli]";
26
28
  /**
27
29
  * Longer than the door waits for the manager server (50 s, `MANAGER_DOOR_TIMEOUT_MS`) and than the door's own
@@ -30,7 +32,7 @@ const TAG = "[manager cli]";
30
32
  */
31
33
  export const MANAGER_DEADLINE_MS = 65_000;
32
34
  /** The verbs that change an existing item. */
33
- const WRITES = new Set(["summary", "edit", "done", "drop", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
35
+ const WRITES = new Set(["summary", "edit", "done", "drop", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive", "decide-tie", "decide"]);
34
36
  /**
35
37
  * `--fields-file`: the parser left the fields unread. Read the file (or stdin), and parse the same args again with its text,
36
38
  * so the file's values get exactly the flags' checks. Nothing has touched the network yet, so a bad file costs nothing.
@@ -138,7 +140,13 @@ function renderAnswer(command, body, color) {
138
140
  case "run": return runLines(body);
139
141
  case "list": return listLines(body, timeline, color, new Date(), command.archived !== true && command.review !== "rejected", command.ownerFilter !== undefined || command.noOwner === true);
140
142
  case "queue": return queueLines(body, color);
143
+ case "meetings": return meetingTiePageLines(body, command, color);
144
+ case "tie":
145
+ case "decide-tie": return tieWrittenLines(command, body, color);
141
146
  case "show": return itemDetailLines(body, timeline, color);
147
+ case "decisions": return decisionListLines(body, color);
148
+ case "add-decision":
149
+ case "decide": return decisionWriteLines(command, body, color);
142
150
  default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color), ...(command.action === "add" ? savedTextLines(body) : [])];
143
151
  }
144
152
  }
@@ -242,6 +250,12 @@ function refuse(door, answer, command, requestId) {
242
250
  ? `This client already has a card: pass its version (\`cockpit manager home ${clientWord(command)}\` shows it).`
243
251
  : text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
244
252
  const reseen = answer.reason === "invalid_state" && (refusal === undefined || RESEEN_REFUSALS.has(refusal));
253
+ // BLI-22187: a decision's next steps name `decisions <client>`, never `show` (decisions have no show); an add keeps the generic retry.
254
+ const decisionStep = command ? decisionNextStep(command, answer.reason, refusal, requestId === undefined && (answer.httpStatus === undefined || answer.httpStatus >= 500)) : undefined;
255
+ if (decisionStep !== undefined) {
256
+ const end = /[.!?]$/.test(sentence) ? "" : ".";
257
+ return failAgentDoor(door, TAG, answer.reason, `${sentence}${end}${decisionStep}`, { ...(refusal ? { refusal } : {}) });
258
+ }
245
259
  const hint = cardExists || noDrop ? "" : reseen && command?.id
246
260
  ? ` ${itemHints(command.id, command.timeline ? " --timeline" : "").item_changed}`
247
261
  : nextStep(answer.reason, refusal, command);
@@ -84,6 +84,7 @@ function writeHowToConnect(door) {
84
84
  writeLine(door.io.stdout, "");
85
85
  writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
86
86
  writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
87
+ writeLine(door.io.stdout, ' or: printf "%s" "<your fireflies api key>" | cockpit notes connect --provider fireflies --key-stdin');
87
88
  writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
88
89
  }
89
90
  function writeAccountRows(door, accounts) {
@@ -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.249");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.251");
25
25
  return 0;
26
26
  }
27
27
 
@@ -123,7 +123,7 @@ async function setSwitch(command, tower, io) {
123
123
  writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
124
124
  return 0;
125
125
  }
126
- writeLine(io.stdout, `Switch ${String(body.key)} is now ${String(body.value)}.`);
126
+ writeLine(io.stdout, `Switch ${String(body.key)} is now ${String(body.value) === "" ? "empty" : String(body.value)}.`);
127
127
  return 0;
128
128
  }
129
129
  // ── the fleet CLI floor ───────────────────────────────────────────────
@@ -1,4 +1,5 @@
1
- import { RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
1
+ import { RAW_EVIDENCE_UPLOAD_MAX_CHUNK_BYTES, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
2
+ import { isLocalDashboardUrl } from "./dev-build.js";
2
3
  import { readBeginDispositions } from "./evidence-upload-plan.js";
3
4
  import { failedOutcome } from "./evidence-upload-object.js";
4
5
  import { rekeyedEvidencePointer } from "./evidence-upload-rekey.js";
@@ -184,17 +185,49 @@ function conformReasonLabel(reason) {
184
185
  const conformed = reason.replace(/[^a-z0-9_:.-]/gi, "_").slice(0, 120);
185
186
  return conformed.length > 0 ? conformed : "upload_failed_unlabelled";
186
187
  }
188
+ /**
189
+ * The most raw bytes the legacy single-shot route is offered on a hosted dashboard (BLI-3936).
190
+ *
191
+ * The route carries the file as base64 inside one JSON body, and Vercel refuses a body over about
192
+ * 4.5 MB before our code runs (a 93-byte `text/plain` 413). 3 MiB of raw bytes is 4 MiB of base64
193
+ * plus a few KB of pointer: the exact size the chunk route already sends in every chunk body, so
194
+ * it is proven to pass. Above it the request cannot succeed, only cost a full upload of the object
195
+ * to be told so (14 MB, every backoff window, on the reference Mac).
196
+ */
197
+ export const LEGACY_SINGLE_SHOT_MAX_BYTES = RAW_EVIDENCE_UPLOAD_MAX_CHUNK_BYTES;
198
+ /** A 413 from the legacy route: the host's body limit refused bytes we expected to fit. Terminal for those bytes. */
199
+ export const EVIDENCE_TOO_LARGE_HTTP_413_REASON = "evidence_too_large_http_413";
200
+ /** The reason an object too big for the legacy route carries while the chunk route is down. Retryable: `begin` recovering delivers it. */
201
+ export const LEGACY_SINGLE_SHOT_TOO_LARGE_REASON = "legacy_single_shot_too_large_chunk_route_unavailable";
187
202
  /**
188
203
  * Older dashboards predate the chunk endpoints. Fall back to one legacy
189
204
  * request per file so an oversized or already-existing object fails (or
190
- * reuses) individually instead of dragging the whole batch down. Large files
191
- * are attempted rather than pre-failed: a non-serverless dashboard (the
192
- * default localhost deployment) accepts them like the old CLI could, and a
193
- * platform body-limit rejection comes back as a labeled per-file failure.
205
+ * reuses) individually instead of dragging the whole batch down. On a hosted
206
+ * dashboard an object over `LEGACY_SINGLE_SHOT_MAX_BYTES` is not sent at all
207
+ * (BLI-3936): the host's body limit refuses it for certain. A non-serverless
208
+ * dashboard (the default localhost deployment) is still attempted at any size,
209
+ * like the old CLI, and a platform refusal comes back as a labeled per-file
210
+ * failure; a 413 there is `evidence_too_large_http_413`, terminal (`terminalDeliveryReason`).
194
211
  */
195
212
  export async function uploadWithLegacyFallback(options, loaded, outcomes) {
196
213
  for (const entry of loaded) {
197
214
  let outcome;
215
+ if (legacyRouteCannotCarry(options, entry.bytes.byteLength)) {
216
+ // Said out loud, with the numbers: this object was NOT sent, because the route would refuse it.
217
+ console.error("[evidence-upload] legacy single-shot upload not attempted; the object is too large for it and the chunk route is unavailable", JSON.stringify({
218
+ reason: LEGACY_SINGLE_SHOT_TOO_LARGE_REASON,
219
+ kind: entry.file.kind ?? "unknown",
220
+ byte_size: entry.bytes.byteLength,
221
+ limit_bytes: LEGACY_SINGLE_SHOT_MAX_BYTES,
222
+ content_hash_prefix: (entry.file.pointer.content_hash_sha256 ?? "none").slice(0, 12),
223
+ next: "retried through the chunk route once `begin` answers again",
224
+ }));
225
+ outcome = failedOutcome(entry.file, LEGACY_SINGLE_SHOT_TOO_LARGE_REASON);
226
+ outcomes.push(outcome);
227
+ for (const duplicate of entry.duplicates)
228
+ outcomes.push(duplicateOutcome(outcome, duplicate));
229
+ continue;
230
+ }
198
231
  const response = await requestJson(options, "/api/ambient/evidence/upload", {
199
232
  schema_version: "ambient-raw-evidence-upload.v1",
200
233
  generated_at: options.generatedAt,
@@ -214,6 +247,10 @@ export async function uploadWithLegacyFallback(options, loaded, outcomes) {
214
247
  }
215
248
  return summarizeOutcomes(outcomes, true);
216
249
  }
250
+ /** True when sending this many raw bytes to the legacy route is certain to be refused by the host's body limit. */
251
+ function legacyRouteCannotCarry(options, byteSize) {
252
+ return byteSize > LEGACY_SINGLE_SHOT_MAX_BYTES && !isLocalDashboardUrl(options.dashboardUrl);
253
+ }
217
254
  function legacyUploadOutcome(file, response) {
218
255
  let outcome;
219
256
  if (response.ok) {
@@ -250,6 +287,11 @@ function legacyUploadOutcome(file, response) {
250
287
  // unverified pointer.
251
288
  outcome = failedOutcome(file, "legacy_upload_conflict_http_409");
252
289
  }
290
+ else if (response.status === 413) {
291
+ // BLI-3936: the host refused this body for its size. The same bytes meet the same limit on
292
+ // every retry, so this is terminal (`terminalDeliveryReason`) and says what it is.
293
+ outcome = failedOutcome(file, EVIDENCE_TOO_LARGE_HTTP_413_REASON);
294
+ }
253
295
  else {
254
296
  outcome = failedOutcome(file, `legacy_upload_failed_http_${response.status}`);
255
297
  }
@@ -10,9 +10,13 @@ import { postImageToDescribeDoor } from "./image-describe-client.js";
10
10
  import { resolveImageHandlingMode } from "./image-mode-state.js";
11
11
  import { slimOrKeep, slimTranscript } from "./slim/slim-transcript.js";
12
12
  import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
13
- /** New images sent to the door in one sync (all worktrees together). */
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;
15
- /** After this long in one sync, remaining images wait for the next tick. */
15
+ /**
16
+ * After this long spent DESCRIBING (the clock starts at the first image sent, not when the session
17
+ * opens: collection can reach image-bearing transcripts minutes into a tick), remaining images
18
+ * wait for the next tick.
19
+ */
16
20
  export const IMAGE_DESCRIBE_MAX_RUN_MS = 120_000;
17
21
  const CONCURRENCY = 3;
18
22
  const TAG = "[image-descriptions]";
@@ -25,11 +29,10 @@ export async function openImageDescriptions(options) {
25
29
  const stateDirty = false;
26
30
  const settledThisRun = new Map();
27
31
  const now = options.now ?? Date.now;
28
- const startedAt = now();
29
32
  const maxPerRun = options.maxPerRun ?? IMAGE_DESCRIBE_MAX_PER_RUN;
30
33
  const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
31
34
  const sentThisRun = 0;
32
- const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, startedAt, maxPerRun, maxRunMs, sentThisRun };
35
+ const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, firstSendAt: null, maxPerRun, maxRunMs, sentThisRun };
33
36
  return {
34
37
  mode,
35
38
  async prepareTranscript(input) {
@@ -52,6 +55,7 @@ function markDescribed(run, sha256) {
52
55
  async function sendOne(run, image, text, input) {
53
56
  const { options, fetchImpl, settledThisRun, failedImages } = run;
54
57
  run.sentThisRun += 1;
58
+ run.firstSendAt ??= run.now();
55
59
  const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
56
60
  schema_version: "image-describe.v1",
57
61
  sha256: image.sha256,
@@ -92,12 +96,12 @@ async function describeAndReplace(run, input) {
92
96
  const { mode, described, options, orderedKeys, failedImages } = run;
93
97
  const images = findEmbeddedImages(input.text);
94
98
  if (images.length === 0)
95
- return { text: input.text, imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0 };
99
+ return { text: input.text, imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
96
100
  const unique = new Map();
97
101
  for (const image of images)
98
102
  if (!unique.has(image.sha256))
99
103
  unique.set(image.sha256, image);
100
- const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
104
+ const counts = { settled_earlier: 0, already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
101
105
  const toSend = selectImagesToDescribe(run, unique, counts);
102
106
  await sendPendingImages(run, toSend, counts, input);
103
107
  const failedReasons = countImageOutcomes(run, toSend, counts);
@@ -105,6 +109,8 @@ async function describeAndReplace(run, input) {
105
109
  await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
106
110
  run.stateDirty = false;
107
111
  }
112
+ const deferredOnly = counts.over_budget + counts.backing_off > 0
113
+ && counts.described_now + counts.known + counts.failed + counts.too_large + counts.settled_earlier === 0;
108
114
  const replaced = mode === "describe_and_drop"
109
115
  ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
110
116
  : { text: input.text, replacedCount: 0, charsSaved: 0 };
@@ -114,6 +120,7 @@ async function describeAndReplace(run, input) {
114
120
  images: images.length,
115
121
  unique: unique.size,
116
122
  ...counts,
123
+ deferred_only: deferredOnly,
117
124
  failed_reasons: failedReasons,
118
125
  replaced: replaced.replacedCount,
119
126
  chars_saved: replaced.charsSaved,
@@ -125,6 +132,7 @@ async function describeAndReplace(run, input) {
125
132
  uniqueCount: unique.size,
126
133
  replacedCount: replaced.replacedCount,
127
134
  charsSaved: replaced.charsSaved,
135
+ deferredOnly,
128
136
  };
129
137
  }
130
138
  function selectImagesToDescribe(run, unique, counts) {
@@ -136,8 +144,10 @@ function selectImagesToDescribe(run, unique, counts) {
136
144
  continue;
137
145
  }
138
146
  const earlier = settledThisRun.get(image.sha256);
139
- if (earlier)
147
+ if (earlier) {
148
+ counts.settled_earlier += 1;
140
149
  continue;
150
+ }
141
151
  if (image.byteSize > IMAGE_DESCRIBE_MAX_BYTES) {
142
152
  settledThisRun.set(image.sha256, "too_large");
143
153
  counts.too_large += 1;
@@ -153,12 +163,13 @@ function selectImagesToDescribe(run, unique, counts) {
153
163
  return toSend;
154
164
  }
155
165
  async function sendPendingImages(run, toSend, counts, input) {
156
- const { now, maxPerRun, startedAt, maxRunMs } = run;
166
+ const { now, maxPerRun, maxRunMs } = run;
157
167
  let next = 0;
158
168
  const worker = async () => {
159
169
  while (next < toSend.length) {
160
170
  const image = toSend[next++];
161
- if (run.sentThisRun >= maxPerRun || now() - startedAt > maxRunMs) {
171
+ const timeUp = run.firstSendAt !== null && now() - run.firstSendAt > maxRunMs;
172
+ if (run.sentThisRun >= maxPerRun || timeUp) {
162
173
  counts.over_budget += 1;
163
174
  continue;
164
175
  }
@@ -184,7 +184,8 @@ export function clearDeliveryAttempt(state, contentHash) {
184
184
  export function terminalDeliveryReason(reason) {
185
185
  return Boolean(reason && (reason.startsWith("staging_restage_failed:") ||
186
186
  ["staging_missing_after_restage", "secret_guard_rejected", "storage_rejected_object_too_large", "storage_rejected_media_type"].includes(reason) ||
187
- /^(?:begin|commit|chunk_\d+)_failed_http_413(?:_|$)/u.test(reason)));
187
+ /^(?:begin|commit|chunk_\d+)_failed_http_413(?:_|$)/u.test(reason) ||
188
+ reason === "evidence_too_large_http_413"));
188
189
  }
189
190
  export function deliveryHold(state, contentHash, now) {
190
191
  if (!contentHash)
@@ -120,6 +120,9 @@ export async function persistDeliveryAttempts(stateDir, staging, outcomes, attem
120
120
  first_failed_at: entry.first_failed_at,
121
121
  next_attempt_at: entry.next_attempt_at,
122
122
  byte_size: entry.byte_size,
123
+ // BLI-3936: a refusal that cannot succeed on retry (a 413) says so here, with the object's id.
124
+ terminal: entry.terminal_reason !== undefined,
125
+ content_hash_prefix: contentHash.slice(0, 12),
123
126
  }));
124
127
  continue;
125
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.249",
3
+ "version": "0.2.251",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.73",
31
- "@bli-cockpit/mcp": "0.1.153",
31
+ "@bli-cockpit/mcp": "0.1.154",
32
32
  "@bli-cockpit/telemetry-core": "0.1.80"
33
33
  },
34
34
  "repository": {