@bli-cockpit/mcp 0.1.2 → 0.1.3
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 +126 -1
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +130 -24
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/ops-tools.d.ts +27 -0
- package/dist/ops-tools.js +151 -0
- package/dist/pages-tools.d.ts +24 -0
- package/dist/pages-tools.js +123 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +77 -0
- package/dist/search-tool.d.ts +54 -0
- package/dist/search-tool.js +134 -0
- package/dist/server.js +19 -0
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/tool-result.d.ts +70 -0
- package/dist/tool-result.js +79 -0
- package/dist/verb-census.d.ts +12 -0
- package/dist/verb-census.js +99 -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,97 @@ 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
|
+
**39 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 status` | `brief_status` | `GET /api/ops/brief-status` |
|
|
48
|
+
| `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
|
|
49
|
+
| `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
|
|
50
|
+
| `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
|
|
51
|
+
| `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
|
|
52
|
+
| `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
|
|
53
|
+
| `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
|
|
54
|
+
| `cockpit issue create` | `work_create_issue` | `POST /api/work/issues` |
|
|
55
|
+
| `cockpit issue history` | `work_issue_history` | `GET /api/work/issues/[id]/history` |
|
|
56
|
+
| `cockpit issue list` | `work_list_issues` | `GET /api/work/issues` |
|
|
57
|
+
| `cockpit issue move` | `work_move_issue` | `POST /api/work/issues/[id]/state` |
|
|
58
|
+
| `cockpit issue show` | `work_get_issue` | `GET /api/work/issues/[id]` |
|
|
59
|
+
| `cockpit issue update` | `work_update_issue` | `PATCH /api/work/issues/[id]` |
|
|
60
|
+
| `cockpit jarvis` | `jarvis_ask` | `POST /api/jarvis/cli` |
|
|
61
|
+
| `cockpit jarvis --trace` | `jarvis_trace` | `GET /api/ops/trace/[id]` |
|
|
62
|
+
| `cockpit model show` | `model_show` | `GET /api/settings/jarvis-model` |
|
|
63
|
+
| `cockpit msg channels` | `msg_channels` | `GET /api/msg/channels` |
|
|
64
|
+
| `cockpit msg create` | `msg_create_channel` | `POST /api/msg/channels` |
|
|
65
|
+
| `cockpit msg dm` | `msg_dm` | `POST /api/msg/channels (dm)` |
|
|
66
|
+
| `cockpit msg read` | `msg_read` | `GET /api/msg/channels/[id]/messages` |
|
|
67
|
+
| `cockpit msg send` | `msg_send` | `POST /api/msg/channels/[id]/messages` |
|
|
68
|
+
| `cockpit msg thread` | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` |
|
|
69
|
+
| `cockpit notes list` | `notes_list` | `GET /api/notes/library` |
|
|
70
|
+
| `cockpit notes shelf` | `notes_shelf` | `GET /api/notes/shelf` |
|
|
71
|
+
| `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
|
|
72
|
+
| `cockpit notes show` | `notes_show` | `GET /api/notes/library/[id]` |
|
|
73
|
+
| `cockpit ops status` | `ops_status` | `GET /api/ops/status` |
|
|
74
|
+
| `cockpit project list` | `work_list_projects` | `GET /api/work/projects` |
|
|
75
|
+
| `cockpit scout board` | `scout_board` | `GET /api/cockpit/scout` |
|
|
76
|
+
| `cockpit search` | `tower_search` | `GET /api/search` |
|
|
77
|
+
| `cockpit settings list` | `settings_list` | `GET /api/settings/env-blobs` |
|
|
78
|
+
| `cockpit settings show` | `settings_show` | `GET /api/settings/* + /api/team/members` |
|
|
79
|
+
| `cockpit slack coverage` | `slack_coverage` | `GET /api/ops/slack/coverage` |
|
|
80
|
+
| `cockpit slack read` | `slack_read` | `POST /api/ops/slack/read` |
|
|
81
|
+
| `cockpit team device-list` | `team_device_list` | `GET /api/team/devices` |
|
|
82
|
+
| `cockpit team members` | `team_members` | `GET /api/team/members` |
|
|
83
|
+
| `cockpit workbook` | `workbook_read` | `GET /api/cockpit/workbook` |
|
|
84
|
+
|
|
85
|
+
### Owed a twin
|
|
86
|
+
|
|
87
|
+
This table should only ever shrink.
|
|
88
|
+
|
|
89
|
+
| CLI verb | Who owes it, and why it is not built yet |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `cockpit brief rewrite` | BLI-3756 batch 2 — spends a model call; wants its own gate on this surface |
|
|
92
|
+
| `cockpit correct` | BLI-3756 batch 2 — filing a correction against a claim has no tool |
|
|
93
|
+
| `cockpit model set` | BLI-3756 batch 2 — a shorthand over settings personal; follows settings |
|
|
94
|
+
| `cockpit notes move` | BLI-3756 batch 2 — needs a signed-in session server-side; reachable, just unwritten |
|
|
95
|
+
| `cockpit notes paste` | BLI-3756 batch 2 — the CLI takes the body on stdin; a tool would take it as an argument |
|
|
96
|
+
| `cockpit notes share` | BLI-3756 batch 2 — a deliberate act; wants an explicit confirmation on this surface |
|
|
97
|
+
| `cockpit notes unshare` | BLI-3756 batch 2 — same gate as notes share |
|
|
98
|
+
| `cockpit notes upload` | BLI-3756 batch 2 — takes local file paths, which an MCP server on the same machine can also read |
|
|
99
|
+
| `cockpit ops recompile` | BLI-3756 batch 2 — spends a model call; wants its own gate on this surface |
|
|
100
|
+
| `cockpit scout dismiss` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
|
|
101
|
+
| `cockpit scout start` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
|
|
102
|
+
| `cockpit scout undo` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
|
|
103
|
+
| `cockpit settings delete` | BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface |
|
|
104
|
+
| `cockpit settings set` | BLI-3756 batch 2 — mostly reachable, but env CONTENT is stdin-only by rule; a tool must keep that |
|
|
105
|
+
| `cockpit team device-revoke` | BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface |
|
|
106
|
+
| `cockpit team invite` | BLI-3756 batch 2 — super_admin; a tool would relay, not decide |
|
|
107
|
+
| `cockpit team role` | BLI-3756 batch 2 — super_admin; a tool would relay, not decide |
|
|
108
|
+
|
|
109
|
+
### Terminal-only
|
|
110
|
+
|
|
111
|
+
A claim about the verb's nature, not a backlog.
|
|
112
|
+
|
|
113
|
+
| CLI verb | Why it can never have a twin |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `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) |
|
|
116
|
+
|
|
117
|
+
<!-- END GENERATED verb census -->
|
|
118
|
+
|
|
28
119
|
## `jarvis_*` tools (BLI-3732)
|
|
29
120
|
|
|
30
121
|
JARVIS itself — the same assistant Tower web chat, the Slack DM and
|
|
@@ -74,16 +165,50 @@ terminal, because nothing on the wire can tell a person-relayed code from one
|
|
|
74
165
|
the model lifted out of the previous answer; `src/jarvis-tools.ts` says so in
|
|
75
166
|
full at the top of the file.
|
|
76
167
|
|
|
168
|
+
## The read tools (BLI-3756 batch 1)
|
|
169
|
+
|
|
170
|
+
Every Tower READ the CLI already had. Doors and tool names are in the
|
|
171
|
+
generated table above; what is worth knowing before you call one:
|
|
172
|
+
|
|
173
|
+
- **`brief_read` / `brief_history` / `brief_status`** — the TODAY page as
|
|
174
|
+
finished text, the days a person has a page for, and why a brief was or was
|
|
175
|
+
not delivered. `subject` changes WHOSE page is asked for, never who is
|
|
176
|
+
asking. A delivery reason other than `delivered` (`not_monday`,
|
|
177
|
+
`no_slack_identity`, …) is a real answer and is NOT returned as an error.
|
|
178
|
+
- **`notes_list` / `notes_show` / `notes_shelf` / `notes_shelves`** — the
|
|
179
|
+
meeting-notes library. A device token has no signed-in browser session, so
|
|
180
|
+
these reads are routinely NARROWER than a person's; every one of them
|
|
181
|
+
appends the door's own `scope`/`degradedBecause` reason rather than letting
|
|
182
|
+
an agent conclude a note was never taken.
|
|
183
|
+
- **`ops_status` / `slack_coverage` / `slack_read`** — the pipeline board
|
|
184
|
+
(each job judged against its own expected interval, server-side), Slack
|
|
185
|
+
collection health, and the collected messages themselves. `slack_read` has a
|
|
186
|
+
narrower audience than `slack_coverage`, and says so on a 403.
|
|
187
|
+
- **`settings_show` / `settings_list` / `model_show` / `team_members` /
|
|
188
|
+
`team_device_list`** — settings as this caller may see them. A section you
|
|
189
|
+
may not see comes back as `admin only`, not as an error; `settings_list`
|
|
190
|
+
returns env-file names, sizes and stamps and can never return a value.
|
|
191
|
+
- **`scout_board` / `workbook_read`** — the Scout board (with the page's own
|
|
192
|
+
standing sentences, and a truncated read saying so) and the per-project
|
|
193
|
+
document library, rendered by the same walker the page uses.
|
|
194
|
+
|
|
195
|
+
Writes — `brief rewrite`, `correct`, `notes move|share|unshare|paste|upload`,
|
|
196
|
+
`ops recompile`, `scout start|dismiss|undo`, `settings set|delete`,
|
|
197
|
+
`team invite|role|device-revoke`, `model set` — are batch 2, each with the
|
|
198
|
+
reason it waited in the generated "Owed a twin" table.
|
|
199
|
+
|
|
77
200
|
## `docs_*` / `msg_*` tools (BLI-3706)
|
|
78
201
|
|
|
79
202
|
| Tool | Wraps | Notes |
|
|
80
203
|
| --- | --- | --- |
|
|
81
204
|
| `docs_list` | `GET /api/docs/documents` | Every document you may read: id, slug, title, visibility. Never a body. |
|
|
205
|
+
| `docs_tree` | `GET /api/docs/tree` | The library as its nesting rather than a flat list. Never a body. |
|
|
82
206
|
| `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
|
|
83
207
|
| `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
|
|
84
208
|
| `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). |
|
|
85
209
|
| `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,
|
|
210
|
+
| `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
|
|
211
|
+
| `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
|
|
87
212
|
| `msg_send` | `POST /api/msg/channels/[id]/messages` | Posts a message. |
|
|
88
213
|
|
|
89
214
|
**Auth: this machine's collector device token, not `BLI_OPERATOR_TOKEN`.**
|
|
@@ -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
|
+
}
|
package/dist/docs-msg-tools.d.ts
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
* on a machine with no collector pairing at all, so a missing session must
|
|
10
10
|
* fail the ONE call that needed it, never the whole process.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
13
|
+
export type DocsMsgDeps = ToolDeps;
|
|
14
|
+
/**
|
|
15
|
+
* `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
|
|
16
|
+
* The list itself is metadata only — asking for a whole library used to cost
|
|
17
|
+
* every document's prose, so an agent that wants one folder or one phrase
|
|
18
|
+
* says so here and the database does the filtering.
|
|
19
|
+
*/
|
|
20
|
+
export declare function docsListPath(args: {
|
|
21
|
+
parent_id?: unknown;
|
|
22
|
+
query?: unknown;
|
|
23
|
+
limit?: unknown;
|
|
24
|
+
}): string;
|
|
19
25
|
export declare function registerDocsMsgTools(server: {
|
|
20
26
|
registerTool: (...args: never[]) => unknown;
|
|
21
27
|
}, deps: DocsMsgDeps): void;
|
package/dist/docs-msg-tools.js
CHANGED
|
@@ -10,28 +10,37 @@
|
|
|
10
10
|
* fail the ONE call that needed it, never the whole process.
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
14
13
|
import { callAgentDoor } from "./agent-door.js";
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
15
|
+
/** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
|
|
16
|
+
function renderDocTree(nodes, depth) {
|
|
17
|
+
return nodes
|
|
18
|
+
.map((node) => {
|
|
19
|
+
const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
|
|
20
|
+
const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
|
|
21
|
+
return `${line}${children}`;
|
|
22
|
+
})
|
|
23
|
+
.join("\n");
|
|
17
24
|
}
|
|
18
|
-
function
|
|
19
|
-
return
|
|
25
|
+
function countTree(nodes) {
|
|
26
|
+
return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
|
|
20
27
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
/**
|
|
29
|
+
* `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
|
|
30
|
+
* The list itself is metadata only — asking for a whole library used to cost
|
|
31
|
+
* every document's prose, so an agent that wants one folder or one phrase
|
|
32
|
+
* says so here and the database does the filtering.
|
|
33
|
+
*/
|
|
34
|
+
export function docsListPath(args) {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
if (typeof args.parent_id === "string" && args.parent_id.length > 0)
|
|
37
|
+
params.set("parent_id", args.parent_id);
|
|
38
|
+
if (typeof args.query === "string" && args.query.trim().length > 0)
|
|
39
|
+
params.set("query", args.query);
|
|
40
|
+
if (typeof args.limit === "number" && Number.isInteger(args.limit))
|
|
41
|
+
params.set("limit", String(args.limit));
|
|
42
|
+
const search = params.toString();
|
|
43
|
+
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
35
44
|
}
|
|
36
45
|
async function resolveDocumentId(deps, session, ref) {
|
|
37
46
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
|
|
@@ -51,18 +60,37 @@ async function resolveChannelId(deps, session, ref) {
|
|
|
51
60
|
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
52
61
|
}
|
|
53
62
|
export function registerDocsMsgTools(server, deps) {
|
|
54
|
-
const register =
|
|
63
|
+
const register = registrarFor(server);
|
|
55
64
|
register("docs_list", {
|
|
56
65
|
title: "List Tower documents",
|
|
57
|
-
description: "Every document you may read: id, slug, title, visibility, source
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
|
|
67
|
+
+ "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
|
|
68
|
+
+ "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), limit (1-1000).",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
parent_id: z.string().min(1).max(64).optional().describe('A document id to list under, or "root" for the top of the tree.'),
|
|
71
|
+
query: z.string().min(1).max(200).optional().describe("Keep only documents whose title or body contains this text."),
|
|
72
|
+
limit: z.number().int().min(1).max(1000).optional().describe("At most this many documents."),
|
|
73
|
+
},
|
|
74
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
75
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", docsListPath(args));
|
|
61
76
|
if (!response.ok)
|
|
62
77
|
return errorResult(doorFailureText("docs_list", response));
|
|
63
78
|
const documents = response.body.documents ?? [];
|
|
64
79
|
return textResult(`${documents.length} document(s).`, { documents });
|
|
65
80
|
}));
|
|
81
|
+
register("docs_tree", {
|
|
82
|
+
title: "Read the Tower document tree",
|
|
83
|
+
description: "The document library as its nesting, not as a flat list: every document you may read with its children "
|
|
84
|
+
+ "under it, id, slug and title. Never a body — call docs_read for one.",
|
|
85
|
+
inputSchema: {},
|
|
86
|
+
}, async () => withSession(deps, async (session) => {
|
|
87
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/tree");
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
return errorResult(doorFailureText("docs_tree", response));
|
|
90
|
+
const tree = (Array.isArray(response.body.tree) ? response.body.tree : []);
|
|
91
|
+
const rendered = renderDocTree(tree, 0);
|
|
92
|
+
return textResult(`${countTree(tree)} document(s).${rendered ? `\n${rendered}` : ""}`, { tree });
|
|
93
|
+
}));
|
|
66
94
|
register("docs_read", {
|
|
67
95
|
title: "Read a Tower document",
|
|
68
96
|
description: "One document's title and full body, by its id or slug (docs_list returns both).",
|
|
@@ -161,6 +189,84 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
161
189
|
const messages = response.body.messages ?? [];
|
|
162
190
|
return textResult(`${messages.length} message(s) in ${ref}.`, { channelId: resolved.id, messages });
|
|
163
191
|
}));
|
|
192
|
+
register("msg_thread", {
|
|
193
|
+
title: "Read a Tower message thread",
|
|
194
|
+
description: "The replies under one message, oldest first. `thread_parent_id` is the id of the message the thread hangs "
|
|
195
|
+
+ "off — msg_read returns it. `channel` is a channel id, or its name with or without a leading #.",
|
|
196
|
+
inputSchema: {
|
|
197
|
+
channel: z.string().min(1).max(200),
|
|
198
|
+
thread_parent_id: z.string().uuid().describe("The id of the message the replies hang off."),
|
|
199
|
+
},
|
|
200
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
201
|
+
const ref = String(args.channel ?? "");
|
|
202
|
+
const resolved = await resolveChannelId(deps, session, ref);
|
|
203
|
+
if (resolved.status === "list_failed")
|
|
204
|
+
return errorResult(resolved.text);
|
|
205
|
+
if (resolved.status === "not_found")
|
|
206
|
+
return errorResult(`No channel matches "${ref}" — check msg_channels for the id or name.`);
|
|
207
|
+
const parent = String(args.thread_parent_id ?? "");
|
|
208
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages?thread_parent_id=${encodeURIComponent(parent)}`);
|
|
209
|
+
if (!response.ok)
|
|
210
|
+
return errorResult(doorFailureText("msg_thread", response));
|
|
211
|
+
const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
|
|
212
|
+
// Oldest first: a thread reads as a conversation, unlike the channel
|
|
213
|
+
// list, which the door returns newest-first.
|
|
214
|
+
const lines = [...messages]
|
|
215
|
+
.reverse()
|
|
216
|
+
.map((message) => `${String(message.created_at ?? "?")} ${String(message.agent_label ?? message.user_id ?? "?")} `
|
|
217
|
+
+ `${String(message.content ?? "")}`)
|
|
218
|
+
.join("\n");
|
|
219
|
+
return textResult(`${messages.length} repl${messages.length === 1 ? "y" : "ies"} in ${ref}.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, threadParentId: parent, messages });
|
|
220
|
+
}));
|
|
221
|
+
register("msg_create_channel", {
|
|
222
|
+
title: "Create a Tower channel",
|
|
223
|
+
description: "Creates a channel and returns its id. `private` means membership decides who may read it. `member_emails` names who joins at birth BY EMAIL — an address Tower does not carry refuses the whole create, so no half-built channel is left behind.",
|
|
224
|
+
inputSchema: {
|
|
225
|
+
name: z.string().min(1).max(80).describe("The channel name. A leading # is accepted and dropped."),
|
|
226
|
+
description: z.string().max(500).optional(),
|
|
227
|
+
private: z.boolean().optional(),
|
|
228
|
+
member_emails: z.array(z.string().min(3).max(320)).max(200).optional(),
|
|
229
|
+
},
|
|
230
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
231
|
+
const raw = String(args.name ?? "");
|
|
232
|
+
const name = raw.startsWith("#") ? raw.slice(1) : raw;
|
|
233
|
+
if (name.trim() === "")
|
|
234
|
+
return errorResult("A channel needs a name. Nothing was created.");
|
|
235
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/msg/channels", {
|
|
236
|
+
name,
|
|
237
|
+
is_private: args.private === true,
|
|
238
|
+
...(args.description ? { description: args.description } : {}),
|
|
239
|
+
...(Array.isArray(args.member_emails) ? { member_emails: args.member_emails } : {}),
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok)
|
|
242
|
+
return errorResult(doorFailureText("msg_create_channel", response));
|
|
243
|
+
const channel = response.body.channel;
|
|
244
|
+
const membersAdded = (response.body.members_added ?? []);
|
|
245
|
+
const membersFailed = (response.body.members_failed ?? []);
|
|
246
|
+
// A member the channel could not take is named in the SENTENCE, not
|
|
247
|
+
// just in the structured half: an agent that only reads the text
|
|
248
|
+
// would otherwise report a clean create over a partial one.
|
|
249
|
+
const failedNote = membersFailed.length > 0
|
|
250
|
+
? ` ${membersFailed.length} member(s) were not added (${membersFailed.map((f) => f.reason ?? "unknown").join(", ")}).`
|
|
251
|
+
: "";
|
|
252
|
+
return textResult(`Created #${channel?.name ?? name} (${channel?.id ?? "?"}) with ${membersAdded.length} member(s) added.${failedNote}`, { channel, members_added: membersAdded, members_failed: membersFailed });
|
|
253
|
+
}));
|
|
254
|
+
register("msg_dm", {
|
|
255
|
+
title: "Open a Tower direct message",
|
|
256
|
+
description: "Opens (or re-opens) the direct message with one person, by their email address. Idempotent: the same address always resolves to the same channel, and you never name yourself.",
|
|
257
|
+
inputSchema: { email: z.string().min(3).max(320).describe("The other person's email address.") },
|
|
258
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
259
|
+
const email = String(args.email ?? "").trim();
|
|
260
|
+
if (email === "")
|
|
261
|
+
return errorResult("A direct message needs the person's email address.");
|
|
262
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/msg/channels", {
|
|
263
|
+
dm_participant_emails: [email],
|
|
264
|
+
});
|
|
265
|
+
if (!response.ok)
|
|
266
|
+
return errorResult(doorFailureText("msg_dm", response));
|
|
267
|
+
const channel = response.body.channel;
|
|
268
|
+
return textResult(`Direct message with ${email}: ${channel?.id ?? "?"}.`, { channel });
|
|
269
|
+
}));
|
|
164
270
|
register("msg_send", {
|
|
165
271
|
title: "Send a Tower message",
|
|
166
272
|
description: "Posts a message to one channel. `channel` is a channel id, or its name with or without a leading #.",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_*` MCP tools (BLI-3756) — the meeting-notes library, read by an
|
|
3
|
+
* agent, over the same `/api/notes/**` doors `cockpit notes` calls with the
|
|
4
|
+
* same device token.
|
|
5
|
+
*
|
|
6
|
+
* **The narrowing is always said out loud.** `lib/notes/api-doors.ts` gives a
|
|
7
|
+
* caller with no signed-in browser session a NARROWER read and names it in the
|
|
8
|
+
* answer's own `scope`/`degradedBecause` — a device token is exactly such a
|
|
9
|
+
* caller, so an agent reading this library is routinely seeing less than a
|
|
10
|
+
* person would in a browser. Every tool here appends that reason to its text.
|
|
11
|
+
* Swallowing it would let an agent conclude a note was never taken when in
|
|
12
|
+
* fact it simply was not this caller's to read, which is a fact invented out
|
|
13
|
+
* of a permission.
|
|
14
|
+
*
|
|
15
|
+
* Reads only. `notes move|share|unshare|paste|upload` are batch 2 — sharing is
|
|
16
|
+
* a deliberate act and the paste door takes a body on stdin by rule.
|
|
17
|
+
*/
|
|
18
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
19
|
+
export type NotesDeps = ToolDeps;
|
|
20
|
+
interface ScopedBody {
|
|
21
|
+
scope?: string;
|
|
22
|
+
degradedBecause?: string | null;
|
|
23
|
+
degradedNote?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The door's own words for "this answer is narrower than the browser's", or
|
|
27
|
+
* "" when it was not narrowed. Never rephrased here.
|
|
28
|
+
*/
|
|
29
|
+
export declare function narrowingNote(body: ScopedBody): string;
|
|
30
|
+
export declare function registerNotesTools(server: {
|
|
31
|
+
registerTool: (...args: never[]) => unknown;
|
|
32
|
+
}, deps: NotesDeps): void;
|
|
33
|
+
export {};
|