@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.
Files changed (41) hide show
  1. package/README.md +168 -2
  2. package/dist/agent-door.d.ts +8 -1
  3. package/dist/agent-door.js +7 -2
  4. package/dist/brief-tools.d.ts +26 -0
  5. package/dist/brief-tools.js +160 -0
  6. package/dist/brief-write-tools.d.ts +37 -0
  7. package/dist/brief-write-tools.js +178 -0
  8. package/dist/docs-msg-tools.d.ts +13 -7
  9. package/dist/docs-msg-tools.js +141 -25
  10. package/dist/jarvis-answer-envelope.d.ts +13 -1
  11. package/dist/jarvis-answer-envelope.js +2 -0
  12. package/dist/jarvis-door.js +16 -1
  13. package/dist/jarvis-tools.d.ts +18 -10
  14. package/dist/jarvis-tools.js +28 -17
  15. package/dist/notes-tools.d.ts +33 -0
  16. package/dist/notes-tools.js +143 -0
  17. package/dist/notes-write-tools.d.ts +34 -0
  18. package/dist/notes-write-tools.js +211 -0
  19. package/dist/ops-tools.d.ts +23 -0
  20. package/dist/ops-tools.js +212 -0
  21. package/dist/pages-tools.d.ts +28 -0
  22. package/dist/pages-tools.js +190 -0
  23. package/dist/readme-census.d.ts +18 -0
  24. package/dist/readme-census.js +79 -0
  25. package/dist/search-tool.d.ts +54 -0
  26. package/dist/search-tool.js +134 -0
  27. package/dist/server.d.ts +1 -1
  28. package/dist/server.js +34 -1
  29. package/dist/settings-tools.d.ts +29 -0
  30. package/dist/settings-tools.js +151 -0
  31. package/dist/settings-write-tools.d.ts +47 -0
  32. package/dist/settings-write-tools.js +183 -0
  33. package/dist/team-write-tools.d.ts +39 -0
  34. package/dist/team-write-tools.js +141 -0
  35. package/dist/tool-result.d.ts +86 -0
  36. package/dist/tool-result.js +105 -0
  37. package/dist/verb-census.d.ts +22 -0
  38. package/dist/verb-census.js +108 -6
  39. package/dist/work-tools.d.ts +2 -7
  40. package/dist/work-tools.js +35 -25
  41. package/package.json +5 -4
@@ -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 { loadAgentDoorSession } from "./agent-door-session.js";
13
- import { type FetchImpl } from "./agent-door.js";
14
- export interface DocsMsgDeps {
15
- fetchImpl: FetchImpl;
16
- /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
17
- loadSession?: typeof loadAgentDoorSession;
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;
@@ -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
- function textResult(text, structured) {
16
- return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
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 errorResult(text) {
19
- return { isError: true, content: [{ type: "text", text }] };
25
+ function countTree(nodes) {
26
+ return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
20
27
  }
21
- async function withSession(deps, run) {
22
- const loadSession = deps.loadSession ?? loadAgentDoorSession;
23
- const loaded = loadSession();
24
- if (!loaded.ok)
25
- return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
26
- return run(loaded.session);
27
- }
28
- function doorFailureText(door, response) {
29
- if (response.transportError) {
30
- return `Tower could not be reached for ${door} (${response.transportError}). Nothing was read or written.`;
31
- }
32
- const reason = typeof response.body.reason === "string" ? response.body.reason : typeof response.body.error === "string" ? response.body.error : "unknown_error";
33
- const message = typeof response.body.message === "string" ? response.body.message : `Tower answered ${response.status}.`;
34
- return `Tower refused ${door} (${reason}): ${message}`;
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 = server.registerTool.bind(server);
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. Never a document's body call docs_read for that.",
58
- inputSchema: {},
59
- }, async () => withSession(deps, async (session) => {
60
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
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 pass change.",
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
  };
@@ -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", body, TURN_TIMEOUT_MS);
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)
@@ -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, 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.
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";
@@ -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, 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.
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 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.",
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 read back to you from the previous "
192
- + "call's plan. Omit it on the first call."),
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 confirmation code so the person can approve it.",
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
+ }