@bli-cockpit/mcp 0.1.1 → 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 +190 -9
- 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/jarvis-answer-envelope.d.ts +107 -0
- package/dist/jarvis-answer-envelope.js +82 -0
- package/dist/jarvis-door.d.ts +95 -0
- package/dist/jarvis-door.js +163 -0
- package/dist/jarvis-tools.d.ts +50 -0
- package/dist/jarvis-tools.js +248 -0
- package/dist/jarvis-turn-bookmark.d.ts +25 -0
- package/dist/jarvis-turn-bookmark.js +43 -0
- 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.d.ts +1 -1
- package/dist/server.js +25 -1
- 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 +53 -0
- package/dist/verb-census.js +201 -0
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- package/package.json +5 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jarvis_*` MCP tools (BLI-3732) — JARVIS on the `bli-tower` server, over the
|
|
3
|
+
* exact doors `cockpit jarvis` calls with this machine's device token.
|
|
4
|
+
*
|
|
5
|
+
* The point of this file in one sentence: an agent on Codex or Claude Code
|
|
6
|
+
* should be able to ask JARVIS a question, read how it answered, and spend a
|
|
7
|
+
* human's coding-arm approval, without a browser and without a second
|
|
8
|
+
* vocabulary. Four tools, three doors, no new server-side surface:
|
|
9
|
+
*
|
|
10
|
+
* jarvis_ask POST /api/jarvis/cli one turn, the same one
|
|
11
|
+
* `cockpit jarvis` takes
|
|
12
|
+
* jarvis_trace GET /api/ops/trace/<id> that turn's step tree, rendered
|
|
13
|
+
* SERVER-side and printed verbatim
|
|
14
|
+
* jarvis_dispatch POST /api/jarvis/cli a coding-arm turn, carrying the
|
|
15
|
+
* jarvis_check person's own approval code
|
|
16
|
+
*
|
|
17
|
+
* The wire mechanics they share — the session gate, the request the dashboard
|
|
18
|
+
* actually receives, the envelope it becomes, and what `last` resolves to —
|
|
19
|
+
* live in `jarvis-door.ts`, so this file reads as four tool descriptions,
|
|
20
|
+
* which is the part a person and a model both have to understand.
|
|
21
|
+
*
|
|
22
|
+
* ## The one contract
|
|
23
|
+
*
|
|
24
|
+
* `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
|
|
25
|
+
* `cockpit jarvis --json` prints. A script and an agent read one shape.
|
|
26
|
+
*
|
|
27
|
+
* ## The approval code, and what this surface can and cannot prove
|
|
28
|
+
*
|
|
29
|
+
* The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
|
|
30
|
+
* exact plan folded with the asking account, and it must have come out of the
|
|
31
|
+
* HUMAN's own message. Lock 1 is untouched here — this server holds no secret
|
|
32
|
+
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
|
+
* the dashboard exactly as it always was.
|
|
34
|
+
*
|
|
35
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal, and saying so is
|
|
36
|
+
* the honest thing to do. In a terminal the person types the code themselves.
|
|
37
|
+
* Through MCP the code arrives as a tool argument, and the dashboard cannot
|
|
38
|
+
* tell a code a person handed their agent from one the agent lifted out of the
|
|
39
|
+
* previous answer by itself. So: this tool takes the code as a parameter, it
|
|
40
|
+
* NEVER derives, guesses or fabricates one, its description tells the model in
|
|
41
|
+
* plain words that the code must come from the person, and every relay is
|
|
42
|
+
* logged (presence and length only, never the code). Anything stronger — a
|
|
43
|
+
* per-code single use, an out-of-band confirmation — is a server-side change
|
|
44
|
+
* to the gate itself and belongs with the gate, not here.
|
|
45
|
+
*/
|
|
46
|
+
import { z } from "zod";
|
|
47
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
48
|
+
import { APPROVAL_CODE, defaultLog, doorFailureText, errorResult, READ_TIMEOUT_MS, resolveLastTurn, TAG, takeTurn, textResult, withSession, } from "./jarvis-door.js";
|
|
49
|
+
export function registerJarvisTools(server, deps) {
|
|
50
|
+
const register = server.registerTool.bind(server);
|
|
51
|
+
// The newest turn this SERVER took, so `jarvis_trace last` works in a fresh
|
|
52
|
+
// process that has asked something but never opened a terminal. It is only
|
|
53
|
+
// ever read here; the collector's own bookmark file is read as a fallback
|
|
54
|
+
// and deliberately never written, so a background agent cannot clobber the
|
|
55
|
+
// turn a person is in the middle of tracing.
|
|
56
|
+
let lastTurn = { turnId: null, traceThreadId: null };
|
|
57
|
+
const remember = (turn) => {
|
|
58
|
+
if (turn.turnId || turn.traceThreadId)
|
|
59
|
+
lastTurn = turn;
|
|
60
|
+
};
|
|
61
|
+
register("jarvis_ask", {
|
|
62
|
+
title: "Ask JARVIS",
|
|
63
|
+
description: "Asks JARVIS one question and returns its answer, the Source: lines behind it, this turn's "
|
|
64
|
+
+ "turn_id and the conversation's thread_id. Same JARVIS, same tool belt and same evidence "
|
|
65
|
+
+ "rules as Tower web chat, the Slack DM and `cockpit jarvis`. Pass thread to continue an "
|
|
66
|
+
+ "earlier conversation; pass the returned turn_id to jarvis_trace to see how the answer was "
|
|
67
|
+
+ "reached. JARVIS speaks as the person this machine is paired to and cannot be made to "
|
|
68
|
+
+ "speak as anyone else.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
question: z.string().min(1).max(50_000).describe("What to ask, in plain words."),
|
|
71
|
+
thread: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.max(200)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('The conversation to continue. Omit for "main", the default terminal thread.'),
|
|
77
|
+
subject: z
|
|
78
|
+
.string()
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(200)
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Whose person page the question is ABOUT (a name or email). Never who is authenticated — "
|
|
83
|
+
+ "the server decides what this caller may read about them."),
|
|
84
|
+
date: z
|
|
85
|
+
.string()
|
|
86
|
+
.min(1)
|
|
87
|
+
.max(20)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('YYYY-MM-DD, "today" or "yesterday" — binds that day\'s page. Sent verbatim.'),
|
|
90
|
+
model: z
|
|
91
|
+
.string()
|
|
92
|
+
.min(1)
|
|
93
|
+
.max(120)
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("A provider:model key to request. An unknown key is refused by the dashboard."),
|
|
96
|
+
},
|
|
97
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
98
|
+
const { result } = await takeTurn(deps, session, "jarvis_ask", {
|
|
99
|
+
question: String(args.question ?? ""),
|
|
100
|
+
thread: typeof args.thread === "string" ? args.thread : "main",
|
|
101
|
+
...(typeof args.subject === "string" ? { subject: args.subject } : {}),
|
|
102
|
+
...(typeof args.date === "string" ? { date: args.date } : {}),
|
|
103
|
+
...(typeof args.model === "string" ? { model: args.model } : {}),
|
|
104
|
+
}, remember);
|
|
105
|
+
return result;
|
|
106
|
+
}));
|
|
107
|
+
register("jarvis_trace", {
|
|
108
|
+
title: "How JARVIS answered",
|
|
109
|
+
description: "The step tree of one JARVIS turn — every model step, tool call and server-side memory step, "
|
|
110
|
+
+ "with how long each took, which model ran it, what it spent and what failed. This is the "
|
|
111
|
+
+ "same tree `cockpit jarvis --trace` prints, rendered by the server and relayed verbatim. "
|
|
112
|
+
+ 'Metadata only: no prompt, no answer, no tool argument. Pass "last" for the newest turn '
|
|
113
|
+
+ "this server asked, or the turn_id jarvis_ask returned.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
turn_id: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(1)
|
|
118
|
+
.max(200)
|
|
119
|
+
.describe('A turn_id from jarvis_ask, or "last".'),
|
|
120
|
+
},
|
|
121
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
122
|
+
const log = deps.log ?? defaultLog;
|
|
123
|
+
const requested = String(args.turn_id ?? "");
|
|
124
|
+
let traceId = requested;
|
|
125
|
+
let resolvedFrom = "argument";
|
|
126
|
+
if (requested.toLowerCase() === "last") {
|
|
127
|
+
const resolved = await resolveLastTurn(deps, session, lastTurn);
|
|
128
|
+
if (!resolved.ok) {
|
|
129
|
+
log(`${TAG} trace unavailable ${JSON.stringify({ reason: resolved.reason })}`);
|
|
130
|
+
return errorResult(resolved.message);
|
|
131
|
+
}
|
|
132
|
+
traceId = resolved.traceId;
|
|
133
|
+
resolvedFrom = resolved.from;
|
|
134
|
+
}
|
|
135
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/trace/${encodeURIComponent(traceId)}`, undefined, READ_TIMEOUT_MS);
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
return errorResult(doorFailureText("jarvis_trace", response));
|
|
138
|
+
const body = response.body;
|
|
139
|
+
if (body.ok === false) {
|
|
140
|
+
log(`${TAG} trace unavailable ${JSON.stringify({ reason: body.error ?? "trace_unavailable" })}`);
|
|
141
|
+
return errorResult(`Tower has no step tree for that turn (${body.error ?? "trace_unavailable"}). `
|
|
142
|
+
+ (body.message ?? "Traces are kept for 30 days."));
|
|
143
|
+
}
|
|
144
|
+
const lines = body.lines ?? [];
|
|
145
|
+
log(`${TAG} trace read ${JSON.stringify({
|
|
146
|
+
resolved_from: resolvedFrom,
|
|
147
|
+
lines: lines.length,
|
|
148
|
+
truncated: body.truncated ?? false,
|
|
149
|
+
})}`);
|
|
150
|
+
const rendered = [
|
|
151
|
+
`Turn ${traceId}`,
|
|
152
|
+
...(body.headline ? [body.headline] : []),
|
|
153
|
+
"",
|
|
154
|
+
...(lines.length > 0 ? lines : ["That turn recorded no steps."]),
|
|
155
|
+
...(body.truncated ? ["", "Only the first 1000 spans of this turn are shown."] : []),
|
|
156
|
+
].join("\n");
|
|
157
|
+
return textResult(rendered, {
|
|
158
|
+
turn_id: traceId,
|
|
159
|
+
resolved_from: resolvedFrom,
|
|
160
|
+
headline: body.headline ?? null,
|
|
161
|
+
lines,
|
|
162
|
+
truncated: body.truncated ?? false,
|
|
163
|
+
...(body.trace ? { trace: body.trace } : {}),
|
|
164
|
+
});
|
|
165
|
+
}));
|
|
166
|
+
register("jarvis_dispatch", {
|
|
167
|
+
title: "Dispatch a coding task to JARVIS's coding arm",
|
|
168
|
+
description: "Asks JARVIS to run a coding task on the runner: it clones the repo, works, pushes a branch "
|
|
169
|
+
+ "and opens a pull request. It never merges. TWO CALLS, always. Call this once WITHOUT "
|
|
170
|
+
+ "approval_code to get the plan and the 8-character confirmation code back; show both to the "
|
|
171
|
+
+ "person; call it again with the code THEY give you and the same instruction. Never derive, "
|
|
172
|
+
+ "guess, or reuse a code the person has not just handed you — a code you produce yourself is "
|
|
173
|
+
+ "exactly what the approval gate exists to refuse.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
instruction: z
|
|
176
|
+
.string()
|
|
177
|
+
.min(1)
|
|
178
|
+
.max(50_000)
|
|
179
|
+
.describe("What the coding task should do, in full. This is the birth prompt the arm runs."),
|
|
180
|
+
repo: z
|
|
181
|
+
.string()
|
|
182
|
+
.min(1)
|
|
183
|
+
.max(200)
|
|
184
|
+
.optional()
|
|
185
|
+
.describe("Which repo, if the instruction does not already name one. Server-side allowlist applies."),
|
|
186
|
+
approval_code: z
|
|
187
|
+
.string()
|
|
188
|
+
.min(1)
|
|
189
|
+
.max(64)
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("The 8-character hex confirmation code THE PERSON read back to you from the previous "
|
|
192
|
+
+ "call's plan. Omit it on the first call."),
|
|
193
|
+
thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
|
|
194
|
+
},
|
|
195
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
196
|
+
const log = deps.log ?? defaultLog;
|
|
197
|
+
const code = typeof args.approval_code === "string" ? args.approval_code.trim() : "";
|
|
198
|
+
if (code.length > 0 && !APPROVAL_CODE.test(code)) {
|
|
199
|
+
// Refused HERE rather than relayed: a malformed code cannot be the
|
|
200
|
+
// one JARVIS issued, and pasting it into the question would put
|
|
201
|
+
// unvalidated text where the server scans for approvals.
|
|
202
|
+
log(`${TAG} dispatch refused ${JSON.stringify({ reason: "approval_code_malformed", chars: code.length })}`);
|
|
203
|
+
return errorResult("That approval code is not a confirmation code (it must be exactly 8 hex characters, "
|
|
204
|
+
+ "e.g. 3f9c1a02). Ask the person for the code JARVIS printed with the plan — do not "
|
|
205
|
+
+ "construct one.");
|
|
206
|
+
}
|
|
207
|
+
log(`${TAG} dispatch relay ${JSON.stringify({
|
|
208
|
+
approval_code_present: code.length > 0,
|
|
209
|
+
instruction_chars: String(args.instruction ?? "").length,
|
|
210
|
+
repo_named: typeof args.repo === "string",
|
|
211
|
+
})}`);
|
|
212
|
+
const question = [
|
|
213
|
+
code.length > 0
|
|
214
|
+
? "Dispatch this coding task to the coding arm now."
|
|
215
|
+
: "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the confirmation code so the person can approve it.",
|
|
216
|
+
...(typeof args.repo === "string" ? [`Repo: ${args.repo}`] : []),
|
|
217
|
+
"",
|
|
218
|
+
String(args.instruction ?? ""),
|
|
219
|
+
...(code.length > 0
|
|
220
|
+
? ["", `The person approved this plan. Confirmation code: ${code.toLowerCase()}`]
|
|
221
|
+
: []),
|
|
222
|
+
].join("\n");
|
|
223
|
+
const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
|
|
224
|
+
return result;
|
|
225
|
+
}));
|
|
226
|
+
register("jarvis_check", {
|
|
227
|
+
title: "Check a dispatched coding task",
|
|
228
|
+
description: "Asks JARVIS where a coding task it dispatched has got to — queued, running, pushed, or "
|
|
229
|
+
+ "failed, and the pull request when there is one. Takes no approval: reading a task's state "
|
|
230
|
+
+ "starts nothing.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
task: z
|
|
233
|
+
.string()
|
|
234
|
+
.min(1)
|
|
235
|
+
.max(200)
|
|
236
|
+
.optional()
|
|
237
|
+
.describe("The task id JARVIS named when it dispatched. Omit to ask about the recent ones."),
|
|
238
|
+
thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
|
|
239
|
+
},
|
|
240
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
241
|
+
const task = typeof args.task === "string" ? args.task.trim() : "";
|
|
242
|
+
const question = task.length > 0
|
|
243
|
+
? `Check the coding task ${task} and tell me its status, its branch and its pull request if it has one.`
|
|
244
|
+
: "Check the coding tasks dispatched for me recently and tell me the status of each.";
|
|
245
|
+
const { result } = await takeTurn(deps, session, "jarvis_check", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
|
|
246
|
+
return result;
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn `cockpit jarvis --trace last` would open, read from the same
|
|
3
|
+
* bookmark the collector writes (BLI-3560, read here for BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `jarvis_trace last` prefers the turn THIS server just took. This file is the
|
|
6
|
+
* fallback for the other case: a fresh MCP process that has asked nothing yet,
|
|
7
|
+
* on a machine where the person has been talking to JARVIS in a terminal. Both
|
|
8
|
+
* surfaces then mean the same turn by "last", which is the whole point.
|
|
9
|
+
*
|
|
10
|
+
* READ ONLY, deliberately. A background agent asking questions must not
|
|
11
|
+
* overwrite the bookmark a person is in the middle of tracing, so this server
|
|
12
|
+
* never writes the file — only `cockpit jarvis` does.
|
|
13
|
+
*
|
|
14
|
+
* The file holds two opaque ids and a timestamp. No question, no answer, no
|
|
15
|
+
* name. Nothing here ever throws: a missing or unreadable bookmark is "nothing
|
|
16
|
+
* is remembered", which is a real answer and the caller says so in a sentence.
|
|
17
|
+
*/
|
|
18
|
+
/** The collector's own name for it — `jarvis-trace.ts` `LAST_TURN_FILE`. */
|
|
19
|
+
export declare const LAST_TURN_FILE = "last-turn-trace.json";
|
|
20
|
+
export interface CollectorTurnBookmark {
|
|
21
|
+
traceId: string | null;
|
|
22
|
+
threadId: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function collectorTurnBookmarkPath(homeDir?: string): string;
|
|
25
|
+
export declare function readCollectorTurnBookmark(homeDir?: string): Promise<CollectorTurnBookmark | null>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn `cockpit jarvis --trace last` would open, read from the same
|
|
3
|
+
* bookmark the collector writes (BLI-3560, read here for BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `jarvis_trace last` prefers the turn THIS server just took. This file is the
|
|
6
|
+
* fallback for the other case: a fresh MCP process that has asked nothing yet,
|
|
7
|
+
* on a machine where the person has been talking to JARVIS in a terminal. Both
|
|
8
|
+
* surfaces then mean the same turn by "last", which is the whole point.
|
|
9
|
+
*
|
|
10
|
+
* READ ONLY, deliberately. A background agent asking questions must not
|
|
11
|
+
* overwrite the bookmark a person is in the middle of tracing, so this server
|
|
12
|
+
* never writes the file — only `cockpit jarvis` does.
|
|
13
|
+
*
|
|
14
|
+
* The file holds two opaque ids and a timestamp. No question, no answer, no
|
|
15
|
+
* name. Nothing here ever throws: a missing or unreadable bookmark is "nothing
|
|
16
|
+
* is remembered", which is a real answer and the caller says so in a sentence.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { getUserLocalCockpitPaths } from "@bli-cockpit/telemetry-core";
|
|
22
|
+
/** The collector's own name for it — `jarvis-trace.ts` `LAST_TURN_FILE`. */
|
|
23
|
+
export const LAST_TURN_FILE = "last-turn-trace.json";
|
|
24
|
+
export function collectorTurnBookmarkPath(homeDir = os.homedir()) {
|
|
25
|
+
return path.join(getUserLocalCockpitPaths(homeDir).state_dir, LAST_TURN_FILE);
|
|
26
|
+
}
|
|
27
|
+
export async function readCollectorTurnBookmark(homeDir) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(collectorTurnBookmarkPath(homeDir), "utf8");
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
const traceId = typeof parsed.traceId === "string" ? parsed.traceId : null;
|
|
32
|
+
const threadId = typeof parsed.threadId === "string" ? parsed.threadId : null;
|
|
33
|
+
if (!traceId && !threadId)
|
|
34
|
+
return null;
|
|
35
|
+
return { traceId, threadId };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Missing, unreadable, or not JSON — all three mean the same thing to the
|
|
39
|
+
// one caller, which names it (`no_remembered_turn`) rather than printing
|
|
40
|
+
// nothing.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 { type ToolDeps } from "./tool-result.js";
|
|
19
|
+
export type NotesDeps = ToolDeps;
|
|
20
|
+
interface ScopedBody {
|
|
21
|
+
scope?: string;
|
|
22
|
+
degradedBecause?: string | null;
|
|
23
|
+
degradedNote?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The door's own words for "this answer is narrower than the browser's", or
|
|
27
|
+
* "" when it was not narrowed. Never rephrased here.
|
|
28
|
+
*/
|
|
29
|
+
export declare function narrowingNote(body: ScopedBody): string;
|
|
30
|
+
export declare function registerNotesTools(server: {
|
|
31
|
+
registerTool: (...args: never[]) => unknown;
|
|
32
|
+
}, deps: NotesDeps): void;
|
|
33
|
+
export {};
|
|
@@ -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;
|