@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.
- package/dist/adapters/raw-evidence-collection-state.js +2 -0
- package/dist/adapters/raw-evidence-prepare-memo.js +212 -0
- package/dist/adapters/raw-evidence-transcript-file.js +122 -9
- package/dist/adapters/raw-evidence.js +20 -0
- package/dist/autostart-darwin-plist.js +21 -5
- package/dist/autostart.js +1 -1
- package/dist/commands/backfill-live-window.js +41 -0
- package/dist/commands/backfill-report.js +11 -1
- package/dist/commands/backfill-scan.js +8 -4
- package/dist/commands/doctor-disk-words.js +1 -1
- package/dist/commands/local-args-tower-manager.js +28 -17
- package/dist/commands/local-args-tower-notes.js +1 -1
- package/dist/commands/local-args-tower-settings.js +24 -3
- package/dist/commands/local-help-commands-tower-ops.js +20 -2
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help-commands.js +1 -1
- package/dist/commands/local-help-project.js +1 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/manager-decisions.js +189 -0
- package/dist/commands/manager-hints.js +2 -0
- package/dist/commands/manager-limits.json +42 -1
- package/dist/commands/manager-meetings.js +184 -0
- package/dist/commands/manager-requests.js +15 -0
- package/dist/commands/manager.js +16 -2
- package/dist/commands/notes-accounts.js +1 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/settings.js +1 -1
- package/dist/evidence-upload-terminal.js +47 -5
- package/dist/image-description-session.js +20 -9
- package/dist/raw-evidence-staging.js +2 -1
- package/dist/upload-evidence-delivery-offer.js +3 -0
- 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 : {};
|
package/dist/commands/manager.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
-
/**
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.154",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.80"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|