@bli-cockpit/cli 0.2.255 → 0.2.257
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-session-root-scope.js +19 -0
- package/dist/commands/backfill-outside-roots.js +7 -3
- package/dist/commands/codex-mcp-table.js +185 -0
- package/dist/commands/doctor-registration.js +5 -1
- package/dist/commands/local-args-tower-notes.js +18 -1
- package/dist/commands/local-command-shapes-notes.js +1 -0
- package/dist/commands/local-help-commands-tower.js +2 -46
- package/dist/commands/local-help-notes.js +50 -0
- package/dist/commands/memory-install-codex.js +21 -72
- package/dist/commands/memory-install-toml.js +17 -1
- package/dist/commands/notes-record-capture.js +152 -0
- package/dist/commands/notes-record-wav.js +244 -0
- package/dist/commands/notes-record.js +214 -0
- package/dist/commands/notes.js +10 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-outside-roots.js +10 -5
- package/dist/commands/toml-duplicate-keys.js +192 -0
- package/dist/commands/tower-mcp-codex.js +19 -68
- package/package.json +4 -4
|
@@ -16,4 +16,23 @@ import { OUTSIDE_APPROVED_ROOT_REASONS } from "./session-sync-health.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export function rootScopeForReason(reason) {
|
|
18
18
|
return OUTSIDE_APPROVED_ROOT_REASONS.has(reason) ? "outside" : "inside";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The terminal attribution states: attribution finished and named no repo. They
|
|
22
|
+
* are labels, never a reason to withhold (session-first collection).
|
|
23
|
+
*/
|
|
24
|
+
const TERMINAL_ATTRIBUTION_STATES = new Set(["ambiguous", "unattributed", "skipped"]);
|
|
25
|
+
/**
|
|
26
|
+
* Reasons a session carries no workspace of its own that are NOT an outside-root
|
|
27
|
+
* fact, so `rootScopeForReason` keeps calling them "inside". Today: a transcript
|
|
28
|
+
* with no cwd, workspace or origin signal at all (`no_repo_signals`).
|
|
29
|
+
*/
|
|
30
|
+
export const NO_WORKSPACE_REASONS = new Set(["no_repo_signals"]);
|
|
31
|
+
/**
|
|
32
|
+
* Does this session still lack a workspace the upload can stand in? Every
|
|
33
|
+
* terminal state without a worktree rides the collector's synthetic workspace,
|
|
34
|
+
* whatever its reason; attribution stays exactly as scored.
|
|
35
|
+
*/
|
|
36
|
+
export function needsTransportWorkspace(result) {
|
|
37
|
+
return !result.worktree && TERMINAL_ATTRIBUTION_STATES.has(result.state);
|
|
19
38
|
}
|
|
@@ -24,7 +24,7 @@ import fs from "node:fs/promises";
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
26
26
|
import { repoFingerprintFromLocalRoot, stableWorktreeFingerprint, } from "../repo-identity.js";
|
|
27
|
-
import { rootScopeForReason } from "./agent-session-root-scope.js";
|
|
27
|
+
import { NO_WORKSPACE_REASONS, needsTransportWorkspace, rootScopeForReason } from "./agent-session-root-scope.js";
|
|
28
28
|
/**
|
|
29
29
|
* The label the sync path writes for a report with no real worktree
|
|
30
30
|
* (`session-sync-record.ts`). Reused verbatim: one label, not a second one.
|
|
@@ -64,18 +64,22 @@ export function isOutsideRootCandidate(candidate) {
|
|
|
64
64
|
export function labelOutsideRootCandidates(candidates, workspace) {
|
|
65
65
|
let codex = 0;
|
|
66
66
|
let claude = 0;
|
|
67
|
+
let noRepoSignals = 0;
|
|
67
68
|
const labelled = candidates.map((candidate) => {
|
|
68
|
-
if (
|
|
69
|
+
if (!needsTransportWorkspace(candidate))
|
|
69
70
|
return candidate;
|
|
70
71
|
if (candidate.source === "codex")
|
|
71
72
|
codex += 1;
|
|
72
73
|
else
|
|
73
74
|
claude += 1;
|
|
75
|
+
if (NO_WORKSPACE_REASONS.has(candidate.reason))
|
|
76
|
+
noRepoSignals += 1;
|
|
74
77
|
return { ...candidate, worktree: workspace };
|
|
75
78
|
});
|
|
76
79
|
console.error("[backfill] outside-root sessions labelled for upload", JSON.stringify({
|
|
77
|
-
|
|
80
|
+
reason: "unattributed_sessions_labelled_for_upload",
|
|
78
81
|
count: codex + claude,
|
|
82
|
+
no_repo_signals_count: noRepoSignals,
|
|
79
83
|
codex_count: codex,
|
|
80
84
|
claude_count: claude,
|
|
81
85
|
candidate_count: candidates.length,
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { findTomlTableSpan, tableContentEnd, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
|
|
2
|
+
import { findTomlDuplicateKey } from "./toml-duplicate-keys.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads EVERY env sub-table before removing any (BLI-22766 review: reading one
|
|
5
|
+
* span and then deleting all of them lost the second table's keys), then the
|
|
6
|
+
* inline form from what is left.
|
|
7
|
+
*/
|
|
8
|
+
export function readStoredCodexEnv(text, tablePath) {
|
|
9
|
+
const envPath = [...tablePath, "env"];
|
|
10
|
+
const subTables = [];
|
|
11
|
+
let rest = text;
|
|
12
|
+
while (findTomlTableSpan(rest, envPath)) {
|
|
13
|
+
subTables.push(stringEntries(readTomlTable(rest, envPath) ?? {}));
|
|
14
|
+
rest = removeTableKeepingTrailingNotes(rest, envPath);
|
|
15
|
+
}
|
|
16
|
+
const inlineValue = readTomlTable(rest, tablePath)?.["env"];
|
|
17
|
+
return {
|
|
18
|
+
inline: inlineValue && typeof inlineValue === "object" && !Array.isArray(inlineValue)
|
|
19
|
+
? inlineValue
|
|
20
|
+
: null,
|
|
21
|
+
subTables,
|
|
22
|
+
withoutSubTables: rest,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The env we write: our owned keys win, then the first-seen person value
|
|
27
|
+
* (inline form first, then sub-tables in file order). No key is dropped.
|
|
28
|
+
*/
|
|
29
|
+
export function mergeCodexEnv(stored, owned) {
|
|
30
|
+
const merged = {};
|
|
31
|
+
for (const form of [stored.inline ?? {}, ...stored.subTables]) {
|
|
32
|
+
for (const [key, value] of Object.entries(form)) {
|
|
33
|
+
if (!(key in merged))
|
|
34
|
+
merged[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...merged, ...owned };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* True when the stored table already says what we would write. A table with
|
|
41
|
+
* env in more than one form (inline plus a sub-table, or two sub-tables) never
|
|
42
|
+
* matches: it is the broken shape, and only a write repairs it. One sub-table
|
|
43
|
+
* alone matches, so a config Codex itself wrote is not churned every tick.
|
|
44
|
+
*/
|
|
45
|
+
export function codexServerTableMatches(raw, tablePath, config) {
|
|
46
|
+
const stored = readStoredCodexEnv(raw, tablePath);
|
|
47
|
+
const table = readTomlTable(stored.withoutSubTables, tablePath);
|
|
48
|
+
if (!table)
|
|
49
|
+
return false;
|
|
50
|
+
if (table["command"] !== config.mcp_server.command)
|
|
51
|
+
return false;
|
|
52
|
+
const args = table["args"];
|
|
53
|
+
if (!Array.isArray(args)) {
|
|
54
|
+
if (config.mcp_server.args.length !== 0)
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (args.length !== config.mcp_server.args.length)
|
|
59
|
+
return false;
|
|
60
|
+
if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const hasInlineKey = Object.prototype.hasOwnProperty.call(table, "env");
|
|
64
|
+
const forms = (hasInlineKey ? 1 : 0) + stored.subTables.length;
|
|
65
|
+
if (forms > 1)
|
|
66
|
+
return false;
|
|
67
|
+
const env = stored.inline ?? stored.subTables[0] ?? {};
|
|
68
|
+
return Object.entries(config.mcp_server.env).every(([key, value]) => env[key] === value);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Writes our table in the inline form, folding in and removing every env
|
|
72
|
+
* sub-table. Returns the new file text and how many env keys came from the
|
|
73
|
+
* person's own forms (for the log line; never the values).
|
|
74
|
+
*/
|
|
75
|
+
export function upsertCodexServerTable(raw, tablePath, config) {
|
|
76
|
+
const stored = readStoredCodexEnv(raw, tablePath);
|
|
77
|
+
const env = mergeCodexEnv(stored, config.mcp_server.env);
|
|
78
|
+
const keptKeys = Object.keys(env).filter((key) => !(key in config.mcp_server.env)).length;
|
|
79
|
+
const entries = [
|
|
80
|
+
["command", config.mcp_server.command],
|
|
81
|
+
["args", config.mcp_server.args],
|
|
82
|
+
];
|
|
83
|
+
if (Object.keys(env).length > 0)
|
|
84
|
+
entries.push(["env", env]);
|
|
85
|
+
const text = upsertTomlTable(stored.withoutSubTables, tablePath, renderTomlTable(tablePath, entries));
|
|
86
|
+
return { text, keptKeys, removedSubTable: stored.subTables.length > 0 };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The verdict `cockpit doctor` and the installers attach to a Codex config
|
|
90
|
+
* that Codex cannot load. Null when the file is absent or loads. `ignorePaths`
|
|
91
|
+
* lets a writer verifying its own work skip a duplicate inside the OTHER Tower
|
|
92
|
+
* server's table, which that server's own writer repairs on the same run.
|
|
93
|
+
*/
|
|
94
|
+
export function codexConfigUnparseable(raw, file, target, ignorePaths = []) {
|
|
95
|
+
if (raw === null)
|
|
96
|
+
return null;
|
|
97
|
+
const found = findTomlDuplicateKey(raw);
|
|
98
|
+
if (!found)
|
|
99
|
+
return null;
|
|
100
|
+
if (ignorePaths.some((ignored) => isWithin(found.key, ignored)))
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
target,
|
|
104
|
+
status: "failed",
|
|
105
|
+
reason: "config_unparseable",
|
|
106
|
+
path: file,
|
|
107
|
+
detail: `${file} line ${found.line}: the key ${found.key} is defined twice, so Codex will not start;`
|
|
108
|
+
+ " `cockpit memory install` repairs it when the key is one of Tower's own servers",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function isWithin(key, tablePath) {
|
|
112
|
+
const prefix = tablePath.join(".");
|
|
113
|
+
return key === prefix || key.startsWith(`${prefix}.`);
|
|
114
|
+
}
|
|
115
|
+
function stringEntries(values) {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [key, value] of Object.entries(values)) {
|
|
118
|
+
if (typeof value === "string")
|
|
119
|
+
out[key] = value;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Removes a table's header and key lines, but keeps the comments and blank
|
|
125
|
+
* lines at its tail: those usually introduce the NEXT section, which is not
|
|
126
|
+
* ours to delete.
|
|
127
|
+
*/
|
|
128
|
+
function removeTableKeepingTrailingNotes(text, tablePath) {
|
|
129
|
+
const span = findTomlTableSpan(text, tablePath);
|
|
130
|
+
if (!span)
|
|
131
|
+
return text;
|
|
132
|
+
const lines = text.split("\n");
|
|
133
|
+
const end = tableContentEnd(lines, span);
|
|
134
|
+
return [...lines.slice(0, span.start_line), ...lines.slice(end)].join("\n");
|
|
135
|
+
}
|
|
136
|
+
/** The one write path for both servers' Codex tables. */
|
|
137
|
+
export async function applyCodexServerTable(options) {
|
|
138
|
+
const { file, target, tablePath, siblingPath } = options;
|
|
139
|
+
const raw = (await options.io.readText(file)) ?? "";
|
|
140
|
+
if (codexServerTableMatches(raw, tablePath, options.config)) {
|
|
141
|
+
// Our table being current does not make the file loadable.
|
|
142
|
+
const broken = codexConfigUnparseable(raw, file, target, [siblingPath]);
|
|
143
|
+
if (broken) {
|
|
144
|
+
console.error("[memory-install] codex config does not load", JSON.stringify({ target, reason: broken.reason }));
|
|
145
|
+
return broken;
|
|
146
|
+
}
|
|
147
|
+
return { target, status: "already", reason: "already_current", path: file };
|
|
148
|
+
}
|
|
149
|
+
const next = upsertCodexServerTable(raw, tablePath, options.config);
|
|
150
|
+
if (options.dryRun)
|
|
151
|
+
return { target, status: "would_install", reason: "dry_run", path: file };
|
|
152
|
+
if (next.removedSubTable) {
|
|
153
|
+
console.error("[memory-install] folded a separate env table into the inline env", JSON.stringify({ target, kept_keys: next.keptKeys }));
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await options.io.writeText(file, next.text);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return {
|
|
160
|
+
target,
|
|
161
|
+
status: "failed",
|
|
162
|
+
reason: "write_failed",
|
|
163
|
+
path: file,
|
|
164
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// BLI-2541: parse the table back out of the stored bytes the way Codex will
|
|
168
|
+
// read it. A successful write is not a correct registration.
|
|
169
|
+
const stored = await options.io.readText(file);
|
|
170
|
+
if (stored === null || !codexServerTableMatches(stored, tablePath, options.config)) {
|
|
171
|
+
return {
|
|
172
|
+
target,
|
|
173
|
+
status: "failed",
|
|
174
|
+
reason: "read_back_mismatch",
|
|
175
|
+
path: file,
|
|
176
|
+
detail: "the table on disk does not parse back to the entry that was written",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const broken = codexConfigUnparseable(stored, file, target, [siblingPath]);
|
|
180
|
+
if (broken) {
|
|
181
|
+
console.error("[memory-install] codex config still does not load after our write", JSON.stringify({ target, reason: broken.reason }));
|
|
182
|
+
return broken;
|
|
183
|
+
}
|
|
184
|
+
return { target, status: "installed", reason: "wrote_entry", path: file };
|
|
185
|
+
}
|
|
@@ -100,7 +100,11 @@ function memoryStepState(outcome, phase) {
|
|
|
100
100
|
const broken = outcome.targets.filter((target) => target.target !== "bin" && target.status === "failed");
|
|
101
101
|
if (broken.length > 0) {
|
|
102
102
|
const first = broken[0];
|
|
103
|
-
|
|
103
|
+
// BLI-22766: an unloadable Codex config is named by file and line, so the
|
|
104
|
+
// person can find it even when the repair cannot (a duplicate in their own
|
|
105
|
+
// section of the file).
|
|
106
|
+
const named = first?.reason === "config_unparseable" && first.detail ? ` (${first.detail})` : "";
|
|
107
|
+
const message = `${first?.target ?? "memory"}: ${first?.reason ?? "failed"}${named}`;
|
|
104
108
|
return phase === "fix"
|
|
105
109
|
? fail("memory-registered", first?.reason ?? "memory_install_failed", message)
|
|
106
110
|
: needsFix("memory-registered", first?.reason ?? "memory_install_failed", message);
|
|
@@ -53,6 +53,7 @@ const NOTES_ACTIONS = new Set([
|
|
|
53
53
|
"granola-status",
|
|
54
54
|
"transcription-stats",
|
|
55
55
|
"bot-upcoming",
|
|
56
|
+
"record",
|
|
56
57
|
]);
|
|
57
58
|
/** Actions whose first positional is the note it acts on. */
|
|
58
59
|
const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
|
|
@@ -141,6 +142,7 @@ export function parseNotesArgs(args) {
|
|
|
141
142
|
const workspace = optionalNonEmpty(values.flags.get("--workspace"));
|
|
142
143
|
if (action === "connect")
|
|
143
144
|
rejectBadConnectRequest(values, provider, workspace, rest);
|
|
145
|
+
const record = action === "record" ? readRecordRequest(rest) : undefined;
|
|
144
146
|
const paths = readNotesPaths(action, rest);
|
|
145
147
|
if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
|
|
146
148
|
throw new Error("notes rename needs --name <name>.");
|
|
@@ -163,6 +165,7 @@ export function parseNotesArgs(args) {
|
|
|
163
165
|
...(showWebhook ? { showWebhook: true } : {}),
|
|
164
166
|
...(action === "connect" && values.booleans.has("--key-stdin") ? { keyStdin: true } : {}),
|
|
165
167
|
...(paths ? { paths } : {}),
|
|
168
|
+
...(record ? record : {}),
|
|
166
169
|
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
167
170
|
name: optionalNonEmpty(values.flags.get("--name")),
|
|
168
171
|
exclude: optionalNonEmpty(values.flags.get("--exclude")),
|
|
@@ -187,10 +190,23 @@ function readNotesAction(first) {
|
|
|
187
190
|
// almost always wants — same reasoning as bare `cockpit` running convergence.
|
|
188
191
|
const action = (first === undefined ? "list" : first);
|
|
189
192
|
if (!NOTES_ACTIONS.has(action)) {
|
|
190
|
-
throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats.`);
|
|
193
|
+
throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats (and record start|stop|status|cancel).`);
|
|
191
194
|
}
|
|
192
195
|
return action;
|
|
193
196
|
}
|
|
197
|
+
const RECORD_VERBS = ["start", "stop", "status", "cancel"];
|
|
198
|
+
/** BLI-22979: `record <start|stop|status|cancel>`; only `stop` takes a session id (to finish a recoverable one). */
|
|
199
|
+
function readRecordRequest(rest) {
|
|
200
|
+
const verb = rest[0];
|
|
201
|
+
if (!verb || !RECORD_VERBS.includes(verb)) {
|
|
202
|
+
throw new Error("notes record needs one of: start, stop, status, cancel.");
|
|
203
|
+
}
|
|
204
|
+
if (verb === "stop" && rest.length === 2 && rest[1].trim())
|
|
205
|
+
return { recordAction: verb, sessionId: rest[1].trim() };
|
|
206
|
+
if (rest.length > 1)
|
|
207
|
+
throw new Error(`notes record ${verb} does not take "${rest[1]}".`);
|
|
208
|
+
return { recordAction: verb };
|
|
209
|
+
}
|
|
194
210
|
function readRepairTruncated(action, values) {
|
|
195
211
|
const repairTruncated = values.booleans.has("--repair-truncated");
|
|
196
212
|
if (repairTruncated && action !== "sync") {
|
|
@@ -297,6 +313,7 @@ function readNotesPaths(action, rest) {
|
|
|
297
313
|
return undefined;
|
|
298
314
|
}
|
|
299
315
|
const takesNoPositional = action !== "paste"
|
|
316
|
+
&& action !== "record"
|
|
300
317
|
&& action !== "connect"
|
|
301
318
|
&& action !== "sync"
|
|
302
319
|
&& action !== "sync-status"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Every string moved verbatim. Help output is what an intern pastes back when
|
|
12
12
|
* something breaks, so it is a user-visible contract like any other.
|
|
13
13
|
*/
|
|
14
|
+
import { NOTES_HELP } from "./local-help-notes.js";
|
|
14
15
|
import { DOCS_FILES_HELP } from "./local-help-docs-files.js";
|
|
15
16
|
import { DOCS_REFS_HELP } from "./local-help-docs-refs.js";
|
|
16
17
|
import { DOCS_SYNC_HELP } from "./local-help-docs-sync.js";
|
|
@@ -401,52 +402,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
401
402
|
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
402
403
|
],
|
|
403
404
|
],
|
|
404
|
-
|
|
405
|
-
"notes",
|
|
406
|
-
[
|
|
407
|
-
"Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>] [flags]",
|
|
408
|
-
"Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
|
|
409
|
-
"",
|
|
410
|
-
"folders [--tree] [--json]: list folders and visible note counts.",
|
|
411
|
-
"mkdir <path> [--json]: create a folder and any missing ancestors.",
|
|
412
|
-
"rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
|
|
413
|
-
"rmdir <path> [--json]: delete an empty folder; refuses notes or subfolders.",
|
|
414
|
-
"upload, paste and move accept --folder <path>; use / for Root. Shelf placement stays independent.",
|
|
415
|
-
"The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
|
|
416
|
-
"list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
|
|
417
|
-
"show <id> [--offset <chars>] [--limit <chars>] — one note with its text, its shelf, and why you are allowed to see it. A long transcript can be read in parts: --offset and --limit are characters (as in `docs read`), and the answer says the note's total size and the --offset to continue from (BLI-18006).",
|
|
418
|
-
"shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
|
|
419
|
-
"shelves — the shelves in use, with how many notes are on each.",
|
|
420
|
-
"upload <paths...> [--exclude \"<sentence>\"] — put one or more local files in. Explicit paths only; your shell does any globbing, and a file that does not exist is named rather than skipped.",
|
|
421
|
-
"paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in. --file <path> is safest (any editor encoding is decoded). Stdin works too: `pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 (`Get-Clipboard | cockpit notes paste`) — PowerShell 5.1 turns non-ASCII into `?` on pipes.",
|
|
422
|
-
"--exclude carries your own sentence about what to leave out, exactly as the browser's box does.",
|
|
423
|
-
"share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
|
|
424
|
-
"summarize <id> — write a new summary (decisions, action items, key points) where every claim cites the speaker, timestamp and segment id (s12) it came from; claims the transcript does not support are dropped. Takes up to a minute; earlier summaries are kept. `show <id>` prints the newest one.",
|
|
425
|
-
"unshare <id> — take it back. Never asks: it only ever narrows who can read.",
|
|
426
|
-
"move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
|
|
427
|
-
"place <id> [--apply] — say which shelf this note belongs on and why, from the meeting it is part of, who was in the room and its own name. Moves nothing without --apply, and never overrules a shelf somebody typed.",
|
|
428
|
-
"",
|
|
429
|
-
"Your own notetaker (BLI-4383). Fellow, Circleback, Fireflies and Wispr Flow today; Otter needs an Enterprise plan and Granola still runs on its own cron.",
|
|
430
|
-
"accounts [--json] — the notetakers you have connected, with health and when each was last read, and under it who on the team feeds Tower with which notetaker (a member with none is named: that is the gap).",
|
|
431
|
-
"recordings <note-id> [--json] — every recording of the meeting this note is. When several notetakers or people recorded one meeting, the library shows ONE note, the recording with the longest transcript; the others are alternates kept by id (BLI-12926).",
|
|
432
|
-
"recording <recording-id> [--json] — one recording, text and all, shown or alternate.",
|
|
433
|
-
"accounts --show-webhook <account-id> — that account's webhook URL, where to paste it at the vendor, and whether a signing secret is stored (BLI-4394). Until one is, every request to that URL is refused, which is the right way to be off.",
|
|
434
|
-
"webhook <account-id> --secret-stdin — store the VENDOR's webhook signing secret. THE SECRET IS READ FROM STDIN, never a flag: printf \"%s\" \"<signing secret>\" | cockpit notes webhook <account-id> --secret-stdin",
|
|
435
|
-
"connect --provider wisprflow — Wispr Flow has no API key: this asks Tower to start the sign-in (the same door the Settings button, the MCP tool notes_connect_wisprflow and JARVIS use), prints and opens Wispr's approval page (Google, Apple, Microsoft or company single sign-on), and waits up to 10 minutes for Tower to report it connected, then Tower reads your Notetaker meetings from then on. Needs Notetaker and MCP access on your Wispr account. Exits 1 with a reason if nothing connects (BLI-12926, BLI-17973).",
|
|
436
|
-
"connect --provider fellow|circleback|fireflies [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
|
|
437
|
-
"detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
|
|
438
|
-
"sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
|
|
439
|
-
"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).",
|
|
440
|
-
"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).",
|
|
441
|
-
"bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
|
|
442
|
-
"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`.",
|
|
443
|
-
"transcription-stats [--month YYYY-MM] [--json] — what meeting transcription cost and did (BLI-18003): minutes, dollars (list and real), requests, failures and fallbacks per provider for the UTC month (default: this one), the spend against the monthly cap and how close the 80% message and the hold are, how many meetings are waiting for budget, which provider keys the deploy holds (by name), and how old the numbers are. Switch providers or move the cap with `cockpit settings switches set transcription.provider|transcription.fallback_chain|transcription.monthly_cap_usd <value>`.",
|
|
444
|
-
"A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
|
|
445
|
-
"--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
|
|
446
|
-
"Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
|
|
447
|
-
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
448
|
-
],
|
|
449
|
-
],
|
|
405
|
+
...NOTES_HELP,
|
|
450
406
|
[
|
|
451
407
|
"memory",
|
|
452
408
|
[
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The `notes` help entry, split out of `local-help-commands-tower.ts`, which sits at the file-size ratchet. */
|
|
2
|
+
export const NOTES_HELP = [
|
|
3
|
+
[
|
|
4
|
+
"notes",
|
|
5
|
+
[
|
|
6
|
+
"Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel] [flags]",
|
|
7
|
+
"Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
|
|
8
|
+
"",
|
|
9
|
+
"record start|stop [<session-id>]|status|cancel — record a meeting in a room from this Mac's microphone (Apple Silicon macOS; Windows says it is not supported yet). Nothing leaves the machine while recording and there is no auto-stop. `stop` trims the silence away locally, uploads only the speech, and Tower writes a note with Speaker 1, Speaker 2... lines (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording or waiting; `stop <session-id>` finishes a cancelled or crashed session. The original recording stays on this machine.",
|
|
10
|
+
"folders [--tree] [--json]: list folders and visible note counts.",
|
|
11
|
+
"mkdir <path> [--json]: create a folder and any missing ancestors.",
|
|
12
|
+
"rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
|
|
13
|
+
"rmdir <path> [--json]: delete an empty folder; refuses notes or subfolders.",
|
|
14
|
+
"upload, paste and move accept --folder <path>; use / for Root. Shelf placement stays independent.",
|
|
15
|
+
"The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
|
|
16
|
+
"list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
|
|
17
|
+
"show <id> [--offset <chars>] [--limit <chars>] — one note with its text, its shelf, and why you are allowed to see it. A long transcript can be read in parts: --offset and --limit are characters (as in `docs read`), and the answer says the note's total size and the --offset to continue from (BLI-18006).",
|
|
18
|
+
"shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
|
|
19
|
+
"shelves — the shelves in use, with how many notes are on each.",
|
|
20
|
+
"upload <paths...> [--exclude \"<sentence>\"] — put one or more local files in. Explicit paths only; your shell does any globbing, and a file that does not exist is named rather than skipped.",
|
|
21
|
+
"paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in. --file <path> is safest (any editor encoding is decoded). Stdin works too: `pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 (`Get-Clipboard | cockpit notes paste`) — PowerShell 5.1 turns non-ASCII into `?` on pipes.",
|
|
22
|
+
"--exclude carries your own sentence about what to leave out, exactly as the browser's box does.",
|
|
23
|
+
"share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
|
|
24
|
+
"summarize <id> — write a new summary (decisions, action items, key points) where every claim cites the speaker, timestamp and segment id (s12) it came from; claims the transcript does not support are dropped. Takes up to a minute; earlier summaries are kept. `show <id>` prints the newest one.",
|
|
25
|
+
"unshare <id> — take it back. Never asks: it only ever narrows who can read.",
|
|
26
|
+
"move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
|
|
27
|
+
"place <id> [--apply] — say which shelf this note belongs on and why, from the meeting it is part of, who was in the room and its own name. Moves nothing without --apply, and never overrules a shelf somebody typed.",
|
|
28
|
+
"",
|
|
29
|
+
"Your own notetaker (BLI-4383). Fellow, Circleback, Fireflies and Wispr Flow today; Otter needs an Enterprise plan and Granola still runs on its own cron.",
|
|
30
|
+
"accounts [--json] — the notetakers you have connected, with health and when each was last read, and under it who on the team feeds Tower with which notetaker (a member with none is named: that is the gap).",
|
|
31
|
+
"recordings <note-id> [--json] — every recording of the meeting this note is. When several notetakers or people recorded one meeting, the library shows ONE note, the recording with the longest transcript; the others are alternates kept by id (BLI-12926).",
|
|
32
|
+
"recording <recording-id> [--json] — one recording, text and all, shown or alternate.",
|
|
33
|
+
"accounts --show-webhook <account-id> — that account's webhook URL, where to paste it at the vendor, and whether a signing secret is stored (BLI-4394). Until one is, every request to that URL is refused, which is the right way to be off.",
|
|
34
|
+
"webhook <account-id> --secret-stdin — store the VENDOR's webhook signing secret. THE SECRET IS READ FROM STDIN, never a flag: printf \"%s\" \"<signing secret>\" | cockpit notes webhook <account-id> --secret-stdin",
|
|
35
|
+
"connect --provider wisprflow — Wispr Flow has no API key: this asks Tower to start the sign-in (the same door the Settings button, the MCP tool notes_connect_wisprflow and JARVIS use), prints and opens Wispr's approval page (Google, Apple, Microsoft or company single sign-on), and waits up to 10 minutes for Tower to report it connected, then Tower reads your Notetaker meetings from then on. Needs Notetaker and MCP access on your Wispr account. Exits 1 with a reason if nothing connects (BLI-12926, BLI-17973).",
|
|
36
|
+
"connect --provider fellow|circleback|fireflies [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
|
|
37
|
+
"detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
|
|
38
|
+
"sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
|
|
39
|
+
"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).",
|
|
40
|
+
"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).",
|
|
41
|
+
"bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
|
|
42
|
+
"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`.",
|
|
43
|
+
"transcription-stats [--month YYYY-MM] [--json] — what meeting transcription cost and did (BLI-18003): minutes, dollars (list and real), requests, failures and fallbacks per provider for the UTC month (default: this one), the spend against the monthly cap and how close the 80% message and the hold are, how many meetings are waiting for budget, which provider keys the deploy holds (by name), and how old the numbers are. Switch providers or move the cap with `cockpit settings switches set transcription.provider|transcription.fallback_chain|transcription.monthly_cap_usd <value>`.",
|
|
44
|
+
"A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
|
|
45
|
+
"--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
|
|
46
|
+
"Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
|
|
47
|
+
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
48
|
+
],
|
|
49
|
+
],
|
|
50
|
+
];
|
|
@@ -29,10 +29,14 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
|
|
32
|
-
import { findTomlTableSpan
|
|
32
|
+
import { findTomlTableSpan } from "./memory-install-toml.js";
|
|
33
33
|
import { memoryCodexSkillFiles } from "./memory-install-skills.js";
|
|
34
34
|
import { installCodexMemoryHooks, inspectCodexMemoryHooks, } from "./memory-install-codex-hooks.js";
|
|
35
|
+
import { applyCodexServerTable, codexConfigUnparseable, codexServerTableMatches, } from "./codex-mcp-table.js";
|
|
36
|
+
import { TOWER_MCP_SERVER_ID } from "./tower-mcp-contract.js";
|
|
35
37
|
const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
|
|
38
|
+
/** `bli-tower`'s writer runs right after this one and repairs its own table. */
|
|
39
|
+
const SIBLING_TOML_PATH = ["mcp_servers", TOWER_MCP_SERVER_ID];
|
|
36
40
|
export function codexConfigFile(homeDir) {
|
|
37
41
|
return path.join(homeDir, ".codex", "config.toml");
|
|
38
42
|
}
|
|
@@ -51,13 +55,15 @@ export async function installCodexMemoryIntegration(options) {
|
|
|
51
55
|
export async function inspectCodexMemoryIntegration(options) {
|
|
52
56
|
const file = codexConfigFile(options.homeDir);
|
|
53
57
|
const raw = await options.io.readText(file);
|
|
58
|
+
// BLI-22766: a file Codex cannot load is named first, whatever our table says.
|
|
54
59
|
const mcp = raw === null
|
|
55
60
|
? { target: "codex_mcp", status: "missing", reason: "file_absent", path: file }
|
|
56
|
-
:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
: codexConfigUnparseable(raw, file, "codex_mcp") ??
|
|
62
|
+
(codexServerTableMatches(raw, MEMORY_TOML_PATH, options.config)
|
|
63
|
+
? { target: "codex_mcp", status: "installed", reason: "already_current", path: file }
|
|
64
|
+
: findTomlTableSpan(raw, MEMORY_TOML_PATH)
|
|
65
|
+
? { target: "codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
|
|
66
|
+
: { target: "codex_mcp", status: "missing", reason: "entry_absent", path: file });
|
|
61
67
|
const directory = codexSkillDirectory(options.homeDir);
|
|
62
68
|
const files = memoryCodexSkillFiles();
|
|
63
69
|
let present = 0;
|
|
@@ -85,40 +91,15 @@ export async function inspectCodexMemoryIntegration(options) {
|
|
|
85
91
|
return [mcp, skills, await inspectCodexMemoryHooks(options)];
|
|
86
92
|
}
|
|
87
93
|
async function applyCodexMcpTable(options) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
try {
|
|
98
|
-
await options.io.writeText(file, next);
|
|
99
|
-
}
|
|
100
|
-
catch (error) {
|
|
101
|
-
return {
|
|
102
|
-
target: "codex_mcp",
|
|
103
|
-
status: "failed",
|
|
104
|
-
reason: "write_failed",
|
|
105
|
-
path: file,
|
|
106
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
// BLI-2541: parse the table back out of the stored bytes the way Codex will
|
|
110
|
-
// read it. A successful write is not a correct registration.
|
|
111
|
-
const stored = await options.io.readText(file);
|
|
112
|
-
if (stored === null || !tableMatches(stored, options.config)) {
|
|
113
|
-
return {
|
|
114
|
-
target: "codex_mcp",
|
|
115
|
-
status: "failed",
|
|
116
|
-
reason: "read_back_mismatch",
|
|
117
|
-
path: file,
|
|
118
|
-
detail: "the table on disk does not parse back to the entry that was written",
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
return { target: "codex_mcp", status: "installed", reason: "wrote_entry", path: file };
|
|
94
|
+
return applyCodexServerTable({
|
|
95
|
+
file: codexConfigFile(options.homeDir),
|
|
96
|
+
target: "codex_mcp",
|
|
97
|
+
tablePath: MEMORY_TOML_PATH,
|
|
98
|
+
siblingPath: SIBLING_TOML_PATH,
|
|
99
|
+
config: options.config,
|
|
100
|
+
dryRun: options.dryRun,
|
|
101
|
+
io: options.io,
|
|
102
|
+
});
|
|
122
103
|
}
|
|
123
104
|
async function applyCodexSkills(options) {
|
|
124
105
|
const directory = codexSkillDirectory(options.homeDir);
|
|
@@ -184,36 +165,4 @@ async function findStaleCodexSkillFiles(directory, files, io) {
|
|
|
184
165
|
stale.push(relative);
|
|
185
166
|
}
|
|
186
167
|
return stale;
|
|
187
|
-
}
|
|
188
|
-
export function renderMemoryTable(config) {
|
|
189
|
-
const entries = [
|
|
190
|
-
["command", config.mcp_server.command],
|
|
191
|
-
["args", config.mcp_server.args],
|
|
192
|
-
];
|
|
193
|
-
if (Object.keys(config.mcp_server.env).length > 0) {
|
|
194
|
-
entries.push(["env", config.mcp_server.env]);
|
|
195
|
-
}
|
|
196
|
-
return renderTomlTable(MEMORY_TOML_PATH, entries);
|
|
197
|
-
}
|
|
198
|
-
function tableMatches(raw, config) {
|
|
199
|
-
const table = readTomlTable(raw, MEMORY_TOML_PATH);
|
|
200
|
-
if (!table)
|
|
201
|
-
return false;
|
|
202
|
-
if (table["command"] !== config.mcp_server.command)
|
|
203
|
-
return false;
|
|
204
|
-
const args = table["args"];
|
|
205
|
-
if (!Array.isArray(args))
|
|
206
|
-
return config.mcp_server.args.length === 0;
|
|
207
|
-
if (args.length !== config.mcp_server.args.length)
|
|
208
|
-
return false;
|
|
209
|
-
if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
|
|
210
|
-
return false;
|
|
211
|
-
const env = table["env"];
|
|
212
|
-
for (const [key, value] of Object.entries(config.mcp_server.env)) {
|
|
213
|
-
if (!env || Array.isArray(env) || typeof env === "string")
|
|
214
|
-
return false;
|
|
215
|
-
if (env[key] !== value)
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
return true;
|
|
219
168
|
}
|
|
@@ -121,9 +121,25 @@ export function upsertTomlTable(text, tablePath, renderedTable, logTag) {
|
|
|
121
121
|
}
|
|
122
122
|
const lines = text.split("\n");
|
|
123
123
|
const before = lines.slice(0, span.start_line);
|
|
124
|
-
const after = lines.slice(span
|
|
124
|
+
const after = lines.slice(tableContentEnd(lines, span));
|
|
125
125
|
return [...before, ...block.split("\n"), ...after].join("\n");
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Where a table's own lines stop. Comments and blank lines at the tail of a
|
|
129
|
+
* span usually introduce the NEXT section (BLI-22766: a person's
|
|
130
|
+
* `# the projects I trust` above `[projects]` was being deleted with our
|
|
131
|
+
* table), so they are left in place.
|
|
132
|
+
*/
|
|
133
|
+
export function tableContentEnd(lines, span) {
|
|
134
|
+
let end = span.end_line;
|
|
135
|
+
while (end > span.start_line + 1) {
|
|
136
|
+
const trimmed = (lines[end - 1] ?? "").trim();
|
|
137
|
+
if (trimmed && !trimmed.startsWith("#"))
|
|
138
|
+
break;
|
|
139
|
+
end -= 1;
|
|
140
|
+
}
|
|
141
|
+
return end;
|
|
142
|
+
}
|
|
127
143
|
/** Removes our table's lines. Used by `--uninstall`-shaped callers and tests. */
|
|
128
144
|
export function removeTomlTable(text, tablePath) {
|
|
129
145
|
const span = findTomlTableSpan(text, tablePath);
|