@bli-cockpit/mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +168 -2
  2. package/dist/agent-door.d.ts +8 -1
  3. package/dist/agent-door.js +7 -2
  4. package/dist/brief-tools.d.ts +26 -0
  5. package/dist/brief-tools.js +160 -0
  6. package/dist/brief-write-tools.d.ts +37 -0
  7. package/dist/brief-write-tools.js +178 -0
  8. package/dist/docs-msg-tools.d.ts +13 -7
  9. package/dist/docs-msg-tools.js +141 -25
  10. package/dist/jarvis-answer-envelope.d.ts +13 -1
  11. package/dist/jarvis-answer-envelope.js +2 -0
  12. package/dist/jarvis-door.js +16 -1
  13. package/dist/jarvis-tools.d.ts +18 -10
  14. package/dist/jarvis-tools.js +28 -17
  15. package/dist/notes-tools.d.ts +33 -0
  16. package/dist/notes-tools.js +143 -0
  17. package/dist/notes-write-tools.d.ts +34 -0
  18. package/dist/notes-write-tools.js +211 -0
  19. package/dist/ops-tools.d.ts +23 -0
  20. package/dist/ops-tools.js +212 -0
  21. package/dist/pages-tools.d.ts +28 -0
  22. package/dist/pages-tools.js +190 -0
  23. package/dist/readme-census.d.ts +18 -0
  24. package/dist/readme-census.js +79 -0
  25. package/dist/search-tool.d.ts +54 -0
  26. package/dist/search-tool.js +134 -0
  27. package/dist/server.d.ts +1 -1
  28. package/dist/server.js +34 -1
  29. package/dist/settings-tools.d.ts +29 -0
  30. package/dist/settings-tools.js +151 -0
  31. package/dist/settings-write-tools.d.ts +47 -0
  32. package/dist/settings-write-tools.js +183 -0
  33. package/dist/team-write-tools.d.ts +39 -0
  34. package/dist/team-write-tools.js +141 -0
  35. package/dist/tool-result.d.ts +86 -0
  36. package/dist/tool-result.js +105 -0
  37. package/dist/verb-census.d.ts +22 -0
  38. package/dist/verb-census.js +108 -6
  39. package/dist/work-tools.d.ts +2 -7
  40. package/dist/work-tools.js +35 -25
  41. package/package.json +5 -4
package/README.md CHANGED
@@ -25,6 +25,96 @@ dead"). `src/verb-census.test.ts` is that rule as a test — it reads the CLI's
25
25
  own decision tables and fails when a verb is in neither the twin map, the
26
26
  terminal-only list, nor the written follow-up queue.
27
27
 
28
+ ## Every Tower verb, and the tool that does the same thing
29
+
30
+ The three tables below are GENERATED from `src/verb-census.ts` by
31
+ `npm run mcp:readme` — do not hand-edit them. They are the same three lists
32
+ `src/verb-census.test.ts` polices, so this page and that suite cannot disagree
33
+ about what an agent can reach.
34
+
35
+ <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
36
+
37
+ **56 of 57 Tower verbs have an MCP twin.**
38
+ Each tool goes through the SAME door its CLI verb calls, with the same
39
+ collector device token — never a second route and never a service-role
40
+ reader. `src/verb-census.test.ts` fails when a verb is in none of the
41
+ three tables below.
42
+
43
+ | CLI verb | MCP tool | Door |
44
+ | --- | --- | --- |
45
+ | `cockpit brief history` | `brief_history` | `GET /api/jarvis/brief?history=1` |
46
+ | `cockpit brief read` | `brief_read` | `GET /api/jarvis/brief` |
47
+ | `cockpit brief rewrite` | `brief_rewrite` | `POST /api/jarvis/recompile` |
48
+ | `cockpit brief status` | `brief_status` | `GET /api/ops/brief-status` |
49
+ | `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
50
+ | `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
51
+ | `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
52
+ | `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
53
+ | `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
54
+ | `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
55
+ | `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
56
+ | `cockpit issue create` | `work_create_issue` | `POST /api/work/issues` |
57
+ | `cockpit issue history` | `work_issue_history` | `GET /api/work/issues/[id]/history` |
58
+ | `cockpit issue list` | `work_list_issues` | `GET /api/work/issues` |
59
+ | `cockpit issue move` | `work_move_issue` | `POST /api/work/issues/[id]/state` |
60
+ | `cockpit issue show` | `work_get_issue` | `GET /api/work/issues/[id]` |
61
+ | `cockpit issue update` | `work_update_issue` | `PATCH /api/work/issues/[id]` |
62
+ | `cockpit jarvis` | `jarvis_ask` | `POST /api/jarvis/cli` |
63
+ | `cockpit jarvis --trace` | `jarvis_trace` | `GET /api/ops/trace/[id]` |
64
+ | `cockpit model set` | `model_set` | `POST /api/settings/jarvis-model` |
65
+ | `cockpit model show` | `model_show` | `GET /api/settings/jarvis-model` |
66
+ | `cockpit msg channels` | `msg_channels` | `GET /api/msg/channels` |
67
+ | `cockpit msg create` | `msg_create_channel` | `POST /api/msg/channels` |
68
+ | `cockpit msg dm` | `msg_dm` | `POST /api/msg/channels (dm)` |
69
+ | `cockpit msg read` | `msg_read` | `GET /api/msg/channels/[id]/messages` |
70
+ | `cockpit msg send` | `msg_send` | `POST /api/msg/channels/[id]/messages` |
71
+ | `cockpit msg thread` | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` |
72
+ | `cockpit notes list` | `notes_list` | `GET /api/notes/library` |
73
+ | `cockpit notes move` | `notes_move` | `POST /api/notes/move` |
74
+ | `cockpit notes paste` | `notes_paste` | `POST /api/notes/upload (text)` |
75
+ | `cockpit notes share` | `notes_share` | `POST /api/notes/share` |
76
+ | `cockpit notes shelf` | `notes_shelf` | `GET /api/notes/shelf` |
77
+ | `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
78
+ | `cockpit notes show` | `notes_show` | `GET /api/notes/library/[id]` |
79
+ | `cockpit notes unshare` | `notes_unshare` | `POST /api/notes/share (share=false)` |
80
+ | `cockpit notes upload` | `notes_upload` | `POST /api/notes/upload` |
81
+ | `cockpit ops recompile` | `ops_recompile` | `POST /api/ops/recompile` |
82
+ | `cockpit ops status` | `ops_status` | `GET /api/ops/status` |
83
+ | `cockpit project list` | `work_list_projects` | `GET /api/work/projects` |
84
+ | `cockpit scout board` | `scout_board` | `GET /api/cockpit/scout` |
85
+ | `cockpit scout dismiss` | `scout_dismiss` | `POST /api/cockpit/scout (dismiss)` |
86
+ | `cockpit scout start` | `scout_start` | `POST /api/cockpit/scout (start)` |
87
+ | `cockpit scout undo` | `scout_undo` | `POST /api/cockpit/scout (undo_dismiss)` |
88
+ | `cockpit search` | `tower_search` | `GET /api/search` |
89
+ | `cockpit settings delete` | `settings_delete` | `DELETE /api/settings/env-blobs` |
90
+ | `cockpit settings list` | `settings_list` | `GET /api/settings/env-blobs` |
91
+ | `cockpit settings set` | `settings_set` | `POST /api/settings/{jarvis-model,switches,model-routing,env-blobs} + PUT /api/settings/cli-floor` |
92
+ | `cockpit settings show` | `settings_show` | `GET /api/settings/* + /api/team/members` |
93
+ | `cockpit slack coverage` | `slack_coverage` | `GET /api/ops/slack/coverage` |
94
+ | `cockpit slack read` | `slack_read` | `POST /api/ops/slack/read` |
95
+ | `cockpit team device-list` | `team_device_list` | `GET /api/team/devices` |
96
+ | `cockpit team device-revoke` | `team_device_revoke` | `POST /api/ambient/devices/[deviceId]/revoke` |
97
+ | `cockpit team invite` | `team_invite` | `POST /api/team/invite` |
98
+ | `cockpit team members` | `team_members` | `GET /api/team/members` |
99
+ | `cockpit team role` | `team_role` | `PATCH /api/team/members/[userId]/role` |
100
+ | `cockpit workbook` | `workbook_read` | `GET /api/cockpit/workbook` |
101
+
102
+ ### Owed a twin
103
+
104
+ This table should only ever shrink.
105
+
106
+ _Nothing. Every Tower verb an agent could want has a door (BLI-3756)._
107
+
108
+ ### Terminal-only
109
+
110
+ A claim about the verb's nature, not a backlog.
111
+
112
+ | CLI verb | Why it can never have a twin |
113
+ | --- | --- |
114
+ | `cockpit brief edit` | opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts) |
115
+
116
+ <!-- END GENERATED verb census -->
117
+
28
118
  ## `jarvis_*` tools (BLI-3732)
29
119
 
30
120
  JARVIS itself — the same assistant Tower web chat, the Slack DM and
@@ -74,16 +164,92 @@ terminal, because nothing on the wire can tell a person-relayed code from one
74
164
  the model lifted out of the previous answer; `src/jarvis-tools.ts` says so in
75
165
  full at the top of the file.
76
166
 
167
+ ## The read tools (BLI-3756 batch 1)
168
+
169
+ Every Tower READ the CLI already had. Doors and tool names are in the
170
+ generated table above; what is worth knowing before you call one:
171
+
172
+ - **`brief_read` / `brief_history` / `brief_status`** — the TODAY page as
173
+ finished text, the days a person has a page for, and why a brief was or was
174
+ not delivered. `subject` changes WHOSE page is asked for, never who is
175
+ asking. A delivery reason other than `delivered` (`not_monday`,
176
+ `no_slack_identity`, …) is a real answer and is NOT returned as an error.
177
+ - **`notes_list` / `notes_show` / `notes_shelf` / `notes_shelves`** — the
178
+ meeting-notes library. A device token has no signed-in browser session, so
179
+ these reads are routinely NARROWER than a person's; every one of them
180
+ appends the door's own `scope`/`degradedBecause` reason rather than letting
181
+ an agent conclude a note was never taken.
182
+ - **`ops_status` / `slack_coverage` / `slack_read`** — the pipeline board
183
+ (each job judged against its own expected interval, server-side), Slack
184
+ collection health, and the collected messages themselves. `slack_read` has a
185
+ narrower audience than `slack_coverage`, and says so on a 403.
186
+ - **`settings_show` / `settings_list` / `model_show` / `team_members` /
187
+ `team_device_list`** — settings as this caller may see them. A section you
188
+ may not see comes back as `admin only`, not as an error; `settings_list`
189
+ returns env-file names, sizes and stamps and can never return a value.
190
+ - **`scout_board` / `workbook_read`** — the Scout board (with the page's own
191
+ standing sentences, and a truncated read saying so) and the per-project
192
+ document library, rendered by the same walker the page uses.
193
+
194
+ ## The write tools (BLI-3756 batch 2)
195
+
196
+ The seventeen things a person can DO from `cockpit` and could not do from an
197
+ agent. Same doors, same device token, same refusal words — plus one rule the
198
+ reads never needed:
199
+
200
+ **An act that cannot be undone is refused without `confirm: true`.** The CLI's
201
+ gate is `--yes` or a person at a keyboard; a client has no keyboard, so the
202
+ explicit flag is the whole of it here, and the reason label is the CLI's own
203
+ `confirmation_required`. It guards `notes_share` (sharing lets everyone signed
204
+ in read the note), `settings_delete`, `team_role` and `team_device_revoke`.
205
+ Taking a share BACK asks for nothing — nobody needs to be talked out of
206
+ narrowing who can read something.
207
+
208
+ - **`brief_rewrite` / `brief_correct`** — ask for a page to be written again,
209
+ and say that one line on it is wrong. `brief_rewrite` QUEUES and returns the
210
+ request id immediately; it does not wait, because an 800-second compile
211
+ outlives any client deadline and "the tool timed out" would be
212
+ indistinguishable from "the compile failed". `brief_correct` refuses a claim
213
+ id the page does not carry BEFORE writing, and Tower's push-back ("the record
214
+ disagrees") is a normal result — the correction is filed either way.
215
+ - **`notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
216
+ `notes_move`** — put a note in from this machine's disk or from text, share
217
+ it, take it back, move it to another shelf. A path whose NAME looks like a
218
+ key or credential file is refused without being opened; so is a missing,
219
+ empty or over-20-MB file. A large note is one model call server-side and can
220
+ take minutes — your client may give up first, and the upload keeps going.
221
+ - **`ops_recompile`** — write one person's page again. It spends a model call;
222
+ `dry_run: true` resolves the person and reads the current page without
223
+ compiling. If Tower's own 800-second budget runs out the effect is UNKNOWN,
224
+ not failed, and it says so rather than inviting you to pay twice.
225
+ - **`scout_start` / `scout_dismiss` / `scout_undo`** — move one card. Each
226
+ reads the board first, matches the id EXACTLY (no prefixes on this surface —
227
+ a near-miss would settle a card nobody decided on), and a 403 names the gate
228
+ without costing you the read.
229
+ - **`settings_set` / `settings_delete` / `model_set`** — write one settings
230
+ section, delete an env file, or just change which model answers you. Env
231
+ content is an argument here rather than stdin (a tool call has no argv, no
232
+ shell history and no process list), and the rule that survives is the one
233
+ that mattered: it is **never echoed back** — not in the answer, not in the
234
+ structured payload, not in a log.
235
+ - **`team_invite` / `team_role` / `team_device_revoke`** — invite somebody,
236
+ change a role, end a machine's pairing. All three relay a super_admin
237
+ decision made on the server. `team_device_revoke` looks the device up and
238
+ names it back to you first; without `confirm: true` that naming IS the
239
+ answer and nothing is revoked.
240
+
77
241
  ## `docs_*` / `msg_*` tools (BLI-3706)
78
242
 
79
243
  | Tool | Wraps | Notes |
80
244
  | --- | --- | --- |
81
245
  | `docs_list` | `GET /api/docs/documents` | Every document you may read: id, slug, title, visibility. Never a body. |
246
+ | `docs_tree` | `GET /api/docs/tree` | The library as its nesting rather than a flat list. Never a body. |
82
247
  | `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
83
248
  | `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
84
- | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). |
249
+ | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). Emptying a document that holds text needs `allow_empty: true` (BLI-3757's guard). |
85
250
  | `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
86
- | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, or one thread's replies. `channel` is an id or a `#name`. |
251
+ | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
252
+ | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
87
253
  | `msg_send` | `POST /api/msg/channels/[id]/messages` | Posts a message. |
88
254
 
89
255
  **Auth: this machine's collector device token, not `BLI_OPERATOR_TOKEN`.**
@@ -12,4 +12,11 @@ export interface AgentDoorResponse {
12
12
  /** Set only when no JSON answer was produced at all — DNS, TLS, timeout, non-JSON body. */
13
13
  transportError: string | null;
14
14
  }
15
- export declare function callAgentDoor(session: AgentDoorSession, fetchImpl: FetchImpl, method: "GET" | "POST" | "PATCH", path: string, body?: unknown, timeoutMs?: number): Promise<AgentDoorResponse>;
15
+ /**
16
+ * The four verbs Tower's agent doors answer to. `PUT` is the CLI floor
17
+ * (`PUT /api/settings/cli-floor`) and `DELETE` is an env blob — both arrived
18
+ * with BLI-3756 batch 2, and both are the door's own choice of verb, not this
19
+ * package's.
20
+ */
21
+ export type AgentDoorMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
22
+ export declare function callAgentDoor(session: AgentDoorSession, fetchImpl: FetchImpl, method: AgentDoorMethod, path: string, body?: unknown, timeoutMs?: number): Promise<AgentDoorResponse>;
@@ -9,16 +9,21 @@ export async function callAgentDoor(session, fetchImpl, method, path, body, time
9
9
  const controller = new AbortController();
10
10
  const timer = setTimeout(() => controller.abort(), timeoutMs);
11
11
  timer.unref?.();
12
+ // A note goes in as multipart, exactly as `cockpit notes upload` sends it,
13
+ // so `content-type` must be left to fetch: it is the only thing that knows
14
+ // the boundary it just generated, and a hand-set JSON header would make the
15
+ // route read an empty form.
16
+ const multipart = typeof FormData !== "undefined" && body instanceof FormData;
12
17
  let response;
13
18
  try {
14
19
  response = await fetchImpl(url, {
15
20
  method,
16
21
  headers: {
17
22
  authorization: `Bearer ${session.deviceToken}`,
18
- "content-type": "application/json",
23
+ ...(multipart ? {} : { "content-type": "application/json" }),
19
24
  accept: "application/json",
20
25
  },
21
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
26
+ ...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
22
27
  signal: controller.signal,
23
28
  });
24
29
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `brief_*` MCP tools (BLI-3756) — the TODAY page, read by an agent, over the
3
+ * same doors `cockpit brief` calls with the same device token.
4
+ *
5
+ * Nothing here renders a page. `GET /api/jarvis/brief` hands back finished
6
+ * text (the serializer's terminal flavour, the same one the CLI prints), and
7
+ * `GET /api/ops/brief-status` hands back the delivery reason computed by the
8
+ * code the Slack DM cron delivers with. A second renderer in this package
9
+ * would let the agent and the browser drift into describing two different
10
+ * days.
11
+ *
12
+ * `subject` changes WHOSE page is asked for, never who is asking — the same
13
+ * rule `cockpit brief --for` and `cockpit jarvis --as` follow. The server owns
14
+ * whether this caller may see that person at all, and says so in its own words.
15
+ *
16
+ * One judgement call worth naming: `brief_status` answers `not_monday` or
17
+ * `no_slack_identity` as a normal RESULT, not as an `isError`. The CLI exits 1
18
+ * on anything but `delivered` because a shell script needs a branch; a tool
19
+ * that reported "error" for a successful read of a true fact would teach an
20
+ * agent that the surface is broken.
21
+ */
22
+ import { type ToolDeps } from "./tool-result.js";
23
+ export type BriefDeps = ToolDeps;
24
+ export declare function registerBriefTools(server: {
25
+ registerTool: (...args: never[]) => unknown;
26
+ }, deps: BriefDeps): void;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `brief_*` MCP tools (BLI-3756) — the TODAY page, read by an agent, over the
3
+ * same doors `cockpit brief` calls with the same device token.
4
+ *
5
+ * Nothing here renders a page. `GET /api/jarvis/brief` hands back finished
6
+ * text (the serializer's terminal flavour, the same one the CLI prints), and
7
+ * `GET /api/ops/brief-status` hands back the delivery reason computed by the
8
+ * code the Slack DM cron delivers with. A second renderer in this package
9
+ * would let the agent and the browser drift into describing two different
10
+ * days.
11
+ *
12
+ * `subject` changes WHOSE page is asked for, never who is asking — the same
13
+ * rule `cockpit brief --for` and `cockpit jarvis --as` follow. The server owns
14
+ * whether this caller may see that person at all, and says so in its own words.
15
+ *
16
+ * One judgement call worth naming: `brief_status` answers `not_monday` or
17
+ * `no_slack_identity` as a normal RESULT, not as an `isError`. The CLI exits 1
18
+ * on anything but `delivered` because a shell script needs a branch; a tool
19
+ * that reported "error" for a successful read of a true fact would teach an
20
+ * agent that the surface is broken.
21
+ */
22
+ import { z } from "zod";
23
+ import { callAgentDoor } from "./agent-door.js";
24
+ import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
25
+ const SUBJECT = z
26
+ .string()
27
+ .min(1)
28
+ .max(200)
29
+ .optional()
30
+ .describe("Whose page — a name, email or person id. Omit for your own. Changes the SUBJECT, never who is asking.");
31
+ export function registerBriefTools(server, deps) {
32
+ const register = registrarFor(server);
33
+ register("brief_read", {
34
+ title: "Read a Tower daily page",
35
+ description: "The TODAY page as text — the same page the website and `cockpit brief` show. Optionally one pinned version, "
36
+ + "one of the subject's own past days, the TLDR only, the claim ids you would file a correction against, or "
37
+ + "what changed since the day before.",
38
+ inputSchema: {
39
+ subject: SUBJECT,
40
+ date: z
41
+ .string()
42
+ .min(1)
43
+ .max(20)
44
+ .optional()
45
+ .describe('YYYY-MM-DD, "today" or "yesterday" — sent verbatim; the server owns where a day starts.'),
46
+ version: z.string().min(1).max(100).optional().describe("One exact stored page id instead of the newest."),
47
+ tldr: z.boolean().optional().describe("Just the TLDR."),
48
+ versions: z.boolean().optional().describe("Also list the earlier stored versions."),
49
+ claims: z.boolean().optional().describe("Also list claim ids — what `cockpit correct --claim` takes."),
50
+ delta: z.boolean().optional().describe("Also say what changed since the previous day."),
51
+ against: z.string().min(1).max(20).optional().describe("Compare against this date instead of the previous day."),
52
+ },
53
+ }, async (args) => withSession(deps, async (session) => {
54
+ const params = new URLSearchParams();
55
+ if (args.subject)
56
+ params.set("p", String(args.subject));
57
+ if (args.version)
58
+ params.set("v", String(args.version));
59
+ if (args.date)
60
+ params.set("d", String(args.date));
61
+ if (args.tldr)
62
+ params.set("tldr", "1");
63
+ if (args.versions)
64
+ params.set("versions", "1");
65
+ if (args.claims)
66
+ params.set("claims", "1");
67
+ if (args.delta) {
68
+ params.set("delta", "1");
69
+ if (args.against)
70
+ params.set("against", String(args.against));
71
+ }
72
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/jarvis/brief${queryString(params)}`);
73
+ if (!response.ok)
74
+ return errorResult(doorFailureText("brief_read", response));
75
+ const body = response.body;
76
+ if (!body.ok || typeof body.text !== "string") {
77
+ // The route's own plain-words notice ("Nothing has been written for
78
+ // you yet.") is an answer, not a stack trace — relayed as it stands.
79
+ return errorResult(`Tower returned no page for brief_read (${body.error ?? "no_page"}): `
80
+ + (body.reply ?? "Tower answered without a page."));
81
+ }
82
+ const whose = body.page?.displayName ? `${body.page.displayName}'s page` : "This page";
83
+ const day = body.day?.date ? ` · ${body.day.date}${body.day.zone ? ` ${body.day.zone}` : ""}` : "";
84
+ const pinned = body.page?.olderVersionLabel ? ` · version from ${body.page.olderVersionLabel}` : "";
85
+ // A missing delta is SAID, never left as an empty space: an agent that
86
+ // asked what changed and got silence would report that nothing did.
87
+ const deltaText = args.delta
88
+ ? `\n\n--- what changed ---\n${body.delta?.headline ?? `No delta (${body.deltaReason ?? "no reason given"}).`}`
89
+ : "";
90
+ const claimText = body.claims?.length
91
+ ? `\n\n--- claim ids ---\n${body.claims.map((claim) => `[${claim.claimId}] ${claim.text ?? ""}`).join("\n")}`
92
+ : "";
93
+ const versionText = body.versions
94
+ ? body.versions.length === 0
95
+ ? `\n\n(earlier versions could not be listed: ${body.versionsReason ?? "no reason given"})`
96
+ : `\n\n--- versions ---\n${body.versions.map((v) => `${v.version}/${v.of} ${v.pageId} ${v.asOf}`).join("\n")}`
97
+ : "";
98
+ return textResult(`${whose}${pinned}${day}\n\n${body.text}${deltaText}${claimText}${versionText}`, {
99
+ text: body.text,
100
+ page: body.page ?? null,
101
+ day: body.day ?? null,
102
+ visibleBecause: body.visibleBecause ?? null,
103
+ ...(body.claims ? { claims: body.claims } : {}),
104
+ ...(body.versions ? { versions: body.versions, versionsReason: body.versionsReason ?? null } : {}),
105
+ ...(args.delta ? { delta: body.delta ?? null, deltaReason: body.deltaReason ?? null } : {}),
106
+ });
107
+ }));
108
+ register("brief_history", {
109
+ title: "List a subject's daily pages",
110
+ description: "Which days a person has a page for, newest first — dates and page ids, no page text. Pass a page id to "
111
+ + "brief_read's `version` to open one.",
112
+ inputSchema: {
113
+ subject: SUBJECT,
114
+ days: z.number().int().min(1).max(365).optional().describe("How many days back to list."),
115
+ },
116
+ }, async (args) => withSession(deps, async (session) => {
117
+ const params = new URLSearchParams({ history: "1" });
118
+ if (args.subject)
119
+ params.set("p", String(args.subject));
120
+ if (typeof args.days === "number")
121
+ params.set("days", String(args.days));
122
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/jarvis/brief${queryString(params)}`);
123
+ if (!response.ok)
124
+ return errorResult(doorFailureText("brief_history", response));
125
+ const body = response.body;
126
+ const history = body.history ?? [];
127
+ const lines = history
128
+ .map((row) => `${row.date ?? "?"} ${row.pageId ?? "?"} ${row.versionCount ?? 1}v ${row.heading ?? ""}`.trimEnd())
129
+ .join("\n");
130
+ return textResult(`${history.length} day(s)${body.historyTruncated ? ", and more beyond the window" : ""}.${lines ? `\n${lines}` : ""}`, { history, historyTruncated: body.historyTruncated ?? false });
131
+ }));
132
+ register("brief_status", {
133
+ title: "Why a Tower brief was or was not delivered",
134
+ description: "The delivery reason for one person's brief, from the closed set the Slack DM cron itself computes "
135
+ + "(`delivered`, `not_monday`, `no_slack_identity`, …). A reason other than `delivered` is a real answer, "
136
+ + "not a failure. `render` also returns the TLDR the DM would have carried.",
137
+ inputSchema: {
138
+ subject: SUBJECT,
139
+ render: z.boolean().optional().describe("Also return the text the DM would have carried."),
140
+ },
141
+ }, async (args) => withSession(deps, async (session) => {
142
+ const params = new URLSearchParams();
143
+ if (args.subject)
144
+ params.set("who", String(args.subject));
145
+ if (args.render)
146
+ params.set("render", "1");
147
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/brief-status${queryString(params)}`);
148
+ if (!response.ok)
149
+ return errorResult(doorFailureText("brief_status", response));
150
+ const body = response.body;
151
+ const status = body.status ?? {};
152
+ const reason = status.reason ?? "unknown";
153
+ const detail = status.detail ? `\n${status.detail}` : "";
154
+ const slack = status.slack?.hasIdentity === false ? "\nNo Slack identity is on file for them." : "";
155
+ const readError = status.readError ? `\n(one source could not be read: ${status.readError})` : "";
156
+ const rendered = body.render?.text ? `\n\n--- the DM would have said ---\n${body.render.text}` : "";
157
+ return textResult(`${status.displayName ?? "This person"} — ${reason}${detail}${slack}${readError}`
158
+ + `\n${status.owningCadence ?? "?"} owns this morning${status.timeZone ? ` (${status.timeZone})` : ""}${rendered}`, { status, ...(body.render ? { render: body.render } : {}) });
159
+ }));
160
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `brief_rewrite` / `brief_correct` MCP tools (BLI-3756 batch 2) — the two
3
+ * things a person can DO to the daily page, over the same doors
4
+ * `cockpit brief rewrite` and `cockpit correct` call with the same device
5
+ * token.
6
+ *
7
+ * Reads live in `brief-tools.ts`; these two write, and each carries one
8
+ * judgement the read side never has to make.
9
+ *
10
+ * **`brief_rewrite` asks and does not wait.** The CLI queues the compile and
11
+ * then watches the page for up to fifteen minutes, because a terminal can sit
12
+ * there. A tool call cannot: the client's own deadline would cut the call
13
+ * long before an 800-second compile finished, and "the tool timed out" is
14
+ * indistinguishable from "the compile failed" — the exact confusion this repo
15
+ * keeps paying for. So this tool sends `mode: "queue"`, reports the request id
16
+ * the moment it exists, and says in its own answer that the page is not
17
+ * written yet and `brief_read` is how to find out. That is the CLI's
18
+ * `--no-wait` shape, chosen deliberately and named out loud rather than
19
+ * silently.
20
+ *
21
+ * **`brief_correct` treats push-back as the answer, not an error.** The
22
+ * corrections door checks the claim against the live record BEFORE writing and
23
+ * can answer that the record disagrees. That sentence is what the caller came
24
+ * for: the correction WAS filed, with its outcome recorded, so it comes back
25
+ * as a normal result. Only a failure to file at all is an error.
26
+ *
27
+ * A claim id the page does not carry is refused HERE, before any write, in the
28
+ * CLI's own words — filing it anyway would land a correction on a clause
29
+ * nobody can find, which is how a ledger fills with rows that mean nothing.
30
+ */
31
+ import { type ToolDeps } from "./tool-result.js";
32
+ export type BriefWriteDeps = ToolDeps;
33
+ /** The same ceiling `cockpit correct` enforces before it sends anything. */
34
+ export declare const MAX_CORRECTION_LENGTH = 4000;
35
+ export declare function registerBriefWriteTools(server: {
36
+ registerTool: (...args: never[]) => unknown;
37
+ }, deps: BriefWriteDeps): void;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `brief_rewrite` / `brief_correct` MCP tools (BLI-3756 batch 2) — the two
3
+ * things a person can DO to the daily page, over the same doors
4
+ * `cockpit brief rewrite` and `cockpit correct` call with the same device
5
+ * token.
6
+ *
7
+ * Reads live in `brief-tools.ts`; these two write, and each carries one
8
+ * judgement the read side never has to make.
9
+ *
10
+ * **`brief_rewrite` asks and does not wait.** The CLI queues the compile and
11
+ * then watches the page for up to fifteen minutes, because a terminal can sit
12
+ * there. A tool call cannot: the client's own deadline would cut the call
13
+ * long before an 800-second compile finished, and "the tool timed out" is
14
+ * indistinguishable from "the compile failed" — the exact confusion this repo
15
+ * keeps paying for. So this tool sends `mode: "queue"`, reports the request id
16
+ * the moment it exists, and says in its own answer that the page is not
17
+ * written yet and `brief_read` is how to find out. That is the CLI's
18
+ * `--no-wait` shape, chosen deliberately and named out loud rather than
19
+ * silently.
20
+ *
21
+ * **`brief_correct` treats push-back as the answer, not an error.** The
22
+ * corrections door checks the claim against the live record BEFORE writing and
23
+ * can answer that the record disagrees. That sentence is what the caller came
24
+ * for: the correction WAS filed, with its outcome recorded, so it comes back
25
+ * as a normal result. Only a failure to file at all is an error.
26
+ *
27
+ * A claim id the page does not carry is refused HERE, before any write, in the
28
+ * CLI's own words — filing it anyway would land a correction on a clause
29
+ * nobody can find, which is how a ledger fills with rows that mean nothing.
30
+ */
31
+ import { z } from "zod";
32
+ import { callAgentDoor } from "./agent-door.js";
33
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
34
+ /** The same ceiling `cockpit correct` enforces before it sends anything. */
35
+ export const MAX_CORRECTION_LENGTH = 4000;
36
+ /** A rewrite queues in seconds; the compile it queues takes minutes. */
37
+ const QUEUE_DEADLINE_MS = 60_000;
38
+ const SUBJECT = z
39
+ .string()
40
+ .min(1)
41
+ .max(200)
42
+ .optional()
43
+ .describe("Whose page — a name, email or person id. Omit for your own. Changes the SUBJECT, never who is asking.");
44
+ /**
45
+ * Which page, and whose. The same read both verbs open with, and the only way
46
+ * either learns the person id the write doors need.
47
+ */
48
+ async function readPage(deps, session, params) {
49
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/jarvis/brief?${params.toString()}`, undefined, QUEUE_DEADLINE_MS);
50
+ if (!response.ok)
51
+ return { ok: false, text: doorFailureText("brief", response) };
52
+ const body = response.body;
53
+ if (!body.ok || !body.page?.personId) {
54
+ return {
55
+ ok: false,
56
+ text: body.reply ?? "There is no page here to work on yet. The first one is compiled by the daily job.",
57
+ };
58
+ }
59
+ return { ok: true, body };
60
+ }
61
+ export function registerBriefWriteTools(server, deps) {
62
+ const register = registrarFor(server);
63
+ register("brief_rewrite", {
64
+ title: "Ask for a Tower daily page to be written again",
65
+ description: "Queues a recompile of the TODAY page — the website's button and `cockpit brief rewrite`, same door. It spends "
66
+ + "a model call and takes several minutes, so this tool ASKS and returns the request id immediately; it does "
67
+ + "not wait. Read the result later with brief_read. An admin may ask about anybody; everyone else about their "
68
+ + "own page, decided on the server.",
69
+ inputSchema: { subject: SUBJECT },
70
+ }, async (args) => withSession(deps, async (session) => {
71
+ const params = new URLSearchParams({ tldr: "1" });
72
+ if (args.subject)
73
+ params.set("p", String(args.subject));
74
+ const page = await readPage(deps, session, params);
75
+ if (!page.ok)
76
+ return errorResult(page.text);
77
+ const personId = page.body.page?.personId;
78
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/recompile", { personIds: [personId], mode: "queue" }, QUEUE_DEADLINE_MS);
79
+ if (!response.ok)
80
+ return errorResult(doorFailureText("brief_rewrite", response));
81
+ const answer = response.body;
82
+ const request = (answer.queued ?? [])[0];
83
+ if (!answer.ok || !request?.requestId) {
84
+ return errorResult(answer.reply ?? "Tower did not accept that ask.");
85
+ }
86
+ const whose = request.name ?? page.body.page?.displayName ?? "that page";
87
+ const scheduled = answer.compileScheduled !== false;
88
+ // Queue-only, said as queue-only. The ask is a real row; what is
89
+ // missing is somebody to run it in the next minute, and pretending
90
+ // otherwise would leave a caller watching a page that was never going
91
+ // to change.
92
+ const sentence = scheduled
93
+ ? `Asked. ${whose} is being written again — this takes a few minutes. Call brief_read then; nothing here waits for it.`
94
+ : `${whose} is on the list to be written again, but this Tower could not start the work now `
95
+ + `(${answer.compileReason ?? "no reason given"}). The daily compile will reach it. Nothing is lost.`;
96
+ return textResult(sentence, {
97
+ ok: true,
98
+ outcome: scheduled ? "queued" : "queued_only",
99
+ requestId: request.requestId,
100
+ personId,
101
+ compileScheduled: scheduled,
102
+ compileReason: answer.compileReason ?? null,
103
+ });
104
+ }));
105
+ register("brief_correct", {
106
+ title: "Say that one line on a Tower page is wrong",
107
+ description: "Files a correction against ONE claim on the daily page — `cockpit correct`, same door and same ledger. Get "
108
+ + "claim ids from brief_read with `claims: true`. Tower checks the claim against the live record before "
109
+ + "writing and may answer that the record disagrees; that push-back is the RESULT, not a failure — the "
110
+ + "correction is filed either way, with its outcome recorded.",
111
+ inputSchema: {
112
+ claim_id: z.string().min(1).max(200).describe("A claim id from brief_read({claims: true})."),
113
+ text: z
114
+ .string()
115
+ .min(1)
116
+ .max(MAX_CORRECTION_LENGTH)
117
+ .describe("What is wrong, and what is true instead."),
118
+ subject: SUBJECT,
119
+ version: z.string().min(1).max(100).optional().describe("Correct one exact stored page id instead of the newest."),
120
+ supersedes: z
121
+ .string()
122
+ .min(1)
123
+ .max(200)
124
+ .optional()
125
+ .describe("A correction on the same clause that this one takes back."),
126
+ },
127
+ }, async (args) => withSession(deps, async (session) => {
128
+ const claimId = String(args.claim_id ?? "");
129
+ const params = new URLSearchParams({ claims: "1" });
130
+ if (args.subject)
131
+ params.set("p", String(args.subject));
132
+ if (args.version)
133
+ params.set("v", String(args.version));
134
+ const page = await readPage(deps, session, params);
135
+ if (!page.ok)
136
+ return errorResult(page.text);
137
+ const claim = (page.body.claims ?? []).find((candidate) => candidate.claimId === claimId);
138
+ if (!claim) {
139
+ return errorResult(`Refused (unknown_claim): that page has no line called “${claimId}”. `
140
+ + "Call brief_read with `claims: true` to see the ids. Nothing was filed.");
141
+ }
142
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/corrections", {
143
+ personId: page.body.page?.personId,
144
+ pageId: page.body.page?.pageId ?? null,
145
+ claimId,
146
+ // The line as it reads now, so the ledger records what was
147
+ // disputed and the live check has the sentence to work from.
148
+ quotedText: claim.text ?? null,
149
+ correctionText: String(args.text ?? ""),
150
+ contextLinks: claim.links ?? [],
151
+ clauseOnPage: true,
152
+ clauseIsObserved: Boolean(claim.observed),
153
+ ...(args.supersedes ? { supersedes: String(args.supersedes) } : {}),
154
+ }, QUEUE_DEADLINE_MS);
155
+ if (!response.ok)
156
+ return errorResult(doorFailureText("brief_correct", response));
157
+ const filed = response.body;
158
+ if (!filed.correctionId) {
159
+ return errorResult(filed.error ?? "Tower did not record that correction.");
160
+ }
161
+ const said = [
162
+ filed.reply ?? "Filed.",
163
+ filed.finding ? `The record says: ${filed.finding}` : "",
164
+ filed.link ?? "",
165
+ ]
166
+ .filter(Boolean)
167
+ .join("\n");
168
+ return textResult(said, {
169
+ ok: true,
170
+ correctionId: filed.correctionId,
171
+ tier: filed.tier ?? null,
172
+ outcome: filed.outcome ?? null,
173
+ pushBack: filed.pushBack ?? null,
174
+ finding: filed.finding ?? null,
175
+ link: filed.link ?? null,
176
+ });
177
+ }));
178
+ }