@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.
- package/README.md +126 -1
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +130 -24
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/ops-tools.d.ts +27 -0
- package/dist/ops-tools.js +151 -0
- package/dist/pages-tools.d.ts +24 -0
- package/dist/pages-tools.js +123 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +77 -0
- package/dist/search-tool.d.ts +54 -0
- package/dist/search-tool.js +134 -0
- package/dist/server.js +19 -0
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/tool-result.d.ts +70 -0
- package/dist/tool-result.js +79 -0
- package/dist/verb-census.d.ts +12 -0
- package/dist/verb-census.js +99 -6
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- package/package.json +5 -4
|
@@ -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 {};
|