@bli-cockpit/mcp 0.1.2 → 0.1.4
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 +168 -2
- package/dist/agent-door.d.ts +8 -1
- package/dist/agent-door.js +7 -2
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/brief-write-tools.d.ts +37 -0
- package/dist/brief-write-tools.js +178 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +141 -25
- package/dist/jarvis-answer-envelope.d.ts +13 -1
- package/dist/jarvis-answer-envelope.js +2 -0
- package/dist/jarvis-door.js +16 -1
- package/dist/jarvis-tools.d.ts +18 -10
- package/dist/jarvis-tools.js +28 -17
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/notes-write-tools.d.ts +34 -0
- package/dist/notes-write-tools.js +211 -0
- package/dist/ops-tools.d.ts +23 -0
- package/dist/ops-tools.js +212 -0
- package/dist/pages-tools.d.ts +28 -0
- package/dist/pages-tools.js +190 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +79 -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 +34 -1
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/settings-write-tools.d.ts +47 -0
- package/dist/settings-write-tools.js +183 -0
- package/dist/team-write-tools.d.ts +39 -0
- package/dist/team-write-tools.js +141 -0
- package/dist/tool-result.d.ts +86 -0
- package/dist/tool-result.js +105 -0
- package/dist/verb-census.d.ts +22 -0
- package/dist/verb-census.js +108 -6
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- package/package.json +5 -4
package/dist/docs-msg-tools.d.ts
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
* on a machine with no collector pairing at all, so a missing session must
|
|
10
10
|
* fail the ONE call that needed it, never the whole process.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
13
|
+
export type DocsMsgDeps = ToolDeps;
|
|
14
|
+
/**
|
|
15
|
+
* `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
|
|
16
|
+
* The list itself is metadata only — asking for a whole library used to cost
|
|
17
|
+
* every document's prose, so an agent that wants one folder or one phrase
|
|
18
|
+
* says so here and the database does the filtering.
|
|
19
|
+
*/
|
|
20
|
+
export declare function docsListPath(args: {
|
|
21
|
+
parent_id?: unknown;
|
|
22
|
+
query?: unknown;
|
|
23
|
+
limit?: unknown;
|
|
24
|
+
}): string;
|
|
19
25
|
export declare function registerDocsMsgTools(server: {
|
|
20
26
|
registerTool: (...args: never[]) => unknown;
|
|
21
27
|
}, deps: DocsMsgDeps): void;
|
package/dist/docs-msg-tools.js
CHANGED
|
@@ -10,28 +10,37 @@
|
|
|
10
10
|
* fail the ONE call that needed it, never the whole process.
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
14
13
|
import { callAgentDoor } from "./agent-door.js";
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
15
|
+
/** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
|
|
16
|
+
function renderDocTree(nodes, depth) {
|
|
17
|
+
return nodes
|
|
18
|
+
.map((node) => {
|
|
19
|
+
const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
|
|
20
|
+
const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
|
|
21
|
+
return `${line}${children}`;
|
|
22
|
+
})
|
|
23
|
+
.join("\n");
|
|
17
24
|
}
|
|
18
|
-
function
|
|
19
|
-
return
|
|
25
|
+
function countTree(nodes) {
|
|
26
|
+
return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
|
|
20
27
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
/**
|
|
29
|
+
* `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
|
|
30
|
+
* The list itself is metadata only — asking for a whole library used to cost
|
|
31
|
+
* every document's prose, so an agent that wants one folder or one phrase
|
|
32
|
+
* says so here and the database does the filtering.
|
|
33
|
+
*/
|
|
34
|
+
export function docsListPath(args) {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
if (typeof args.parent_id === "string" && args.parent_id.length > 0)
|
|
37
|
+
params.set("parent_id", args.parent_id);
|
|
38
|
+
if (typeof args.query === "string" && args.query.trim().length > 0)
|
|
39
|
+
params.set("query", args.query);
|
|
40
|
+
if (typeof args.limit === "number" && Number.isInteger(args.limit))
|
|
41
|
+
params.set("limit", String(args.limit));
|
|
42
|
+
const search = params.toString();
|
|
43
|
+
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
35
44
|
}
|
|
36
45
|
async function resolveDocumentId(deps, session, ref) {
|
|
37
46
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
|
|
@@ -51,18 +60,37 @@ async function resolveChannelId(deps, session, ref) {
|
|
|
51
60
|
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
52
61
|
}
|
|
53
62
|
export function registerDocsMsgTools(server, deps) {
|
|
54
|
-
const register =
|
|
63
|
+
const register = registrarFor(server);
|
|
55
64
|
register("docs_list", {
|
|
56
65
|
title: "List Tower documents",
|
|
57
|
-
description: "Every document you may read: id, slug, title, visibility, source
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
|
|
67
|
+
+ "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
|
|
68
|
+
+ "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), limit (1-1000).",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
parent_id: z.string().min(1).max(64).optional().describe('A document id to list under, or "root" for the top of the tree.'),
|
|
71
|
+
query: z.string().min(1).max(200).optional().describe("Keep only documents whose title or body contains this text."),
|
|
72
|
+
limit: z.number().int().min(1).max(1000).optional().describe("At most this many documents."),
|
|
73
|
+
},
|
|
74
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
75
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", docsListPath(args));
|
|
61
76
|
if (!response.ok)
|
|
62
77
|
return errorResult(doorFailureText("docs_list", response));
|
|
63
78
|
const documents = response.body.documents ?? [];
|
|
64
79
|
return textResult(`${documents.length} document(s).`, { documents });
|
|
65
80
|
}));
|
|
81
|
+
register("docs_tree", {
|
|
82
|
+
title: "Read the Tower document tree",
|
|
83
|
+
description: "The document library as its nesting, not as a flat list: every document you may read with its children "
|
|
84
|
+
+ "under it, id, slug and title. Never a body — call docs_read for one.",
|
|
85
|
+
inputSchema: {},
|
|
86
|
+
}, async () => withSession(deps, async (session) => {
|
|
87
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/tree");
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
return errorResult(doorFailureText("docs_tree", response));
|
|
90
|
+
const tree = (Array.isArray(response.body.tree) ? response.body.tree : []);
|
|
91
|
+
const rendered = renderDocTree(tree, 0);
|
|
92
|
+
return textResult(`${countTree(tree)} document(s).${rendered ? `\n${rendered}` : ""}`, { tree });
|
|
93
|
+
}));
|
|
66
94
|
register("docs_read", {
|
|
67
95
|
title: "Read a Tower document",
|
|
68
96
|
description: "One document's title and full body, by its id or slug (docs_list returns both).",
|
|
@@ -103,13 +131,20 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
103
131
|
}));
|
|
104
132
|
register("docs_update", {
|
|
105
133
|
title: "Update a Tower document",
|
|
106
|
-
description: "Updates a document's title, body, visibility, or parent (a parent change IS a move). Only the fields you
|
|
134
|
+
description: "Updates a document's title, body, visibility, or parent (a parent change IS a move). Only the fields you "
|
|
135
|
+
+ "pass change. Emptying a document that holds text needs allow_empty: true.",
|
|
107
136
|
inputSchema: {
|
|
108
137
|
id: z.string().min(1).max(200).describe("A document id, or its slug."),
|
|
109
138
|
title: z.string().min(1).max(200).optional(),
|
|
110
139
|
body_markdown: z.string().max(200_000).optional(),
|
|
111
140
|
visibility: z.enum(["org", "private"]).optional(),
|
|
112
141
|
parent_id: z.string().uuid().nullable().optional(),
|
|
142
|
+
allow_empty: z
|
|
143
|
+
.boolean()
|
|
144
|
+
.optional()
|
|
145
|
+
.describe("Only when you mean to CLEAR the page. A body_markdown that would empty a document that holds "
|
|
146
|
+
+ "text is refused with refused_empty_body unless this is true (BLI-3757 — a blank save is far more "
|
|
147
|
+
+ "often a surface that lost the content than a person who meant it)."),
|
|
113
148
|
},
|
|
114
149
|
}, async (args) => withSession(deps, async (session) => {
|
|
115
150
|
const ref = String(args.id ?? "");
|
|
@@ -123,6 +158,9 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
123
158
|
...(args.body_markdown !== undefined ? { body_markdown: args.body_markdown } : {}),
|
|
124
159
|
...(args.visibility !== undefined ? { visibility: args.visibility } : {}),
|
|
125
160
|
...(args.parent_id !== undefined ? { parent_id: args.parent_id } : {}),
|
|
161
|
+
// BLI-3759: sent only when the caller asked for it; the door's own
|
|
162
|
+
// refusal comes back verbatim otherwise, never retried with the flag.
|
|
163
|
+
...(args.allow_empty ? { allow_empty: true } : {}),
|
|
126
164
|
});
|
|
127
165
|
if (!response.ok)
|
|
128
166
|
return errorResult(doorFailureText("docs_update", response));
|
|
@@ -161,6 +199,84 @@ export function registerDocsMsgTools(server, deps) {
|
|
|
161
199
|
const messages = response.body.messages ?? [];
|
|
162
200
|
return textResult(`${messages.length} message(s) in ${ref}.`, { channelId: resolved.id, messages });
|
|
163
201
|
}));
|
|
202
|
+
register("msg_thread", {
|
|
203
|
+
title: "Read a Tower message thread",
|
|
204
|
+
description: "The replies under one message, oldest first. `thread_parent_id` is the id of the message the thread hangs "
|
|
205
|
+
+ "off — msg_read returns it. `channel` is a channel id, or its name with or without a leading #.",
|
|
206
|
+
inputSchema: {
|
|
207
|
+
channel: z.string().min(1).max(200),
|
|
208
|
+
thread_parent_id: z.string().uuid().describe("The id of the message the replies hang off."),
|
|
209
|
+
},
|
|
210
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
211
|
+
const ref = String(args.channel ?? "");
|
|
212
|
+
const resolved = await resolveChannelId(deps, session, ref);
|
|
213
|
+
if (resolved.status === "list_failed")
|
|
214
|
+
return errorResult(resolved.text);
|
|
215
|
+
if (resolved.status === "not_found")
|
|
216
|
+
return errorResult(`No channel matches "${ref}" — check msg_channels for the id or name.`);
|
|
217
|
+
const parent = String(args.thread_parent_id ?? "");
|
|
218
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages?thread_parent_id=${encodeURIComponent(parent)}`);
|
|
219
|
+
if (!response.ok)
|
|
220
|
+
return errorResult(doorFailureText("msg_thread", response));
|
|
221
|
+
const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
|
|
222
|
+
// Oldest first: a thread reads as a conversation, unlike the channel
|
|
223
|
+
// list, which the door returns newest-first.
|
|
224
|
+
const lines = [...messages]
|
|
225
|
+
.reverse()
|
|
226
|
+
.map((message) => `${String(message.created_at ?? "?")} ${String(message.agent_label ?? message.user_id ?? "?")} `
|
|
227
|
+
+ `${String(message.content ?? "")}`)
|
|
228
|
+
.join("\n");
|
|
229
|
+
return textResult(`${messages.length} repl${messages.length === 1 ? "y" : "ies"} in ${ref}.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, threadParentId: parent, messages });
|
|
230
|
+
}));
|
|
231
|
+
register("msg_create_channel", {
|
|
232
|
+
title: "Create a Tower channel",
|
|
233
|
+
description: "Creates a channel and returns its id. `private` means membership decides who may read it. `member_emails` names who joins at birth BY EMAIL — an address Tower does not carry refuses the whole create, so no half-built channel is left behind.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
name: z.string().min(1).max(80).describe("The channel name. A leading # is accepted and dropped."),
|
|
236
|
+
description: z.string().max(500).optional(),
|
|
237
|
+
private: z.boolean().optional(),
|
|
238
|
+
member_emails: z.array(z.string().min(3).max(320)).max(200).optional(),
|
|
239
|
+
},
|
|
240
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
241
|
+
const raw = String(args.name ?? "");
|
|
242
|
+
const name = raw.startsWith("#") ? raw.slice(1) : raw;
|
|
243
|
+
if (name.trim() === "")
|
|
244
|
+
return errorResult("A channel needs a name. Nothing was created.");
|
|
245
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/msg/channels", {
|
|
246
|
+
name,
|
|
247
|
+
is_private: args.private === true,
|
|
248
|
+
...(args.description ? { description: args.description } : {}),
|
|
249
|
+
...(Array.isArray(args.member_emails) ? { member_emails: args.member_emails } : {}),
|
|
250
|
+
});
|
|
251
|
+
if (!response.ok)
|
|
252
|
+
return errorResult(doorFailureText("msg_create_channel", response));
|
|
253
|
+
const channel = response.body.channel;
|
|
254
|
+
const membersAdded = (response.body.members_added ?? []);
|
|
255
|
+
const membersFailed = (response.body.members_failed ?? []);
|
|
256
|
+
// A member the channel could not take is named in the SENTENCE, not
|
|
257
|
+
// just in the structured half: an agent that only reads the text
|
|
258
|
+
// would otherwise report a clean create over a partial one.
|
|
259
|
+
const failedNote = membersFailed.length > 0
|
|
260
|
+
? ` ${membersFailed.length} member(s) were not added (${membersFailed.map((f) => f.reason ?? "unknown").join(", ")}).`
|
|
261
|
+
: "";
|
|
262
|
+
return textResult(`Created #${channel?.name ?? name} (${channel?.id ?? "?"}) with ${membersAdded.length} member(s) added.${failedNote}`, { channel, members_added: membersAdded, members_failed: membersFailed });
|
|
263
|
+
}));
|
|
264
|
+
register("msg_dm", {
|
|
265
|
+
title: "Open a Tower direct message",
|
|
266
|
+
description: "Opens (or re-opens) the direct message with one person, by their email address. Idempotent: the same address always resolves to the same channel, and you never name yourself.",
|
|
267
|
+
inputSchema: { email: z.string().min(3).max(320).describe("The other person's email address.") },
|
|
268
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
269
|
+
const email = String(args.email ?? "").trim();
|
|
270
|
+
if (email === "")
|
|
271
|
+
return errorResult("A direct message needs the person's email address.");
|
|
272
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/msg/channels", {
|
|
273
|
+
dm_participant_emails: [email],
|
|
274
|
+
});
|
|
275
|
+
if (!response.ok)
|
|
276
|
+
return errorResult(doorFailureText("msg_dm", response));
|
|
277
|
+
const channel = response.body.channel;
|
|
278
|
+
return textResult(`Direct message with ${email}: ${channel?.id ?? "?"}.`, { channel });
|
|
279
|
+
}));
|
|
164
280
|
register("msg_send", {
|
|
165
281
|
title: "Send a Tower message",
|
|
166
282
|
description: "Posts a message to one channel. `channel` is a channel id, or its name with or without a leading #.",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* Every key of the envelope, in the order it is written. The literal list IS
|
|
35
35
|
* the contract: both copies assert against it, so a drift is a red suite.
|
|
36
36
|
*/
|
|
37
|
-
export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "degraded", "degraded_reasons"];
|
|
37
|
+
export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "proposal_id", "degraded", "degraded_reasons"];
|
|
38
38
|
/**
|
|
39
39
|
* The closed set of ways an answer can be second-rate without being a failure.
|
|
40
40
|
* A degraded answer is still an answer — it is printed, returned and counted —
|
|
@@ -77,6 +77,16 @@ export interface JarvisAnswerEnvelope {
|
|
|
77
77
|
* `thread_id` above and deliberately named apart from it.
|
|
78
78
|
*/
|
|
79
79
|
trace_thread_id: string | null;
|
|
80
|
+
/**
|
|
81
|
+
* BLI-3755: the coding-arm proposal this turn made, when it made one.
|
|
82
|
+
*
|
|
83
|
+
* It is what an AGENT surface is given INSTEAD of the eight-hex approval
|
|
84
|
+
* code, which is never sent here: a code a program can read is a code it
|
|
85
|
+
* could approve with, and that is not approval. The person fetches the code
|
|
86
|
+
* on a surface a person reads and hands it back. Null on every turn that
|
|
87
|
+
* proposed no coding task, which is nearly all of them.
|
|
88
|
+
*/
|
|
89
|
+
proposal_id: string | null;
|
|
80
90
|
degraded: boolean;
|
|
81
91
|
degraded_reasons: JarvisDegradedReason[];
|
|
82
92
|
}
|
|
@@ -103,5 +113,7 @@ export interface JarvisAnswerEnvelopeInput {
|
|
|
103
113
|
trace?: ReadonlyArray<{
|
|
104
114
|
status?: string;
|
|
105
115
|
}> | null;
|
|
116
|
+
/** `body.proposalId` — BLI-3755's coding-arm proposal id, when the turn made one. */
|
|
117
|
+
proposalId?: string | null;
|
|
106
118
|
}
|
|
107
119
|
export declare function buildJarvisAnswerEnvelope(input: JarvisAnswerEnvelopeInput): JarvisAnswerEnvelope;
|
|
@@ -41,6 +41,7 @@ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
|
|
|
41
41
|
"turn_id",
|
|
42
42
|
"thread_id",
|
|
43
43
|
"trace_thread_id",
|
|
44
|
+
"proposal_id",
|
|
44
45
|
"degraded",
|
|
45
46
|
"degraded_reasons",
|
|
46
47
|
];
|
|
@@ -76,6 +77,7 @@ export function buildJarvisAnswerEnvelope(input) {
|
|
|
76
77
|
turn_id: turnId,
|
|
77
78
|
thread_id: input.thread ?? null,
|
|
78
79
|
trace_thread_id: input.traceThread ?? null,
|
|
80
|
+
proposal_id: input.proposalId ?? null,
|
|
79
81
|
degraded: reasons.length > 0,
|
|
80
82
|
degraded_reasons: reasons,
|
|
81
83
|
};
|
package/dist/jarvis-door.js
CHANGED
|
@@ -72,7 +72,14 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
72
72
|
// `accept: application/json` (which `callAgentDoor` always sends) is what
|
|
73
73
|
// makes the dashboard answer one JSON body instead of the NDJSON stream the
|
|
74
74
|
// terminal takes — an MCP tool has nobody to show live trace lines to.
|
|
75
|
-
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/cli",
|
|
75
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/cli",
|
|
76
|
+
// BLI-3755: every turn this server takes says who is reading the answer.
|
|
77
|
+
// An MCP tool result is read by a MODEL, so the coding arm withholds its
|
|
78
|
+
// approval code and answers with a `proposal_id` instead — the one thing
|
|
79
|
+
// this server could not prove about a code it relayed was that a person
|
|
80
|
+
// had ever seen it. Sent on every tool, not just the dispatch, because the
|
|
81
|
+
// arm is on the belt of every turn.
|
|
82
|
+
{ ...body, surface: "agent" }, TURN_TIMEOUT_MS);
|
|
76
83
|
if (!response.ok) {
|
|
77
84
|
log(`${TAG} turn failed ${JSON.stringify({
|
|
78
85
|
door,
|
|
@@ -102,6 +109,7 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
102
109
|
modelFallback: reply.model?.fallback,
|
|
103
110
|
revised: reply.revised,
|
|
104
111
|
trace: reply.trace,
|
|
112
|
+
proposalId: reply.proposalId,
|
|
105
113
|
});
|
|
106
114
|
remember({ turnId: envelope.turn_id, traceThreadId: envelope.trace_thread_id });
|
|
107
115
|
log(`${TAG} answered ${JSON.stringify({
|
|
@@ -109,6 +117,9 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
109
117
|
reply_chars: envelope.answer.length,
|
|
110
118
|
sources: envelope.sources.length,
|
|
111
119
|
trace_steps: reply.trace?.length ?? 0,
|
|
120
|
+
// BLI-3755: whether this turn left a coding-arm proposal behind. The id
|
|
121
|
+
// itself is in the envelope; this line says one was made.
|
|
122
|
+
proposal_recorded: envelope.proposal_id !== null,
|
|
112
123
|
degraded: envelope.degraded,
|
|
113
124
|
degraded_reasons: envelope.degraded_reasons,
|
|
114
125
|
has_turn_id: envelope.turn_id !== null,
|
|
@@ -117,6 +128,10 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
117
128
|
const footer = [
|
|
118
129
|
envelope.turn_id ? `turn_id: ${envelope.turn_id} (open it with jarvis_trace)` : null,
|
|
119
130
|
envelope.thread_id ? `thread_id: ${envelope.thread_id}` : null,
|
|
131
|
+
envelope.proposal_id
|
|
132
|
+
? `proposal_id: ${envelope.proposal_id} — the person fetches the approval code for it themself, `
|
|
133
|
+
+ `in a Tower tab, their Slack DM, or their own \`cockpit jarvis\` terminal. You were not sent one.`
|
|
134
|
+
: null,
|
|
120
135
|
envelope.degraded ? `degraded: ${envelope.degraded_reasons.join(", ")}` : null,
|
|
121
136
|
]
|
|
122
137
|
.filter((line) => line !== null)
|
package/dist/jarvis-tools.d.ts
CHANGED
|
@@ -32,16 +32,24 @@
|
|
|
32
32
|
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
33
|
* the dashboard exactly as it always was.
|
|
34
34
|
*
|
|
35
|
-
* Lock 2 is weaker on THIS surface than it is in a terminal
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* to
|
|
35
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal: in a terminal the
|
|
36
|
+
* person types the code themselves, while through MCP it arrives as a tool
|
|
37
|
+
* argument and nothing on the wire distinguishes a code a person handed their
|
|
38
|
+
* agent from one the agent lifted out of the previous answer. BLI-3755 closed
|
|
39
|
+
* the two halves of that gap where the gate lives, not here:
|
|
40
|
+
*
|
|
41
|
+
* * `jarvis_ask` and `jarvis_dispatch` tell the dashboard they are an AGENT
|
|
42
|
+
* surface (`jarvis-door.ts` sends it on every turn), so a proposal comes
|
|
43
|
+
* back with a `proposal_id` and NO code. There is nothing in the previous
|
|
44
|
+
* answer to lift. The person fetches the code where a person reads — a
|
|
45
|
+
* Tower tab, their Slack DM, their own `cockpit jarvis` — and hands it on.
|
|
46
|
+
* * A code is spendable ONCE. A replay is refused with `approval_code_spent`,
|
|
47
|
+
* so a code that did reach an agent buys at most the one run the person
|
|
48
|
+
* approved.
|
|
49
|
+
*
|
|
50
|
+
* What is unchanged here: this server holds no secret and cannot compute a code
|
|
51
|
+
* for any plan, it never derives, guesses or fabricates one, and every relay is
|
|
52
|
+
* logged (presence and length only, never the code).
|
|
45
53
|
*/
|
|
46
54
|
import { type JarvisDeps } from "./jarvis-door.js";
|
|
47
55
|
export type { JarvisDeps } from "./jarvis-door.js";
|
package/dist/jarvis-tools.js
CHANGED
|
@@ -32,16 +32,24 @@
|
|
|
32
32
|
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
33
|
* the dashboard exactly as it always was.
|
|
34
34
|
*
|
|
35
|
-
* Lock 2 is weaker on THIS surface than it is in a terminal
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* to
|
|
35
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal: in a terminal the
|
|
36
|
+
* person types the code themselves, while through MCP it arrives as a tool
|
|
37
|
+
* argument and nothing on the wire distinguishes a code a person handed their
|
|
38
|
+
* agent from one the agent lifted out of the previous answer. BLI-3755 closed
|
|
39
|
+
* the two halves of that gap where the gate lives, not here:
|
|
40
|
+
*
|
|
41
|
+
* * `jarvis_ask` and `jarvis_dispatch` tell the dashboard they are an AGENT
|
|
42
|
+
* surface (`jarvis-door.ts` sends it on every turn), so a proposal comes
|
|
43
|
+
* back with a `proposal_id` and NO code. There is nothing in the previous
|
|
44
|
+
* answer to lift. The person fetches the code where a person reads — a
|
|
45
|
+
* Tower tab, their Slack DM, their own `cockpit jarvis` — and hands it on.
|
|
46
|
+
* * A code is spendable ONCE. A replay is refused with `approval_code_spent`,
|
|
47
|
+
* so a code that did reach an agent buys at most the one run the person
|
|
48
|
+
* approved.
|
|
49
|
+
*
|
|
50
|
+
* What is unchanged here: this server holds no secret and cannot compute a code
|
|
51
|
+
* for any plan, it never derives, guesses or fabricates one, and every relay is
|
|
52
|
+
* logged (presence and length only, never the code).
|
|
45
53
|
*/
|
|
46
54
|
import { z } from "zod";
|
|
47
55
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -167,10 +175,13 @@ export function registerJarvisTools(server, deps) {
|
|
|
167
175
|
title: "Dispatch a coding task to JARVIS's coding arm",
|
|
168
176
|
description: "Asks JARVIS to run a coding task on the runner: it clones the repo, works, pushes a branch "
|
|
169
177
|
+ "and opens a pull request. It never merges. TWO CALLS, always. Call this once WITHOUT "
|
|
170
|
-
+ "approval_code
|
|
171
|
-
+ "
|
|
172
|
-
+ "
|
|
173
|
-
+ "
|
|
178
|
+
+ "approval_code: you get the plan and a proposal_id back, and DELIBERATELY no code — a code "
|
|
179
|
+
+ "you can read is a code you could approve with. Show the person the plan and the "
|
|
180
|
+
+ "proposal_id and ask them to fetch the code themself, in a Tower browser tab, their Slack "
|
|
181
|
+
+ "DM with JARVIS, or their own `cockpit jarvis` terminal. Then call this again with the code "
|
|
182
|
+
+ "THEY give you and the same instruction. A code approves exactly ONE run: a reused one is "
|
|
183
|
+
+ "refused (approval_code_spent) and the answer is a fresh approval, never a retry. Never "
|
|
184
|
+
+ "derive, guess, or reuse a code the person has not just handed you.",
|
|
174
185
|
inputSchema: {
|
|
175
186
|
instruction: z
|
|
176
187
|
.string()
|
|
@@ -188,8 +199,8 @@ export function registerJarvisTools(server, deps) {
|
|
|
188
199
|
.min(1)
|
|
189
200
|
.max(64)
|
|
190
201
|
.optional()
|
|
191
|
-
.describe("The 8-character hex confirmation code THE PERSON
|
|
192
|
-
+ "
|
|
202
|
+
.describe("The 8-character hex confirmation code THE PERSON fetched for this proposal and handed "
|
|
203
|
+
+ "to you. Omit it on the first call — that call is never given one."),
|
|
193
204
|
thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
|
|
194
205
|
},
|
|
195
206
|
}, async (args) => withSession(deps, async (session) => {
|
|
@@ -212,7 +223,7 @@ export function registerJarvisTools(server, deps) {
|
|
|
212
223
|
const question = [
|
|
213
224
|
code.length > 0
|
|
214
225
|
? "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
|
|
226
|
+
: "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the proposal id so the person can fetch the approval code themselves.",
|
|
216
227
|
...(typeof args.repo === "string" ? [`Repo: ${args.repo}`] : []),
|
|
217
228
|
"",
|
|
218
229
|
String(args.instruction ?? ""),
|
|
@@ -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
|
+
}
|