@bli-cockpit/cli 0.2.288 → 0.2.290

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.
@@ -52,8 +52,10 @@ export async function exportBook(door, book, dir) {
52
52
  if (!(await isEmptyOrMissing(dir)))
53
53
  return failAgentDoor(door, TAG, "export_dir_not_empty", `${dir} is not empty. Export into a new or empty folder so no old record is mixed in.`);
54
54
  const counts = {};
55
+ const pending = [];
55
56
  for (const collection of BOOK_COLLECTIONS) {
56
57
  counts[collection] = 0;
58
+ const cursors = new Set();
57
59
  let after = null;
58
60
  do {
59
61
  const query = new URLSearchParams({ collection, limit: "500" });
@@ -61,17 +63,31 @@ export async function exportBook(door, book, dir) {
61
63
  query.set("after", after);
62
64
  const answer = await booksRequest(door, "GET", `/api/books/${encodeURIComponent(book)}/records?${query}`, "export");
63
65
  if (!answer.ok)
64
- return failAgentDoor(door, TAG, answer.reason, answer.detail, { collection, written: counts });
66
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, { collection, read: counts });
65
67
  const page = answer.body;
66
- if (page.records.length > 0)
67
- await mkdir(join(dir, collection), { recursive: true });
68
68
  for (const record of page.records) {
69
- await writeFile(join(dir, collection, `${fileNameFor(record.doc_id)}.json`), `${JSON.stringify(sorted(record.data), null, 2)}\n`, "utf8");
69
+ pending.push({ collection, ...record });
70
70
  counts[collection] += 1;
71
71
  }
72
72
  after = page.next_cursor;
73
+ if (after !== null && cursors.has(after))
74
+ return failAgentDoor(door, TAG, "invalid_cursor", "The export door repeated a cursor. No files were written.");
75
+ if (after !== null)
76
+ cursors.add(after);
73
77
  } while (after !== null);
74
78
  }
79
+ // Validate the entire manifest before creating a single file on a case-insensitive filesystem.
80
+ const names = new Set();
81
+ for (const record of pending) {
82
+ const name = `${record.collection}/${fileNameFor(record.doc_id)}`.toLowerCase();
83
+ if (names.has(name))
84
+ return failAgentDoor(door, TAG, "export_id_collision", "Duplicate or case-only record ids cannot be exported safely.");
85
+ names.add(name);
86
+ }
87
+ for (const record of pending) {
88
+ await mkdir(join(dir, record.collection), { recursive: true });
89
+ await writeFile(join(dir, record.collection, `${fileNameFor(record.doc_id)}.json`), `${JSON.stringify(sorted(record.data), null, 2)}\n`, "utf8");
90
+ }
75
91
  const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
76
92
  console.error(`${TAG} exported ${JSON.stringify({ book, total })}`);
77
93
  if (door.json)
@@ -84,15 +100,14 @@ export async function exportBook(door, book, dir) {
84
100
  async function readLayout(dir) {
85
101
  const writes = [];
86
102
  const skipped = [];
103
+ const ids = new Set();
87
104
  for (const entry of await readdir(dir, { withFileTypes: true })) {
88
105
  if (!entry.isDirectory() || !BOOK_COLLECTIONS.includes(entry.name)) {
89
- skipped.push(entry.name);
90
- continue;
106
+ throw new Error(`Unknown folder or skipped file: ${entry.name}. Nothing was imported.`);
91
107
  }
92
108
  for (const file of (await readdir(join(dir, entry.name))).sort()) {
93
109
  if (!file.endsWith(".json")) {
94
- skipped.push(`${entry.name}/${file}`);
95
- continue;
110
+ throw new Error(`Skipped file: ${entry.name}/${file}. Nothing was imported.`);
96
111
  }
97
112
  const text = (await readFile(join(dir, entry.name, file), "utf8")).replace(/^/, "");
98
113
  let data;
@@ -102,7 +117,18 @@ async function readLayout(dir) {
102
117
  catch {
103
118
  throw new Error(`${entry.name}/${file} is not valid JSON.`);
104
119
  }
105
- writes.push({ op: "put", collection: entry.name, doc_id: docIdFor(file.slice(0, -".json".length)), data, merge: false });
120
+ const docId = docIdFor(file.slice(0, -".json".length));
121
+ if (!docId || docId.length > 200 || /[/\u0000-\u001f\u007f-\u009f]/.test(docId))
122
+ throw new Error(`${entry.name}/${file} has an invalid record id.`);
123
+ const key = `${entry.name}/${docId}`.toLowerCase();
124
+ if (ids.has(key))
125
+ throw new Error(`Duplicate or case-only id collision: ${entry.name}/${docId}. Nothing was imported.`);
126
+ ids.add(key);
127
+ if (!data || typeof data !== "object" || Array.isArray(data))
128
+ throw new Error(`${entry.name}/${file} must contain a JSON object.`);
129
+ if (Buffer.byteLength(JSON.stringify(data), "utf8") > 262_144)
130
+ throw new Error(`${entry.name}/${file} exceeds the 256 KiB record limit.`);
131
+ writes.push({ op: "put", collection: entry.name, doc_id: docId, data, merge: false, expected_version: 0 });
106
132
  }
107
133
  }
108
134
  return { writes, skipped };
@@ -135,6 +161,15 @@ export async function importBook(door, book, dir) {
135
161
  return failAgentDoor(door, TAG, "import_read_failed", error.message);
136
162
  }
137
163
  const counts = Object.fromEntries(BOOK_COLLECTIONS.map((c) => [c, 0]));
164
+ // Include tombstones: an import must never revive or overwrite any existing record.
165
+ for (const collection of BOOK_COLLECTIONS) {
166
+ const query = new URLSearchParams({ collection, limit: "1", include_deleted: "1" });
167
+ const answer = await booksRequest(door, "GET", `/api/books/${encodeURIComponent(book)}/records?${query}`, "import check");
168
+ if (!answer.ok)
169
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
170
+ if (answer.body.records.length)
171
+ return failAgentDoor(door, TAG, "import_book_not_empty", "This book already has records. Import requires an empty book; use versioned puts to recover a partial import.");
172
+ }
138
173
  const batches = batchesOf(layout.writes);
139
174
  for (const [index, batch] of batches.entries()) {
140
175
  const answer = await booksRequest(door, "POST", `/api/books/${encodeURIComponent(book)}/batch`, "import", { writes: batch });
@@ -9,6 +9,7 @@ export const BOOKS_USAGE = [
9
9
  " cockpit books delete <book> <collection> <doc_id> [--version <n>] [--json] (soft: the record is kept)",
10
10
  " cockpit books batch <book> [--json] ({\"writes\": [...]} on stdin, 1 to 50 writes, all or nothing)",
11
11
  " cockpit books export <book> <dir> [--json] (<dir>/<collection>/<doc_id>.json)",
12
- " cockpit books import <book> <dir> [--json] (the export layout, through the batch door)",
12
+ " cockpit books import <book> <dir> [--json] (empty book only; validated export layout, through the batch door)",
13
+ "Import refuses existing records (including deleted), unknown folders, skipped files and duplicate or case-only ids. Partial batches require versioned puts for recovery.",
13
14
  "Collections: meta, timeline, actions, documents, meetings, conversations, workstreams, sources, syncs.",
14
15
  ].join("\n");
@@ -111,6 +111,7 @@ const NOTES_ALLOWED_FLAGS = [
111
111
  // transcript was stored cut short. A FLAG, not a verb: it is the same sync
112
112
  // door with a different request.
113
113
  "--repair-truncated",
114
+ "--repair-empty", // BLI-23914: re-read Wispr notes filed with no transcript (meeting still running).
114
115
  // BLI-22350. `review <note-id> --submit` reads `{ raw_hash, answers }` from stdin; `--accept-all` sends the defaults.
115
116
  "--submit",
116
117
  "--accept-all",
@@ -153,7 +154,7 @@ export function parseNotesArgs(args) {
153
154
  if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
154
155
  throw new Error("notes rename needs --name <name>.");
155
156
  const { to, clearShelf } = readNotesShelfTarget(action, values);
156
- const repairTruncated = readRepairTruncated(action, values);
157
+ const { repairTruncated, repairEmpty } = readRepairFlags(action, values);
157
158
  const review = readReviewRequest(action, values, rest);
158
159
  const { limit, month, offset } = readNotesWindow(action, values);
159
160
  return {
@@ -189,6 +190,7 @@ export function parseNotesArgs(args) {
189
190
  ...(month ? { month } : {}),
190
191
  ...(offset === undefined ? {} : { offset }),
191
192
  ...(repairTruncated ? { repairTruncated: true } : {}),
193
+ ...(repairEmpty ? { repairEmpty: true } : {}),
192
194
  ...review,
193
195
  yes,
194
196
  json,
@@ -243,12 +245,16 @@ function readReviewRequest(action, values, rest) {
243
245
  throw new Error(`notes review ${flag} needs a note id: cockpit notes review <note-id> ${flag}.`);
244
246
  return { ...(noteId ? { noteId } : {}), ...(flag === "--submit" ? { submit: true } : {}), ...(flag === "--accept-all" ? { acceptAll: true } : {}) };
245
247
  }
246
- function readRepairTruncated(action, values) {
248
+ function readRepairFlags(action, values) {
247
249
  const repairTruncated = values.booleans.has("--repair-truncated");
248
- if (repairTruncated && action !== "sync") {
249
- throw new Error("--repair-truncated belongs to `cockpit notes sync <account-id> --repair-truncated`.");
250
+ const repairEmpty = values.booleans.has("--repair-empty");
251
+ for (const [on, flag] of [[repairTruncated, "--repair-truncated"], [repairEmpty, "--repair-empty"]]) {
252
+ if (on && action !== "sync")
253
+ throw new Error(`${flag} belongs to \`cockpit notes sync <account-id> ${flag}\`.`);
250
254
  }
251
- return repairTruncated;
255
+ if (repairEmpty && repairTruncated)
256
+ throw new Error("notes sync takes --repair-truncated or --repair-empty, not both.");
257
+ return { repairTruncated, repairEmpty };
252
258
  }
253
259
  function readNotesConfirmation(values) {
254
260
  const json = values.booleans.has("--json");
@@ -217,7 +217,7 @@ export const TOWER_OPS_COMMAND_HELP = [
217
217
  "Tower Manager's clients, from the terminal: each client's Home and \"Where we are\", its actions and its timeline items, the same things the manager page does.",
218
218
  " cockpit manager clients every client, with its id",
219
219
  " cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt (a summary check says what it did to the card, not \"0 pages seen\"). A run id is a UUID from `queue <client> --json` (a row's changedByRunId), `show <id>` (a suggested change's runId) or `client <client> --json` (lastBriefRebuild.runId); a read's workflow id (lastRead.workflowRunId, like sched_wf_…) is not one",
220
- " cockpit manager home <client> the client's Home: how fresh its data is (last read, when Notion last changed), \"Where we are\" (health, headline, up to 5 dated bullets,",
220
+ " cockpit manager home <client> the client's Home: how fresh its data is (last read, when its pages last changed), \"Where we are\" (health, headline, up to 5 dated bullets,",
221
221
  " next step, blocker, who wrote it and when), live counts, what needs attention by owner in two parts, \"Due this week\" first (today to six days ahead) then \"Overdue\" (rows sit under their owners, oldest first under each; a line \"Overdue longest: <title>, due <day>\" names the one overdue longest), each with its own total and, when cut, \"Showing X of N\" and the `list` command for the rest, what is coming up,",
222
222
  " what the newest read that changed anything did (proposals first), \"In Tower, last 7 days\" (what people and agents added, closed, dropped and dismissed, and who, by name; a count of none is left out, and when all are none: \"nothing added, closed, dropped or dismissed by people or agents\"), and a What changed tally that likewise leaves out a kind with none (\"12 added, 2 proposed\"; \"nothing changed\" when none), and the review line (\"N suggested changes need a decision · M new items to check\"; `queue <client>` lists them), and how many sync changes are \"new from syncs\" (`queue <client>` lists them; `list` counts every unreviewed item as \"need review\", the first read's included).",
223
223
  " Home also says how old the last check of the card is (\"Last checked 2 h ago\"), \"The last check failed; showing the card from Oct 5\" when it did, \"Last checked\" only once a check has looked at the card (a card a person wrote has none yet), a failed read's reason in the page\'s own sentence (\"Why the latest read failed: ...\"), and \"First read not done yet\" for a client no read has finished (\"First read failed\" when it failed: the manager page shows only that failure until a read succeeds, though writes made here are saved); a card computed from counts reads \"AI unavailable · computed from counts\".",
@@ -38,6 +38,7 @@ export const NOTES_HELP = [
38
38
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
39
39
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
40
40
  "sync <account-id> --repair-truncated — re-read Wispr Flow notes that were stored with only the first part of a long transcript (the \"truncated, N chars remaining\" line), and rewrite them whole. Same door and the same bounded passes; prints found, fixed and failed with each failure's reason, and is safe to run again (BLI-18006).",
41
+ "sync <account-id> --repair-empty — re-read Wispr Flow notes that were filed with no transcript because the meeting was still running when Tower read it (last 7 days only). Wispr finalizes the transcript about 20 minutes later; this writes it in and re-runs the longest-transcript pick. Same door and the same bounded passes; prints found, fixed and failed with each failure's reason, and is safe to run again (BLI-23914).",
41
42
  "sync-status [<account-id>] — every notetaker's health, or one's: last successful sync, the last pass's own reason, how many meetings it carried, failures in the last 24h, and the lag between the newest meeting it has read and when it last ran (ticket 10730).",
42
43
  "bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
43
44
  "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
@@ -12,12 +12,13 @@ export const READ_REASON_SENTENCES = {
12
12
  client_disabled: "This client is disabled.",
13
13
  notion_not_configured: "Notion is not connected on this Tower deployment.",
14
14
  notion_root_unreadable: "Tower cannot read this client's Notion page. Share it with the Tower Manager Dashboard integration again, then sync.",
15
+ tower_root_unreadable: "Tower cannot find this client's main page: it was deleted or is in the trash. Restore it in Tower, then sync.",
15
16
  notion_unavailable: "Notion did not answer. The next sync will try again.",
16
17
  model_failed: "The model did not answer. Nothing was lost; the next sync reads the same pages again.",
17
18
  model_timeout: "The model took too long to answer. Nothing was lost; the next sync reads the same pages again, fewer at a time.",
18
19
  model_output_rejected: "The model's answer was not usable. Nothing was lost; the next sync reads the same pages again.",
19
- root_owned_by_other_client: "This client's Notion page sits inside another client's tree, which holds it. Nothing was read.",
20
- interrupted: "Interrupted when Tower restarted; will retry. The daily sync carries it on, or Sync now.",
20
+ root_owned_by_other_client: "This client's page sits inside another client's tree, which holds it. Nothing was read.",
21
+ interrupted: "Interrupted when Tower restarted; will retry. The scheduled sync carries it on, or Sync now.",
21
22
  batch_too_dense: "Those pages held more than one answer can carry, so they are being read again in smaller batches.",
22
23
  database_error: "Tower could not save the result. The next sync will try again.",
23
24
  unexpected_error: "The run stopped on an unexpected error. The next sync will try again.",
@@ -122,10 +122,18 @@ export function coverageLines(client) {
122
122
  return [
123
123
  walk && !walk.complete ? `Walk incomplete (${walk.cap ? CAP_WORDS[walk.cap] : "stopped early"}): archiving skipped.` : null,
124
124
  walk && walk.pageReadFailed > 0 ? `${plural(walk.pageReadFailed, "page")} could not be read this time; the next sync tries again.` : null,
125
- walk && walk.unreadableSkipped > 0 ? `${plural(walk.unreadableSkipped, "page")} not shared with the Tower integration.` : null,
125
+ walk && walk.unreadableSkipped > 0
126
+ ? client.source === "docs"
127
+ ? `${plural(walk.unreadableSkipped, "page")} gone from Tower since the sync began; the next sync tries again.`
128
+ : `${plural(walk.unreadableSkipped, "page")} not shared with the Tower integration.`
129
+ : null,
126
130
  walk && walk.otherClientRoots > 0 ? `Stopped at ${walk.otherClientRoots} other ${walk.otherClientRoots === 1 ? "client's tree" : "clients' trees"}.` : null,
127
131
  walk && walk.otherClientSkipped > 0 ? `${plural(walk.otherClientSkipped, "page")} held by another client.` : null,
128
- incomplete > 0 ? `Notion handed over ${plural(incomplete, "page")} only in part.` : null,
132
+ incomplete > 0
133
+ ? client.source === "docs"
134
+ ? `${plural(incomplete, "page")} came into Tower from Notion only in part.`
135
+ : `Notion handed over ${plural(incomplete, "page")} only in part.`
136
+ : null,
129
137
  idle && waiting > 0 ? `${plural(waiting, "page")} still waiting for the next read.` : null,
130
138
  ].filter((line) => line !== null);
131
139
  }
@@ -137,7 +145,10 @@ function freshnessLines(home, now) {
137
145
  ? `failed ${when(read.finishedAt ?? read.startedAt, now)}; ${synced ? `last good read ${when(synced, now)}` : "no good read yet"}`
138
146
  : null;
139
147
  const lastRead = failed ?? (read ? `last read ${when(read.finishedAt ?? read.startedAt, now)}${read.finishedAt ? "" : ", still running"}` : synced ? `last synced ${when(synced, now)}` : "never read");
140
- const notion = home.freshness.notionLastEditedAt ? `Notion last changed ${when(home.freshness.notionLastEditedAt, now)}` : "no Notion page yet";
148
+ const sourceName = home.client.source === "docs" ? "Tower" : "Notion";
149
+ const notion = home.freshness.notionLastEditedAt
150
+ ? `${home.client.source === "docs" ? "Tower pages" : "Notion"} last changed ${when(home.freshness.notionLastEditedAt, now)}`
151
+ : `no ${sourceName} page yet`;
141
152
  // What the read could not see sits under the Data line, so "fresh" never hides it.
142
153
  return [`Data: ${FRESHNESS[home.freshness.state] ?? home.freshness.state}. ${lastRead[0].toUpperCase()}${lastRead.slice(1)}; ${notion}.`, ...coverageLines(home.client).map((line) => ` ${line}`), ...(failed && read?.reason ? [` Why the latest read failed: ${readReasonText(read.reason)}`] : [])];
143
154
  }
@@ -220,12 +220,15 @@ export function itemDetailLines(detail, timeline, color) {
220
220
  /** One client in full: its state, its last read and how many pages wait. */
221
221
  export function clientDetailLines(client, color) {
222
222
  const lines = [...clientLines([client], color)];
223
- if (client.notionRootUrl)
224
- lines.push(` Notion root: ${client.notionRootUrl}`);
223
+ const source = client.source === "docs" ? "Tower" : "Notion";
224
+ lines.push(` Read from: ${source} pages`);
225
+ const root = client.rootUrl ?? client.notionRootUrl;
226
+ if (root)
227
+ lines.push(` ${source} root: ${root}`);
225
228
  if (client.queued)
226
229
  lines.push(" Queued behind another run (Sync all).");
227
230
  if (client.storedPages !== undefined)
228
- lines.push(` ${client.storedPages} stored pages${client.incompletePages ? `, ${client.incompletePages} only partly handed over by Notion` : ""}`);
231
+ lines.push(` ${client.storedPages} stored pages${client.incompletePages ? `, ${client.incompletePages} only partly handed over${client.source === "docs" ? " when they came into Tower from Notion" : " by Notion"}` : ""}`);
229
232
  const read = client.lastRead;
230
233
  if (read) {
231
234
  lines.push(` Last read: ${read.kind}, ${read.reason}, started ${managerTime(read.startedAt)}${read.finishedAt ? `, finished ${managerTime(read.finishedAt)}` : ", still running"}`);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `cockpit manager` (BLI-5006): Tower Manager's client actions and timeline items from the terminal.
3
3
  *
4
- * Plain words: Tower reads a client's Notion pages and keeps their actions and timeline. This lists
4
+ * Plain words: Tower reads a client's pages (from Tower, or from Notion) and keeps their actions and timeline. This lists
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
  *
@@ -89,11 +89,15 @@ function fileBytes(file) {
89
89
  return typeof size === 'number' ? size : 0;
90
90
  } catch (e) { return 0; }
91
91
  }
92
+ // macOS 27 no longer exposes AVCaptureDevice on \$ after importing AVFoundation; by name it carries no bridge
93
+ // metadata, so the status comes back as the string "3" and Number() is what makes === work (BLI-23872).
94
+ var CaptureDevice = $.AVCaptureDevice || $.NSClassFromString('AVCaptureDevice');
95
+ function micStatus() { return Number(CaptureDevice.authorizationStatusForMediaType('soun')); }
92
96
  ${RECORDER_STOP_RULE}
93
97
  function run(argv) {
94
98
  var dir = argv[0];
95
99
  var startedMs = Date.now();
96
- var auth = $.AVCaptureDevice.authorizationStatusForMediaType('soun');
100
+ var auth = micStatus();
97
101
  if (auth === 1 || auth === 2) { writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
98
102
  var settings = $.NSDictionary.dictionaryWithObjectsForKeys(
99
103
  $([1819304813, ${RECORDING_SAMPLE_RATE}, 1, 16, 0, 0]),
@@ -105,7 +109,7 @@ function run(argv) {
105
109
  while (!exists(dir + '/stop')) {
106
110
  tick(0.2);
107
111
  waited += 0.2;
108
- var now = $.AVCaptureDevice.authorizationStatusForMediaType('soun');
112
+ var now = micStatus();
109
113
  if (now === 1 || now === 2) { recorder.stop; writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
110
114
  if (!announced && now === 3) { announced = true; writeText(dir + '/ready', 'ok'); }
111
115
  if (!announced && waited > 120) { recorder.stop; writeText(dir + '/failed.txt', 'mic_permission_unanswered'); $.exit(5); }
@@ -49,7 +49,9 @@ export async function syncNotetaker(command, door) {
49
49
  // BLI-18006: `--repair-truncated` asks the same door to re-read Wispr Flow notes
50
50
  // that were stored cut short. Fixed ones count as "unchanged" in the totals
51
51
  // (the ledger's column), failed ones as "skipped"; the repair line says which.
52
- repairing: command.repairTruncated === true,
52
+ repairing: command.repairTruncated === true || command.repairEmpty === true,
53
+ // BLI-23914: `--repair-empty` re-reads notes filed with no transcript, same door.
54
+ repairKind: command.repairEmpty === true ? "empty_transcripts" : command.repairTruncated === true ? "truncated_transcripts" : null,
53
55
  liveWaitMs: Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS,
54
56
  };
55
57
  const state = {
@@ -73,9 +75,9 @@ async function runOnePass(door, settings, state) {
73
75
  const answer = await ask(door, {
74
76
  path: `/api/notes/accounts/${encodeURIComponent(settings.accountId)}/sync`,
75
77
  method: "POST",
76
- label: settings.repairing ? "notes sync (repair truncated)" : "notes sync",
78
+ label: settings.repairKind === "empty_transcripts" ? "notes sync (repair empty)" : settings.repairing ? "notes sync (repair truncated)" : "notes sync",
77
79
  timeoutMs: settings.repairing ? REPAIR_DEADLINE_MS : WRITE_DEADLINE_MS,
78
- ...(settings.repairing ? { body: { repair: "truncated_transcripts" } } : {}),
80
+ ...(settings.repairKind ? { body: { repair: settings.repairKind } } : {}),
79
81
  });
80
82
  if (!answer.ok)
81
83
  return await retryOrStop(door, settings, state, answer);
@@ -144,7 +146,7 @@ function finishSync(door, settings, state, run) {
144
146
  if (door.json)
145
147
  return emit(door, { ok: true, passes, totals, ...(settings.repairing ? { repair: repairTotals } : {}), run });
146
148
  writeLine(door.io.stdout, settings.repairing
147
- ? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} carried the marker when the first pass began).`
149
+ ? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} ${settings.repairKind === "empty_transcripts" ? "had no transcript" : "carried the marker"} when the first pass began).`
148
150
  : `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
149
151
  return 0;
150
152
  }
@@ -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.288");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.290");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.288",
3
+ "version": "0.2.290",
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.89",
31
- "@bli-cockpit/mcp": "0.1.175",
31
+ "@bli-cockpit/mcp": "0.1.176",
32
32
  "@bli-cockpit/telemetry-core": "0.1.95",
33
33
  "unpdf": "1.8.1"
34
34
  },