@bli-cockpit/mcp 0.1.63 → 0.1.66
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/dist/docs-msg-helpers.d.ts +1 -0
- package/dist/docs-msg-helpers.js +6 -2
- package/dist/docs-msg-tools.js +32 -17
- 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/work-directory.d.ts +15 -0
- package/dist/work-directory.js +39 -0
- package/dist/work-issue-rows.d.ts +94 -0
- package/dist/work-issue-rows.js +75 -0
- package/dist/work-tools.js +76 -48
- package/package.json +1 -1
|
@@ -53,6 +53,7 @@ export declare function docListLine(row: DocRow): string;
|
|
|
53
53
|
export declare function docsListPath(args: {
|
|
54
54
|
parent_id?: unknown;
|
|
55
55
|
query?: unknown;
|
|
56
|
+
word?: unknown;
|
|
56
57
|
limit?: unknown;
|
|
57
58
|
}): string;
|
|
58
59
|
export declare function resolveDocumentId(deps: DocsMsgDeps, session: {
|
package/dist/docs-msg-helpers.js
CHANGED
|
@@ -67,8 +67,12 @@ export function docsListPath(args) {
|
|
|
67
67
|
const params = new URLSearchParams();
|
|
68
68
|
if (typeof args.parent_id === "string" && args.parent_id.length > 0)
|
|
69
69
|
params.set("parent_id", args.parent_id);
|
|
70
|
-
|
|
70
|
+
const hasQuery = typeof args.query === "string" && args.query.trim().length > 0;
|
|
71
|
+
if (hasQuery)
|
|
71
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");
|
|
72
76
|
if (typeof args.limit === "number" && Number.isInteger(args.limit))
|
|
73
77
|
params.set("limit", String(args.limit));
|
|
74
78
|
// No limit named: one page plus one row, so "more exist" is read off the
|
|
@@ -102,5 +106,5 @@ export async function resolveChannelId(deps, session, ref) {
|
|
|
102
106
|
/** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
|
|
103
107
|
export function messageLine(message) {
|
|
104
108
|
return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
|
|
105
|
-
+ `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
|
|
109
|
+
+ `${String(message.agent_label ?? message.user_id ?? message.slack_author_label ?? "?")} ${String(message.content ?? "")}`;
|
|
106
110
|
}
|
package/dist/docs-msg-tools.js
CHANGED
|
@@ -13,6 +13,7 @@ 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
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";
|
|
16
17
|
export { docsListPath } from "./docs-msg-helpers.js";
|
|
17
18
|
export function registerDocsMsgTools(server, deps) {
|
|
18
19
|
const register = registrarFor(server);
|
|
@@ -20,11 +21,13 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
20
21
|
title: "List Tower documents",
|
|
21
22
|
description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
|
|
22
23
|
+ "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
|
|
23
|
-
+ "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\"), "
|
|
24
26
|
+ `limit (1-1000, default ${DEFAULT_LIST_LIMIT}; the answer says when more exist).`,
|
|
25
27
|
inputSchema: {
|
|
26
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.'),
|
|
27
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."),
|
|
28
31
|
limit: z.number().int().min(1).max(1000).optional().describe(`At most this many documents. Default ${DEFAULT_LIST_LIMIT}.`),
|
|
29
32
|
},
|
|
30
33
|
}, async (args) => withSession(deps, async (session) => {
|
|
@@ -52,21 +55,25 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
52
55
|
}));
|
|
53
56
|
register("docs_read", {
|
|
54
57
|
title: "Read a Tower document",
|
|
55
|
-
description: "
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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));
|
|
70
77
|
register("docs_create", {
|
|
71
78
|
title: "Create a Tower document",
|
|
72
79
|
description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
|
|
@@ -179,7 +186,15 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
179
186
|
// which is what msg_thread takes. It used to say only "2 message(s)",
|
|
180
187
|
// so a Claude-family client never saw one (BLI-10432).
|
|
181
188
|
const lines = messages.map(messageLine).join("\n");
|
|
182
|
-
|
|
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 });
|
|
183
198
|
}));
|
|
184
199
|
register("msg_thread", {
|
|
185
200
|
title: "Read a Tower message thread",
|
|
@@ -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,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row shapes the `work_*` tools read off `/api/work/**`, and the pure
|
|
3
|
+
* functions that turn them into what a tool answers with: the trimmed list
|
|
4
|
+
* row, the completeness and outside-project notes, one issue's text, one
|
|
5
|
+
* history line.
|
|
6
|
+
*
|
|
7
|
+
* Split out of `work-tools.ts` (BLI-10445) when that file crossed the
|
|
8
|
+
* 500-line readability ratchet. The seam: `work-tools.ts` registers the tools
|
|
9
|
+
* and calls the doors; nothing here calls a door or knows a session, so this
|
|
10
|
+
* module changes when a door's row shape or a rendered sentence does, and can
|
|
11
|
+
* be read without the registration code around it.
|
|
12
|
+
*
|
|
13
|
+
* Every string moved verbatim.
|
|
14
|
+
*/
|
|
15
|
+
import { type AssigneeIdentity } from "./work-directory.js";
|
|
16
|
+
export interface IssueRow {
|
|
17
|
+
id: string;
|
|
18
|
+
identifier: string;
|
|
19
|
+
title: string;
|
|
20
|
+
state: string;
|
|
21
|
+
priority?: number | null;
|
|
22
|
+
assignee_id?: string | null;
|
|
23
|
+
project_id?: string | null;
|
|
24
|
+
parent_id?: string | null;
|
|
25
|
+
updated_at?: string | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The fields `work_list_issues` promises. The door's row carries the whole
|
|
29
|
+
* description and a dozen bookkeeping columns; with no limit that was 1,000
|
|
30
|
+
* issues and 4.2 MB, 3.3 MB of it descriptions the tool said it never
|
|
31
|
+
* returned (BLI-10432). work_get_issue reads one issue in full.
|
|
32
|
+
*/
|
|
33
|
+
export interface IssueListRow {
|
|
34
|
+
id: string;
|
|
35
|
+
identifier: string;
|
|
36
|
+
title: string;
|
|
37
|
+
state: string;
|
|
38
|
+
priority: number | null;
|
|
39
|
+
assignee_id: string | null;
|
|
40
|
+
project_id: string | null;
|
|
41
|
+
parent_id: string | null;
|
|
42
|
+
updated_at: string | null;
|
|
43
|
+
}
|
|
44
|
+
export declare function issueListRow(row: IssueRow): IssueListRow;
|
|
45
|
+
export interface ListCompleteness {
|
|
46
|
+
status: "complete" | "partial" | "unknown";
|
|
47
|
+
total: number | null;
|
|
48
|
+
}
|
|
49
|
+
export declare function listCompleteness(value: unknown): ListCompleteness | null;
|
|
50
|
+
export interface OutsideProject {
|
|
51
|
+
status: string;
|
|
52
|
+
issues: {
|
|
53
|
+
identifier: string;
|
|
54
|
+
title: string;
|
|
55
|
+
project_id: string | null;
|
|
56
|
+
parent_identifier: string | null;
|
|
57
|
+
}[];
|
|
58
|
+
}
|
|
59
|
+
export declare function outsideProject(value: unknown): OutsideProject | null;
|
|
60
|
+
/** BLI-10447: sub-issues of the listed issues that live in no project or another one. */
|
|
61
|
+
export declare function outsideNote(outside: OutsideProject | null): string;
|
|
62
|
+
export interface OneIssueOk {
|
|
63
|
+
ref: string;
|
|
64
|
+
ok: true;
|
|
65
|
+
issue?: IssueRow & {
|
|
66
|
+
description?: string | null;
|
|
67
|
+
};
|
|
68
|
+
comments: Array<{
|
|
69
|
+
body_markdown?: string;
|
|
70
|
+
created_at?: string;
|
|
71
|
+
}>;
|
|
72
|
+
commentsUnread?: boolean;
|
|
73
|
+
}
|
|
74
|
+
export interface OneIssueFailure {
|
|
75
|
+
ref: string;
|
|
76
|
+
ok: false;
|
|
77
|
+
text: string;
|
|
78
|
+
}
|
|
79
|
+
export declare function renderOneIssue(result: OneIssueOk, directory: Map<string, AssigneeIdentity>): string;
|
|
80
|
+
export interface HistoryRow {
|
|
81
|
+
occurred_at?: string;
|
|
82
|
+
change_type?: string;
|
|
83
|
+
from_value?: string | null;
|
|
84
|
+
to_value?: string | null;
|
|
85
|
+
actor_name?: string | null;
|
|
86
|
+
source?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A row the Linear import could not give state names to says which it is.
|
|
90
|
+
* Printing "(none) -> (none)" is honest and useless; naming the gap keeps
|
|
91
|
+
* nobody reading a real gap as a broken tool (`commands/issue.ts` says the
|
|
92
|
+
* same thing in the same words).
|
|
93
|
+
*/
|
|
94
|
+
export declare function historyChange(row: HistoryRow): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row shapes the `work_*` tools read off `/api/work/**`, and the pure
|
|
3
|
+
* functions that turn them into what a tool answers with: the trimmed list
|
|
4
|
+
* row, the completeness and outside-project notes, one issue's text, one
|
|
5
|
+
* history line.
|
|
6
|
+
*
|
|
7
|
+
* Split out of `work-tools.ts` (BLI-10445) when that file crossed the
|
|
8
|
+
* 500-line readability ratchet. The seam: `work-tools.ts` registers the tools
|
|
9
|
+
* and calls the doors; nothing here calls a door or knows a session, so this
|
|
10
|
+
* module changes when a door's row shape or a rendered sentence does, and can
|
|
11
|
+
* be read without the registration code around it.
|
|
12
|
+
*
|
|
13
|
+
* Every string moved verbatim.
|
|
14
|
+
*/
|
|
15
|
+
import { assigneeLabel } from "./work-directory.js";
|
|
16
|
+
export function issueListRow(row) {
|
|
17
|
+
return {
|
|
18
|
+
id: row.id,
|
|
19
|
+
identifier: row.identifier,
|
|
20
|
+
title: row.title,
|
|
21
|
+
state: row.state,
|
|
22
|
+
priority: row.priority ?? null,
|
|
23
|
+
assignee_id: row.assignee_id ?? null,
|
|
24
|
+
project_id: row.project_id ?? null,
|
|
25
|
+
parent_id: row.parent_id ?? null,
|
|
26
|
+
updated_at: row.updated_at ?? null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function listCompleteness(value) {
|
|
30
|
+
if (!value || typeof value !== "object")
|
|
31
|
+
return null;
|
|
32
|
+
const record = value;
|
|
33
|
+
const status = record["status"];
|
|
34
|
+
if (status !== "complete" && status !== "partial" && status !== "unknown")
|
|
35
|
+
return null;
|
|
36
|
+
return { status, total: typeof record["total"] === "number" ? record["total"] : null };
|
|
37
|
+
}
|
|
38
|
+
export function outsideProject(value) {
|
|
39
|
+
if (!value || typeof value !== "object")
|
|
40
|
+
return null;
|
|
41
|
+
const record = value;
|
|
42
|
+
if (!Array.isArray(record["issues"]))
|
|
43
|
+
return null;
|
|
44
|
+
return { status: String(record["status"] ?? "complete"), issues: record["issues"] };
|
|
45
|
+
}
|
|
46
|
+
/** BLI-10447: sub-issues of the listed issues that live in no project or another one. */
|
|
47
|
+
export function outsideNote(outside) {
|
|
48
|
+
if (!outside)
|
|
49
|
+
return "";
|
|
50
|
+
const named = outside.issues.map((issue) => `${issue.identifier} (under ${issue.parent_identifier ?? "?"}, ${issue.project_id ? `project ${issue.project_id}` : "no project"})`);
|
|
51
|
+
const found = named.length > 0 ? ` ${named.length} sub-issue(s) of these sit outside the project: ${named.join(", ")}.` : "";
|
|
52
|
+
const gap = outside.status === "failed" ? " The outside-project check failed (read_failed)."
|
|
53
|
+
: outside.status === "capped" ? " The outside-project check was capped; more may exist." : "";
|
|
54
|
+
return found + gap;
|
|
55
|
+
}
|
|
56
|
+
export function renderOneIssue(result, directory) {
|
|
57
|
+
const { issue, comments } = result;
|
|
58
|
+
const commentText = comments.map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`).join("\n\n");
|
|
59
|
+
return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"}) · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
|
|
60
|
+
+ `\n\n${issue?.description ?? "(no description)"}`
|
|
61
|
+
+ (commentText ? `\n\n--- comments ---\n${commentText}` : "")
|
|
62
|
+
+ (result.commentsUnread ? "\n\n(comments could not be read on this call)" : "");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A row the Linear import could not give state names to says which it is.
|
|
66
|
+
* Printing "(none) -> (none)" is honest and useless; naming the gap keeps
|
|
67
|
+
* nobody reading a real gap as a broken tool (`commands/issue.ts` says the
|
|
68
|
+
* same thing in the same words).
|
|
69
|
+
*/
|
|
70
|
+
export function historyChange(row) {
|
|
71
|
+
if (row.from_value === null && row.to_value === null) {
|
|
72
|
+
return `(no state names recorded — imported from ${row.source ?? "elsewhere"})`;
|
|
73
|
+
}
|
|
74
|
+
return `${row.from_value ?? "(none)"} -> ${row.to_value ?? "(none)"}`;
|
|
75
|
+
}
|
package/dist/work-tools.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
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";
|
|
21
|
+
import { historyChange, issueListRow, listCompleteness, outsideNote, outsideProject, renderOneIssue, } from "./work-issue-rows.js";
|
|
20
22
|
/** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
|
|
21
23
|
export const WORK_ISSUE_STATES = [
|
|
22
24
|
"backlog",
|
|
@@ -28,37 +30,24 @@ export const WORK_ISSUE_STATES = [
|
|
|
28
30
|
];
|
|
29
31
|
/** `/api/work/issues`' own ceiling, and the most a caller may ask for here. */
|
|
30
32
|
const ISSUE_LIST_MAX = 1000;
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
/** One issue plus its comments — a comment read that fails does not lose the issue already in hand. */
|
|
34
|
+
async function fetchOneIssue(deps, session, ref) {
|
|
35
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
|
|
36
|
+
if (!response.ok)
|
|
37
|
+
return { ref, ok: false, text: doorFailureText("work_get_issue", response) };
|
|
38
|
+
const issue = response.body.issue;
|
|
39
|
+
const commentsResponse = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(issue?.id ?? ref)}/comments`);
|
|
40
|
+
const comments = commentsResponse.ok
|
|
41
|
+
? (commentsResponse.body.comments ?? [])
|
|
42
|
+
: [];
|
|
38
43
|
return {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
assignee_id: row.assignee_id ?? null,
|
|
45
|
-
project_id: row.project_id ?? null,
|
|
46
|
-
parent_id: row.parent_id ?? null,
|
|
47
|
-
updated_at: row.updated_at ?? null,
|
|
44
|
+
ref,
|
|
45
|
+
ok: true,
|
|
46
|
+
issue,
|
|
47
|
+
comments,
|
|
48
|
+
...(commentsResponse.ok ? {} : { commentsUnread: true }),
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
|
-
/**
|
|
51
|
-
* A row the Linear import could not give state names to says which it is.
|
|
52
|
-
* Printing "(none) -> (none)" is honest and useless; naming the gap keeps
|
|
53
|
-
* nobody reading a real gap as a broken tool (`commands/issue.ts` says the
|
|
54
|
-
* same thing in the same words).
|
|
55
|
-
*/
|
|
56
|
-
function historyChange(row) {
|
|
57
|
-
if (row.from_value === null && row.to_value === null) {
|
|
58
|
-
return `(no state names recorded — imported from ${row.source ?? "elsewhere"})`;
|
|
59
|
-
}
|
|
60
|
-
return `${row.from_value ?? "(none)"} -> ${row.to_value ?? "(none)"}`;
|
|
61
|
-
}
|
|
62
51
|
/** A project id from a project NAME or a uuid — exact after case-folding, never fuzzy. */
|
|
63
52
|
async function resolveProjectId(deps, session, ref) {
|
|
64
53
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/work/projects?include_archived=true");
|
|
@@ -111,33 +100,72 @@ export function registerWorkTools(server, deps) {
|
|
|
111
100
|
if (!response.ok)
|
|
112
101
|
return errorResult(doorFailureText("work_list_issues", response));
|
|
113
102
|
const all = (Array.isArray(response.body.issues) ? response.body.issues : []);
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
// BLI-10447: the door says whether a full read was every match; a named
|
|
104
|
+
// limit that filled up used to answer "more: false" regardless.
|
|
105
|
+
const completeness = listCompleteness(response.body.completeness);
|
|
106
|
+
const more = args.limit === undefined
|
|
107
|
+
? all.length > DEFAULT_LIST_LIMIT
|
|
108
|
+
: completeness?.status === "partial" || completeness?.status === "unknown";
|
|
109
|
+
const issues = args.limit === undefined && more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
|
|
110
|
+
const outside = outsideProject(response.body.outside_project);
|
|
111
|
+
// BLI-10450: one directory call for the whole page, never one per row.
|
|
112
|
+
const directory = await resolveAssigneeDirectory(deps, session, issues.map((issue) => issue.assignee_id));
|
|
116
113
|
const lines = issues
|
|
117
114
|
.map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`
|
|
118
|
-
+ ` [p${issue.priority ?? 0}, assignee ${issue.assignee_id
|
|
115
|
+
+ ` [p${issue.priority ?? 0}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`)
|
|
119
116
|
.join("\n");
|
|
120
|
-
|
|
117
|
+
const totalNote = completeness?.total != null && completeness.total > issues.length ? ` ${completeness.total} match in all.` : "";
|
|
118
|
+
return textResult(`${issues.length} issue(s).${totalNote}${moreNote(more, ISSUE_LIST_MAX)}${outsideNote(outside)}${lines ? `\n${lines}` : ""}`, {
|
|
119
|
+
issues: issues.map((issue) => withAssigneeIdentity(issueListRow(issue), directory)),
|
|
120
|
+
more,
|
|
121
|
+
...(completeness ? { total: completeness.total } : {}),
|
|
122
|
+
...(outside ? { outside_project: outside } : {}),
|
|
123
|
+
});
|
|
121
124
|
}));
|
|
122
125
|
register("work_get_issue", {
|
|
123
126
|
title: "Read a Tower issue",
|
|
124
|
-
description: "One issue's title, state, priority, assignee, description and every
|
|
125
|
-
|
|
127
|
+
description: "One issue's title, state, priority, assignee (name and email, not just its id), description and every "
|
|
128
|
+
+ "comment on it. `ids` reads several in one call — still one request per issue plus its comments, "
|
|
129
|
+
+ "no bulk door exists yet. A single id answers `{ issue, comments }`; several answer "
|
|
130
|
+
+ "`{ issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }` so one bad id never "
|
|
131
|
+
+ "loses the rest.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
id: ISSUE_REF.optional().describe("A single issue. Omit when passing ids."),
|
|
134
|
+
ids: z.array(ISSUE_REF).min(1).max(50).optional().describe("Several issues in one call. Omit when passing id."),
|
|
135
|
+
},
|
|
126
136
|
}, async (args) => withSession(deps, async (session) => {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
const refs = Array.isArray(args.ids) && args.ids.length > 0
|
|
138
|
+
? args.ids.map(String)
|
|
139
|
+
: [String(args.id ?? "")];
|
|
140
|
+
if (refs.length === 1 && refs[0] === "")
|
|
141
|
+
return errorResult("work_get_issue needs id or ids.");
|
|
142
|
+
const results = await Promise.all(refs.map((ref) => fetchOneIssue(deps, session, ref)));
|
|
143
|
+
const directory = await resolveAssigneeDirectory(deps, session, results.filter((result) => result.ok).map((result) => result.issue?.assignee_id));
|
|
144
|
+
if (refs.length === 1) {
|
|
145
|
+
const [result] = results;
|
|
146
|
+
if (!result?.ok)
|
|
147
|
+
return errorResult(result?.text ?? "work_get_issue could not read that issue.");
|
|
148
|
+
return textResult(renderOneIssue(result, directory), {
|
|
149
|
+
issue: withAssigneeIdentity(result.issue ?? {}, directory),
|
|
150
|
+
comments: result.comments,
|
|
151
|
+
...(result.commentsUnread ? { comments_unread: true } : {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const failed = results.filter((result) => !result.ok);
|
|
155
|
+
const lines = results
|
|
156
|
+
.map((result) => (result.ok ? renderOneIssue(result, directory) : `${result.ref}: ${result.text}`))
|
|
157
|
+
.join("\n\n---\n\n");
|
|
158
|
+
return textResult(`${results.length - failed.length}/${results.length} issue(s) read.\n\n${lines}`, {
|
|
159
|
+
issues: results.map((result) => result.ok
|
|
160
|
+
? {
|
|
161
|
+
ref: result.ref,
|
|
162
|
+
ok: true,
|
|
163
|
+
issue: withAssigneeIdentity(result.issue ?? {}, directory),
|
|
164
|
+
comments: result.comments,
|
|
165
|
+
...(result.commentsUnread ? { comments_unread: true } : {}),
|
|
166
|
+
}
|
|
167
|
+
: { ref: result.ref, ok: false, error: result.text }),
|
|
168
|
+
});
|
|
141
169
|
}));
|
|
142
170
|
register("work_create_issue", {
|
|
143
171
|
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.66",
|
|
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",
|