@bli-cockpit/mcp 0.1.59 → 0.1.63

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Helpers behind the `docs_*` and `msg_*` tools (split out of
3
+ * `docs-msg-tools.ts` to keep it under the file-size ratchet): id resolution,
4
+ * list shaping and one-line renderers. No tool is registered here.
5
+ */
6
+ import { callAgentDoor } from "./agent-door.js";
7
+ import { DEFAULT_LIST_LIMIT, doorFailureText } from "./tool-result.js";
8
+ /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
9
+ export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
10
+ /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
11
+ export const DOCS_LIST_MAX = 1000;
12
+ /**
13
+ * The tree as the text half prints it: id, title, slug, children. The door
14
+ * also sends visibility, source and a timestamp per node, which nothing here
15
+ * promised and which made the structured half 48 KB for 226 documents
16
+ * (BLI-10432). `docs_list` carries those fields for a flat read.
17
+ */
18
+ export function slimTree(nodes) {
19
+ return nodes.map((node) => ({
20
+ id: node.id,
21
+ title: node.title,
22
+ slug: node.slug ?? null,
23
+ children: slimTree(Array.isArray(node.children) ? node.children : []),
24
+ }));
25
+ }
26
+ /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
27
+ export function renderDocTree(nodes, depth) {
28
+ return nodes
29
+ .map((node) => {
30
+ const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
31
+ const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
32
+ return `${line}${children}`;
33
+ })
34
+ .join("\n");
35
+ }
36
+ export function countTree(nodes) {
37
+ return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
38
+ }
39
+ /**
40
+ * The fields `docs_list` promises, and only those. The door's row also carries
41
+ * `created_by`, `updated_by`, `created_at`, `import_truncated` and
42
+ * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
43
+ */
44
+ export function docListRow(row) {
45
+ return {
46
+ id: row.id,
47
+ slug: row.slug ?? null,
48
+ title: row.title,
49
+ visibility: row.visibility,
50
+ source: row.source ?? null,
51
+ parent_id: row.parent_id ?? null,
52
+ updated_at: row.updated_at,
53
+ body_chars: row.body_chars ?? null,
54
+ };
55
+ }
56
+ export function docListLine(row) {
57
+ return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
58
+ + `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
59
+ }
60
+ /**
61
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
62
+ * The list itself is metadata only — asking for a whole library used to cost
63
+ * every document's prose, so an agent that wants one folder or one phrase
64
+ * says so here and the database does the filtering.
65
+ */
66
+ export function docsListPath(args) {
67
+ const params = new URLSearchParams();
68
+ if (typeof args.parent_id === "string" && args.parent_id.length > 0)
69
+ params.set("parent_id", args.parent_id);
70
+ if (typeof args.query === "string" && args.query.trim().length > 0)
71
+ params.set("query", args.query);
72
+ if (typeof args.limit === "number" && Number.isInteger(args.limit))
73
+ params.set("limit", String(args.limit));
74
+ // No limit named: one page plus one row, so "more exist" is read off the
75
+ // door instead of guessed from a full page (BLI-10432).
76
+ else if (args.limit === undefined)
77
+ params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
78
+ const search = params.toString();
79
+ return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
80
+ }
81
+ export async function resolveDocumentId(deps, session, ref) {
82
+ // An id needs no lookup: the read itself answers 404 by name when it is
83
+ // wrong. Only a slug costs the whole-library list read (BLI-10432).
84
+ if (UUID_PATTERN.test(ref))
85
+ return { status: "ok", id: ref };
86
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
87
+ if (!response.ok)
88
+ return { status: "list_failed", text: doorFailureText("docs_list", response) };
89
+ const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
90
+ const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
91
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
92
+ }
93
+ export async function resolveChannelId(deps, session, ref) {
94
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
95
+ if (!response.ok)
96
+ return { status: "list_failed", text: doorFailureText("msg_channels", response) };
97
+ const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
98
+ const bare = ref.startsWith("#") ? ref.slice(1) : ref;
99
+ const match = channels.find((channel) => channel.id === ref || channel.name === bare);
100
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
101
+ }
102
+ /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
103
+ export function messageLine(message) {
104
+ return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
105
+ + `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
106
+ }
@@ -9,19 +9,9 @@
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 { 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;
12
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
13
+ export { docsListPath } from "./docs-msg-helpers.js";
14
+ export type { DocsMsgDeps } from "./docs-msg-helpers.js";
25
15
  export declare function registerDocsMsgTools(server: {
26
16
  registerTool: (...args: never[]) => unknown;
27
17
  }, deps: DocsMsgDeps): void;
@@ -11,72 +11,31 @@
11
11
  */
12
12
  import { z } from "zod";
13
13
  import { callAgentDoor } from "./agent-door.js";
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");
24
- }
25
- function countTree(nodes) {
26
- return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
27
- }
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";
44
- }
45
- async function resolveDocumentId(deps, session, ref) {
46
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
47
- if (!response.ok)
48
- return { status: "list_failed", text: doorFailureText("docs_list", response) };
49
- const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
50
- const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
51
- return match ? { status: "ok", id: match.id } : { status: "not_found" };
52
- }
53
- async function resolveChannelId(deps, session, ref) {
54
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
55
- if (!response.ok)
56
- return { status: "list_failed", text: doorFailureText("msg_channels", response) };
57
- const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
58
- const bare = ref.startsWith("#") ? ref.slice(1) : ref;
59
- const match = channels.find((channel) => channel.id === ref || channel.name === bare);
60
- return match ? { status: "ok", id: match.id } : { status: "not_found" };
61
- }
14
+ import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
15
+ import { DOCS_LIST_MAX, slimTree, renderDocTree, countTree, docListRow, docListLine, resolveDocumentId, resolveChannelId, messageLine, docsListPath } from "./docs-msg-helpers.js";
16
+ export { docsListPath } from "./docs-msg-helpers.js";
62
17
  export function registerDocsMsgTools(server, deps) {
63
18
  const register = registrarFor(server);
64
19
  register("docs_list", {
65
20
  title: "List Tower documents",
66
21
  description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
67
22
  + "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).",
23
+ + "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), "
24
+ + `limit (1-1000, default ${DEFAULT_LIST_LIMIT}; the answer says when more exist).`,
69
25
  inputSchema: {
70
26
  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
27
  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."),
28
+ limit: z.number().int().min(1).max(1000).optional().describe(`At most this many documents. Default ${DEFAULT_LIST_LIMIT}.`),
73
29
  },
74
30
  }, async (args) => withSession(deps, async (session) => {
75
31
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", docsListPath(args));
76
32
  if (!response.ok)
77
33
  return errorResult(doorFailureText("docs_list", response));
78
- const documents = response.body.documents ?? [];
79
- return textResult(`${documents.length} document(s).`, { documents });
34
+ const all = (Array.isArray(response.body.documents) ? response.body.documents : []);
35
+ const more = args.limit === undefined && all.length > DEFAULT_LIST_LIMIT;
36
+ const rows = more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
37
+ const lines = rows.map(docListLine).join("\n");
38
+ return textResult(`${rows.length} document(s).${moreNote(more, DOCS_LIST_MAX)}${lines ? `\n${lines}` : ""}`, { documents: rows.map(docListRow), more });
80
39
  }));
81
40
  register("docs_tree", {
82
41
  title: "Read the Tower document tree",
@@ -87,9 +46,9 @@ export function registerDocsMsgTools(server, deps) {
87
46
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/tree");
88
47
  if (!response.ok)
89
48
  return errorResult(doorFailureText("docs_tree", response));
90
- const tree = (Array.isArray(response.body.tree) ? response.body.tree : []);
49
+ const tree = slimTree((Array.isArray(response.body.tree) ? response.body.tree : []));
91
50
  const rendered = renderDocTree(tree, 0);
92
- return textResult(`${countTree(tree)} document(s).${rendered ? `\n${rendered}` : ""}`, { tree });
51
+ return textResult(`${countTree(tree)} document(s), each as "title (slug or id)"; docs_read takes either.${rendered ? `\n${rendered}` : ""}`, { tree });
93
52
  }));
94
53
  register("docs_read", {
95
54
  title: "Read a Tower document",
@@ -112,10 +71,10 @@ export function registerDocsMsgTools(server, deps) {
112
71
  title: "Create a Tower document",
113
72
  description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
114
73
  inputSchema: {
115
- title: z.string().min(1).max(200),
116
- body_markdown: z.string().max(200_000).optional(),
117
- parent_id: z.string().uuid().optional(),
118
- visibility: z.enum(["org", "private"]).optional(),
74
+ title: z.string().min(1).max(200).describe("The document's title."),
75
+ body_markdown: z.string().max(200_000).optional().describe("The body, markdown. Omit for an empty document."),
76
+ parent_id: z.string().uuid().optional().describe("A document id to nest this one under. Omit for the top of the tree."),
77
+ visibility: z.enum(["org", "private"]).optional().describe("org: everyone signed in may read it. private: only you."),
119
78
  },
120
79
  }, async (args) => withSession(deps, async (session) => {
121
80
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/docs/documents", {
@@ -135,10 +94,10 @@ export function registerDocsMsgTools(server, deps) {
135
94
  + "pass change. Emptying a document that holds text needs allow_empty: true.",
136
95
  inputSchema: {
137
96
  id: z.string().min(1).max(200).describe("A document id, or its slug."),
138
- title: z.string().min(1).max(200).optional(),
139
- body_markdown: z.string().max(200_000).optional(),
140
- visibility: z.enum(["org", "private"]).optional(),
141
- parent_id: z.string().uuid().nullable().optional(),
97
+ title: z.string().min(1).max(200).optional().describe("A new title."),
98
+ body_markdown: z.string().max(200_000).optional().describe("The WHOLE new body, markdown. It replaces the old one; it is not appended."),
99
+ visibility: z.enum(["org", "private"]).optional().describe("org: everyone signed in may read it. private: only you."),
100
+ parent_id: z.string().uuid().nullable().optional().describe("Move it under this document id; null moves it to the top of the tree."),
142
101
  allow_empty: z
143
102
  .boolean()
144
103
  .optional()
@@ -169,21 +128,40 @@ export function registerDocsMsgTools(server, deps) {
169
128
  }));
170
129
  register("msg_channels", {
171
130
  title: "List Tower channels",
172
- description: "Every channel you are a member of.",
131
+ description: "Every channel you are a member of: id, name (a direct message has none and lists its participants instead), "
132
+ + "private, and description. msg_read takes the id or the name.",
173
133
  inputSchema: {},
174
134
  }, async () => withSession(deps, async (session) => {
175
135
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
176
136
  if (!response.ok)
177
137
  return errorResult(doorFailureText("msg_channels", response));
178
- const channels = response.body.channels ?? [];
179
- return textResult(`${channels.length} channel(s).`, { channels });
138
+ const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
139
+ const rows = channels.map((channel) => ({
140
+ id: channel.id,
141
+ name: channel.name ?? null,
142
+ description: channel.description ?? null,
143
+ is_private: channel.is_private === true,
144
+ is_dm: channel.is_dm === true,
145
+ ...(channel.is_dm === true ? { dm_participants: channel.dm_participants ?? [] } : {}),
146
+ archived_at: channel.archived_at ?? null,
147
+ }));
148
+ const lines = rows
149
+ .map((row) => {
150
+ const label = row.is_dm
151
+ ? `DM with ${JSON.stringify(row.dm_participants ?? [])}`
152
+ : `#${String(row.name ?? "?")}${row.is_private ? " (private)" : ""}`;
153
+ return `${String(row.id)} ${label}${row.archived_at ? " [archived]" : ""}`
154
+ + `${row.description ? ` ${String(row.description)}` : ""}`;
155
+ })
156
+ .join("\n");
157
+ return textResult(`${rows.length} channel(s).${lines ? `\n${lines}` : ""}`, { channels: rows });
180
158
  }));
181
159
  register("msg_read", {
182
160
  title: "Read a Tower channel",
183
161
  description: "Recent messages of one channel, newest first. `channel` is a channel id, or its name with or without a leading #.",
184
162
  inputSchema: {
185
- channel: z.string().min(1).max(200),
186
- limit: z.number().int().min(1).max(200).optional(),
163
+ channel: z.string().min(1).max(200).describe("A channel id, or its name with or without a leading #."),
164
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many messages, newest first."),
187
165
  },
188
166
  }, async (args) => withSession(deps, async (session) => {
189
167
  const ref = String(args.channel ?? "");
@@ -196,15 +174,19 @@ export function registerDocsMsgTools(server, deps) {
196
174
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages${query}`);
197
175
  if (!response.ok)
198
176
  return errorResult(doorFailureText("msg_read", response));
199
- const messages = response.body.messages ?? [];
200
- return textResult(`${messages.length} message(s) in ${ref}.`, { channelId: resolved.id, messages });
177
+ const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
178
+ // The text half carries the messages themselves, each with its id,
179
+ // which is what msg_thread takes. It used to say only "2 message(s)",
180
+ // so a Claude-family client never saw one (BLI-10432).
181
+ const lines = messages.map(messageLine).join("\n");
182
+ return textResult(`${messages.length} message(s) in ${ref}, newest first; each line is id, time, author, text.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, messages });
201
183
  }));
202
184
  register("msg_thread", {
203
185
  title: "Read a Tower message thread",
204
186
  description: "The replies under one message, oldest first. `thread_parent_id` is the id of the message the thread hangs "
205
187
  + "off — msg_read returns it. `channel` is a channel id, or its name with or without a leading #.",
206
188
  inputSchema: {
207
- channel: z.string().min(1).max(200),
189
+ channel: z.string().min(1).max(200).describe("A channel id, or its name with or without a leading #."),
208
190
  thread_parent_id: z.string().uuid().describe("The id of the message the replies hang off."),
209
191
  },
210
192
  }, async (args) => withSession(deps, async (session) => {
@@ -221,11 +203,7 @@ export function registerDocsMsgTools(server, deps) {
221
203
  const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
222
204
  // Oldest first: a thread reads as a conversation, unlike the channel
223
205
  // list, which the door returns newest-first.
224
- const lines = [...messages]
225
- .reverse()
226
- .map((message) => `${String(message.created_at ?? "?")} ${String(message.agent_label ?? message.user_id ?? "?")} `
227
- + `${String(message.content ?? "")}`)
228
- .join("\n");
206
+ const lines = [...messages].reverse().map(messageLine).join("\n");
229
207
  return textResult(`${messages.length} repl${messages.length === 1 ? "y" : "ies"} in ${ref}.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, threadParentId: parent, messages });
230
208
  }));
231
209
  register("msg_create_channel", {
@@ -233,9 +211,9 @@ export function registerDocsMsgTools(server, deps) {
233
211
  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.",
234
212
  inputSchema: {
235
213
  name: z.string().min(1).max(80).describe("The channel name. A leading # is accepted and dropped."),
236
- description: z.string().max(500).optional(),
237
- private: z.boolean().optional(),
238
- member_emails: z.array(z.string().min(3).max(320)).max(200).optional(),
214
+ description: z.string().max(500).optional().describe("One line saying what the channel is for."),
215
+ private: z.boolean().optional().describe("true: only members may read it. Default false."),
216
+ member_emails: z.array(z.string().min(3).max(320)).max(200).optional().describe("Email addresses of the people who join at birth. You join either way."),
239
217
  },
240
218
  }, async (args) => withSession(deps, async (session) => {
241
219
  const raw = String(args.name ?? "");
@@ -281,9 +259,9 @@ export function registerDocsMsgTools(server, deps) {
281
259
  title: "Send a Tower message",
282
260
  description: "Posts a message to one channel. `channel` is a channel id, or its name with or without a leading #.",
283
261
  inputSchema: {
284
- channel: z.string().min(1).max(200),
285
- content: z.string().min(1).max(8_000),
286
- thread_parent_id: z.string().uuid().optional(),
262
+ channel: z.string().min(1).max(200).describe("A channel id, or its name with or without a leading #."),
263
+ content: z.string().min(1).max(8_000).describe("The message text."),
264
+ thread_parent_id: z.string().uuid().optional().describe("Reply in the thread under this message id (msg_read prints ids)."),
287
265
  },
288
266
  }, async (args) => withSession(deps, async (session) => {
289
267
  const ref = String(args.channel ?? "");
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Notion half of the `docs_*` tools (BLI-10430) — the same door
3
+ * `cockpit docs link` calls, over this machine's device token. A sibling of
4
+ * `docs-msg-tools.ts` rather than more of it, the same split the collector
5
+ * makes (`commands/docs-notion.ts`).
6
+ *
7
+ * `docs_link` → `PUT /api/docs/documents/[id]/notion-link` (or `DELETE` with
8
+ * `clear: true`). A link makes the Notion page the truth: the next sync pulls
9
+ * it over the Tower copy and keeps the copy as a version.
10
+ * `docs_sync_status` → `GET /api/docs/notion-sync[?document_id=]`: Tower's half
11
+ * of the sync state, metadata only.
12
+ */
13
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
14
+ export declare function registerDocsNotionTools(server: {
15
+ registerTool: (...args: never[]) => unknown;
16
+ }, deps: DocsMsgDeps): void;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The Notion half of the `docs_*` tools (BLI-10430) — the same door
3
+ * `cockpit docs link` calls, over this machine's device token. A sibling of
4
+ * `docs-msg-tools.ts` rather than more of it, the same split the collector
5
+ * makes (`commands/docs-notion.ts`).
6
+ *
7
+ * `docs_link` → `PUT /api/docs/documents/[id]/notion-link` (or `DELETE` with
8
+ * `clear: true`). A link makes the Notion page the truth: the next sync pulls
9
+ * it over the Tower copy and keeps the copy as a version.
10
+ * `docs_sync_status` → `GET /api/docs/notion-sync[?document_id=]`: Tower's half
11
+ * of the sync state, metadata only.
12
+ */
13
+ import { z } from "zod";
14
+ import { callAgentDoor } from "./agent-door.js";
15
+ import { resolveDocumentId } from "./docs-msg-helpers.js";
16
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession } from "./tool-result.js";
17
+ export function registerDocsNotionTools(server, deps) {
18
+ const register = registrarFor(server);
19
+ register("docs_link", {
20
+ title: "Link a Tower document to a Notion page",
21
+ description: "Ties a document to a Notion page so the two-way sync moves it both ways, or unties it with clear: true. "
22
+ + "The page becomes the truth: the next sync pulls it over this copy and keeps the copy as a version. "
23
+ + "Refusals keep Tower's reason: invalid_notion_page, notion_page_already_linked, document_not_writable.",
24
+ inputSchema: {
25
+ id: z.string().min(1).max(200).describe("A document id, or its slug."),
26
+ notion_page: z
27
+ .string()
28
+ .min(1)
29
+ .max(500)
30
+ .optional()
31
+ .describe("The Notion page URL (app.notion.com/p/… or notion.so/…) or its page id."),
32
+ clear: z.boolean().optional().describe("true to untie the document from Notion instead."),
33
+ },
34
+ }, async (args) => withSession(deps, async (session) => {
35
+ const clear = args.clear === true;
36
+ const notionPage = typeof args.notion_page === "string" ? args.notion_page : undefined;
37
+ if (clear === Boolean(notionPage)) {
38
+ return errorResult("docs_link needs exactly one of notion_page (to link) or clear: true (to unlink).");
39
+ }
40
+ const ref = String(args.id ?? "");
41
+ const resolved = await resolveDocumentId(deps, session, ref);
42
+ if (resolved.status === "list_failed")
43
+ return errorResult(resolved.text);
44
+ if (resolved.status === "not_found")
45
+ return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
46
+ const path = `/api/docs/documents/${encodeURIComponent(resolved.id)}/notion-link`;
47
+ const response = clear
48
+ ? await callAgentDoor(session, deps.fetchImpl, "DELETE", path)
49
+ : await callAgentDoor(session, deps.fetchImpl, "PUT", path, { notion_page: notionPage });
50
+ if (!response.ok)
51
+ return errorResult(doorFailureText("docs_link", response));
52
+ const outcome = String(response.body.outcome ?? "");
53
+ const document = response.body.document;
54
+ return textResult(`${outcome || "done"}: "${document?.title ?? ""}" (${document?.id ?? resolved.id}).`, {
55
+ outcome,
56
+ document,
57
+ });
58
+ }));
59
+ register("docs_sync_status", {
60
+ title: "Read the Tower-Notion sync status",
61
+ description: "Every document linked to a Notion page (or one, by id or slug) with its sync state: synced (the body is what "
62
+ + "both sides last agreed on), tower_changed (edited in Tower since; the next write-back pushes it), never_synced "
63
+ + "(linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull. Never a body.",
64
+ inputSchema: {
65
+ id: z.string().min(1).max(200).optional().describe("A document id or slug, for one document's status."),
66
+ },
67
+ }, async (args) => withSession(deps, async (session) => {
68
+ let path = "/api/docs/notion-sync";
69
+ if (typeof args.id === "string" && args.id.length > 0) {
70
+ const resolved = await resolveDocumentId(deps, session, args.id);
71
+ if (resolved.status === "list_failed")
72
+ return errorResult(resolved.text);
73
+ if (resolved.status === "not_found")
74
+ return errorResult(`No document matches "${args.id}" — check docs_list for the id or slug.`);
75
+ path = `${path}?document_id=${encodeURIComponent(resolved.id)}`;
76
+ }
77
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", path);
78
+ if (!response.ok)
79
+ return errorResult(doorFailureText("docs_sync_status", response));
80
+ const counts = response.body.counts;
81
+ const summary = Object.entries(counts?.by_state ?? {})
82
+ .map(([state, count]) => `${count} ${state}`)
83
+ .join(", ");
84
+ return textResult(`${counts?.linked ?? 0} linked: ${summary}.`, {
85
+ counts,
86
+ documents: response.body.documents ?? [],
87
+ });
88
+ }));
89
+ }
@@ -26,12 +26,12 @@
26
26
  */
27
27
  import { z } from "zod";
28
28
  import { callAgentDoor } from "./agent-door.js";
29
- import { CONFIRM_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
29
+ import { CONFIRM_INPUT, EMAIL_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
30
30
  function messageLine(message) {
31
31
  const from = message.from_address?.address ?? "(no sender)";
32
32
  return `${message.is_unread ? "*" : " "} ${message.internal_date.slice(0, 16)} ${from} ${message.subject ?? "(no subject)"} [thread ${message.thread_id ?? "?"}]`;
33
33
  }
34
- const ADDRESS = z.string().email().max(320);
34
+ const ADDRESS = EMAIL_INPUT.describe("An email address.");
35
35
  export function registerMailTools(server, deps) {
36
36
  const register = registrarFor(server);
37
37
  register("mail_accounts", {
@@ -56,11 +56,11 @@ export function registerMailTools(server, deps) {
56
56
  description: "Messages across every mailbox you may read, newest first. Never a body — call mail_read on a thread for that. "
57
57
  + "`account_id` narrows to one mailbox; `unread` and `label` filter; `before` (an ISO instant) pages backwards.",
58
58
  inputSchema: {
59
- account_id: z.string().uuid().optional(),
60
- unread: z.boolean().optional(),
61
- label: z.string().min(1).max(100).optional(),
62
- before: z.string().min(1).max(40).optional(),
63
- limit: z.number().int().min(1).max(200).optional(),
59
+ account_id: z.string().uuid().optional().describe("One mailbox id (mail_accounts). Omit for every mailbox you may read."),
60
+ unread: z.boolean().optional().describe("true: unread messages only."),
61
+ label: z.string().min(1).max(100).optional().describe("Only messages carrying this provider label, e.g. INBOX or IMPORTANT."),
62
+ before: z.string().min(1).max(40).optional().describe("An ISO instant: messages received before it, for the next page back."),
63
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many messages, newest first."),
64
64
  },
65
65
  }, async (args) => withSession(deps, async (session) => {
66
66
  const query = new URLSearchParams();
@@ -84,30 +84,51 @@ export function registerMailTools(server, deps) {
84
84
  }));
85
85
  register("mail_read", {
86
86
  title: "Read one conversation",
87
- description: "One thread with every message's text body, oldest first. Thread ids come from mail_inbox or mail_search. "
88
- + "Read the thread before saying what somebody wrote — a snippet is the first 240 characters and nothing more.",
89
- inputSchema: { thread_id: z.string().uuid() },
87
+ description: "One thread with every message's text body, oldest first, and each message's attachments by id (the id "
88
+ + "mail_attachment takes). Thread ids come from mail_inbox or mail_search. Read the thread before saying what "
89
+ + "somebody wrote — a snippet is the first 240 characters and nothing more.",
90
+ inputSchema: { thread_id: z.string().uuid().describe("A thread id, as mail_inbox or mail_search print it.") },
90
91
  }, async (args) => withSession(deps, async (session) => {
91
92
  const threadId = String(args.thread_id ?? "");
92
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/threads/${encodeURIComponent(threadId)}`);
93
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET",
94
+ // `?attachments=1` adds each message's attachment pointers. Without
95
+ // it no tool here ever showed an attachment id, so mail_attachment
96
+ // could not be reached from an agent at all (BLI-10432).
97
+ `/api/mail/threads/${encodeURIComponent(threadId)}?attachments=1`);
93
98
  if (!response.ok)
94
99
  return errorResult(doorFailureText("mail_read", response));
95
100
  const thread = response.body.thread;
96
101
  const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
102
+ const attachments = (response.body.attachments && typeof response.body.attachments === "object"
103
+ ? response.body.attachments
104
+ : {});
97
105
  const rendered = messages
98
- .map((message) => `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]\n`
99
- + (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim())
106
+ .map((message) => {
107
+ const files = (attachments[message.id] ?? [])
108
+ .map((file) => `\n attachment ${file.id} ${file.filename ?? "(unnamed)"} ${file.mime_type ?? "?"} ${file.size_bytes ?? "?"} bytes${file.is_inline ? " (inline)" : ""}`)
109
+ .join("");
110
+ return `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]\n`
111
+ + (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim()
112
+ + files;
113
+ })
100
114
  .join("\n\n");
101
- return textResult(`${thread?.subject ?? "(no subject)"} — ${thread?.account_address ?? "?"}, ${messages.length} message(s)\n\n${rendered}`, { thread, messages });
115
+ // The HTML body is the text body again with markup, and nothing on
116
+ // this surface renders it; its length stays so an HTML-only message
117
+ // still says it has one.
118
+ const slim = messages.map(({ body_html: html, ...rest }) => ({
119
+ ...rest,
120
+ body_html_chars: typeof html === "string" ? html.length : 0,
121
+ }));
122
+ return textResult(`${thread?.subject ?? "(no subject)"} — ${thread?.account_address ?? "?"}, ${messages.length} message(s)\n\n${rendered}`, { thread, messages: slim, attachments });
102
123
  }));
103
124
  register("mail_search", {
104
125
  title: "Search your mail",
105
126
  description: "Full text over the subject and plain-text body of every message you may read. Quoted phrases and OR work the way "
106
127
  + "they do in a search box. Returns headers only; call mail_read on a hit's thread to read it.",
107
128
  inputSchema: {
108
- query: z.string().min(1).max(400),
109
- account_id: z.string().uuid().optional(),
110
- limit: z.number().int().min(1).max(200).optional(),
129
+ query: z.string().min(1).max(400).describe("Words to find. Quoted phrases and OR work."),
130
+ account_id: z.string().uuid().optional().describe("One mailbox id (mail_accounts). Omit for every mailbox you may read."),
131
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many matches, newest first."),
111
132
  },
112
133
  }, async (args) => withSession(deps, async (session) => {
113
134
  const query = new URLSearchParams({ q: String(args.query ?? "") });
@@ -129,12 +150,12 @@ export function registerMailTools(server, deps) {
129
150
  + "that is never something to guess. `in_reply_to_message_id` (a message id from mail_read) threads the reply. "
130
151
  + "This actually sends mail to real people: confirm must be true.",
131
152
  inputSchema: {
132
- account_id: z.string().uuid(),
133
- to: z.array(ADDRESS).min(1).max(50),
134
- cc: z.array(ADDRESS).max(50).optional(),
135
- subject: z.string().max(998),
136
- text: z.string().min(1).max(500_000),
137
- in_reply_to_message_id: z.string().uuid().optional(),
153
+ account_id: z.string().uuid().describe("The mailbox it goes out from (mail_accounts). Never guessed."),
154
+ to: z.array(ADDRESS).min(1).max(50).describe("Recipient email addresses."),
155
+ cc: z.array(ADDRESS).max(50).optional().describe("Copied email addresses."),
156
+ subject: z.string().max(998).describe("The subject line."),
157
+ text: z.string().min(1).max(500_000).describe("The plain-text body."),
158
+ in_reply_to_message_id: z.string().uuid().optional().describe("A message id from mail_read: send this as a reply in that thread."),
138
159
  confirm: CONFIRM_INPUT,
139
160
  },
140
161
  }, async (args) => withSession(deps, async (session) => {
@@ -160,7 +181,7 @@ export function registerMailTools(server, deps) {
160
181
  title: "Describe an attachment",
161
182
  description: "The filename, mime type and size of one attachment. Attachments are POINTERS in Tower — nothing is stored — and "
162
183
  + "the bytes are not returned here: `cockpit mail attachment <id> --out <path>` downloads the file at a terminal.",
163
- inputSchema: { attachment_id: z.string().uuid() },
184
+ inputSchema: { attachment_id: z.string().uuid().describe("An attachment id, as mail_read lists it.") },
164
185
  }, async (args) => withSession(deps, async (session) => {
165
186
  const id = String(args.attachment_id ?? "");
166
187
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/attachments/${encodeURIComponent(id)}?meta=1`);
@@ -174,7 +195,7 @@ export function registerMailTools(server, deps) {
174
195
  title: "Read a mailbox now",
175
196
  description: "Runs one sync pass on one mailbox instead of waiting for the 15-minute cron, and answers with what landed or the "
176
197
  + "reason it did not (token_revoked, history_gap, auth_failed, quota…). Only the mailbox's OWNER may do this.",
177
- inputSchema: { account_id: z.string().uuid() },
198
+ inputSchema: { account_id: z.string().uuid().describe("A mailbox id you own (mail_accounts).") },
178
199
  }, async (args) => withSession(deps, async (session) => {
179
200
  const id = String(args.account_id ?? "");
180
201
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/mail/accounts/${encodeURIComponent(id)}/sync`);
@@ -190,7 +211,7 @@ export function registerMailTools(server, deps) {
190
211
  title: "Detach a mailbox",
191
212
  description: "Removes a mailbox from Tower along with its stored mail and its credential. The mail itself is untouched at the "
192
213
  + "provider — this unhooks Tower, it does not delete anybody's email. Irreversible here: confirm must be true.",
193
- inputSchema: { account_id: z.string().uuid(), confirm: CONFIRM_INPUT },
214
+ inputSchema: { account_id: z.string().uuid().describe("A mailbox id you own (mail_accounts)."), confirm: CONFIRM_INPUT },
194
215
  }, async (args) => withSession(deps, async (session) => {
195
216
  const refusal = unconfirmed(args, "Detaching removes this mailbox's stored mail from Tower.");
196
217
  if (refusal)