@bli-cockpit/mcp 0.1.61 → 0.1.65
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 +5 -1
- package/dist/docs-msg-helpers.d.ts +90 -0
- package/dist/docs-msg-helpers.js +110 -0
- package/dist/docs-msg-tools.d.ts +3 -13
- package/dist/docs-msg-tools.js +34 -116
- package/dist/docs-notion-tools.d.ts +16 -0
- package/dist/docs-notion-tools.js +89 -0
- package/dist/docs-read-tool.d.ts +23 -0
- package/dist/docs-read-tool.js +175 -0
- package/dist/notes-meeting-docs.d.ts +26 -0
- package/dist/notes-meeting-docs.js +26 -0
- package/dist/notes-tools.js +10 -1
- package/dist/ops-tools.js +24 -5
- package/dist/server.js +3 -0
- package/dist/tool-catalog.d.ts +2 -2
- package/dist/tool-catalog.js +2 -0
- package/dist/verb-census.js +2 -0
- package/dist/work-directory.d.ts +15 -0
- package/dist/work-directory.js +39 -0
- package/dist/work-tools.js +113 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ about what an agent can reach.
|
|
|
103
103
|
|
|
104
104
|
<!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
|
|
105
105
|
|
|
106
|
-
**
|
|
106
|
+
**109 of 114 Tower verbs have an MCP twin.**
|
|
107
107
|
Each tool goes through the SAME door its CLI verb calls, with the same
|
|
108
108
|
collector device token — never a second route and never a service-role
|
|
109
109
|
reader. `src/verb-census.test.ts` fails when a verb is in none of the
|
|
@@ -139,8 +139,10 @@ three tables below.
|
|
|
139
139
|
| `cockpit careers takehome show` | `careers_takehome_show` | `GET /api/careers/takehomes/[role]` |
|
|
140
140
|
| `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
|
|
141
141
|
| `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
|
|
142
|
+
| `cockpit docs link` | `docs_link` | `PUT /api/docs/documents/[id]/notion-link` |
|
|
142
143
|
| `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
|
|
143
144
|
| `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
|
|
145
|
+
| `cockpit docs sync-status` | `docs_sync_status` | `GET /api/docs/notion-sync` |
|
|
144
146
|
| `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
|
|
145
147
|
| `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
|
|
146
148
|
| `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
|
|
@@ -382,6 +384,8 @@ narrowing who can read something.
|
|
|
382
384
|
| `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
|
|
383
385
|
| `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
|
|
384
386
|
| `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). Emptying a document that holds text needs `allow_empty: true` (BLI-3757's guard). |
|
|
387
|
+
| `docs_link` | `PUT`/`DELETE /api/docs/documents/[id]/notion-link` | Ties a document to a Notion page for the two-way sync (`notion_page`: a URL or page id), or unties it with `clear: true`. The page becomes the truth: the next sync pulls it over the copy and keeps the copy as a version. |
|
|
388
|
+
| `docs_sync_status` | `GET /api/docs/notion-sync` | Every linked document's sync state (`synced` / `tower_changed` / `never_synced`), or one's by `id`. Tower's half only; metadata, never a body. |
|
|
385
389
|
| `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
|
|
386
390
|
| `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
|
|
387
391
|
| `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { type ToolDeps } from "./tool-result.js";
|
|
7
|
+
/** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
|
|
8
|
+
export declare const UUID_PATTERN: RegExp;
|
|
9
|
+
/** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
|
|
10
|
+
export declare const DOCS_LIST_MAX = 1000;
|
|
11
|
+
export type DocsMsgDeps = ToolDeps;
|
|
12
|
+
/** One node of `GET /api/docs/tree`. The door nests; nothing here re-nests. */
|
|
13
|
+
export interface DocTreeNode {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
slug: string | null;
|
|
17
|
+
children: DocTreeNode[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The tree as the text half prints it: id, title, slug, children. The door
|
|
21
|
+
* also sends visibility, source and a timestamp per node, which nothing here
|
|
22
|
+
* promised and which made the structured half 48 KB for 226 documents
|
|
23
|
+
* (BLI-10432). `docs_list` carries those fields for a flat read.
|
|
24
|
+
*/
|
|
25
|
+
export declare function slimTree(nodes: DocTreeNode[]): DocTreeNode[];
|
|
26
|
+
/** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
|
|
27
|
+
export declare function renderDocTree(nodes: DocTreeNode[], depth: number): string;
|
|
28
|
+
export declare function countTree(nodes: DocTreeNode[]): number;
|
|
29
|
+
export interface DocRow {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
slug: string | null;
|
|
33
|
+
visibility: string;
|
|
34
|
+
source?: string | null;
|
|
35
|
+
parent_id?: string | null;
|
|
36
|
+
updated_at: string;
|
|
37
|
+
/** BLI-3737: the body's length, the one fact about a body the LIST still carries. */
|
|
38
|
+
body_chars?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The fields `docs_list` promises, and only those. The door's row also carries
|
|
42
|
+
* `created_by`, `updated_by`, `created_at`, `import_truncated` and
|
|
43
|
+
* `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
|
|
44
|
+
*/
|
|
45
|
+
export declare function docListRow(row: DocRow): Record<string, unknown>;
|
|
46
|
+
export declare function docListLine(row: DocRow): string;
|
|
47
|
+
/**
|
|
48
|
+
* `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
|
|
49
|
+
* The list itself is metadata only — asking for a whole library used to cost
|
|
50
|
+
* every document's prose, so an agent that wants one folder or one phrase
|
|
51
|
+
* says so here and the database does the filtering.
|
|
52
|
+
*/
|
|
53
|
+
export declare function docsListPath(args: {
|
|
54
|
+
parent_id?: unknown;
|
|
55
|
+
query?: unknown;
|
|
56
|
+
word?: unknown;
|
|
57
|
+
limit?: unknown;
|
|
58
|
+
}): string;
|
|
59
|
+
export declare function resolveDocumentId(deps: DocsMsgDeps, session: {
|
|
60
|
+
dashboardUrl: string;
|
|
61
|
+
deviceToken: string;
|
|
62
|
+
}, ref: string): Promise<{
|
|
63
|
+
status: "ok";
|
|
64
|
+
id: string;
|
|
65
|
+
} | {
|
|
66
|
+
status: "not_found";
|
|
67
|
+
} | {
|
|
68
|
+
status: "list_failed";
|
|
69
|
+
text: string;
|
|
70
|
+
}>;
|
|
71
|
+
export interface ChannelRow {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string | null;
|
|
74
|
+
is_private: boolean;
|
|
75
|
+
is_dm: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare function resolveChannelId(deps: DocsMsgDeps, session: {
|
|
78
|
+
dashboardUrl: string;
|
|
79
|
+
deviceToken: string;
|
|
80
|
+
}, ref: string): Promise<{
|
|
81
|
+
status: "ok";
|
|
82
|
+
id: string;
|
|
83
|
+
} | {
|
|
84
|
+
status: "not_found";
|
|
85
|
+
} | {
|
|
86
|
+
status: "list_failed";
|
|
87
|
+
text: string;
|
|
88
|
+
}>;
|
|
89
|
+
/** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
|
|
90
|
+
export declare function messageLine(message: Record<string, unknown>): string;
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
const hasQuery = typeof args.query === "string" && args.query.trim().length > 0;
|
|
71
|
+
if (hasQuery)
|
|
72
|
+
params.set("query", args.query);
|
|
73
|
+
// BLI-10451: whole words, the same `word=1` the CLI's `--word` sends.
|
|
74
|
+
if (hasQuery && args.word === true)
|
|
75
|
+
params.set("word", "1");
|
|
76
|
+
if (typeof args.limit === "number" && Number.isInteger(args.limit))
|
|
77
|
+
params.set("limit", String(args.limit));
|
|
78
|
+
// No limit named: one page plus one row, so "more exist" is read off the
|
|
79
|
+
// door instead of guessed from a full page (BLI-10432).
|
|
80
|
+
else if (args.limit === undefined)
|
|
81
|
+
params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
|
|
82
|
+
const search = params.toString();
|
|
83
|
+
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
84
|
+
}
|
|
85
|
+
export async function resolveDocumentId(deps, session, ref) {
|
|
86
|
+
// An id needs no lookup: the read itself answers 404 by name when it is
|
|
87
|
+
// wrong. Only a slug costs the whole-library list read (BLI-10432).
|
|
88
|
+
if (UUID_PATTERN.test(ref))
|
|
89
|
+
return { status: "ok", id: ref };
|
|
90
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
return { status: "list_failed", text: doorFailureText("docs_list", response) };
|
|
93
|
+
const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
|
|
94
|
+
const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
|
|
95
|
+
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
96
|
+
}
|
|
97
|
+
export async function resolveChannelId(deps, session, ref) {
|
|
98
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
return { status: "list_failed", text: doorFailureText("msg_channels", response) };
|
|
101
|
+
const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
|
|
102
|
+
const bare = ref.startsWith("#") ? ref.slice(1) : ref;
|
|
103
|
+
const match = channels.find((channel) => channel.id === ref || channel.name === bare);
|
|
104
|
+
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
105
|
+
}
|
|
106
|
+
/** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
|
|
107
|
+
export function messageLine(message) {
|
|
108
|
+
return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
|
|
109
|
+
+ `${String(message.agent_label ?? message.user_id ?? message.slack_author_label ?? "?")} ${String(message.content ?? "")}`;
|
|
110
|
+
}
|
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
|
@@ -12,116 +12,22 @@
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { callAgentDoor } from "./agent-door.js";
|
|
14
14
|
import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const DOCS_LIST_MAX = 1000;
|
|
19
|
-
/**
|
|
20
|
-
* The tree as the text half prints it: id, title, slug, children. The door
|
|
21
|
-
* also sends visibility, source and a timestamp per node, which nothing here
|
|
22
|
-
* promised and which made the structured half 48 KB for 226 documents
|
|
23
|
-
* (BLI-10432). `docs_list` carries those fields for a flat read.
|
|
24
|
-
*/
|
|
25
|
-
function slimTree(nodes) {
|
|
26
|
-
return nodes.map((node) => ({
|
|
27
|
-
id: node.id,
|
|
28
|
-
title: node.title,
|
|
29
|
-
slug: node.slug ?? null,
|
|
30
|
-
children: slimTree(Array.isArray(node.children) ? node.children : []),
|
|
31
|
-
}));
|
|
32
|
-
}
|
|
33
|
-
/** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
|
|
34
|
-
function renderDocTree(nodes, depth) {
|
|
35
|
-
return nodes
|
|
36
|
-
.map((node) => {
|
|
37
|
-
const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
|
|
38
|
-
const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
|
|
39
|
-
return `${line}${children}`;
|
|
40
|
-
})
|
|
41
|
-
.join("\n");
|
|
42
|
-
}
|
|
43
|
-
function countTree(nodes) {
|
|
44
|
-
return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* The fields `docs_list` promises, and only those. The door's row also carries
|
|
48
|
-
* `created_by`, `updated_by`, `created_at`, `import_truncated` and
|
|
49
|
-
* `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
|
|
50
|
-
*/
|
|
51
|
-
function docListRow(row) {
|
|
52
|
-
return {
|
|
53
|
-
id: row.id,
|
|
54
|
-
slug: row.slug ?? null,
|
|
55
|
-
title: row.title,
|
|
56
|
-
visibility: row.visibility,
|
|
57
|
-
source: row.source ?? null,
|
|
58
|
-
parent_id: row.parent_id ?? null,
|
|
59
|
-
updated_at: row.updated_at,
|
|
60
|
-
body_chars: row.body_chars ?? null,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
function docListLine(row) {
|
|
64
|
-
return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
|
|
65
|
-
+ `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
|
|
69
|
-
* The list itself is metadata only — asking for a whole library used to cost
|
|
70
|
-
* every document's prose, so an agent that wants one folder or one phrase
|
|
71
|
-
* says so here and the database does the filtering.
|
|
72
|
-
*/
|
|
73
|
-
export function docsListPath(args) {
|
|
74
|
-
const params = new URLSearchParams();
|
|
75
|
-
if (typeof args.parent_id === "string" && args.parent_id.length > 0)
|
|
76
|
-
params.set("parent_id", args.parent_id);
|
|
77
|
-
if (typeof args.query === "string" && args.query.trim().length > 0)
|
|
78
|
-
params.set("query", args.query);
|
|
79
|
-
if (typeof args.limit === "number" && Number.isInteger(args.limit))
|
|
80
|
-
params.set("limit", String(args.limit));
|
|
81
|
-
// No limit named: one page plus one row, so "more exist" is read off the
|
|
82
|
-
// door instead of guessed from a full page (BLI-10432).
|
|
83
|
-
else if (args.limit === undefined)
|
|
84
|
-
params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
|
|
85
|
-
const search = params.toString();
|
|
86
|
-
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
87
|
-
}
|
|
88
|
-
async function resolveDocumentId(deps, session, ref) {
|
|
89
|
-
// An id needs no lookup: the read itself answers 404 by name when it is
|
|
90
|
-
// wrong. Only a slug costs the whole-library list read (BLI-10432).
|
|
91
|
-
if (UUID_PATTERN.test(ref))
|
|
92
|
-
return { status: "ok", id: ref };
|
|
93
|
-
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
|
|
94
|
-
if (!response.ok)
|
|
95
|
-
return { status: "list_failed", text: doorFailureText("docs_list", response) };
|
|
96
|
-
const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
|
|
97
|
-
const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
|
|
98
|
-
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
99
|
-
}
|
|
100
|
-
async function resolveChannelId(deps, session, ref) {
|
|
101
|
-
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
|
|
102
|
-
if (!response.ok)
|
|
103
|
-
return { status: "list_failed", text: doorFailureText("msg_channels", response) };
|
|
104
|
-
const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
|
|
105
|
-
const bare = ref.startsWith("#") ? ref.slice(1) : ref;
|
|
106
|
-
const match = channels.find((channel) => channel.id === ref || channel.name === bare);
|
|
107
|
-
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
108
|
-
}
|
|
109
|
-
/** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
|
|
110
|
-
function messageLine(message) {
|
|
111
|
-
return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
|
|
112
|
-
+ `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
|
|
113
|
-
}
|
|
15
|
+
import { DOCS_LIST_MAX, slimTree, renderDocTree, countTree, docListRow, docListLine, resolveDocumentId, resolveChannelId, messageLine, docsListPath } from "./docs-msg-helpers.js";
|
|
16
|
+
import { runDocsReadTool } from "./docs-read-tool.js";
|
|
17
|
+
export { docsListPath } from "./docs-msg-helpers.js";
|
|
114
18
|
export function registerDocsMsgTools(server, deps) {
|
|
115
19
|
const register = registrarFor(server);
|
|
116
20
|
register("docs_list", {
|
|
117
21
|
title: "List Tower documents",
|
|
118
22
|
description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
|
|
119
23
|
+ "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
|
|
120
|
-
+ "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy
|
|
24
|
+
+ "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy; "
|
|
25
|
+
+ "word: true matches it as whole words, so \"ott\" stops matching \"bottom\"), "
|
|
121
26
|
+ `limit (1-1000, default ${DEFAULT_LIST_LIMIT}; the answer says when more exist).`,
|
|
122
27
|
inputSchema: {
|
|
123
28
|
parent_id: z.string().min(1).max(64).optional().describe('A document id to list under, or "root" for the top of the tree.'),
|
|
124
29
|
query: z.string().min(1).max(200).optional().describe("Keep only documents whose title or body contains this text."),
|
|
30
|
+
word: z.boolean().optional().describe("Match query as whole words rather than a substring. Needs query."),
|
|
125
31
|
limit: z.number().int().min(1).max(1000).optional().describe(`At most this many documents. Default ${DEFAULT_LIST_LIMIT}.`),
|
|
126
32
|
},
|
|
127
33
|
}, async (args) => withSession(deps, async (session) => {
|
|
@@ -149,21 +55,25 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
149
55
|
}));
|
|
150
56
|
register("docs_read", {
|
|
151
57
|
title: "Read a Tower document",
|
|
152
|
-
description: "
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
58
|
+
description: "A document's title and body, by id or slug (docs_list returns both). Three shapes (BLI-10450): "
|
|
59
|
+
+ "`id` — one document, answers { document }. `ids` — several in one call (one shared list resolve, then "
|
|
60
|
+
+ "one GET per document — no bulk body door exists), answers { documents: [{ ref, ok, document|error }] }, "
|
|
61
|
+
+ "one bad ref never losing the rest. `parent_id` + `bodies: true` — every document filed directly under "
|
|
62
|
+
+ "that parent, bodies included, in ONE call, answers { documents: [...] }. "
|
|
63
|
+
+ "`section`/`offset`/`limit` narrow whatever body was fetched, in every shape: `section` slices from a "
|
|
64
|
+
+ "matching Markdown heading (any level, case-insensitive) to the next heading at the same or shallower "
|
|
65
|
+
+ "level, mirroring `cockpit workbook`'s own --section; `offset`/`limit` slice by character range — for a "
|
|
66
|
+
+ "100-225 KB transcript, read it in parts instead of whole.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
id: z.string().min(1).max(200).optional().describe("A document id, or its slug. Omit when passing ids or parent_id."),
|
|
69
|
+
ids: z.array(z.string().min(1).max(200)).min(1).max(50).optional().describe("Several documents in one call."),
|
|
70
|
+
parent_id: z.string().min(1).max(64).optional().describe("With bodies: true, read every document filed directly under this one (or \"root\")."),
|
|
71
|
+
bodies: z.boolean().optional().describe("Only with parent_id: include each document's body_markdown."),
|
|
72
|
+
section: z.string().min(1).max(200).optional().describe("One section's heading text — slices from there to the next heading at the same or shallower level."),
|
|
73
|
+
offset: z.number().int().min(0).optional().describe("The body's character offset to start from; paired with limit."),
|
|
74
|
+
limit: z.number().int().min(1).optional().describe("With an explicit ref: at most this many characters. With parent_id + bodies: at most this many documents (1-50)."),
|
|
75
|
+
},
|
|
76
|
+
}, async (args) => runDocsReadTool(deps, args));
|
|
167
77
|
register("docs_create", {
|
|
168
78
|
title: "Create a Tower document",
|
|
169
79
|
description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
|
|
@@ -276,7 +186,15 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
276
186
|
// which is what msg_thread takes. It used to say only "2 message(s)",
|
|
277
187
|
// so a Claude-family client never saw one (BLI-10432).
|
|
278
188
|
const lines = messages.map(messageLine).join("\n");
|
|
279
|
-
|
|
189
|
+
// BLI-10449: Tower's own channel, or a Slack snapshot and how fresh —
|
|
190
|
+
// the door's sentence, word for word, the same line the CLI prints.
|
|
191
|
+
const provenance = (response.body.provenance ?? null);
|
|
192
|
+
const limitApplied = typeof response.body.limitApplied === "number" ? response.body.limitApplied : null;
|
|
193
|
+
const header = [
|
|
194
|
+
provenance?.sentence ?? null,
|
|
195
|
+
limitApplied !== null ? `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.` : null,
|
|
196
|
+
].filter(Boolean).join("\n");
|
|
197
|
+
return textResult(`${header ? `${header}\n` : ""}${messages.length} message(s) in ${ref}, newest first; each line is id, time, author, text.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, provenance, limitApplied, messages });
|
|
280
198
|
}));
|
|
281
199
|
register("msg_thread", {
|
|
282
200
|
title: "Read a Tower message thread",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type DocsMsgDeps } from "./docs-msg-helpers.js";
|
|
2
|
+
import { type ToolResult } from "./tool-result.js";
|
|
3
|
+
/** Mirrors `commands/docs-read.ts` `sliceDocSection` exactly. */
|
|
4
|
+
export declare function sliceDocSection(markdown: string, wanted: string): {
|
|
5
|
+
text: string;
|
|
6
|
+
headings: string[];
|
|
7
|
+
} | null;
|
|
8
|
+
/** Mirrors `commands/docs-read.ts` `sliceDocRange` exactly. */
|
|
9
|
+
export declare function sliceDocRange(body: string, offset: number | undefined, limit: number | undefined): {
|
|
10
|
+
text: string;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
totalChars: number;
|
|
13
|
+
};
|
|
14
|
+
export interface DocsReadArgs {
|
|
15
|
+
id?: unknown;
|
|
16
|
+
ids?: unknown;
|
|
17
|
+
parent_id?: unknown;
|
|
18
|
+
bodies?: unknown;
|
|
19
|
+
section?: unknown;
|
|
20
|
+
offset?: unknown;
|
|
21
|
+
limit?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export declare function runDocsReadTool(deps: DocsMsgDeps, args: DocsReadArgs): Promise<ToolResult>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docs_read` (BLI-10450) — the MCP twin of `cockpit docs read`'s three
|
|
3
|
+
* shapes, split out of `docs-msg-tools.ts` to keep it under the file-size
|
|
4
|
+
* ratchet:
|
|
5
|
+
*
|
|
6
|
+
* 1. `id` — unchanged: one document, `{ document }`.
|
|
7
|
+
* 2. `ids` — several references in one call. One shared list resolves every
|
|
8
|
+
* ref (a uuid ref skips the list read entirely — `resolveDocumentIds`),
|
|
9
|
+
* then one GET per document. `{ documents: [{ ref, ok, document|error }] }`.
|
|
10
|
+
* 3. `parent_id` + `bodies: true` — every document filed directly under that
|
|
11
|
+
* parent, bodies included, in ONE call
|
|
12
|
+
* (`GET /api/docs/documents?parent_id=...&bodies=true`).
|
|
13
|
+
*
|
|
14
|
+
* `section`/`offset`/`limit` narrow whatever body was fetched, in every
|
|
15
|
+
* shape above — the same slicing `commands/docs-read.ts` runs on the CLI.
|
|
16
|
+
*/
|
|
17
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
18
|
+
import { resolveDocumentId } from "./docs-msg-helpers.js";
|
|
19
|
+
import { doorFailureText, errorResult, textResult, withSession } from "./tool-result.js";
|
|
20
|
+
function headingsOf(markdown) {
|
|
21
|
+
return markdown
|
|
22
|
+
.split("\n")
|
|
23
|
+
.map((line, lineIndex) => ({ lineIndex, line }))
|
|
24
|
+
.map(({ lineIndex, line }) => {
|
|
25
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
26
|
+
return match?.[1] && match[2] ? { lineIndex, level: match[1].length, title: match[2] } : null;
|
|
27
|
+
})
|
|
28
|
+
.filter((heading) => heading !== null);
|
|
29
|
+
}
|
|
30
|
+
/** Mirrors `commands/docs-read.ts` `sliceDocSection` exactly. */
|
|
31
|
+
export function sliceDocSection(markdown, wanted) {
|
|
32
|
+
const headings = headingsOf(markdown);
|
|
33
|
+
const target = headings.find((heading) => heading.title.toLowerCase() === wanted.trim().toLowerCase());
|
|
34
|
+
if (!target)
|
|
35
|
+
return null;
|
|
36
|
+
const lines = markdown.split("\n");
|
|
37
|
+
const next = headings.find((heading) => heading.lineIndex > target.lineIndex && heading.level <= target.level);
|
|
38
|
+
const endLine = next ? next.lineIndex : lines.length;
|
|
39
|
+
return { text: lines.slice(target.lineIndex, endLine).join("\n").trim(), headings: headings.map((h) => h.title) };
|
|
40
|
+
}
|
|
41
|
+
/** Mirrors `commands/docs-read.ts` `sliceDocRange` exactly. */
|
|
42
|
+
export function sliceDocRange(body, offset, limit) {
|
|
43
|
+
const totalChars = body.length;
|
|
44
|
+
if (offset === undefined && limit === undefined)
|
|
45
|
+
return { text: body, truncated: false, totalChars };
|
|
46
|
+
const start = Math.min(offset ?? 0, totalChars);
|
|
47
|
+
const end = limit === undefined ? totalChars : Math.min(start + limit, totalChars);
|
|
48
|
+
return { text: body.slice(start, end), truncated: end < totalChars, totalChars };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `applyCharRange` is false in `parent_id` + `bodies: true` mode: there,
|
|
52
|
+
* `limit` already picked the DOCUMENT COUNT (mirrors `commands/docs-read.ts`
|
|
53
|
+
* — a live-fire probe against production caught this exact overload
|
|
54
|
+
* silently truncating every body to a handful of characters when `limit`
|
|
55
|
+
* was passed meaning "N documents").
|
|
56
|
+
*/
|
|
57
|
+
function narrowBody(document, section, offset, limit, applyCharRange) {
|
|
58
|
+
let body = typeof document.body_markdown === "string" ? document.body_markdown : "";
|
|
59
|
+
if (section) {
|
|
60
|
+
const sliced = sliceDocSection(body, section);
|
|
61
|
+
if (!sliced) {
|
|
62
|
+
const known = headingsOf(body).map((heading) => heading.title);
|
|
63
|
+
return { ok: false, text: `No section "${section}" in ${document.id} (${known.join(", ") || "none"}).` };
|
|
64
|
+
}
|
|
65
|
+
body = sliced.text;
|
|
66
|
+
}
|
|
67
|
+
if (applyCharRange && (offset !== undefined || limit !== undefined))
|
|
68
|
+
body = sliceDocRange(body, offset, limit).text;
|
|
69
|
+
document.body_markdown = body;
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
73
|
+
/** One shared list read for every non-uuid ref, never one per ref. */
|
|
74
|
+
async function resolveDocumentIds(deps, session, refs) {
|
|
75
|
+
const ids = new Map();
|
|
76
|
+
const needsList = refs.filter((ref) => !UUID_PATTERN.test(ref));
|
|
77
|
+
for (const ref of refs)
|
|
78
|
+
if (UUID_PATTERN.test(ref))
|
|
79
|
+
ids.set(ref, ref);
|
|
80
|
+
if (needsList.length === 0)
|
|
81
|
+
return { status: "ok", ids };
|
|
82
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
return { status: "list_failed", text: doorFailureText("docs_list", response) };
|
|
85
|
+
const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
|
|
86
|
+
for (const ref of needsList) {
|
|
87
|
+
const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
|
|
88
|
+
if (match)
|
|
89
|
+
ids.set(ref, match.id);
|
|
90
|
+
}
|
|
91
|
+
return { status: "ok", ids };
|
|
92
|
+
}
|
|
93
|
+
async function fetchOneDocument(deps, session, ref, id) {
|
|
94
|
+
if (!id)
|
|
95
|
+
return { ref, ok: false, text: `No document matches "${ref}" — check docs_list for the id or slug.` };
|
|
96
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents/${encodeURIComponent(id)}`);
|
|
97
|
+
if (!response.ok)
|
|
98
|
+
return { ref, ok: false, text: doorFailureText("docs_read", response) };
|
|
99
|
+
const document = (response.body.document ?? { id });
|
|
100
|
+
return { ref, ok: true, document };
|
|
101
|
+
}
|
|
102
|
+
export async function runDocsReadTool(deps, args) {
|
|
103
|
+
return withSession(deps, async (session) => {
|
|
104
|
+
const section = typeof args.section === "string" ? args.section : undefined;
|
|
105
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
106
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
107
|
+
// Shape 3: --parent + --bodies. Built by hand rather than through
|
|
108
|
+
// `docsListPath`: that helper defaults a missing limit to
|
|
109
|
+
// DEFAULT_LIST_LIMIT + 1 for the METADATA list's "more exist" check,
|
|
110
|
+
// which would overshoot the door's own DOCUMENT_BODIES_MAX_LIMIT (50)
|
|
111
|
+
// here and turn an unset limit into a refusal.
|
|
112
|
+
if (typeof args.parent_id === "string" && args.parent_id && args.bodies === true) {
|
|
113
|
+
const params = new URLSearchParams({ parent_id: args.parent_id, bodies: "true" });
|
|
114
|
+
if (limit !== undefined)
|
|
115
|
+
params.set("limit", String(limit));
|
|
116
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents?${params.toString()}`);
|
|
117
|
+
if (!response.ok)
|
|
118
|
+
return errorResult(doorFailureText("docs_read", response));
|
|
119
|
+
const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
|
|
120
|
+
const misses = [];
|
|
121
|
+
for (const document of documents) {
|
|
122
|
+
// `limit` already picked the DOCUMENT COUNT above; `false` keeps it
|
|
123
|
+
// from being reapplied as a character count.
|
|
124
|
+
const narrowed = narrowBody(document, section, offset, limit, false);
|
|
125
|
+
if (!narrowed.ok)
|
|
126
|
+
misses.push(narrowed.text);
|
|
127
|
+
}
|
|
128
|
+
return textResult(`${documents.length} document(s) under ${String(args.parent_id)}.${misses.length ? `\n${misses.join("\n")}` : ""}`, { documents });
|
|
129
|
+
}
|
|
130
|
+
// Shapes 1/2: id, or ids (several).
|
|
131
|
+
const refs = Array.isArray(args.ids) && args.ids.length > 0
|
|
132
|
+
? args.ids.map(String)
|
|
133
|
+
: [String(args.id ?? "")];
|
|
134
|
+
if (refs.length === 1 && refs[0] === "") {
|
|
135
|
+
return errorResult("docs_read needs id, ids, or parent_id + bodies: true.");
|
|
136
|
+
}
|
|
137
|
+
const resolved = refs.length === 1
|
|
138
|
+
? await (async () => {
|
|
139
|
+
const one = await resolveDocumentId(deps, session, refs[0]);
|
|
140
|
+
if (one.status === "list_failed")
|
|
141
|
+
return { status: "list_failed", text: one.text };
|
|
142
|
+
const ids = new Map();
|
|
143
|
+
if (one.status === "ok")
|
|
144
|
+
ids.set(refs[0], one.id);
|
|
145
|
+
return { status: "ok", ids };
|
|
146
|
+
})()
|
|
147
|
+
: await resolveDocumentIds(deps, session, refs);
|
|
148
|
+
if (resolved.status === "list_failed")
|
|
149
|
+
return errorResult(resolved.text);
|
|
150
|
+
const results = await Promise.all(refs.map((ref) => fetchOneDocument(deps, session, ref, resolved.ids.get(ref))));
|
|
151
|
+
for (const result of results) {
|
|
152
|
+
if (result.ok) {
|
|
153
|
+
// Not `--parent --bodies` mode: `limit` here is a CHARACTER count.
|
|
154
|
+
const narrowed = narrowBody(result.document, section, offset, limit, true);
|
|
155
|
+
if (!narrowed.ok)
|
|
156
|
+
Object.assign(result, { ok: false, text: narrowed.text });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (refs.length === 1) {
|
|
160
|
+
const [result] = results;
|
|
161
|
+
if (!result?.ok)
|
|
162
|
+
return errorResult(result?.text ?? "docs_read could not read that document.");
|
|
163
|
+
return textResult(`${result.document.title ?? ""}\n\n${result.document.body_markdown ?? ""}`, { document: result.document });
|
|
164
|
+
}
|
|
165
|
+
const failed = results.filter((result) => !result.ok);
|
|
166
|
+
const lines = results
|
|
167
|
+
.map((result) => (result.ok ? `${result.document.title ?? result.ref}\n\n${result.document.body_markdown ?? ""}` : `${result.ref}: ${result.text}`))
|
|
168
|
+
.join("\n\n---\n\n");
|
|
169
|
+
return textResult(`${results.length - failed.length}/${results.length} document(s) read.\n\n${lines}`, {
|
|
170
|
+
documents: results.map((result) => result.ok
|
|
171
|
+
? { ref: result.ref, ok: true, document: result.document }
|
|
172
|
+
: { ref: result.ref, ok: false, error: result.text }),
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-10449: the notes library door's pointer at meeting records that live in
|
|
3
|
+
* Docs, not Notes (`apps/dashboard/src/lib/notes/meeting-docs.ts` decides what
|
|
4
|
+
* counts and which copy is canonical). `notes_list` prints these lines under
|
|
5
|
+
* the notes; the CLI's twin is `meetingDocumentLines` in the collector's
|
|
6
|
+
* `commands/notes-reads.ts`, and the two print the same words.
|
|
7
|
+
*/
|
|
8
|
+
export interface MeetingDocuments {
|
|
9
|
+
count: number;
|
|
10
|
+
copies: number;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
items: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
slug: string | null;
|
|
15
|
+
title: string;
|
|
16
|
+
meetingDate: string | null;
|
|
17
|
+
visibility: string;
|
|
18
|
+
source: string;
|
|
19
|
+
bodyChars: number;
|
|
20
|
+
parts: string[];
|
|
21
|
+
canonical: boolean;
|
|
22
|
+
duplicateOf: string | null;
|
|
23
|
+
canonicalReason: string | null;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export declare function meetingDocumentLines(meeting: MeetingDocuments | null | undefined, readVerb: string): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-10449: the notes library door's pointer at meeting records that live in
|
|
3
|
+
* Docs, not Notes (`apps/dashboard/src/lib/notes/meeting-docs.ts` decides what
|
|
4
|
+
* counts and which copy is canonical). `notes_list` prints these lines under
|
|
5
|
+
* the notes; the CLI's twin is `meetingDocumentLines` in the collector's
|
|
6
|
+
* `commands/notes-reads.ts`, and the two print the same words.
|
|
7
|
+
*/
|
|
8
|
+
export function meetingDocumentLines(meeting, readVerb) {
|
|
9
|
+
if (!meeting || meeting.count === 0)
|
|
10
|
+
return [];
|
|
11
|
+
const refOf = new Map(meeting.items.map((item) => [item.id, item.slug ?? item.id]));
|
|
12
|
+
const copies = meeting.copies > 0 ? `, ${meeting.copies} of them ${meeting.copies === 1 ? "a copy" : "copies"}` : "";
|
|
13
|
+
const lines = [
|
|
14
|
+
`Meeting records in Docs, not in Notes (${meeting.count}${copies}). Read one with \`${readVerb} <ref>\`:`,
|
|
15
|
+
];
|
|
16
|
+
for (const item of meeting.items) {
|
|
17
|
+
const size = `${Math.max(1, Math.round(item.bodyChars / 1000))} KB${item.parts.length > 0 ? ` in ${item.parts.length + 1} parts` : ""}`;
|
|
18
|
+
const mark = item.canonical
|
|
19
|
+
? item.canonicalReason && item.canonicalReason !== "only_copy" ? ` · canonical (${item.canonicalReason})` : ""
|
|
20
|
+
: ` · copy of ${refOf.get(item.duplicateOf ?? "") ?? item.duplicateOf}`;
|
|
21
|
+
lines.push(` ${item.meetingDate ?? "undated "} ${refOf.get(item.id)} ${item.title} ${item.visibility} · ${item.source} · ${size}${mark}`);
|
|
22
|
+
}
|
|
23
|
+
if (meeting.truncated)
|
|
24
|
+
lines.push(" (the read hit its cap, so there may be more)");
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
package/dist/notes-tools.js
CHANGED
|
@@ -18,6 +18,7 @@ import { registerNoteFolderTools } from "./note-folder-tools.js";
|
|
|
18
18
|
*/
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import { callAgentDoor } from "./agent-door.js";
|
|
21
|
+
import { meetingDocumentLines } from "./notes-meeting-docs.js";
|
|
21
22
|
import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
|
|
22
23
|
/**
|
|
23
24
|
* The door's own words for "this answer is narrower than the browser's", or
|
|
@@ -82,8 +83,16 @@ export function registerNotesTools(server, deps) {
|
|
|
82
83
|
return `${shelf.heading} (${shelf.notes.length})\n${notes}`;
|
|
83
84
|
})
|
|
84
85
|
.join("\n\n");
|
|
86
|
+
const inDocs = meetingDocumentLines(body.meetingDocuments, "docs_read").join("\n");
|
|
85
87
|
return textResult(`${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`
|
|
86
|
-
+ `${lines ? `\n\n${lines}` : ""}${narrowingNote(body)}`, {
|
|
88
|
+
+ `${lines ? `\n\n${lines}` : ""}${inDocs ? `\n\n${inDocs}` : ""}${narrowingNote(body)}`, {
|
|
89
|
+
scope: body.scope ?? null,
|
|
90
|
+
count: body.count ?? 0,
|
|
91
|
+
more: body.more ?? false,
|
|
92
|
+
series,
|
|
93
|
+
meetingDocuments: body.meetingDocuments ?? null,
|
|
94
|
+
meetingDocumentsSkipped: body.meetingDocumentsSkipped ?? null,
|
|
95
|
+
});
|
|
87
96
|
}));
|
|
88
97
|
register("notes_show", {
|
|
89
98
|
title: "Read one Tower meeting note",
|
package/dist/ops-tools.js
CHANGED
|
@@ -193,11 +193,15 @@ export function registerOpsTools(server, deps) {
|
|
|
193
193
|
inputSchema: {
|
|
194
194
|
workspace: z.string().min(1).max(100).optional().describe("One workspace key instead of all of them."),
|
|
195
195
|
stale_only: z.boolean().optional().describe("Report only the stale channels, not the whole breakdown."),
|
|
196
|
+
// BLI-10450: counts alone could not answer "is there a Kaiben channel?".
|
|
197
|
+
list: z.boolean().optional().describe("Name every channel and its readable state, not only counts."),
|
|
196
198
|
},
|
|
197
199
|
}, async (args) => withSession(deps, async (session) => {
|
|
198
200
|
const params = new URLSearchParams();
|
|
199
201
|
if (args.workspace)
|
|
200
202
|
params.set("workspace", String(args.workspace));
|
|
203
|
+
if (args.list)
|
|
204
|
+
params.set("list", "1");
|
|
201
205
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/slack/coverage${queryString(params)}`);
|
|
202
206
|
if (!response.ok)
|
|
203
207
|
return errorResult(doorFailureText("slack_coverage", response));
|
|
@@ -214,7 +218,13 @@ export function registerOpsTools(server, deps) {
|
|
|
214
218
|
? ` no channel is over ${staleAfter}h since its last sync`
|
|
215
219
|
: ` ${stale} channel(s) over ${staleAfter}h since last sync`;
|
|
216
220
|
const cursor = args.stale_only ? "" : `\n newest cursor: ${String(workspace["newestCursorIso"] ?? "never")}`;
|
|
217
|
-
|
|
221
|
+
const channels = Array.isArray(workspace["channels"])
|
|
222
|
+
? workspace["channels"]
|
|
223
|
+
: [];
|
|
224
|
+
const channelLines = channels.length > 0
|
|
225
|
+
? `\n${channels.map((channel) => ` #${String(channel["name"] ?? "?")} ${String(channel["meaning"] ?? "?")}`).join("\n")}`
|
|
226
|
+
: "";
|
|
227
|
+
return `${head}\n${staleLine}${cursor}${channelLines}`;
|
|
218
228
|
})
|
|
219
229
|
.join("\n\n");
|
|
220
230
|
return textResult(`${workspaces.length} workspace(s), ${staleTotal} stale channel(s).${lines ? `\n\n${lines}` : ""}`
|
|
@@ -227,7 +237,8 @@ export function registerOpsTools(server, deps) {
|
|
|
227
237
|
inputSchema: {
|
|
228
238
|
person: z.string().min(1).max(200).optional().describe("A person — name, email or Slack member id."),
|
|
229
239
|
channel: z.string().min(1).max(200).optional().describe("One channel name."),
|
|
230
|
-
query: z.string().min(1).max(500).optional().describe("Text to look for."),
|
|
240
|
+
query: z.string().min(1).max(500).optional().describe("Text to look for, as a substring."),
|
|
241
|
+
word: z.boolean().optional().describe("Match query as whole words, so \"excel\" stops matching \"Excellent\"."),
|
|
231
242
|
since: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
|
|
232
243
|
until: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
|
|
233
244
|
limit: z.number().int().min(1).max(500).optional().describe("At most this many messages, newest first."),
|
|
@@ -240,6 +251,9 @@ export function registerOpsTools(server, deps) {
|
|
|
240
251
|
}
|
|
241
252
|
if (typeof args.limit === "number")
|
|
242
253
|
body["limit"] = args.limit;
|
|
254
|
+
// BLI-10451: the same `word: true` the CLI's `--word` sends.
|
|
255
|
+
if (args.query && args.word === true)
|
|
256
|
+
body["word"] = true;
|
|
243
257
|
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/slack/read", body);
|
|
244
258
|
if (!response.ok) {
|
|
245
259
|
// The CLI names the open door beside the closed one on a 403; an
|
|
@@ -256,9 +270,14 @@ export function registerOpsTools(server, deps) {
|
|
|
256
270
|
.map((message) => `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`
|
|
257
271
|
+ `\n ${String(message["text"] ?? "")}`)
|
|
258
272
|
.join("\n");
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
273
|
+
// BLI-10450: `summary` is built for the JARVIS chat tool, which needs
|
|
274
|
+
// ONE self-contained string, so it already embeds a bullet line per
|
|
275
|
+
// message AND the note (tools-slack-render.ts renderSummary). Only
|
|
276
|
+
// the header (summary's first line) is printed here — it is the
|
|
277
|
+
// sentence that keeps an empty answer from reading as "nobody said
|
|
278
|
+
// anything" — and the messages/note print exactly once, not twice.
|
|
279
|
+
const rawSummary = typeof read["summary"] === "string" ? read["summary"] : `${messages.length} message(s).`;
|
|
280
|
+
const summary = rawSummary.split("\n")[0] ?? rawSummary;
|
|
262
281
|
const note = typeof read["note"] === "string" && read["note"] ? `\n\n${read["note"]}` : "";
|
|
263
282
|
return textResult(`${summary}${lines ? `\n\n${lines}` : ""}${note}`, {
|
|
264
283
|
status: read["status"] ?? null,
|
package/dist/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { z } from "zod";
|
|
|
10
10
|
import { registerBriefTools } from "./brief-tools.js";
|
|
11
11
|
import { registerBriefWriteTools } from "./brief-write-tools.js";
|
|
12
12
|
import { registerDocsMsgTools } from "./docs-msg-tools.js";
|
|
13
|
+
import { registerDocsNotionTools } from "./docs-notion-tools.js";
|
|
13
14
|
import { registerJarvisTools } from "./jarvis-tools.js";
|
|
14
15
|
import { registerCalTools } from "./cal-tools.js";
|
|
15
16
|
import { registerMailTools } from "./mail-tools.js";
|
|
@@ -415,6 +416,8 @@ export function createServer(deps) {
|
|
|
415
416
|
// no `cockpit login` pairing yet still serves the three event tools; only a
|
|
416
417
|
// docs/msg call on that machine fails, by name.
|
|
417
418
|
registerDocsMsgTools(server, { fetchImpl: deps.fetchImpl });
|
|
419
|
+
// BLI-10430: docs_link, the Notion half of the docs tools, on the same path.
|
|
420
|
+
registerDocsNotionTools(server, { fetchImpl: deps.fetchImpl });
|
|
418
421
|
// BLI-3716: `work_*` — the issue tracker, on the same device-token path as
|
|
419
422
|
// the docs/msg tools above, for the same reason: a coding session should
|
|
420
423
|
// file and move a Tower issue the way it files and moves a Linear one.
|
package/dist/tool-catalog.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* from `src/` under Node's type stripping, so it may import nothing.
|
|
11
11
|
*/
|
|
12
12
|
/** Tools that change nothing. The smoke calls every one. */
|
|
13
|
-
export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
|
|
13
|
+
export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
|
|
14
14
|
/**
|
|
15
15
|
* Tools that write, but answer a dry run that writes nothing. The smoke calls
|
|
16
16
|
* each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
|
|
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
|
|
|
21
21
|
* candidates, share calendars. Never called by the smoke; covered by the unit
|
|
22
22
|
* suites (`write-tools.test.ts` and each family's own).
|
|
23
23
|
*/
|
|
24
|
-
export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
|
|
24
|
+
export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
|
|
25
25
|
/** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
|
|
26
26
|
export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
|
|
27
27
|
/**
|
package/dist/tool-catalog.js
CHANGED
|
@@ -16,6 +16,7 @@ export const READ_TOOLS = [
|
|
|
16
16
|
"docs_list",
|
|
17
17
|
"docs_tree",
|
|
18
18
|
"docs_read",
|
|
19
|
+
"docs_sync_status",
|
|
19
20
|
"msg_channels",
|
|
20
21
|
"msg_read",
|
|
21
22
|
"msg_thread",
|
|
@@ -84,6 +85,7 @@ export const WRITE_TOOLS = [
|
|
|
84
85
|
"emit_event",
|
|
85
86
|
"docs_create",
|
|
86
87
|
"docs_update",
|
|
88
|
+
"docs_link",
|
|
87
89
|
"msg_create_channel",
|
|
88
90
|
"msg_dm",
|
|
89
91
|
"msg_send",
|
package/dist/verb-census.js
CHANGED
|
@@ -234,6 +234,8 @@ export const MCP_TWINS = {
|
|
|
234
234
|
"docs read": { tool: "docs_read", door: "GET /api/docs/documents/[id]" },
|
|
235
235
|
"docs create": { tool: "docs_create", door: "POST /api/docs/documents" },
|
|
236
236
|
"docs update": { tool: "docs_update", door: "PATCH /api/docs/documents/[id]" },
|
|
237
|
+
"docs link": { tool: "docs_link", door: "PUT /api/docs/documents/[id]/notion-link" },
|
|
238
|
+
"docs sync-status": { tool: "docs_sync_status", door: "GET /api/docs/notion-sync" },
|
|
237
239
|
"msg channels": { tool: "msg_channels", door: "GET /api/msg/channels" },
|
|
238
240
|
"msg create": { tool: "msg_create_channel", door: "POST /api/msg/channels" },
|
|
239
241
|
"msg dm": { tool: "msg_dm", door: "POST /api/msg/channels (dm)" },
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DoorSession, ToolDeps } from "./tool-result.js";
|
|
2
|
+
export interface AssigneeIdentity {
|
|
3
|
+
name: string;
|
|
4
|
+
email: string | null;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveAssigneeDirectory(deps: ToolDeps, session: DoorSession, assigneeIds: Array<string | null | undefined>): Promise<Map<string, AssigneeIdentity>>;
|
|
7
|
+
/** `assignee_name`/`assignee_email` alongside the bare id, for structuredContent. */
|
|
8
|
+
export declare function withAssigneeIdentity<T extends {
|
|
9
|
+
assignee_id?: string | null;
|
|
10
|
+
}>(row: T, directory: Map<string, AssigneeIdentity>): T & {
|
|
11
|
+
assignee_name: string | null;
|
|
12
|
+
assignee_email: string | null;
|
|
13
|
+
};
|
|
14
|
+
/** The text-line assignee phrase: a name (and email) when resolved, the bare id otherwise. */
|
|
15
|
+
export declare function assigneeLabel(assigneeId: string | null | undefined, directory: Map<string, AssigneeIdentity>): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-10450: `work_list_issues`/`work_get_issue` carried only `assignee_id`
|
|
3
|
+
* (a uuid) — answering "who owns what" needed a second `work_list_projects`-
|
|
4
|
+
* shaped call to `/api/team/members` and a join done by hand. One call per
|
|
5
|
+
* tool invocation (never per row), the same door `cockpit team members`
|
|
6
|
+
* calls, mirroring `commands/issue-directory.ts` on the CLI.
|
|
7
|
+
*/
|
|
8
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
9
|
+
export async function resolveAssigneeDirectory(deps, session, assigneeIds) {
|
|
10
|
+
const wanted = new Set(assigneeIds.filter((id) => Boolean(id)));
|
|
11
|
+
const directory = new Map();
|
|
12
|
+
if (wanted.size === 0)
|
|
13
|
+
return directory;
|
|
14
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/team/members");
|
|
15
|
+
if (!response.ok)
|
|
16
|
+
return directory;
|
|
17
|
+
const people = (Array.isArray(response.body.people) ? response.body.people : []);
|
|
18
|
+
for (const person of people) {
|
|
19
|
+
const userId = person.account?.userId;
|
|
20
|
+
if (!userId || !wanted.has(userId))
|
|
21
|
+
continue;
|
|
22
|
+
directory.set(userId, { name: person.name ?? userId, email: person.email ?? null });
|
|
23
|
+
}
|
|
24
|
+
return directory;
|
|
25
|
+
}
|
|
26
|
+
/** `assignee_name`/`assignee_email` alongside the bare id, for structuredContent. */
|
|
27
|
+
export function withAssigneeIdentity(row, directory) {
|
|
28
|
+
const identity = row.assignee_id ? directory.get(row.assignee_id) : undefined;
|
|
29
|
+
return { ...row, assignee_name: identity?.name ?? null, assignee_email: identity?.email ?? null };
|
|
30
|
+
}
|
|
31
|
+
/** The text-line assignee phrase: a name (and email) when resolved, the bare id otherwise. */
|
|
32
|
+
export function assigneeLabel(assigneeId, directory) {
|
|
33
|
+
if (!assigneeId)
|
|
34
|
+
return "none";
|
|
35
|
+
const identity = directory.get(assigneeId);
|
|
36
|
+
if (!identity)
|
|
37
|
+
return assigneeId;
|
|
38
|
+
return identity.email ? `${identity.name} <${identity.email}>` : identity.name;
|
|
39
|
+
}
|
package/dist/work-tools.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { callAgentDoor } from "./agent-door.js";
|
|
19
19
|
import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
20
|
+
import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./work-directory.js";
|
|
20
21
|
/** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
|
|
21
22
|
export const WORK_ISSUE_STATES = [
|
|
22
23
|
"backlog",
|
|
@@ -28,12 +29,6 @@ export const WORK_ISSUE_STATES = [
|
|
|
28
29
|
];
|
|
29
30
|
/** `/api/work/issues`' own ceiling, and the most a caller may ask for here. */
|
|
30
31
|
const ISSUE_LIST_MAX = 1000;
|
|
31
|
-
/**
|
|
32
|
-
* The fields `work_list_issues` promises. The door's row carries the whole
|
|
33
|
-
* description and a dozen bookkeeping columns; with no limit that was 1,000
|
|
34
|
-
* issues and 4.2 MB, 3.3 MB of it descriptions the tool said it never
|
|
35
|
-
* returned (BLI-10432). work_get_issue reads one issue in full.
|
|
36
|
-
*/
|
|
37
32
|
function issueListRow(row) {
|
|
38
33
|
return {
|
|
39
34
|
id: row.id,
|
|
@@ -47,6 +42,59 @@ function issueListRow(row) {
|
|
|
47
42
|
updated_at: row.updated_at ?? null,
|
|
48
43
|
};
|
|
49
44
|
}
|
|
45
|
+
function listCompleteness(value) {
|
|
46
|
+
if (!value || typeof value !== "object")
|
|
47
|
+
return null;
|
|
48
|
+
const record = value;
|
|
49
|
+
const status = record["status"];
|
|
50
|
+
if (status !== "complete" && status !== "partial" && status !== "unknown")
|
|
51
|
+
return null;
|
|
52
|
+
return { status, total: typeof record["total"] === "number" ? record["total"] : null };
|
|
53
|
+
}
|
|
54
|
+
function outsideProject(value) {
|
|
55
|
+
if (!value || typeof value !== "object")
|
|
56
|
+
return null;
|
|
57
|
+
const record = value;
|
|
58
|
+
if (!Array.isArray(record["issues"]))
|
|
59
|
+
return null;
|
|
60
|
+
return { status: String(record["status"] ?? "complete"), issues: record["issues"] };
|
|
61
|
+
}
|
|
62
|
+
/** BLI-10447: sub-issues of the listed issues that live in no project or another one. */
|
|
63
|
+
function outsideNote(outside) {
|
|
64
|
+
if (!outside)
|
|
65
|
+
return "";
|
|
66
|
+
const named = outside.issues.map((issue) => `${issue.identifier} (under ${issue.parent_identifier ?? "?"}, ${issue.project_id ? `project ${issue.project_id}` : "no project"})`);
|
|
67
|
+
const found = named.length > 0 ? ` ${named.length} sub-issue(s) of these sit outside the project: ${named.join(", ")}.` : "";
|
|
68
|
+
const gap = outside.status === "failed" ? " The outside-project check failed (read_failed)."
|
|
69
|
+
: outside.status === "capped" ? " The outside-project check was capped; more may exist." : "";
|
|
70
|
+
return found + gap;
|
|
71
|
+
}
|
|
72
|
+
/** One issue plus its comments — a comment read that fails does not lose the issue already in hand. */
|
|
73
|
+
async function fetchOneIssue(deps, session, ref) {
|
|
74
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
return { ref, ok: false, text: doorFailureText("work_get_issue", response) };
|
|
77
|
+
const issue = response.body.issue;
|
|
78
|
+
const commentsResponse = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(issue?.id ?? ref)}/comments`);
|
|
79
|
+
const comments = commentsResponse.ok
|
|
80
|
+
? (commentsResponse.body.comments ?? [])
|
|
81
|
+
: [];
|
|
82
|
+
return {
|
|
83
|
+
ref,
|
|
84
|
+
ok: true,
|
|
85
|
+
issue,
|
|
86
|
+
comments,
|
|
87
|
+
...(commentsResponse.ok ? {} : { commentsUnread: true }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function renderOneIssue(result, directory) {
|
|
91
|
+
const { issue, comments } = result;
|
|
92
|
+
const commentText = comments.map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`).join("\n\n");
|
|
93
|
+
return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"}) · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
|
|
94
|
+
+ `\n\n${issue?.description ?? "(no description)"}`
|
|
95
|
+
+ (commentText ? `\n\n--- comments ---\n${commentText}` : "")
|
|
96
|
+
+ (result.commentsUnread ? "\n\n(comments could not be read on this call)" : "");
|
|
97
|
+
}
|
|
50
98
|
/**
|
|
51
99
|
* A row the Linear import could not give state names to says which it is.
|
|
52
100
|
* Printing "(none) -> (none)" is honest and useless; naming the gap keeps
|
|
@@ -111,33 +159,72 @@ export function registerWorkTools(server, deps) {
|
|
|
111
159
|
if (!response.ok)
|
|
112
160
|
return errorResult(doorFailureText("work_list_issues", response));
|
|
113
161
|
const all = (Array.isArray(response.body.issues) ? response.body.issues : []);
|
|
114
|
-
|
|
115
|
-
|
|
162
|
+
// BLI-10447: the door says whether a full read was every match; a named
|
|
163
|
+
// limit that filled up used to answer "more: false" regardless.
|
|
164
|
+
const completeness = listCompleteness(response.body.completeness);
|
|
165
|
+
const more = args.limit === undefined
|
|
166
|
+
? all.length > DEFAULT_LIST_LIMIT
|
|
167
|
+
: completeness?.status === "partial" || completeness?.status === "unknown";
|
|
168
|
+
const issues = args.limit === undefined && more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
|
|
169
|
+
const outside = outsideProject(response.body.outside_project);
|
|
170
|
+
// BLI-10450: one directory call for the whole page, never one per row.
|
|
171
|
+
const directory = await resolveAssigneeDirectory(deps, session, issues.map((issue) => issue.assignee_id));
|
|
116
172
|
const lines = issues
|
|
117
173
|
.map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`
|
|
118
|
-
+ ` [p${issue.priority ?? 0}, assignee ${issue.assignee_id
|
|
174
|
+
+ ` [p${issue.priority ?? 0}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`)
|
|
119
175
|
.join("\n");
|
|
120
|
-
|
|
176
|
+
const totalNote = completeness?.total != null && completeness.total > issues.length ? ` ${completeness.total} match in all.` : "";
|
|
177
|
+
return textResult(`${issues.length} issue(s).${totalNote}${moreNote(more, ISSUE_LIST_MAX)}${outsideNote(outside)}${lines ? `\n${lines}` : ""}`, {
|
|
178
|
+
issues: issues.map((issue) => withAssigneeIdentity(issueListRow(issue), directory)),
|
|
179
|
+
more,
|
|
180
|
+
...(completeness ? { total: completeness.total } : {}),
|
|
181
|
+
...(outside ? { outside_project: outside } : {}),
|
|
182
|
+
});
|
|
121
183
|
}));
|
|
122
184
|
register("work_get_issue", {
|
|
123
185
|
title: "Read a Tower issue",
|
|
124
|
-
description: "One issue's title, state, priority, assignee, description and every
|
|
125
|
-
|
|
186
|
+
description: "One issue's title, state, priority, assignee (name and email, not just its id), description and every "
|
|
187
|
+
+ "comment on it. `ids` reads several in one call — still one request per issue plus its comments, "
|
|
188
|
+
+ "no bulk door exists yet. A single id answers `{ issue, comments }`; several answer "
|
|
189
|
+
+ "`{ issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }` so one bad id never "
|
|
190
|
+
+ "loses the rest.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
id: ISSUE_REF.optional().describe("A single issue. Omit when passing ids."),
|
|
193
|
+
ids: z.array(ISSUE_REF).min(1).max(50).optional().describe("Several issues in one call. Omit when passing id."),
|
|
194
|
+
},
|
|
126
195
|
}, async (args) => withSession(deps, async (session) => {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
196
|
+
const refs = Array.isArray(args.ids) && args.ids.length > 0
|
|
197
|
+
? args.ids.map(String)
|
|
198
|
+
: [String(args.id ?? "")];
|
|
199
|
+
if (refs.length === 1 && refs[0] === "")
|
|
200
|
+
return errorResult("work_get_issue needs id or ids.");
|
|
201
|
+
const results = await Promise.all(refs.map((ref) => fetchOneIssue(deps, session, ref)));
|
|
202
|
+
const directory = await resolveAssigneeDirectory(deps, session, results.filter((result) => result.ok).map((result) => result.issue?.assignee_id));
|
|
203
|
+
if (refs.length === 1) {
|
|
204
|
+
const [result] = results;
|
|
205
|
+
if (!result?.ok)
|
|
206
|
+
return errorResult(result?.text ?? "work_get_issue could not read that issue.");
|
|
207
|
+
return textResult(renderOneIssue(result, directory), {
|
|
208
|
+
issue: withAssigneeIdentity(result.issue ?? {}, directory),
|
|
209
|
+
comments: result.comments,
|
|
210
|
+
...(result.commentsUnread ? { comments_unread: true } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const failed = results.filter((result) => !result.ok);
|
|
214
|
+
const lines = results
|
|
215
|
+
.map((result) => (result.ok ? renderOneIssue(result, directory) : `${result.ref}: ${result.text}`))
|
|
216
|
+
.join("\n\n---\n\n");
|
|
217
|
+
return textResult(`${results.length - failed.length}/${results.length} issue(s) read.\n\n${lines}`, {
|
|
218
|
+
issues: results.map((result) => result.ok
|
|
219
|
+
? {
|
|
220
|
+
ref: result.ref,
|
|
221
|
+
ok: true,
|
|
222
|
+
issue: withAssigneeIdentity(result.issue ?? {}, directory),
|
|
223
|
+
comments: result.comments,
|
|
224
|
+
...(result.commentsUnread ? { comments_unread: true } : {}),
|
|
225
|
+
}
|
|
226
|
+
: { ref: result.ref, ok: false, error: result.text }),
|
|
227
|
+
});
|
|
141
228
|
}));
|
|
142
229
|
register("work_create_issue", {
|
|
143
230
|
title: "File a Tower issue",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
|
|
6
6
|
"type": "module",
|