@bli-cockpit/cli 0.2.179 → 0.2.183

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 (37) hide show
  1. package/dist/commands/agent-setup.js +152 -0
  2. package/dist/commands/brain.js +35 -0
  3. package/dist/commands/doctor-update.js +4 -0
  4. package/dist/commands/doctor.js +4 -2
  5. package/dist/commands/glossary.js +17 -0
  6. package/dist/commands/heartbeat.js +5 -1
  7. package/dist/commands/local-args-tower-admin.js +6 -1
  8. package/dist/commands/local-args-tower-brain.js +38 -0
  9. package/dist/commands/local-args-tower-memory.js +15 -0
  10. package/dist/commands/local-args-tower-pages.js +16 -3
  11. package/dist/commands/local-args-tower-setup.js +10 -0
  12. package/dist/commands/local-args-tower.js +4 -1
  13. package/dist/commands/local-args.js +8 -1
  14. package/dist/commands/local-command-shapes-brain.js +5 -0
  15. package/dist/commands/local-help-commands-tower-ops.js +17 -0
  16. package/dist/commands/local-help-commands-tower.js +12 -4
  17. package/dist/commands/local-help-commands.js +3 -0
  18. package/dist/commands/local-help.js +7 -1
  19. package/dist/commands/local.js +11 -5
  20. package/dist/commands/memory-store.js +23 -3
  21. package/dist/commands/memory-verb-list.js +4 -1
  22. package/dist/commands/memory-visibility.js +48 -0
  23. package/dist/commands/notes-accounts.js +102 -20
  24. package/dist/commands/notes-reads.js +1 -1
  25. package/dist/commands/notes-recordings.js +113 -0
  26. package/dist/commands/notes-wisprflow-login.js +219 -0
  27. package/dist/commands/notes.js +6 -0
  28. package/dist/commands/public-root.js +1 -1
  29. package/dist/commands/setup.js +39 -0
  30. package/dist/commands/status.js +1 -1
  31. package/dist/commands/sync-doctor-request.js +13 -0
  32. package/dist/commands/sync-doctor.js +145 -0
  33. package/dist/commands/sync.js +4 -3
  34. package/dist/commands/team.js +19 -0
  35. package/dist/local-state-status.js +5 -1
  36. package/dist/raw-evidence-stuck-summary.js +21 -1
  37. package/package.json +4 -4
@@ -66,6 +66,7 @@ export const rootCommandNames = new Set([
66
66
  "memory",
67
67
  "clean",
68
68
  "sources",
69
+ "glossary",
69
70
  "docs",
70
71
  "msg",
71
72
  "issue",
@@ -77,8 +78,10 @@ export const rootCommandNames = new Set([
77
78
  "release",
78
79
  "careers",
79
80
  "rename",
81
+ "setup",
80
82
  "usage",
81
83
  "logs",
84
+ "brain",
82
85
  ...SURFACE_ALIASES.keys(),
83
86
  ]);
84
87
  /** Public names omit compatibility spellings while the router keeps accepting them. */
@@ -112,7 +115,7 @@ export function localCommandHelp(command) {
112
115
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
113
116
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
114
117
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
115
- " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
118
+ " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|connect --provider wisprflow|recordings <note-id>|recording <id>|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
116
119
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
117
120
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
118
121
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -132,8 +135,11 @@ export function localCommandHelp(command) {
132
135
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
133
136
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
134
137
  " cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
138
+ " cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--dashboard-url <url>] [--json]",
135
139
  " cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
136
140
  " cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
141
+ " cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
142
+ " cockpit setup [--person <email>] [--compare <email,email>] [--json]",
137
143
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
138
144
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
139
145
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -1,3 +1,4 @@
1
+ import { runGlossary } from "./glossary.js";
1
2
  import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
2
3
  import { isLocalHelpRequest, localCommandHelp, rootCommandNames } from "./local-help.js";
3
4
  import { beginStage, endStage } from "../crash-guard.js";
@@ -11,6 +12,7 @@ import { runLogin } from "./login.js";
11
12
  import { runLogout } from "./logout.js";
12
13
  import { runStart } from "./start.js";
13
14
  import { runSync } from "./sync.js";
15
+ import { runDoctorAfterSync } from "./sync-doctor.js";
14
16
  import { runAnalyze } from "./analyze.js";
15
17
  import { runBackfillCommand } from "./backfill.js";
16
18
  import { runDoctor } from "./doctor.js";
@@ -48,8 +50,10 @@ import { runGithub } from "./github.js";
48
50
  import { runSearch } from "./search.js";
49
51
  import { runCareers } from "./careers.js";
50
52
  import { runRename } from "./rename-scope.js";
53
+ import { runSetup } from "./setup.js";
51
54
  import { runUsage } from "./usage.js";
52
55
  import { runLogs } from "./logs.js";
56
+ import { runBrain } from "./brain.js";
53
57
  import { parseLocalArgs } from "./local-args.js";
54
58
  // `./local.js` is the published entry point for this command surface: the
55
59
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -60,7 +64,7 @@ export { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorD
60
64
  export { assertCollectionRootPersisted } from "./collection-roots.js";
61
65
  export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
62
66
  import { runRoots } from "./roots.js";
63
- export async function runLocalCockpitCli(argv, io = defaultIo()) {
67
+ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorAfterSync }) {
64
68
  // BLI-10450: `--quiet` is a global toggle, stripped here so no per-command
65
69
  // parser's `allowedFlags` needs to learn it, and mirrored onto
66
70
  // `COCKPIT_QUIET` so `tower-client.ts` can react without every call site
@@ -219,6 +223,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
219
223
  return await runClean(command, io);
220
224
  case "sources":
221
225
  return await runSourcesCheck(command, io);
226
+ case "glossary":
227
+ return await runGlossary(command, io);
222
228
  case "docs":
223
229
  return await runDocs(command, io);
224
230
  case "msg":
@@ -241,10 +247,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
241
247
  return await runCareers(command, io);
242
248
  case "rename":
243
249
  return await runRename(command, io);
244
- case "usage":
245
- return await runUsage(command, io);
246
- case "logs":
247
- return await runLogs(command, io);
250
+ case "setup": return await runSetup(command, io);
251
+ case "usage": return await runUsage(command, io);
252
+ case "logs": return await runLogs(command, io);
253
+ case "brain": return await runBrain(command, io);
248
254
  }
249
255
  }
250
256
  catch (error) {
@@ -48,6 +48,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
48
48
  import { runMemoryConsolidation } from "./memory-consolidation.js";
49
49
  import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
50
50
  import { runMemoryRequeue } from "./memory-requeue.js";
51
+ import { runMemoryVisibility } from "./memory-visibility.js";
51
52
  import { readNoteFile } from "./notes-file.js";
52
53
  export { runMemoryVerbList } from "./memory-verb-list.js";
53
54
  const TAG = "[memory cli]";
@@ -80,6 +81,11 @@ export async function runMemoryStore(command, io) {
80
81
  // Its own module too: a window-shaped, super-admin, dry-run-by-default
81
82
  // verb that refuses nothing the others refuse (BLI-4539).
82
83
  return runMemoryRequeue(command, io);
84
+ case "private":
85
+ case "public":
86
+ // BLI-12927: the narrowing switch. Its own module, so this file stays
87
+ // under the readability ceiling.
88
+ return runMemoryVisibility(command, door);
83
89
  }
84
90
  }
85
91
  /**
@@ -203,14 +209,16 @@ async function searchMemory(command, door) {
203
209
  else {
204
210
  for (const row of results) {
205
211
  writeLine(door.io.stdout, `${similarityLabel(row)}${oneLine(row["memory"])}`);
206
- writeLine(door.io.stdout, ` ${String(row["id"] ?? "")} ${String(row["containerTag"] ?? "")}`);
212
+ writeLine(door.io.stdout, ` ${String(row["id"] ?? "")} ${String(row["containerTag"] ?? "")}${filedByLabel(row)}`);
207
213
  }
208
214
  }
209
215
  if (recent.length > 0) {
210
216
  writeLine(door.io.stdout, "");
211
217
  writeLine(door.io.stdout, "Recent context:");
212
- for (const row of recent)
213
- writeLine(door.io.stdout, ` - ${oneLine(row["preview"] ?? row["memory"])}`);
218
+ for (const row of recent) {
219
+ const author = typeof row["savedBy"] === "string" && row["savedBy"] ? `(from ${row["savedBy"]}) ` : "";
220
+ writeLine(door.io.stdout, ` - ${author}${oneLine(row["preview"] ?? row["memory"])}`);
221
+ }
214
222
  }
215
223
  if (body.degraded) {
216
224
  writeLine(door.io.stdout, "");
@@ -375,6 +383,18 @@ function similarityLabel(row) {
375
383
  ? `[${Math.round(similarity * 100)}%] `
376
384
  : "";
377
385
  }
386
+ /**
387
+ * BLI-12927: memory is public across the team, so a hit can be a teammate's.
388
+ * ` filed by Edward on 2026-09-14` ahead of nothing when it is the caller's own.
389
+ */
390
+ function filedByLabel(row) {
391
+ const name = typeof row["savedBy"] === "string" ? row["savedBy"].trim() : "";
392
+ if (!name)
393
+ return "";
394
+ const filed = typeof row["createdAt"] === "string" ? new Date(row["createdAt"]) : null;
395
+ const day = filed && !Number.isNaN(filed.getTime()) ? filed.toISOString().slice(0, 10) : null;
396
+ return day ? ` filed by ${name} on ${day}` : ` filed by ${name}`;
397
+ }
378
398
  /** One terminal line: newlines folded, so a long memory cannot break the listing. */
379
399
  function oneLine(value) {
380
400
  return String(value ?? "").replace(/\s+/g, " ").trim();
@@ -26,7 +26,7 @@ export function runMemoryVerbList(io, json) {
26
26
  ok: true,
27
27
  noun: "memory",
28
28
  machine_verbs: ["install", "status"],
29
- store_verbs: ["search", "save", "update", "forget", "profile", "consolidation", "requeue"],
29
+ store_verbs: ["search", "save", "update", "forget", "private", "public", "profile", "consolidation", "requeue"],
30
30
  log_verbs: ["log"],
31
31
  }));
32
32
  return 0;
@@ -41,6 +41,9 @@ export function runMemoryVerbList(io, json) {
41
41
  " cockpit memory update <id> [--file <path>] [--json] replacement text on stdin",
42
42
  " cockpit memory forget <id> [--reason <label>] [--json]",
43
43
  " cockpit memory forget --content-stdin --container <tag> [--reason <label>] [--json]",
44
+ " you can forget or update only your own memories (a super admin, any)",
45
+ " cockpit memory private <id> [--json] only you and super admins can read this memory",
46
+ " cockpit memory public <id> [--json] every member can read it again (how every memory starts)",
44
47
  " cockpit memory profile [--container <tag>] [--json]",
45
48
  " the compiled one-page briefing every session opens with; a read, never a compile",
46
49
  " cockpit memory consolidation [--container <tag>] [--json]",
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `cockpit memory private <id>` and `cockpit memory public <id>` (BLI-12927).
3
+ *
4
+ * BLI Memory is public to every member of the team until its owner narrows it:
5
+ * "if it's worth saving, it's worth sharing; when we have a privacy problem, we
6
+ * narrow down." These two verbs are the narrowing switch, one memory at a time.
7
+ * `private` means only you and super admins can read that memory; `public`
8
+ * means every member can again, which is how every memory starts.
9
+ *
10
+ * Both verbs call the SAME door, `POST /api/memory/visibility`, as the
11
+ * `set_memory_visibility` tool on the `bli-memory` MCP server. Who may flip a
12
+ * memory is decided by the database on the caller's own session (the owner of
13
+ * the memory, or a super admin); this file asks and prints the door's own
14
+ * answer, reason label included, and never rephrases a refusal.
15
+ */
16
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
17
+ import { writeLine } from "./cli-io.js";
18
+ const TAG = "[memory cli]";
19
+ const DEADLINE_MS = 60_000;
20
+ export async function runMemoryVisibility(command, door) {
21
+ const visibility = command.action === "private" ? "private" : "public";
22
+ if (!command.memoryId) {
23
+ return failAgentDoor(door, TAG, "id_required", `memory ${visibility} needs the memory id \`cockpit memory search\` printed.`);
24
+ }
25
+ const answer = await askAgentDoor(door, {
26
+ path: "/api/memory/visibility",
27
+ method: "POST",
28
+ label: `memory ${visibility}`,
29
+ timeoutMs: DEADLINE_MS,
30
+ body: { id: command.memoryId, visibility },
31
+ });
32
+ if (!answer.ok)
33
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
34
+ const result = answer.body;
35
+ // Counts and labels only: a memory's text never enters a log line.
36
+ writeLine(door.io.stderr, `${TAG} visibility ${JSON.stringify({
37
+ visibility,
38
+ changed: result["changed"] ?? null,
39
+ profile_invalidated: result["profileInvalidated"] ?? null,
40
+ })}`);
41
+ if (door.json)
42
+ return emitAgentDoor(door, answer.body);
43
+ const already = result["changed"] === false ? " It already was." : "";
44
+ writeLine(door.io.stdout, visibility === "private"
45
+ ? `Memory ${command.memoryId} is now private: only you and super admins can read it.${already}`
46
+ : `Memory ${command.memoryId} is now public: every member can read it.${already}`);
47
+ return 0;
48
+ }
@@ -17,11 +17,67 @@
17
17
  * `cockpit notes` keeps.
18
18
  */
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
- import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
20
+ import { ask, emit, fail, READ_DEADLINE_MS, TAG } from "./notes-door.js";
21
+ import { printCoverage, readCoverage } from "./notes-recordings.js";
22
+ import { signInToWisprflow } from "./notes-wisprflow-login.js";
21
23
  /** A pass can take a while; a read never should. */
22
24
  const WRITE_DEADLINE_MS = 120_000;
23
25
  /** An API key is long; anything past this is not one, and is refused by size. */
24
26
  const KEY_MAX_CHARS = 500;
27
+ /** BLI-12926: a Wispr Flow sign-in bundle holds an OAuth refresh token, which can run longer. */
28
+ const SIGN_IN_MAX_CHARS = 4000;
29
+ /**
30
+ * Granola, read through the one door that answers for it
31
+ * (`GET /api/notes/granola-status`). A ledger that cannot be read is said so on
32
+ * the feed, never turned into "nothing feeds your notes".
33
+ */
34
+ async function readSharedFeeds(door) {
35
+ const feed = {
36
+ provider: "granola",
37
+ name: "Granola",
38
+ how: "read by the organization's own Granola key, swept once a day by Tower's server; no key of yours is involved",
39
+ last_run: null,
40
+ unreadable: null,
41
+ };
42
+ const answer = await ask(door, {
43
+ path: "/api/notes/granola-status?limit=1",
44
+ method: "GET",
45
+ label: "notes granola-status",
46
+ timeoutMs: READ_DEADLINE_MS,
47
+ });
48
+ if (!answer.ok) {
49
+ feed.unreadable = answer.reason;
50
+ writeLine(door.io.stderr, `${TAG} shared feed ledger unreadable ${JSON.stringify({ provider: "granola", reason: answer.reason })}`);
51
+ return [feed];
52
+ }
53
+ const runs = answer.body.runs ?? [];
54
+ const newest = runs[0];
55
+ if (newest) {
56
+ feed.last_run = {
57
+ started_at: newest.started_at,
58
+ reason: newest.reason,
59
+ notes_seen: newest.notes_seen,
60
+ notes_stored: newest.notes_stored,
61
+ notes_failed: newest.notes_failed,
62
+ };
63
+ }
64
+ return [feed];
65
+ }
66
+ function writeSharedFeeds(door, feeds) {
67
+ if (feeds.length === 0)
68
+ return;
69
+ writeLine(door.io.stdout, "");
70
+ writeLine(door.io.stdout, "Also feeding your notes, for the whole organization:");
71
+ for (const feed of feeds) {
72
+ const last = feed.unreadable
73
+ ? `last run unknown (the run ledger could not be read: ${feed.unreadable})`
74
+ : feed.last_run
75
+ ? `last run ${feed.last_run.started_at}: ${feed.last_run.reason}, saw ${feed.last_run.notes_seen}, stored ${feed.last_run.notes_stored}, failed ${feed.last_run.notes_failed}`
76
+ : "no run has landed a row yet";
77
+ writeLine(door.io.stdout, ` ${feed.name} ${feed.how}; ${last}`);
78
+ }
79
+ writeLine(door.io.stdout, " Its runs in full: `cockpit notes granola-status`.");
80
+ }
25
81
  export async function listNotesAccounts(command, door) {
26
82
  const answer = await ask(door, {
27
83
  path: "/api/notes/accounts",
@@ -32,13 +88,23 @@ export async function listNotesAccounts(command, door) {
32
88
  if (!answer.ok)
33
89
  return fail(door, answer.reason, answer.detail);
34
90
  const accounts = answer.body.accounts ?? [];
91
+ // BLI-12925: what feeds this person's notes is more than the accounts they
92
+ // connected. Granola arrives through the organization's own key and cron, so
93
+ // an empty account list never meant "no notetaker".
94
+ const sharedFeeds = await readSharedFeeds(door);
95
+ // BLI-12926: who feeds whom, added after the list so the list stays as it was.
96
+ const coverage = await readCoverage(door);
35
97
  if (door.json)
36
- return emit(door, { ok: true, accounts });
98
+ return emit(door, { ok: true, accounts, shared_feeds: sharedFeeds, coverage });
37
99
  if (accounts.length === 0) {
38
- writeLine(door.io.stdout, "No notetaker is connected yet.");
100
+ writeLine(door.io.stdout, "You have not connected a notetaker of your own.");
101
+ writeSharedFeeds(door, sharedFeeds);
39
102
  writeLine(door.io.stdout, "");
40
103
  writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
41
104
  writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
105
+ writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (signs in through your browser; no key)");
106
+ if (coverage)
107
+ printCoverage(door, coverage);
42
108
  return 0;
43
109
  }
44
110
  for (const account of accounts) {
@@ -52,30 +118,45 @@ export async function listNotesAccounts(command, door) {
52
118
  }
53
119
  writeLine(door.io.stdout, "");
54
120
  writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
121
+ writeSharedFeeds(door, sharedFeeds);
122
+ if (coverage)
123
+ printCoverage(door, coverage);
55
124
  // `command` is unused beyond the door it already carries; naming it keeps
56
125
  // every verb in this file one shape.
57
126
  void command;
58
127
  return 0;
59
128
  }
60
129
  export async function connectNotetaker(command, door) {
61
- // The key comes from stdin and nowhere else. An interactive terminal with
62
- // nothing piped in is told how, rather than left waiting on a prompt that
63
- // would echo the secret into the scrollback.
64
- if (isInteractiveStdin(door.io)) {
65
- return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
66
- }
67
130
  let apiKey;
68
- try {
69
- apiKey = (await readPipedText(door.io.stdin, {
70
- maxChars: KEY_MAX_CHARS,
71
- overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
72
- })).trim();
73
- }
74
- catch (error) {
75
- return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
131
+ if (command.provider === "wisprflow" && !command.keyStdin) {
132
+ // BLI-12926. Wispr Flow has no API key: the person signs in through their
133
+ // browser and what gets sealed is the sign-in. Nothing is read from stdin.
134
+ const signedIn = await signInToWisprflow({ io: door.io });
135
+ if (!signedIn.ok)
136
+ return fail(door, signedIn.reason, signedIn.detail);
137
+ apiKey = signedIn.bundle;
76
138
  }
77
- if (apiKey === "") {
78
- return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
139
+ else {
140
+ // The key comes from stdin and nowhere else. An interactive terminal with
141
+ // nothing piped in is told how, rather than left waiting on a prompt that
142
+ // would echo the secret into the scrollback.
143
+ if (isInteractiveStdin(door.io)) {
144
+ return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
145
+ }
146
+ try {
147
+ apiKey = (await readPipedText(door.io.stdin, {
148
+ // A Wispr Flow sign-in bundle carries an OAuth refresh token, which
149
+ // can be far longer than any API key.
150
+ maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
151
+ overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
152
+ })).trim();
153
+ }
154
+ catch (error) {
155
+ return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
156
+ }
157
+ if (apiKey === "") {
158
+ return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
159
+ }
79
160
  }
80
161
  const answer = await ask(door, {
81
162
  path: "/api/notes/accounts",
@@ -243,7 +324,8 @@ export async function syncStatusNotetakers(command, door) {
243
324
  if (door.json)
244
325
  return emit(door, { ok: true, accounts });
245
326
  if (accounts.length === 0) {
246
- writeLine(door.io.stdout, "No notetaker is connected yet.");
327
+ writeLine(door.io.stdout, "You have not connected a notetaker of your own.");
328
+ writeLine(door.io.stdout, "Granola is read by the organization's key, not by an account of yours: `cockpit notes granola-status` is its health.");
247
329
  return 0;
248
330
  }
249
331
  for (const account of accounts) {
@@ -69,7 +69,7 @@ export async function listNotes(command, door) {
69
69
  writeLine(door.io.stdout, `${one.heading} (${one.notes.length})`);
70
70
  for (const note of one.notes) {
71
71
  const who = note.participants.length > 0 ? ` — ${note.participants.join(", ")}` : "";
72
- writeLine(door.io.stdout, ` ${note.meetingDate} ${note.id} ${note.fileName}${who}`);
72
+ writeLine(door.io.stdout, ` ${note.meetingDate} ${note.id} ${note.title?.trim() || note.fileName}${who}`);
73
73
  }
74
74
  }
75
75
  writeLine(door.io.stdout, "");
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `cockpit notes recordings <note-id>`, `cockpit notes recording <id>` and the
3
+ * "who feeds whom" section of `cockpit notes accounts` (BLI-12926).
4
+ *
5
+ * One meeting that several notetakers (or several people) recorded is ONE note
6
+ * in the library: the recording with the longest transcript. The others are
7
+ * alternates, kept by id and never ingested. `recordings` lists every recording
8
+ * of the meeting a note is; `recording` opens one, body and all.
9
+ *
10
+ * `coverage` is the other half: which notetakers feed each member's notes, so a
11
+ * member with none is a visible gap and not a silence. It rides on
12
+ * `cockpit notes accounts` as an added section; a failed read of it never fails
13
+ * the account list, and says so on stderr.
14
+ *
15
+ * Thin client over `/api/notes/recordings` and `/api/notes/coverage`; every
16
+ * access rule lives behind those doors.
17
+ */
18
+ import { writeLine } from "./cli-io.js";
19
+ import { ask, emit, fail, READ_DEADLINE_MS, TAG } from "./notes-door.js";
20
+ function lengthOf(row) {
21
+ return row.transcript_chars === null ? "length unknown" : `${row.transcript_chars} characters`;
22
+ }
23
+ export async function listRecordings(command, door) {
24
+ const noteId = command.noteId ?? "";
25
+ const answer = await ask(door, {
26
+ path: `/api/notes/recordings?note_id=${encodeURIComponent(noteId)}`,
27
+ method: "GET",
28
+ label: "notes recordings",
29
+ timeoutMs: READ_DEADLINE_MS,
30
+ });
31
+ if (!answer.ok)
32
+ return fail(door, answer.reason, answer.detail);
33
+ const recordings = answer.body.recordings ?? [];
34
+ if (door.json)
35
+ return emit(door, { ok: true, note_id: noteId, recordings });
36
+ if (recordings.length === 0) {
37
+ writeLine(door.io.stdout, "No recording is indexed for this note. It was filed before recordings were tracked, or by hand.");
38
+ return 0;
39
+ }
40
+ for (const row of recordings) {
41
+ const shown = row.role === "primary" ? "SHOWN " : "alternate";
42
+ writeLine(door.io.stdout, `${shown} ${row.source_namespace.padEnd(10)} ${lengthOf(row).padEnd(20)} ${(row.started_at ?? "no start time").padEnd(24)} ${row.id}`);
43
+ if (row.chosen_reason)
44
+ writeLine(door.io.stdout, ` ${row.chosen_reason}`);
45
+ }
46
+ writeLine(door.io.stdout, "");
47
+ writeLine(door.io.stdout, recordings.length === 1
48
+ ? "One recording: the note is the recording."
49
+ : `${recordings.length} recordings of one meeting. Open one with \`cockpit notes recording <id>\`.`);
50
+ return 0;
51
+ }
52
+ export async function showRecording(command, door) {
53
+ const recordingId = command.noteId ?? "";
54
+ const answer = await ask(door, {
55
+ path: `/api/notes/recordings?recording_id=${encodeURIComponent(recordingId)}`,
56
+ method: "GET",
57
+ label: "notes recording",
58
+ timeoutMs: READ_DEADLINE_MS,
59
+ });
60
+ if (!answer.ok)
61
+ return fail(door, answer.reason, answer.detail);
62
+ const recording = answer.body.recording;
63
+ if (!recording)
64
+ return fail(door, "empty_answer", "Tower answered without a recording.");
65
+ if (door.json)
66
+ return emit(door, { ok: true, recording });
67
+ writeLine(door.io.stderr, `${recording.role === "primary" ? "The recording shown as the note" : "An alternate"} from ${recording.source_namespace}, ${lengthOf(recording)}.`);
68
+ if (recording.chosen_reason)
69
+ writeLine(door.io.stderr, recording.chosen_reason);
70
+ writeLine(door.io.stdout, recording.body ?? "(Tower did not keep this recording's text.)");
71
+ return 0;
72
+ }
73
+ /**
74
+ * The "who is connected" section. Returns the rows it printed, or null when the
75
+ * read failed; a failure is reported on stderr and never fails the caller.
76
+ */
77
+ export async function readCoverage(door) {
78
+ const answer = await ask(door, {
79
+ path: "/api/notes/coverage",
80
+ method: "GET",
81
+ label: "notes coverage",
82
+ timeoutMs: READ_DEADLINE_MS,
83
+ });
84
+ if (!answer.ok) {
85
+ writeLine(door.io.stderr, `${TAG} could not read who has a notetaker connected: ${answer.detail}`);
86
+ return null;
87
+ }
88
+ // An answer that is not a roster is not "nobody has a notetaker"; say so.
89
+ const body = answer.body;
90
+ if (!body || !Array.isArray(body.members)) {
91
+ writeLine(door.io.stderr, `${TAG} could not read who has a notetaker connected: Tower's answer was not a roster.`);
92
+ return null;
93
+ }
94
+ return { members: body.members, members_with_none: body.members_with_none ?? 0 };
95
+ }
96
+ export function printCoverage(door, coverage) {
97
+ writeLine(door.io.stdout, "");
98
+ writeLine(door.io.stdout, "Who feeds Tower's notes:");
99
+ for (const member of coverage.members) {
100
+ const who = `${member.display_name ?? member.email}${member.is_you ? " (you)" : ""}`;
101
+ if (member.notetakers.length === 0) {
102
+ writeLine(door.io.stdout, ` ${who.padEnd(32)} NO NOTETAKER CONNECTED`);
103
+ continue;
104
+ }
105
+ const feeds = member.notetakers
106
+ .map((taker) => (taker.status === "active" ? taker.provider : `${taker.provider} (${taker.status})`))
107
+ .join(", ");
108
+ writeLine(door.io.stdout, ` ${who.padEnd(32)} ${feeds}`);
109
+ }
110
+ if (coverage.members_with_none > 0) {
111
+ writeLine(door.io.stdout, `${coverage.members_with_none} member(s) have no notetaker connected, so their meetings reach Tower only if someone else recorded them.`);
112
+ }
113
+ }