@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.
- package/README.md +74 -1
- package/dist/agent-door.d.ts +12 -0
- package/dist/agent-door.js +12 -1
- package/dist/cal-tools.js +25 -22
- package/dist/careers-tools.d.ts +6 -0
- package/dist/careers-tools.js +79 -32
- package/dist/docs-msg-helpers.d.ts +89 -0
- package/dist/docs-msg-helpers.js +106 -0
- package/dist/docs-msg-tools.d.ts +3 -13
- package/dist/docs-msg-tools.js +59 -81
- package/dist/docs-notion-tools.d.ts +16 -0
- package/dist/docs-notion-tools.js +89 -0
- package/dist/mail-tools.js +47 -26
- package/dist/memory-consolidation-tools.js +20 -2
- package/dist/memory-experience-tools.js +5 -1
- package/dist/models-tools.js +15 -2
- package/dist/note-folder-tools.js +11 -5
- package/dist/notes-account-tools.js +2 -2
- package/dist/notes-tools.js +11 -4
- package/dist/ops-tools.js +40 -6
- package/dist/server.d.ts +8 -2
- package/dist/server.js +30 -4
- package/dist/settings-tools.js +35 -8
- package/dist/tool-catalog.d.ts +32 -0
- package/dist/tool-catalog.js +152 -0
- package/dist/tool-result.d.ts +52 -1
- package/dist/tool-result.js +92 -2
- package/dist/usage-tools.js +40 -8
- package/dist/verb-census.js +2 -0
- package/dist/work-tools.js +48 -19
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/docs-msg-tools.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
export
|
|
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;
|
package/dist/docs-msg-tools.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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),
|
|
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(
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/mail-tools.js
CHANGED
|
@@ -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 =
|
|
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
|
|
88
|
-
+ "
|
|
89
|
-
|
|
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",
|
|
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) =>
|
|
99
|
-
|
|
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
|
-
|
|
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)
|