@bli-cockpit/mcp 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `notes_*` MCP tools (BLI-3756) — the meeting-notes library, read by an
3
+ * agent, over the same `/api/notes/**` doors `cockpit notes` calls with the
4
+ * same device token.
5
+ *
6
+ * **The narrowing is always said out loud.** `lib/notes/api-doors.ts` gives a
7
+ * caller with no signed-in browser session a NARROWER read and names it in the
8
+ * answer's own `scope`/`degradedBecause` — a device token is exactly such a
9
+ * caller, so an agent reading this library is routinely seeing less than a
10
+ * person would in a browser. Every tool here appends that reason to its text.
11
+ * Swallowing it would let an agent conclude a note was never taken when in
12
+ * fact it simply was not this caller's to read, which is a fact invented out
13
+ * of a permission.
14
+ *
15
+ * Reads only. `notes move|share|unshare|paste|upload` are batch 2 — sharing is
16
+ * a deliberate act and the paste door takes a body on stdin by rule.
17
+ */
18
+ import { z } from "zod";
19
+ import { callAgentDoor } from "./agent-door.js";
20
+ import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
21
+ /**
22
+ * The door's own words for "this answer is narrower than the browser's", or
23
+ * "" when it was not narrowed. Never rephrased here.
24
+ */
25
+ export function narrowingNote(body) {
26
+ if (!body.degradedBecause)
27
+ return "";
28
+ return `\n\n(scope: ${body.scope ?? "unknown"} — ${body.degradedBecause}. `
29
+ + `${body.degradedNote ?? "This answer is narrower than the browser's."})`;
30
+ }
31
+ function libraryQuery(args) {
32
+ const params = new URLSearchParams();
33
+ if (args.shelf)
34
+ params.set("series", String(args.shelf));
35
+ if (args.kind)
36
+ params.set("kind", String(args.kind));
37
+ if (args.since)
38
+ params.set("since", String(args.since));
39
+ if (args.until)
40
+ params.set("until", String(args.until));
41
+ if (typeof args.limit === "number")
42
+ params.set("limit", String(args.limit));
43
+ return params;
44
+ }
45
+ const LIBRARY_FILTERS = {
46
+ shelf: z.string().min(1).max(200).optional().describe("Only this shelf (the library calls it a series)."),
47
+ kind: z.string().min(1).max(100).optional().describe("Only this meeting kind."),
48
+ since: z.string().min(1).max(20).optional().describe("YYYY-MM-DD — meetings on or after this date."),
49
+ until: z.string().min(1).max(20).optional().describe("YYYY-MM-DD — meetings on or before this date."),
50
+ limit: z.number().int().min(1).max(500).optional(),
51
+ };
52
+ export function registerNotesTools(server, deps) {
53
+ const register = registrarFor(server);
54
+ register("notes_list", {
55
+ title: "List Tower meeting notes",
56
+ description: "The meeting-notes library you may read, grouped by shelf: id, date, kind, participants and file name. "
57
+ + "Never a note's text — call notes_show for one. Says when the answer is narrower than a browser's.",
58
+ inputSchema: LIBRARY_FILTERS,
59
+ }, async (args) => withSession(deps, async (session) => {
60
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/library${queryString(libraryQuery(args))}`);
61
+ if (!response.ok)
62
+ return errorResult(doorFailureText("notes_list", response));
63
+ const body = response.body;
64
+ const series = body.series ?? [];
65
+ const lines = series
66
+ .map((shelf) => {
67
+ const notes = shelf.notes
68
+ .map((note) => {
69
+ const who = note.participants.length > 0 ? ` — ${note.participants.join(", ")}` : "";
70
+ return ` ${note.meetingDate} ${note.id} ${note.fileName}${who}`;
71
+ })
72
+ .join("\n");
73
+ return `${shelf.heading} (${shelf.notes.length})\n${notes}`;
74
+ })
75
+ .join("\n\n");
76
+ return textResult(`${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`
77
+ + `${lines ? `\n\n${lines}` : ""}${narrowingNote(body)}`, { scope: body.scope ?? null, count: body.count ?? 0, more: body.more ?? false, series });
78
+ }));
79
+ register("notes_show", {
80
+ title: "Read one Tower meeting note",
81
+ description: "One note's title, date, shelf, participants and full text, by its id (notes_list returns ids).",
82
+ inputSchema: { id: z.string().min(1).max(200).describe("A note id.") },
83
+ }, async (args) => withSession(deps, async (session) => {
84
+ const ref = String(args.id ?? "");
85
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/library/${encodeURIComponent(ref)}`);
86
+ if (!response.ok)
87
+ return errorResult(doorFailureText("notes_show", response));
88
+ const body = response.body;
89
+ const note = body.note;
90
+ if (!note) {
91
+ return errorResult(`Tower answered notes_show without a note for "${ref}".${narrowingNote(body)}`);
92
+ }
93
+ const room = note.participants.length > 0 ? `\nIn the room: ${note.participants.join(", ")}` : "";
94
+ return textResult(`${note.title}\n${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`
95
+ + `${room}\n${note.visibility}\n\n${note.content}${narrowingNote(body)}`, { scope: body.scope ?? null, note });
96
+ }));
97
+ register("notes_shelf", {
98
+ title: "Your own Tower notes shelf",
99
+ description: "The notes THIS machine's owner put in, with the per-note counts (statements, open to the team, kept back) "
100
+ + "when the server knows them. Says so when it does not, rather than printing zeros.",
101
+ inputSchema: { limit: z.number().int().min(1).max(500).optional() },
102
+ }, async (args) => withSession(deps, async (session) => {
103
+ const params = new URLSearchParams();
104
+ if (typeof args.limit === "number")
105
+ params.set("limit", String(args.limit));
106
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/shelf${queryString(params)}`);
107
+ if (!response.ok)
108
+ return errorResult(doorFailureText("notes_shelf", response));
109
+ const body = response.body;
110
+ const notes = body.notes ?? [];
111
+ const lines = notes
112
+ .map((note) => {
113
+ const counts = note.countsKnown
114
+ ? `${note.statements} statements, ${note.openToTheTeam} open to the team, ${note.keptBack} kept back`
115
+ : "counts unknown on this server";
116
+ return `${note.meetingDate} ${note.id} ${note.shared ? "shared" : "yours"} ${note.name}\n ${counts}`;
117
+ })
118
+ .join("\n");
119
+ return textResult(`${notes.length} note(s) on your shelf.${lines ? `\n${lines}` : ""}${narrowingNote(body)}`, { scope: body.scope ?? null, notes });
120
+ }));
121
+ register("notes_shelves", {
122
+ title: "List Tower note shelves",
123
+ description: "The shelves the library is grouped into and how many notes are on each — a shelf somebody typed is marked "
124
+ + "as such, one implied by the meeting kind is not. Pass a heading to notes_list's `shelf`.",
125
+ inputSchema: LIBRARY_FILTERS,
126
+ }, async (args) => withSession(deps, async (session) => {
127
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/library${queryString(libraryQuery(args))}`);
128
+ if (!response.ok)
129
+ return errorResult(doorFailureText("notes_shelves", response));
130
+ const body = response.body;
131
+ // The same derivation `commands/notes-reads.ts` does, kept identical on
132
+ // purpose: a shelf is free text, so only the typed ones are marked.
133
+ const shelves = (body.series ?? []).map((one) => ({
134
+ shelf: one.heading,
135
+ notes: one.notes.length,
136
+ custom: (body.categories ?? []).includes(one.heading),
137
+ }));
138
+ const lines = shelves
139
+ .map((shelf) => `${String(shelf.notes).padStart(4)} ${shelf.shelf}${shelf.custom ? "" : " (from the meeting kind)"}`)
140
+ .join("\n");
141
+ return textResult(`${shelves.length} shelf/shelves.${lines ? `\n${lines}` : ""}${narrowingNote(body)}`, { scope: body.scope ?? null, shelves });
142
+ }));
143
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `ops_status` / `slack_*` MCP tools (BLI-3756) — the `/api/ops/**` board an
3
+ * agent can open, over the same doors `cockpit ops` and `cockpit slack` call
4
+ * with the same device token.
5
+ *
6
+ * These three share a file because they share a door family and a gate, not
7
+ * because they share a noun. `lib/ops/pipeline-status.ts` is the one place
8
+ * that knows a job's EXPECTED INTERVAL — written next to its reader, so a
9
+ * once-daily job quiet for 18 hours reads healthy (BLI-3276) — and no verdict
10
+ * is computed here. This surface reports the server's verdicts and counts the
11
+ * unhealthy ones so an agent does not have to know the vocabulary to notice a
12
+ * red board.
13
+ *
14
+ * `ops recompile` stays out: it spends a model call and wants its own gate on
15
+ * this surface (batch 2).
16
+ */
17
+ import { type ToolDeps } from "./tool-result.js";
18
+ export type OpsDeps = ToolDeps;
19
+ /**
20
+ * The verdicts that mean something is wrong, verbatim from `commands/ops.ts`.
21
+ * `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
22
+ * reader's input is CURRENT and its answer is bad.
23
+ */
24
+ export declare const UNHEALTHY_VERDICTS: Set<string>;
25
+ export declare function registerOpsTools(server: {
26
+ registerTool: (...args: never[]) => unknown;
27
+ }, deps: OpsDeps): void;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `ops_status` / `slack_*` MCP tools (BLI-3756) — the `/api/ops/**` board an
3
+ * agent can open, over the same doors `cockpit ops` and `cockpit slack` call
4
+ * with the same device token.
5
+ *
6
+ * These three share a file because they share a door family and a gate, not
7
+ * because they share a noun. `lib/ops/pipeline-status.ts` is the one place
8
+ * that knows a job's EXPECTED INTERVAL — written next to its reader, so a
9
+ * once-daily job quiet for 18 hours reads healthy (BLI-3276) — and no verdict
10
+ * is computed here. This surface reports the server's verdicts and counts the
11
+ * unhealthy ones so an agent does not have to know the vocabulary to notice a
12
+ * red board.
13
+ *
14
+ * `ops recompile` stays out: it spends a model call and wants its own gate on
15
+ * this surface (batch 2).
16
+ */
17
+ import { z } from "zod";
18
+ import { callAgentDoor } from "./agent-door.js";
19
+ import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
20
+ /**
21
+ * The verdicts that mean something is wrong, verbatim from `commands/ops.ts`.
22
+ * `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
23
+ * reader's input is CURRENT and its answer is bad.
24
+ */
25
+ export const UNHEALTHY_VERDICTS = new Set(["stale", "never_produced", "unreadable", "failing"]);
26
+ function numberOf(value) {
27
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
28
+ }
29
+ function asRecord(value) {
30
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
31
+ }
32
+ export function registerOpsTools(server, deps) {
33
+ const register = registrarFor(server);
34
+ register("ops_status", {
35
+ title: "Read the Tower pipeline board",
36
+ description: "Every scheduled job's verdict against its own expected interval, plus the collector fleet and collection "
37
+ + "coverage. A job's `detail` is the server's sentence for why — relayed as it stands. Status only: nothing "
38
+ + "here fires a job.",
39
+ inputSchema: {
40
+ job: z.string().min(1).max(100).optional().describe("One pipeline id instead of the whole board."),
41
+ skips: z.boolean().optional().describe("Also return the open ingest-skip ledgers."),
42
+ },
43
+ }, async (args) => withSession(deps, async (session) => {
44
+ const params = new URLSearchParams();
45
+ if (args.job)
46
+ params.set("job", String(args.job));
47
+ if (args.skips)
48
+ params.set("skips", "1");
49
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/status${queryString(params)}`);
50
+ if (!response.ok)
51
+ return errorResult(doorFailureText("ops_status", response));
52
+ const body = response.body;
53
+ const rows = (Array.isArray(body.pipelines) ? body.pipelines : []);
54
+ const unhealthy = rows.filter((row) => UNHEALTHY_VERDICTS.has(row.verdict ?? ""));
55
+ const lines = rows
56
+ .map((row) => `${(row.verdict ?? "?").padEnd(14)} ${row.id ?? "?"} ${row.detail ?? row.label ?? ""}`.trimEnd())
57
+ .join("\n");
58
+ // Both branches say something. A board that is entirely green and
59
+ // reports nothing cannot answer "did anybody look today?".
60
+ return textResult(`${rows.length} pipeline(s), ${unhealthy.length} unhealthy`
61
+ + `${unhealthy.length > 0 ? `: ${unhealthy.map((row) => row.id ?? "?").join(", ")}` : "."}`
62
+ + `${lines ? `\n${lines}` : ""}`, {
63
+ pipelines: rows,
64
+ unhealthy: unhealthy.map((row) => row.id ?? "?"),
65
+ ...(body.fleet ? { fleet: body.fleet } : {}),
66
+ ...(body.coverage ? { coverage: body.coverage } : {}),
67
+ ...(body.skips ? { skips: body.skips } : {}),
68
+ });
69
+ }));
70
+ register("slack_coverage", {
71
+ title: "Read Tower's Slack collection coverage",
72
+ description: "Which Slack channels the bot can actually read, per workspace, and which are stale beyond the server's own "
73
+ + "threshold. Open to everyone — this is collection health, not message content.",
74
+ inputSchema: {
75
+ workspace: z.string().min(1).max(100).optional().describe("One workspace key instead of all of them."),
76
+ stale_only: z.boolean().optional().describe("Report only the stale channels, not the whole breakdown."),
77
+ },
78
+ }, async (args) => withSession(deps, async (session) => {
79
+ const params = new URLSearchParams();
80
+ if (args.workspace)
81
+ params.set("workspace", String(args.workspace));
82
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/slack/coverage${queryString(params)}`);
83
+ if (!response.ok)
84
+ return errorResult(doorFailureText("slack_coverage", response));
85
+ const coverage = asRecord(response.body["coverage"]);
86
+ const workspaces = (Array.isArray(coverage["workspaces"]) ? coverage["workspaces"] : []);
87
+ const staleAfter = numberOf(coverage["staleAfterHours"]);
88
+ const staleTotal = workspaces.reduce((sum, row) => sum + numberOf(row["staleChannelsTotal"]), 0);
89
+ const lines = workspaces
90
+ .map((workspace) => {
91
+ const key = String(workspace["workspace"] ?? "?").toUpperCase();
92
+ const head = `${key} ${numberOf(workspace["covered"])} of ${numberOf(workspace["channelsKnown"])} channels readable`;
93
+ const stale = numberOf(workspace["staleChannelsTotal"]);
94
+ const staleLine = stale === 0
95
+ ? ` no channel is over ${staleAfter}h since its last sync`
96
+ : ` ${stale} channel(s) over ${staleAfter}h since last sync`;
97
+ const cursor = args.stale_only ? "" : `\n newest cursor: ${String(workspace["newestCursorIso"] ?? "never")}`;
98
+ return `${head}\n${staleLine}${cursor}`;
99
+ })
100
+ .join("\n\n");
101
+ return textResult(`${workspaces.length} workspace(s), ${staleTotal} stale channel(s).${lines ? `\n\n${lines}` : ""}`
102
+ + (typeof coverage["note"] === "string" ? `\n\n${coverage["note"]}` : ""), { coverage });
103
+ }));
104
+ register("slack_read", {
105
+ title: "Read Tower-collected Slack messages",
106
+ description: "Messages the Slack collector holds, filtered by person, channel, text and date. A narrower audience than "
107
+ + "slack_coverage: the server decides who may read message content, and refuses in its own words.",
108
+ inputSchema: {
109
+ person: z.string().min(1).max(200).optional().describe("A person — name, email or Slack member id."),
110
+ channel: z.string().min(1).max(200).optional().describe("One channel name."),
111
+ query: z.string().min(1).max(500).optional().describe("Text to look for."),
112
+ since: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
113
+ until: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
114
+ limit: z.number().int().min(1).max(500).optional(),
115
+ },
116
+ }, async (args) => withSession(deps, async (session) => {
117
+ const body = {};
118
+ for (const key of ["person", "channel", "query", "since", "until"]) {
119
+ if (args[key])
120
+ body[key] = String(args[key]);
121
+ }
122
+ if (typeof args.limit === "number")
123
+ body["limit"] = args.limit;
124
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/slack/read", body);
125
+ if (!response.ok) {
126
+ // The CLI names the open door beside the closed one on a 403; an
127
+ // agent told only "forbidden" would stop, when the coverage read it
128
+ // is entitled to may well answer the question it was asking.
129
+ const hint = response.status === 403
130
+ ? " slack_coverage is open to everyone and answers what the bot can see."
131
+ : "";
132
+ return errorResult(`${doorFailureText("slack_read", response)}${hint}`);
133
+ }
134
+ const read = asRecord(response.body["result"]);
135
+ const messages = (Array.isArray(read["messages"]) ? read["messages"] : []);
136
+ const lines = messages
137
+ .map((message) => `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`
138
+ + `\n ${String(message["text"] ?? "")}`)
139
+ .join("\n");
140
+ // The server's own summary first and verbatim: it is the sentence that
141
+ // keeps an empty answer from reading as "nobody said anything".
142
+ const summary = typeof read["summary"] === "string" ? read["summary"] : `${messages.length} message(s).`;
143
+ const note = typeof read["note"] === "string" && read["note"] ? `\n\n${read["note"]}` : "";
144
+ return textResult(`${summary}${lines ? `\n\n${lines}` : ""}${note}`, {
145
+ status: read["status"] ?? null,
146
+ reason: read["reason"] ?? null,
147
+ messages,
148
+ coverage: read["coverage"] ?? null,
149
+ });
150
+ }));
151
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `scout_board` / `workbook_read` MCP tools (BLI-3756) — the two read-only
3
+ * `/api/cockpit/**` pages, over the same doors `cockpit scout` and
4
+ * `cockpit workbook` call with the same device token.
5
+ *
6
+ * **The sentences belong to the page, not to this file.** Both doors return
7
+ * the finished words the browser renders — Scout's standing lines come from
8
+ * `lib/cockpit/scout-lines.ts`, and the workbook's markdown comes from
9
+ * `lib/workbook/to-markdown.ts` walking the same static elements the page
10
+ * renders. A tool that reworded either would eventually disagree with the page
11
+ * about what happened, and an agent would have no way to tell which was right.
12
+ *
13
+ * **A bounded read says so.** The Scout door returns at most 8 cards and 12
14
+ * signals and reports the totals in `coverage`; a truncated board and a quiet
15
+ * board must never read alike.
16
+ *
17
+ * Moving a Scout card (`start`/`dismiss`/`undo`) is super_admin server-side and
18
+ * stays in batch 2.
19
+ */
20
+ import { type ToolDeps } from "./tool-result.js";
21
+ export type PagesDeps = ToolDeps;
22
+ export declare function registerPagesTools(server: {
23
+ registerTool: (...args: never[]) => unknown;
24
+ }, deps: PagesDeps): void;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `scout_board` / `workbook_read` MCP tools (BLI-3756) — the two read-only
3
+ * `/api/cockpit/**` pages, over the same doors `cockpit scout` and
4
+ * `cockpit workbook` call with the same device token.
5
+ *
6
+ * **The sentences belong to the page, not to this file.** Both doors return
7
+ * the finished words the browser renders — Scout's standing lines come from
8
+ * `lib/cockpit/scout-lines.ts`, and the workbook's markdown comes from
9
+ * `lib/workbook/to-markdown.ts` walking the same static elements the page
10
+ * renders. A tool that reworded either would eventually disagree with the page
11
+ * about what happened, and an agent would have no way to tell which was right.
12
+ *
13
+ * **A bounded read says so.** The Scout door returns at most 8 cards and 12
14
+ * signals and reports the totals in `coverage`; a truncated board and a quiet
15
+ * board must never read alike.
16
+ *
17
+ * Moving a Scout card (`start`/`dismiss`/`undo`) is super_admin server-side and
18
+ * stays in batch 2.
19
+ */
20
+ import { z } from "zod";
21
+ import { callAgentDoor } from "./agent-door.js";
22
+ import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
23
+ /** "8 of 23 (truncated)" — never a bare count that hides what was left out. */
24
+ function coverageWord(coverage, shown) {
25
+ if (!coverage)
26
+ return String(shown);
27
+ const total = coverage.total ?? null;
28
+ return `${coverage.returned ?? shown}${total === null ? "" : ` of ${total}`}${coverage.truncated ? " (truncated)" : ""}`;
29
+ }
30
+ export function registerPagesTools(server, deps) {
31
+ const register = registrarFor(server);
32
+ register("scout_board", {
33
+ title: "Read the Tower Scout board",
34
+ description: "The experiment cards Scout is proposing, the ones already settled, and the raw signals behind them — plus "
35
+ + "the page's own standing sentences. Read only; moving a card is not on this surface.",
36
+ inputSchema: {
37
+ days: z.number().int().min(1).max(365).optional().describe("How many days of signals to look back over."),
38
+ },
39
+ }, async (args) => withSession(deps, async (session) => {
40
+ const params = new URLSearchParams();
41
+ if (typeof args.days === "number")
42
+ params.set("days", String(args.days));
43
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cockpit/scout${queryString(params)}`);
44
+ if (!response.ok)
45
+ return errorResult(doorFailureText("scout_board", response));
46
+ const board = (response.body.board ?? {});
47
+ const lines = (response.body.lines ?? {});
48
+ const experiments = board.experiments ?? [];
49
+ const settled = board.settled ?? [];
50
+ const signals = board.signals ?? [];
51
+ const said = [lines.watch, lines.headline, lines.quiet].filter(Boolean).join("\n");
52
+ const cards = experiments
53
+ .map((card) => ` ${card.id ?? "?"} ${card.status ?? "?"} ${card.title ?? ""}\n ${card.claimSummary ?? ""}`)
54
+ .join("\n");
55
+ const settledCards = settled.map((card) => ` ${card.id ?? "?"} ${card.title ?? ""}`).join("\n");
56
+ const rawSignals = signals.map((signal) => ` ${signal.source ?? "?"} ${signal.title ?? ""}`).join("\n");
57
+ return textResult(`${said}\n\n${lines.waitingLabel ?? "OPEN"} — ${coverageWord(board.coverage?.openExperiments, experiments.length)}`
58
+ + `${cards ? `\n${cards}` : ""}`
59
+ + `\n\n${lines.settledLabel ?? "SETTLED"} — ${coverageWord(board.coverage?.settledExperiments, settled.length)}`
60
+ + `${settledCards ? `\n${settledCards}` : ""}`
61
+ + `\n\n${lines.rawWatchLabel ?? "SIGNALS"} — ${coverageWord(board.coverage?.signals, signals.length)}`
62
+ + `${rawSignals ? `\n${rawSignals}` : `\n ${lines.rawWatchEmpty ?? ""}`}`, {
63
+ audience: response.body.audience ?? null,
64
+ windowDays: response.body.windowDays ?? board.windowDays ?? null,
65
+ board,
66
+ lines,
67
+ });
68
+ }));
69
+ register("workbook_read", {
70
+ title: "Read the Tower workbook",
71
+ description: "The per-project document library. With no arguments: every project and the documents in it. With `project` "
72
+ + "and `doc`: that document as markdown, rendered by the same walker the page uses.",
73
+ inputSchema: {
74
+ project: z.string().min(1).max(200).optional().describe("A project slug, as workbook_read lists it."),
75
+ doc: z.string().min(1).max(200).optional().describe("A document slug within that project. Needs `project` too."),
76
+ },
77
+ }, async (args) => withSession(deps, async (session) => {
78
+ const wantsDoc = Boolean(args.doc);
79
+ if (wantsDoc && !args.project) {
80
+ return errorResult("workbook_read needs `project` beside `doc` — a document slug is only unique within its project.");
81
+ }
82
+ const params = new URLSearchParams();
83
+ if (wantsDoc) {
84
+ params.set("project", String(args.project));
85
+ params.set("doc", String(args.doc));
86
+ }
87
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cockpit/workbook${queryString(params)}`);
88
+ if (!response.ok)
89
+ return errorResult(doorFailureText("workbook_read", response));
90
+ if (wantsDoc) {
91
+ const markdown = typeof response.body.markdown === "string" ? response.body.markdown : "";
92
+ if (markdown === "") {
93
+ return errorResult(`Tower answered workbook_read without a document for ${String(args.project)}/${String(args.doc)}.`);
94
+ }
95
+ return textResult(markdown, {
96
+ project: response.body.project ?? null,
97
+ doc: response.body.doc ?? null,
98
+ markdown,
99
+ sections: response.body.sections ?? [],
100
+ });
101
+ }
102
+ const projects = (Array.isArray(response.body.projects) ? response.body.projects : []);
103
+ // A named project that is not there is a refusal with the list beside
104
+ // it: an agent that guessed a slug can fix the guess in one step.
105
+ if (args.project) {
106
+ const found = projects.find((project) => project.slug === args.project);
107
+ if (!found) {
108
+ const known = projects.map((project) => project.slug ?? "").filter(Boolean);
109
+ return errorResult(`No project "${String(args.project)}" in the workbook library (${known.join(", ") || "none"}).`);
110
+ }
111
+ const docs = (found.docs ?? []).map((doc) => ` ${doc.slug ?? "?"} ${doc.title ?? ""}`).join("\n");
112
+ return textResult(`${found.title ?? found.slug ?? ""}\n${found.line ?? ""}${docs ? `\n${docs}` : ""}`, { project: found });
113
+ }
114
+ const docCount = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
115
+ const lines = projects
116
+ .map((project) => {
117
+ const docs = (project.docs ?? []).map((doc) => ` ${doc.slug ?? "?"} ${doc.title ?? ""}`).join("\n");
118
+ return `${(project.title ?? project.slug ?? "").toUpperCase()} (${project.slug ?? "?"})${docs ? `\n${docs}` : ""}`;
119
+ })
120
+ .join("\n\n");
121
+ return textResult(`WORKBOOK · ${projects.length} project(s) · ${docCount} document(s).${lines ? `\n\n${lines}` : ""}`, { projects });
122
+ }));
123
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The README's verb table, rendered from the census (BLI-3756).
3
+ *
4
+ * `npm run mcp:readme` writes the output of `renderCensusMarkdown()` between
5
+ * the two markers below, and `readme-census.test.ts` fails when the committed
6
+ * README no longer matches — so the table a stranger reads and the map the
7
+ * suite polices cannot disagree. A table typed by hand beside a list of tools
8
+ * is a table that goes stale on the next batch; this one cannot.
9
+ *
10
+ * Pure: it reads the census (which reads the collector's own decision tables)
11
+ * and returns a string. No file writing here — that is the script's job, so
12
+ * this can be asserted without touching a disk.
13
+ */
14
+ export declare const CENSUS_BEGIN = "<!-- BEGIN GENERATED verb census \u2014 `npm run mcp:readme` -->";
15
+ export declare const CENSUS_END = "<!-- END GENERATED verb census -->";
16
+ export declare function renderCensusMarkdown(): string;
17
+ /** The README with a fresh census block spliced in. Throws if the markers are gone. */
18
+ export declare function spliceCensus(readme: string): string;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The README's verb table, rendered from the census (BLI-3756).
3
+ *
4
+ * `npm run mcp:readme` writes the output of `renderCensusMarkdown()` between
5
+ * the two markers below, and `readme-census.test.ts` fails when the committed
6
+ * README no longer matches — so the table a stranger reads and the map the
7
+ * suite polices cannot disagree. A table typed by hand beside a list of tools
8
+ * is a table that goes stale on the next batch; this one cannot.
9
+ *
10
+ * Pure: it reads the census (which reads the collector's own decision tables)
11
+ * and returns a string. No file writing here — that is the script's job, so
12
+ * this can be asserted without touching a disk.
13
+ */
14
+ import { AWAITING_TWIN, MCP_TWINS, TERMINAL_ONLY, towerVerbs } from "./verb-census.js";
15
+ export const CENSUS_BEGIN = "<!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->";
16
+ export const CENSUS_END = "<!-- END GENERATED verb census -->";
17
+ /** `cockpit docs read` → `` `cockpit docs read` ``; `jarvis --trace` keeps its flag. */
18
+ function spell(verb) {
19
+ return `\`cockpit ${verb}\``;
20
+ }
21
+ export function renderCensusMarkdown() {
22
+ // Every verb the collector's own tables name, plus the one hand-entered mode
23
+ // flag — in the same order the census sorts them, so a diff is readable.
24
+ const verbs = [...new Set([...towerVerbs().map((verb) => verb.spelling), ...Object.keys(MCP_TWINS)])].sort();
25
+ const twinRows = verbs
26
+ .filter((verb) => MCP_TWINS[verb])
27
+ .map((verb) => {
28
+ const twin = MCP_TWINS[verb];
29
+ return `| ${spell(verb)} | \`${twin.tool}\` | \`${twin.door}\` |`;
30
+ });
31
+ const owedRows = verbs
32
+ .filter((verb) => AWAITING_TWIN[verb])
33
+ .map((verb) => `| ${spell(verb)} | ${AWAITING_TWIN[verb]} |`);
34
+ const terminalRows = verbs
35
+ .filter((verb) => TERMINAL_ONLY[verb])
36
+ .map((verb) => `| ${spell(verb)} | ${TERMINAL_ONLY[verb]} |`);
37
+ return [
38
+ CENSUS_BEGIN,
39
+ "",
40
+ `**${twinRows.length} of ${twinRows.length + owedRows.length + terminalRows.length} Tower verbs have an MCP twin.**`,
41
+ "Each tool goes through the SAME door its CLI verb calls, with the same",
42
+ "collector device token — never a second route and never a service-role",
43
+ "reader. `src/verb-census.test.ts` fails when a verb is in none of the",
44
+ "three tables below.",
45
+ "",
46
+ "| CLI verb | MCP tool | Door |",
47
+ "| --- | --- | --- |",
48
+ ...twinRows,
49
+ "",
50
+ "### Owed a twin",
51
+ "",
52
+ "This table should only ever shrink.",
53
+ "",
54
+ "| CLI verb | Who owes it, and why it is not built yet |",
55
+ "| --- | --- |",
56
+ ...owedRows,
57
+ "",
58
+ "### Terminal-only",
59
+ "",
60
+ "A claim about the verb's nature, not a backlog.",
61
+ "",
62
+ "| CLI verb | Why it can never have a twin |",
63
+ "| --- | --- |",
64
+ ...terminalRows,
65
+ "",
66
+ CENSUS_END,
67
+ ].join("\n");
68
+ }
69
+ /** The README with a fresh census block spliced in. Throws if the markers are gone. */
70
+ export function spliceCensus(readme) {
71
+ const start = readme.indexOf(CENSUS_BEGIN);
72
+ const end = readme.indexOf(CENSUS_END);
73
+ if (start < 0 || end < 0) {
74
+ throw new Error(`README.md has no census markers. Put ${CENSUS_BEGIN} and ${CENSUS_END} back, or the generated table has nowhere to go.`);
75
+ }
76
+ return readme.slice(0, start) + renderCensusMarkdown() + readme.slice(end + CENSUS_END.length);
77
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `tower_search` — one bar over five corpora, as an MCP tool (BLI-3728).
3
+ *
4
+ * The same `GET /api/search` door the browser's search bar and `cockpit
5
+ * search` press, over this machine's device token
6
+ * (`agent-door-session.ts`/`agent-door.ts`). Nothing about ranking, scoping or
7
+ * snippets is decided here; this file turns one HTTP answer into one tool
8
+ * result.
9
+ *
10
+ * WHY IT IS ONE TOOL AND NOT FIVE
11
+ * -------------------------------
12
+ * `docs_list`, `msg_read` and the rest already exist and answer "show me this
13
+ * corpus". The question this tool answers is different and is the reason the
14
+ * ticket exists: "where in Tower is the thing I half-remember?" A model that
15
+ * has to pick a corpus before it can look has already been asked to know the
16
+ * answer. `kinds` narrows it when the model DOES know.
17
+ *
18
+ * Its result carries provenance, and that is not decoration: every hit says
19
+ * which corpus produced it, what its address is, and — for the corpora that
20
+ * could not answer — that they did not. A tool result that folds "messages
21
+ * failed" into "no messages matched" invites the model to report the record as
22
+ * silent when nobody asked it. That is the same rule the door, the CLI and the
23
+ * browser overlay all keep.
24
+ */
25
+ import { loadAgentDoorSession } from "./agent-door-session.js";
26
+ import { type FetchImpl } from "./agent-door.js";
27
+ export interface SearchToolDeps {
28
+ fetchImpl: FetchImpl;
29
+ /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
30
+ loadSession?: typeof loadAgentDoorSession;
31
+ }
32
+ interface SearchHit {
33
+ kind: string;
34
+ id: string;
35
+ title: string;
36
+ snippet: string;
37
+ date: string | null;
38
+ author: string | null;
39
+ href: string | null;
40
+ score: number;
41
+ channels: string[];
42
+ }
43
+ export declare function registerSearchTool(server: {
44
+ registerTool: (...args: never[]) => unknown;
45
+ }, deps: SearchToolDeps): void;
46
+ /**
47
+ * The sentence a model reads.
48
+ *
49
+ * Every line carries its own provenance — corpus, address, author, date — so a
50
+ * model quoting a result can cite it without a second call, and so a `Source:`
51
+ * line in its answer is copied rather than invented.
52
+ */
53
+ export declare function renderHits(query: string, hits: readonly SearchHit[], failures: Record<string, string>): string;
54
+ export {};