@bli-cockpit/cli 0.2.180 → 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.
- package/dist/commands/agent-setup.js +152 -0
- package/dist/commands/doctor-update.js +4 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/glossary.js +17 -0
- package/dist/commands/heartbeat.js +5 -1
- package/dist/commands/local-args-tower-admin.js +6 -1
- package/dist/commands/local-args-tower-memory.js +15 -0
- package/dist/commands/local-args-tower-pages.js +16 -3
- package/dist/commands/local-args-tower-setup.js +10 -0
- package/dist/commands/local-args-tower.js +3 -1
- package/dist/commands/local-args.js +6 -1
- package/dist/commands/local-help-commands-tower.js +12 -4
- package/dist/commands/local-help-commands.js +3 -0
- package/dist/commands/local-help.js +5 -1
- package/dist/commands/local.js +10 -7
- package/dist/commands/memory-store.js +23 -3
- package/dist/commands/memory-verb-list.js +4 -1
- package/dist/commands/memory-visibility.js +48 -0
- package/dist/commands/notes-accounts.js +102 -20
- package/dist/commands/notes-reads.js +1 -1
- package/dist/commands/notes-recordings.js +113 -0
- package/dist/commands/notes-wisprflow-login.js +219 -0
- package/dist/commands/notes.js +6 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup.js +39 -0
- package/dist/commands/sync-doctor-request.js +13 -0
- package/dist/commands/sync-doctor.js +145 -0
- package/dist/commands/sync.js +4 -3
- package/dist/commands/team.js +19 -0
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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, "
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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, "
|
|
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
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit notes connect --provider wisprflow` — signing a person into Wispr
|
|
3
|
+
* Flow's MCP server from a terminal (BLI-12926).
|
|
4
|
+
*
|
|
5
|
+
* Wispr Flow has no API key for meetings. Its Notetaker is read through a
|
|
6
|
+
* remote MCP server behind OAuth (docs.wisprflow.ai, "Connect Claude, ChatGPT
|
|
7
|
+
* or another MCP client to Wispr Flow": "no API key or token is needed"). Its
|
|
8
|
+
* authorization server publishes, at
|
|
9
|
+
* `https://mcp-auth.wisprflow.com/.well-known/oauth-authorization-server`
|
|
10
|
+
* (read 2026-10-08), dynamic client registration, PKCE `S256`, a refresh-token
|
|
11
|
+
* grant and the RFC 8628 DEVICE grant. The device grant is the one a terminal
|
|
12
|
+
* can use: it prints an address and a short code, the person approves in their
|
|
13
|
+
* own browser (Google, Apple, Microsoft or company single sign-on; an
|
|
14
|
+
* email-and-password Wispr account cannot complete MCP authorization), and this
|
|
15
|
+
* file polls until Wispr hands back a refresh token.
|
|
16
|
+
*
|
|
17
|
+
* What leaves this machine: a client registration, then the device and token
|
|
18
|
+
* calls, all to Wispr's own authorization server. The refresh token goes into a
|
|
19
|
+
* bundle (`{ v, client_id, refresh_token }`) that `connect` posts to Tower's
|
|
20
|
+
* accounts door, which seals it. It is never printed, never written to a file
|
|
21
|
+
* and never put in a flag; `--json` shows the account, not the bundle.
|
|
22
|
+
*
|
|
23
|
+
* **No live call has been made from this code.** Every shape is the RFC's, and
|
|
24
|
+
* the first real sign-in is the proof. In particular it is NOT known whether
|
|
25
|
+
* Wispr's server accepts a dynamically registered client on the device grant
|
|
26
|
+
* (the document lists both, not that they combine); if it does not, the refusal
|
|
27
|
+
* below says what Wispr answered and `--key-stdin` accepts a bundle made by any
|
|
28
|
+
* other means.
|
|
29
|
+
*
|
|
30
|
+
* stdout carries nothing from here; every line is stderr commentary.
|
|
31
|
+
*/
|
|
32
|
+
import { writeLine } from "./cli-io.js";
|
|
33
|
+
export const WISPRFLOW_AUTH_ISSUER = "https://mcp-auth.wisprflow.com";
|
|
34
|
+
export const WISPRFLOW_MCP_RESOURCE = "https://api.wisprflow.ai/connect/mcp";
|
|
35
|
+
const SCOPE = "openid offline_access";
|
|
36
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
37
|
+
const REQUEST_DEADLINE_MS = 30_000;
|
|
38
|
+
/** RFC 8628: a client polls no faster than the server's interval, default 5 s. */
|
|
39
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
40
|
+
/** Never wait on a person longer than this, whatever the server says. */
|
|
41
|
+
const MAX_WAIT_SECONDS = 15 * 60;
|
|
42
|
+
const TAG = "[notes wisprflow signin]";
|
|
43
|
+
export async function signInToWisprflow(options) {
|
|
44
|
+
const { io } = options;
|
|
45
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
46
|
+
const now = options.now ?? Date.now;
|
|
47
|
+
const metadata = await readMetadata(io);
|
|
48
|
+
if (!metadata.ok)
|
|
49
|
+
return metadata;
|
|
50
|
+
// 1. Register a client. A public client: no secret, only an id.
|
|
51
|
+
const registered = await postJson(io, metadata.value.registration_endpoint, {
|
|
52
|
+
client_name: "Tower (BLI) notes sync",
|
|
53
|
+
grant_types: [DEVICE_GRANT, "refresh_token"],
|
|
54
|
+
response_types: [],
|
|
55
|
+
token_endpoint_auth_method: "none",
|
|
56
|
+
scope: SCOPE,
|
|
57
|
+
});
|
|
58
|
+
if (!registered.ok)
|
|
59
|
+
return explain(io, "registration", registered);
|
|
60
|
+
const clientId = stringField(registered.body, "client_id");
|
|
61
|
+
if (!clientId) {
|
|
62
|
+
return { ok: false, reason: "wisprflow_registration_unusable", detail: "Wispr Flow accepted the registration but sent no client id." };
|
|
63
|
+
}
|
|
64
|
+
// 2. Ask for a code.
|
|
65
|
+
const device = await postForm(io, metadata.value.device_authorization_endpoint, {
|
|
66
|
+
client_id: clientId,
|
|
67
|
+
scope: SCOPE,
|
|
68
|
+
resource: WISPRFLOW_MCP_RESOURCE,
|
|
69
|
+
});
|
|
70
|
+
if (!device.ok)
|
|
71
|
+
return explain(io, "device code", device);
|
|
72
|
+
const deviceCode = stringField(device.body, "device_code");
|
|
73
|
+
const userCode = stringField(device.body, "user_code");
|
|
74
|
+
const address = stringField(device.body, "verification_uri_complete") ?? stringField(device.body, "verification_uri");
|
|
75
|
+
if (!deviceCode || !userCode || !address) {
|
|
76
|
+
return { ok: false, reason: "wisprflow_device_unusable", detail: "Wispr Flow's device answer was missing its code or its address." };
|
|
77
|
+
}
|
|
78
|
+
const waitSeconds = Math.min(numberField(device.body, "expires_in") ?? MAX_WAIT_SECONDS, MAX_WAIT_SECONDS);
|
|
79
|
+
let intervalMs = (numberField(device.body, "interval") ?? DEFAULT_INTERVAL_SECONDS) * 1000;
|
|
80
|
+
writeLine(io.stderr, "Sign in to Wispr Flow to let Tower read your meeting notes:");
|
|
81
|
+
writeLine(io.stderr, ` 1. Open ${address}`);
|
|
82
|
+
writeLine(io.stderr, ` 2. If it asks, enter the code ${userCode}`);
|
|
83
|
+
writeLine(io.stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
|
|
84
|
+
writeLine(io.stderr, `Waiting up to ${Math.round(waitSeconds / 60)} minutes for you to approve...`);
|
|
85
|
+
log(io, "waiting", { interval_s: intervalMs / 1000, wait_s: waitSeconds });
|
|
86
|
+
// 3. Poll.
|
|
87
|
+
const deadline = now() + waitSeconds * 1000;
|
|
88
|
+
while (now() < deadline) {
|
|
89
|
+
await sleep(intervalMs);
|
|
90
|
+
const polled = await postForm(io, metadata.value.token_endpoint, {
|
|
91
|
+
grant_type: DEVICE_GRANT,
|
|
92
|
+
device_code: deviceCode,
|
|
93
|
+
client_id: clientId,
|
|
94
|
+
resource: WISPRFLOW_MCP_RESOURCE,
|
|
95
|
+
});
|
|
96
|
+
if (polled.ok) {
|
|
97
|
+
const refreshToken = stringField(polled.body, "refresh_token");
|
|
98
|
+
if (!refreshToken) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: "wisprflow_no_offline_access",
|
|
102
|
+
detail: "Wispr Flow signed you in but did not hand back a refresh token, so Tower could not read your meetings"
|
|
103
|
+
+ " every half hour without you. Nothing was connected.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
log(io, "signed in", { client_registered: true, refresh_token_received: true });
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
bundle: JSON.stringify({ v: 1, client_id: clientId, refresh_token: refreshToken }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const code = stringField(polled.body, "error");
|
|
113
|
+
if (code === "authorization_pending")
|
|
114
|
+
continue;
|
|
115
|
+
if (code === "slow_down") {
|
|
116
|
+
intervalMs += 5000;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (code === "expired_token") {
|
|
120
|
+
return { ok: false, reason: "wisprflow_code_expired", detail: "The code expired before it was approved. Run the command again." };
|
|
121
|
+
}
|
|
122
|
+
if (code === "access_denied") {
|
|
123
|
+
return { ok: false, reason: "wisprflow_denied", detail: "Wispr Flow says the request was declined. Nothing was connected." };
|
|
124
|
+
}
|
|
125
|
+
return explain(io, "sign-in", polled);
|
|
126
|
+
}
|
|
127
|
+
return { ok: false, reason: "wisprflow_timed_out", detail: "Nobody approved the code in time. Run the command again." };
|
|
128
|
+
}
|
|
129
|
+
/** Metadata only: a step, a status and an OAuth error label. Never a code or a token. */
|
|
130
|
+
function log(io, message, fields) {
|
|
131
|
+
writeLine(io.stderr, `${TAG} ${message} ${JSON.stringify(fields)}`);
|
|
132
|
+
}
|
|
133
|
+
async function readMetadata(io) {
|
|
134
|
+
const got = await send(io, `${WISPRFLOW_AUTH_ISSUER}/.well-known/oauth-authorization-server`, { method: "GET" });
|
|
135
|
+
if (!got.ok)
|
|
136
|
+
return explain(io, "discovery", got);
|
|
137
|
+
const registration = stringField(got.body, "registration_endpoint");
|
|
138
|
+
const device = stringField(got.body, "device_authorization_endpoint");
|
|
139
|
+
const token = stringField(got.body, "token_endpoint");
|
|
140
|
+
if (!registration || !device || !token) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: "wisprflow_discovery_changed",
|
|
144
|
+
detail: "Wispr Flow's sign-in no longer advertises client registration and the device flow. Tower cannot sign you in from a terminal until it does.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Every endpoint must stay on Wispr's own host: a discovery document that
|
|
148
|
+
// points the token call somewhere else is not one to send a code to.
|
|
149
|
+
for (const endpoint of [registration, device, token]) {
|
|
150
|
+
if (!endpoint.startsWith(`${WISPRFLOW_AUTH_ISSUER}/`)) {
|
|
151
|
+
return { ok: false, reason: "wisprflow_discovery_changed", detail: "Wispr Flow's sign-in points at a host Tower does not recognise, so nothing was sent." };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, value: { registration_endpoint: registration, device_authorization_endpoint: device, token_endpoint: token } };
|
|
155
|
+
}
|
|
156
|
+
async function postJson(io, url, body) {
|
|
157
|
+
return send(io, url, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
160
|
+
body: JSON.stringify(body),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async function postForm(io, url, body) {
|
|
164
|
+
return send(io, url, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
167
|
+
body: new URLSearchParams(body).toString(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async function send(io, url, init) {
|
|
171
|
+
const call = io.fetch;
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const deadline = setTimeout(() => controller.abort(), REQUEST_DEADLINE_MS);
|
|
174
|
+
try {
|
|
175
|
+
const response = await call(url, { ...init, signal: controller.signal });
|
|
176
|
+
let body = null;
|
|
177
|
+
try {
|
|
178
|
+
body = await response.json();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
body = null;
|
|
182
|
+
}
|
|
183
|
+
return response.ok ? { ok: true, body } : { ok: false, status: response.status, body, network: false };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return { ok: false, status: 0, body: null, network: true };
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
clearTimeout(deadline);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** A refusal in the vendor's own words: its OAuth error label, never a body dump. */
|
|
193
|
+
function explain(io, step, failure) {
|
|
194
|
+
if (failure.network) {
|
|
195
|
+
return { ok: false, reason: "wisprflow_unreachable", detail: `Could not reach Wispr Flow's sign-in during ${step}. Check the connection and try again.` };
|
|
196
|
+
}
|
|
197
|
+
const label = stringField(failure.body, "error");
|
|
198
|
+
const words = stringField(failure.body, "error_description");
|
|
199
|
+
log(io, "refused", { step, http_status: failure.status, oauth_error: label ?? "none" });
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
reason: `wisprflow_${step.replace(/\s+/g, "_")}_refused`,
|
|
203
|
+
detail: `Wispr Flow refused the ${step} step (HTTP ${failure.status}${label ? `, ${label}` : ""}).`
|
|
204
|
+
+ `${words ? ` It said: ${words.slice(0, 200)}` : ""}`
|
|
205
|
+
+ " Nothing was connected.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function stringField(body, name) {
|
|
209
|
+
if (typeof body !== "object" || body === null)
|
|
210
|
+
return null;
|
|
211
|
+
const value = body[name];
|
|
212
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
213
|
+
}
|
|
214
|
+
function numberField(body, name) {
|
|
215
|
+
if (typeof body !== "object" || body === null)
|
|
216
|
+
return null;
|
|
217
|
+
const value = body[name];
|
|
218
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
219
|
+
}
|
package/dist/commands/notes.js
CHANGED
|
@@ -45,10 +45,12 @@ import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-
|
|
|
45
45
|
import { runFolderCommand } from "./notes-folders.js";
|
|
46
46
|
import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
|
|
47
47
|
import { showGranolaStatus } from "./notes-granola-status.js";
|
|
48
|
+
import { listRecordings, showRecording } from "./notes-recordings.js";
|
|
48
49
|
export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
49
50
|
export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
|
|
50
51
|
export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
|
|
51
52
|
export { showGranolaStatus } from "./notes-granola-status.js";
|
|
53
|
+
export { listRecordings, showRecording } from "./notes-recordings.js";
|
|
52
54
|
export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
|
|
53
55
|
export async function runNotes(command, io) {
|
|
54
56
|
const session = await loadPairedSession("notes", command.homeDir);
|
|
@@ -98,6 +100,10 @@ export async function runNotes(command, io) {
|
|
|
98
100
|
return syncNotetaker(command, door);
|
|
99
101
|
case "sync-status":
|
|
100
102
|
return syncStatusNotetakers(command, door);
|
|
103
|
+
case "recordings":
|
|
104
|
+
return listRecordings(command, door);
|
|
105
|
+
case "recording":
|
|
106
|
+
return showRecording(command, door);
|
|
101
107
|
case "webhook":
|
|
102
108
|
return armNotesWebhook(command, door);
|
|
103
109
|
case "granola-status":
|