@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.
- package/README.md +168 -2
- package/dist/agent-door.d.ts +8 -1
- package/dist/agent-door.js +7 -2
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/brief-write-tools.d.ts +37 -0
- package/dist/brief-write-tools.js +178 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +141 -25
- package/dist/jarvis-answer-envelope.d.ts +13 -1
- package/dist/jarvis-answer-envelope.js +2 -0
- package/dist/jarvis-door.js +16 -1
- package/dist/jarvis-tools.d.ts +18 -10
- package/dist/jarvis-tools.js +28 -17
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/notes-write-tools.d.ts +34 -0
- package/dist/notes-write-tools.js +211 -0
- package/dist/ops-tools.d.ts +23 -0
- package/dist/ops-tools.js +212 -0
- package/dist/pages-tools.d.ts +28 -0
- package/dist/pages-tools.js +190 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +79 -0
- package/dist/search-tool.d.ts +54 -0
- package/dist/search-tool.js +134 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +34 -1
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/settings-write-tools.d.ts +47 -0
- package/dist/settings-write-tools.js +183 -0
- package/dist/team-write-tools.d.ts +39 -0
- package/dist/team-write-tools.js +141 -0
- package/dist/tool-result.d.ts +86 -0
- package/dist/tool-result.js +105 -0
- package/dist/verb-census.d.ts +22 -0
- package/dist/verb-census.js +108 -6
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- 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,
|
|
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`.**
|
package/dist/agent-door.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/agent-door.js
CHANGED
|
@@ -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
|
+
}
|