@bli-cockpit/cli 0.2.38 → 0.2.40

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.
@@ -22,6 +22,14 @@ export const rootCommandNames = new Set([
22
22
  "sync",
23
23
  "analyze",
24
24
  "jarvis",
25
+ "model",
26
+ "scout",
27
+ "settings",
28
+ "team",
29
+ "workbook",
30
+ "brief",
31
+ "correct",
32
+ "notes",
25
33
  "backfill",
26
34
  "status",
27
35
  "sessions",
@@ -42,11 +50,19 @@ export function localCommandHelp(command) {
42
50
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
43
51
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
44
52
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
45
- " cockpit logout",
53
+ " cockpit logout [--revoke] [--yes] [--dashboard-url <url>] [--json]",
46
54
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
47
55
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
48
56
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
49
- " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--dashboard-url <url>] [--json]",
57
+ " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
58
+ " cockpit model [show|set <provider:model>] [--json]",
59
+ " cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
60
+ " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
61
+ " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes]] [--json]",
62
+ " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
63
+ " cockpit brief [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--dashboard-url <url>] [--json]",
64
+ " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
65
+ " cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--yes] [--dashboard-url <url>] [--json]",
50
66
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
51
67
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
52
68
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -140,7 +156,70 @@ function localSubcommandHelp(command) {
140
156
  "Alias for `cockpit login`.",
141
157
  ],
142
158
  ],
143
- ["logout", ["Usage: cockpit logout [--json]", "", "Removes the local device session."]],
159
+ [
160
+ "logout",
161
+ [
162
+ "Usage: cockpit logout [--revoke] [--yes] [--dashboard-url <url>] [--json]",
163
+ "",
164
+ "Removes the local device session.",
165
+ "--revoke also asks Tower to end this device's token, which is what you want",
166
+ "when the machine is being handed back: without it this laptop stops collecting",
167
+ "but the token it held is still one the server would honour.",
168
+ "A device can only revoke ITSELF; revoking someone else's machine stays in the browser.",
169
+ "The local session is removed either way, and the two outcomes are reported separately.",
170
+ ],
171
+ ],
172
+ [
173
+ "model",
174
+ [
175
+ "Usage: cockpit model [show|set <provider:model>] [--json]",
176
+ "",
177
+ "Shows or changes YOUR chat model — the short way to say",
178
+ "`cockpit settings personal --chat-model`. Affects your account, not the org.",
179
+ "An unrecognised key is refused by the dashboard, not by this command.",
180
+ ],
181
+ ],
182
+ [
183
+ "settings",
184
+ [
185
+ "Usage: cockpit settings [section] [verb] [flags] [--json]",
186
+ "",
187
+ "With no section it shows everything you can see; a section you cannot see reads",
188
+ "`admin only` rather than failing, because that is the system working.",
189
+ "",
190
+ " cockpit settings personal [--chat-model <key>] [--brief-model <key>]",
191
+ " Your teams and your two model choices. No flags = show.",
192
+ " cockpit settings switches [set <key> <value>]",
193
+ " Access and feature switches. Everyone may look; only an admin may set.",
194
+ " cockpit settings models [set --chat <key>] [--memory <id>]",
195
+ " The ORG-WIDE model map. Admin only, in both directions.",
196
+ " cockpit settings env list",
197
+ " Stored env files: project, name, when, id. Never a value — the server",
198
+ " cannot return one and this command has no flag that prints one.",
199
+ " cockpit settings env set --project <project> --file <name> --content-stdin",
200
+ " Pipe the file in; there is no --content flag, because a secret on a",
201
+ " command line lands in shell history and in every process listing.",
202
+ " macOS: cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin",
203
+ " PowerShell: Get-Content <path> -Raw | cockpit settings env set --project <project> --file <name> --content-stdin",
204
+ " Run `cockpit settings env list` to see which file names this project accepts.",
205
+ " cockpit settings env delete --id <uuid> [--yes]",
206
+ " Asks first unless --yes; a headless run without --yes is refused, not assumed.",
207
+ ],
208
+ ],
209
+ [
210
+ "team",
211
+ [
212
+ "Usage: cockpit team [members | invite <email> --role <role> | role <userId> --role <role>] [--json]",
213
+ "",
214
+ " cockpit team [members]",
215
+ " Everyone on your team — roster and accounts together, with each person's id.",
216
+ " Open to every role; it says so when the view is read-only for you.",
217
+ " cockpit team invite <email> --role <role> [--team-id <uuid>]",
218
+ " Admin only. Defaults to your own team; Tower emails them a sign-in link.",
219
+ " cockpit team role <userId> --role <role> [--yes]",
220
+ " Admin only. Asks first unless --yes. You cannot change your own role.",
221
+ ],
222
+ ],
144
223
  [
145
224
  "start",
146
225
  [
@@ -190,7 +269,7 @@ function localSubcommandHelp(command) {
190
269
  [
191
270
  "jarvis",
192
271
  [
193
- "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--dashboard-url <url>] [--json]",
272
+ "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
194
273
  "",
195
274
  "Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
196
275
  "--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
@@ -203,9 +282,95 @@ function localSubcommandHelp(command) {
203
282
  "--json writes one machine-readable response (including trace and model) to stdout; operational metadata stays on stderr. A --json run never streams fragments, whichever mode it is in.",
204
283
  "A turn that outlives the dashboard's own ceiling is reported as turn_timed_out — an incomplete answer is never printed as a finished one.",
205
284
  "The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
285
+ "--threads lists the terminal conversations on this account; --thread <name> --history replays one (the newest --limit <n> messages). Neither takes a question, and neither can reach anybody else's conversations.",
286
+ "Run `cockpit login` first if this machine is not paired.",
287
+ ],
288
+ ],
289
+ [
290
+ "scout",
291
+ [
292
+ "Usage: cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
293
+ "",
294
+ "Prints the same Scout board the Tower page shows, in the same words: the standing",
295
+ "watch line, the cards waiting on a decision, and the raw signal watch underneath.",
296
+ "This is the board itself. To ask a QUESTION about it — what it means, whether it is",
297
+ "worth doing here — use `cockpit jarvis`, whose readScout tool reads the same rows.",
298
+ "--days <n> widens or narrows the window (1 to 90; the board defaults to 14).",
299
+ "Reading follows the Scout page audience setting, so whoever can open /scout can run this.",
300
+ "start, dismiss, and undo move one card and are super_admin actions on every surface;",
301
+ "a refusal is printed as Tower worded it and the board stays readable underneath.",
302
+ "The id can be the first 6 or more characters printed beside each card; an ambiguous",
303
+ "prefix is refused with the candidates rather than acting on the first match.",
304
+ "--json writes one object to stdout; operational lines stay on stderr.",
305
+ "Run `cockpit login` first if this machine is not paired.",
306
+ ],
307
+ ],
308
+ [
309
+ "workbook",
310
+ [
311
+ "Usage: cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
312
+ "",
313
+ "Reads the Tower workbook library: written project documents — workbooks, runbooks —",
314
+ "each with an author and a date. With no arguments it lists every compartment and the",
315
+ "command that opens each document.",
316
+ "`cockpit workbook tower` lists one project's shelf; `cockpit workbook tower workbook`",
317
+ "prints that document as wrapped plain text.",
318
+ "--section <id> prints one section, named by the anchors listed with the document.",
319
+ "--markdown prints the server's Markdown unchanged, for piping into anything else.",
320
+ "--width <n> sets the wrap column; otherwise the terminal's width is used, held between 60 and 100.",
321
+ "There is no pager: pipe it into one if you want paging.",
322
+ "Sign-in is the only gate, exactly as on the website page.",
206
323
  "Run `cockpit login` first if this machine is not paired.",
207
324
  ],
208
325
  ],
326
+ [
327
+ "brief",
328
+ [
329
+ "Usage: cockpit brief [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--dashboard-url <url>] [--json]",
330
+ "",
331
+ "Prints the TODAY page — the same page the Tower website shows, rendered for a terminal.",
332
+ "--for opens somebody else's page; the website's own rule decides whether you may, and it refuses in plain words when you may not.",
333
+ "--tldr prints the ten-second version; the whole page is the default.",
334
+ "--version <pageId> steps back to one exact earlier version; --versions lists them with their ids.",
335
+ "--claims prints the [claimId] beside every line, which is what `cockpit correct --claim` takes.",
336
+ "Reading it here counts as opening it, exactly as opening it in a browser does.",
337
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
338
+ ],
339
+ ],
340
+ [
341
+ "notes",
342
+ [
343
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [flags]",
344
+ "",
345
+ "The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
346
+ "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.",
347
+ "show <id> — one note with its text, its shelf, and why you are allowed to see it.",
348
+ "shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
349
+ "shelves — the shelves in use, with how many notes are on each.",
350
+ "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.",
351
+ "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.",
352
+ "--exclude carries your own sentence about what to leave out, exactly as the browser's box does.",
353
+ "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.",
354
+ "unshare <id> — take it back. Never asks: it only ever narrows who can read.",
355
+ "move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
356
+ "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.",
357
+ "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
358
+ "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.",
359
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
360
+ ],
361
+ ],
362
+ [
363
+ "correct",
364
+ [
365
+ "Usage: cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
366
+ "",
367
+ "Tells Tower that one line on the page is wrong. Same door the website's correction form uses.",
368
+ "Get the claim id from `cockpit brief --claims`; an id the page does not carry is refused before anything is written.",
369
+ "--text may be omitted when the correction is piped in on stdin.",
370
+ "The claim is checked against the live record FIRST. If the record disagrees, JARVIS says so and the correction is still filed with that outcome on it.",
371
+ "--supersedes <correctionId> takes an earlier correction back; nothing is ever edited or deleted.",
372
+ ],
373
+ ],
209
374
  [
210
375
  "backfill",
211
376
  [
@@ -17,6 +17,18 @@
17
17
  * status.ts `cockpit status`
18
18
  * sessions.ts `cockpit sessions`
19
19
  * jarvis.ts terminal adapter for the shared JARVIS gateway
20
+ * scout.ts `cockpit scout` — the board, and the three card verbs
21
+ * (+ scout-render.ts, the pure layout half)
22
+ * workbook.ts `cockpit workbook` — the project document library
23
+ * (+ workbook-render.ts, the pure layout half)
24
+ * brief.ts `cockpit brief` — the TODAY page in the terminal
25
+ * correct.ts `cockpit correct` — one line on it is wrong
26
+ * notes.ts `cockpit notes` — the meeting-notes surface, typed
27
+ * notes-file.ts the local screen a note file passes before it is sent
28
+ * tower-command.ts open a Tower session, call a route, name the refusal
29
+ * settings.ts `cockpit settings` and `cockpit model`
30
+ * settings-render.ts how a settings answer reads in a terminal
31
+ * team.ts `cockpit team` — members, invite, role
20
32
  *
21
33
  * What stays here is orchestration: onboard, login/logout/start, the sync tick,
22
34
  * analyze, serve, autostart and agent-rules.
@@ -34,6 +46,14 @@ import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } fr
34
46
  import { runStatus } from "./status.js";
35
47
  import { runSessions } from "./sessions.js";
36
48
  import { runJarvis } from "./jarvis.js";
49
+ import { runScout } from "./scout.js";
50
+ import { runModel, runSettings } from "./settings.js";
51
+ import { runTeam } from "./team.js";
52
+ import { asRecord, callTower, openTower } from "./tower-command.js";
53
+ import { runWorkbook } from "./workbook.js";
54
+ import { runBrief } from "./brief.js";
55
+ import { runCorrect } from "./correct.js";
56
+ import { runNotes } from "./notes.js";
37
57
  import { createCollectorServer } from "../server.js";
38
58
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
39
59
  import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
@@ -102,6 +122,22 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
102
122
  return await runAnalyze(command, io);
103
123
  case "jarvis":
104
124
  return await runJarvis(command, io);
125
+ case "model":
126
+ return await runModel(command, io);
127
+ case "scout":
128
+ return await runScout(command, io);
129
+ case "settings":
130
+ return await runSettings(command, io);
131
+ case "team":
132
+ return await runTeam(command, io);
133
+ case "workbook":
134
+ return await runWorkbook(command, io);
135
+ case "brief":
136
+ return await runBrief(command, io);
137
+ case "correct":
138
+ return await runCorrect(command, io);
139
+ case "notes":
140
+ return await runNotes(command, io);
105
141
  case "backfill":
106
142
  return await runBackfillCommand(command, io);
107
143
  case "status":
@@ -913,16 +949,68 @@ function nextStepForOnboardBlocker(blocker, options = {}) {
913
949
  return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
914
950
  }
915
951
  }
952
+ /**
953
+ * `cockpit logout` removes the local session file. `--revoke` also tells Tower
954
+ * to end the device's token (BLI-3461).
955
+ *
956
+ * Those are two different facts, and the difference matters when a laptop is
957
+ * being handed back: without the revoke the machine stops collecting but the
958
+ * token it held is still one the server would honour. The revoke is attempted
959
+ * FIRST, because it needs the token the local logout is about to delete, and a
960
+ * revoke that fails does not stop the local logout — the person asked to be
961
+ * signed out of this machine, and they get that either way. The outcome of each
962
+ * half is reported separately rather than folded into one "ok".
963
+ */
916
964
  async function runLogout(command, io) {
965
+ const revoke = command.revoke ? await revokeThisDevice(command, io) : null;
917
966
  const result = await logoutLocalCollector({ homeDir: command.homeDir });
967
+ writeLine(io.stderr, `[logout] done ${JSON.stringify({
968
+ local_session: result.removed ? "removed" : "absent",
969
+ revoke: revoke ? revoke.outcome : "not_requested",
970
+ })}`);
918
971
  if (command.json) {
919
- writeLine(io.stdout, JSON.stringify(result, null, 2));
920
- return 0;
972
+ writeLine(io.stdout, JSON.stringify({ ...result, revoke: revoke ?? { outcome: "not_requested" } }, null, 2));
973
+ return revoke && revoke.outcome !== "revoked" ? 1 : 0;
921
974
  }
922
975
  writeLine(io.stdout, result.removed
923
976
  ? "Tower collector session removed."
924
977
  : "No Tower collector session found.");
925
- return 0;
978
+ if (revoke) {
979
+ writeLine(io.stdout, revoke.outcome === "revoked"
980
+ ? "This device's Tower token is revoked."
981
+ : `This device's Tower token was NOT revoked: ${revoke.detail}`);
982
+ }
983
+ return revoke && revoke.outcome !== "revoked" ? 1 : 0;
984
+ }
985
+ /**
986
+ * Ends this device's own session server-side.
987
+ *
988
+ * The route gates the device arm on `self_revoke_only`, so the device id is
989
+ * read from the paired session rather than accepted as an argument — there is
990
+ * no way to spell "revoke somebody else's laptop" here, and that is deliberate.
991
+ */
992
+ async function revokeThisDevice(command, io) {
993
+ let tower;
994
+ let deviceId;
995
+ try {
996
+ const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
997
+ deviceId = session.device_id;
998
+ tower = await openTower("logout", command, io);
999
+ }
1000
+ catch (error) {
1001
+ return { outcome: "no_paired_session", detail: errorMessage(error) };
1002
+ }
1003
+ const result = await callTower(tower, {
1004
+ path: `/api/ambient/devices/${encodeURIComponent(deviceId)}/revoke`,
1005
+ method: "POST",
1006
+ label: "logout revoke",
1007
+ body: { reason: "Signed out from the device" },
1008
+ });
1009
+ if (!result.ok) {
1010
+ return { outcome: result.reason, detail: result.detail };
1011
+ }
1012
+ const body = asRecord(result.body);
1013
+ return { outcome: "revoked", detail: String(asRecord(body.device).revoked_at ?? "") };
926
1014
  }
927
1015
  async function runStart(command, io) {
928
1016
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Reads a local file for `cockpit notes upload <paths...>` and `cockpit notes
3
+ * paste --file <path>` (BLI-3459), before anything is uploaded.
4
+ *
5
+ * The dashboard's `/api/notes/upload` route is the ONE authority on whether a
6
+ * dropped note is acceptable — `checkUploadedNoteFile` in
7
+ * `@bli-cockpit/harvest-analysis/meeting-notes/uploaded-file` holds the secret
8
+ * guard, the byte ceiling and the format classification, and this package
9
+ * cannot import from it (separate workspace, no dependency edge). So this
10
+ * module only catches what is cheap and purely local to check BEFORE spending a
11
+ * network round trip: does the path exist, is it a regular file this account can
12
+ * read, is there anything in it, and is it under the ceiling. Same discipline as
13
+ * `jarvis-attachment.ts`, and the same rule if the two ever drift: the server's
14
+ * check wins.
15
+ *
16
+ * **There is deliberately no extension refusal here.** BLI-3398 made every file
17
+ * name resolve to one of nine formats with `unknown` ACCEPTED, kept and labeled
18
+ * rather than turned away. A local extension allowlist would refuse files the
19
+ * server would happily store, which is exactly the withholding this repo's
20
+ * session-first rule forbids. The extension is reported so the terminal can name
21
+ * what it is sending; it never decides anything.
22
+ *
23
+ * Nothing here throws. Every failure mode comes back as a named refusal, so the
24
+ * command prints a plain sentence rather than a stack trace, and the refusal
25
+ * label is what travels into the log line.
26
+ */
27
+ import { readFile, stat } from "node:fs/promises";
28
+ import path from "node:path";
29
+ import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
30
+ import { errorMessage } from "./cli-io.js";
31
+ /**
32
+ * Mirrors `UPLOADED_NOTE_MAX_BYTES` in
33
+ * `packages/cockpit-harvest-analysis/src/meeting-notes/uploaded-file.ts`. A
34
+ * courtesy check to avoid uploading something the server is certain to refuse,
35
+ * not a second source of truth.
36
+ */
37
+ export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
38
+ /**
39
+ * Above this, the terminal says it is working before it starts. The route's own
40
+ * ceiling is `maxDuration = 300` because reading a note is one model call over
41
+ * the whole file, and a person watching a still cursor for two minutes assumes
42
+ * it hung.
43
+ */
44
+ export const NOTE_SLOW_UPLOAD_BYTES = 256 * 1024;
45
+ /** The sentence the terminal prints for a refusal caught locally, before any request goes out. */
46
+ export function noteFileRefusalSentence(refusal, filePath) {
47
+ switch (refusal) {
48
+ case "file_not_found":
49
+ return `I could not find that file: ${filePath}`;
50
+ case "file_unreadable":
51
+ return `I could not read that file: ${filePath}`;
52
+ case "file_empty":
53
+ return `There is nothing in that file: ${filePath}`;
54
+ case "file_too_big":
55
+ return "That file is too big to put in as a note — keep it under 20 MB.";
56
+ case "looks_like_a_key_file":
57
+ // The same rule the server's gate applies, said the same way: the name is
58
+ // all it takes to decide, and looking inside to be sure would already be
59
+ // the thing the rule forbids.
60
+ return `That name looks like a key or credential file, so I did not open it: ${filePath}`;
61
+ }
62
+ }
63
+ /**
64
+ * Reads and locally screens one note file. Never throws.
65
+ *
66
+ * The secret-shaped-name check runs FIRST and on the name alone — the bytes are
67
+ * never read for a file it turns away.
68
+ */
69
+ export async function readNoteFile(filePath) {
70
+ const fileName = path.basename(filePath);
71
+ const extension = path.extname(filePath).toLowerCase();
72
+ if (isSecretLikePathSegment(fileName)) {
73
+ return { ok: false, refusal: "looks_like_a_key_file", detail: "secret_like_name" };
74
+ }
75
+ let size;
76
+ try {
77
+ const info = await stat(filePath);
78
+ if (!info.isFile()) {
79
+ return { ok: false, refusal: "file_unreadable", detail: "not_a_regular_file" };
80
+ }
81
+ size = info.size;
82
+ }
83
+ catch (error) {
84
+ const code = error.code;
85
+ if (code === "ENOENT")
86
+ return { ok: false, refusal: "file_not_found", detail: "enoent" };
87
+ return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
88
+ }
89
+ if (size === 0)
90
+ return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
91
+ if (size > NOTE_FILE_MAX_BYTES) {
92
+ return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
93
+ }
94
+ let bytes;
95
+ try {
96
+ bytes = await readFile(filePath);
97
+ }
98
+ catch (error) {
99
+ return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
100
+ }
101
+ return { ok: true, bytes, fileName, extension: extension || "none" };
102
+ }
103
+ /**
104
+ * Decodes note/env bytes to text the way an editor would, not the way UTF-8
105
+ * hopes (BLI-3480). PowerShell 5.1 redirection writes UTF-16LE with a BOM and
106
+ * Notepad's "Unicode" save does the same; decoded blindly as UTF-8 that text
107
+ * becomes NUL-interleaved mojibake that passes an emptiness check and stores
108
+ * silently. The BOM decides the codec; a BOM-less file that still decodes to
109
+ * NULs is refused by name rather than stored as garbage.
110
+ */
111
+ export function decodeTextBytes(bytes) {
112
+ let text;
113
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
114
+ text = bytes.subarray(2).toString("utf16le");
115
+ }
116
+ else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
117
+ text = new TextDecoder("utf-16be").decode(bytes.subarray(2));
118
+ }
119
+ else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
120
+ text = bytes.subarray(3).toString("utf8");
121
+ }
122
+ else {
123
+ text = bytes.toString("utf8");
124
+ }
125
+ if (text.includes("\u0000")) {
126
+ return { ok: false, reason: "undecodable_text_encoding" };
127
+ }
128
+ return { ok: true, text };
129
+ }