@bli-cockpit/mcp 0.1.3 → 0.1.5
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 +75 -26
- package/dist/agent-door.d.ts +8 -1
- package/dist/agent-door.js +7 -2
- package/dist/brief-write-tools.d.ts +37 -0
- package/dist/brief-write-tools.js +178 -0
- package/dist/docs-msg-tools.js +11 -1
- 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 +27 -10
- package/dist/jarvis-tools.js +65 -27
- package/dist/notes-write-tools.d.ts +34 -0
- package/dist/notes-write-tools.js +211 -0
- package/dist/ops-tools.d.ts +3 -7
- package/dist/ops-tools.js +64 -3
- package/dist/pages-tools.d.ts +6 -2
- package/dist/pages-tools.js +69 -2
- package/dist/readme-census.js +8 -6
- package/dist/server.d.ts +1 -1
- package/dist/server.js +15 -1
- 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 +16 -0
- package/dist/tool-result.js +26 -0
- package/dist/verb-census.d.ts +11 -1
- package/dist/verb-census.js +29 -20
- package/package.json +1 -1
package/dist/jarvis-tools.js
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
|
|
25
25
|
* `cockpit jarvis --json` prints. A script and an agent read one shape.
|
|
26
26
|
*
|
|
27
|
+
* ## One conversation per call (BLI-3786)
|
|
28
|
+
*
|
|
29
|
+
* A thread is single-writer: two turns in one conversation at the same time
|
|
30
|
+
* read each other's messages, and one can answer the other's question. Every
|
|
31
|
+
* tool here therefore sends NO thread unless the caller named one, and the
|
|
32
|
+
* door mints a conversation for that call alone and reports it as `thread_id`.
|
|
33
|
+
* Passing that id back is how an agent continues deliberately. `"main"` is the
|
|
34
|
+
* shared terminal default nobody chose and is never sent from here.
|
|
35
|
+
*
|
|
27
36
|
* ## The approval code, and what this surface can and cannot prove
|
|
28
37
|
*
|
|
29
38
|
* The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
|
|
@@ -32,16 +41,24 @@
|
|
|
32
41
|
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
42
|
* the dashboard exactly as it always was.
|
|
34
43
|
*
|
|
35
|
-
* Lock 2 is weaker on THIS surface than it is in a terminal
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* to
|
|
44
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal: in a terminal the
|
|
45
|
+
* person types the code themselves, while through MCP it arrives as a tool
|
|
46
|
+
* argument and nothing on the wire distinguishes a code a person handed their
|
|
47
|
+
* agent from one the agent lifted out of the previous answer. BLI-3755 closed
|
|
48
|
+
* the two halves of that gap where the gate lives, not here:
|
|
49
|
+
*
|
|
50
|
+
* * `jarvis_ask` and `jarvis_dispatch` tell the dashboard they are an AGENT
|
|
51
|
+
* surface (`jarvis-door.ts` sends it on every turn), so a proposal comes
|
|
52
|
+
* back with a `proposal_id` and NO code. There is nothing in the previous
|
|
53
|
+
* answer to lift. The person fetches the code where a person reads — a
|
|
54
|
+
* Tower tab, their Slack DM, their own `cockpit jarvis` — and hands it on.
|
|
55
|
+
* * A code is spendable ONCE. A replay is refused with `approval_code_spent`,
|
|
56
|
+
* so a code that did reach an agent buys at most the one run the person
|
|
57
|
+
* approved.
|
|
58
|
+
*
|
|
59
|
+
* What is unchanged here: this server holds no secret and cannot compute a code
|
|
60
|
+
* for any plan, it never derives, guesses or fabricates one, and every relay is
|
|
61
|
+
* logged (presence and length only, never the code).
|
|
45
62
|
*/
|
|
46
63
|
import { z } from "zod";
|
|
47
64
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -62,10 +79,12 @@ export function registerJarvisTools(server, deps) {
|
|
|
62
79
|
title: "Ask JARVIS",
|
|
63
80
|
description: "Asks JARVIS one question and returns its answer, the Source: lines behind it, this turn's "
|
|
64
81
|
+ "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`.
|
|
66
|
-
+ "
|
|
67
|
-
+ "
|
|
68
|
-
+ "
|
|
82
|
+
+ "rules as Tower web chat, the Slack DM and `cockpit jarvis`. A thread is SINGLE-WRITER: "
|
|
83
|
+
+ "omit thread and this turn gets a FRESH conversation of its own, so two questions asked at "
|
|
84
|
+
+ "the same time can never read each other's; pass the thread_id the answer returned to "
|
|
85
|
+
+ "continue that conversation deliberately. Pass the returned turn_id to jarvis_trace to see "
|
|
86
|
+
+ "how the answer was reached. JARVIS speaks as the person this machine is paired to and "
|
|
87
|
+
+ "cannot be made to speak as anyone else.",
|
|
69
88
|
inputSchema: {
|
|
70
89
|
question: z.string().min(1).max(50_000).describe("What to ask, in plain words."),
|
|
71
90
|
thread: z
|
|
@@ -73,7 +92,9 @@ export function registerJarvisTools(server, deps) {
|
|
|
73
92
|
.min(1)
|
|
74
93
|
.max(200)
|
|
75
94
|
.optional()
|
|
76
|
-
.describe(
|
|
95
|
+
.describe("The thread_id of a conversation to continue, exactly as an earlier answer returned "
|
|
96
|
+
+ 'it. Omit for a fresh conversation for this call alone. "main" is the shared '
|
|
97
|
+
+ "default nobody chose, so it is treated as naming nothing here."),
|
|
77
98
|
subject: z
|
|
78
99
|
.string()
|
|
79
100
|
.min(1)
|
|
@@ -97,7 +118,10 @@ export function registerJarvisTools(server, deps) {
|
|
|
97
118
|
}, async (args) => withSession(deps, async (session) => {
|
|
98
119
|
const { result } = await takeTurn(deps, session, "jarvis_ask", {
|
|
99
120
|
question: String(args.question ?? ""),
|
|
100
|
-
|
|
121
|
+
// BLI-3786: no thread means no thread. The door mints one for this
|
|
122
|
+
// call and reports it as thread_id; sending "main" here would put
|
|
123
|
+
// every agent on one conversation, which is the bug this replaced.
|
|
124
|
+
...(typeof args.thread === "string" ? { thread: args.thread } : {}),
|
|
101
125
|
...(typeof args.subject === "string" ? { subject: args.subject } : {}),
|
|
102
126
|
...(typeof args.date === "string" ? { date: args.date } : {}),
|
|
103
127
|
...(typeof args.model === "string" ? { model: args.model } : {}),
|
|
@@ -167,10 +191,13 @@ export function registerJarvisTools(server, deps) {
|
|
|
167
191
|
title: "Dispatch a coding task to JARVIS's coding arm",
|
|
168
192
|
description: "Asks JARVIS to run a coding task on the runner: it clones the repo, works, pushes a branch "
|
|
169
193
|
+ "and opens a pull request. It never merges. TWO CALLS, always. Call this once WITHOUT "
|
|
170
|
-
+ "approval_code
|
|
171
|
-
+ "
|
|
172
|
-
+ "
|
|
173
|
-
+ "
|
|
194
|
+
+ "approval_code: you get the plan and a proposal_id back, and DELIBERATELY no code — a code "
|
|
195
|
+
+ "you can read is a code you could approve with. Show the person the plan and the "
|
|
196
|
+
+ "proposal_id and ask them to fetch the code themself, in a Tower browser tab, their Slack "
|
|
197
|
+
+ "DM with JARVIS, or their own `cockpit jarvis` terminal. Then call this again with the code "
|
|
198
|
+
+ "THEY give you and the same instruction. A code approves exactly ONE run: a reused one is "
|
|
199
|
+
+ "refused (approval_code_spent) and the answer is a fresh approval, never a retry. Never "
|
|
200
|
+
+ "derive, guess, or reuse a code the person has not just handed you.",
|
|
174
201
|
inputSchema: {
|
|
175
202
|
instruction: z
|
|
176
203
|
.string()
|
|
@@ -188,9 +215,15 @@ export function registerJarvisTools(server, deps) {
|
|
|
188
215
|
.min(1)
|
|
189
216
|
.max(64)
|
|
190
217
|
.optional()
|
|
191
|
-
.describe("The 8-character hex confirmation code THE PERSON
|
|
192
|
-
+ "
|
|
193
|
-
thread: z
|
|
218
|
+
.describe("The 8-character hex confirmation code THE PERSON fetched for this proposal and handed "
|
|
219
|
+
+ "to you. Omit it on the first call — that call is never given one."),
|
|
220
|
+
thread: z
|
|
221
|
+
.string()
|
|
222
|
+
.min(1)
|
|
223
|
+
.max(200)
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("The thread_id of a conversation to continue. Omit for a fresh one — the approval "
|
|
226
|
+
+ "code is bound to the PLAN, not to a conversation, so a dispatch needs no thread."),
|
|
194
227
|
},
|
|
195
228
|
}, async (args) => withSession(deps, async (session) => {
|
|
196
229
|
const log = deps.log ?? defaultLog;
|
|
@@ -212,7 +245,7 @@ export function registerJarvisTools(server, deps) {
|
|
|
212
245
|
const question = [
|
|
213
246
|
code.length > 0
|
|
214
247
|
? "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
|
|
248
|
+
: "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
249
|
...(typeof args.repo === "string" ? [`Repo: ${args.repo}`] : []),
|
|
217
250
|
"",
|
|
218
251
|
String(args.instruction ?? ""),
|
|
@@ -220,7 +253,7 @@ export function registerJarvisTools(server, deps) {
|
|
|
220
253
|
? ["", `The person approved this plan. Confirmation code: ${code.toLowerCase()}`]
|
|
221
254
|
: []),
|
|
222
255
|
].join("\n");
|
|
223
|
-
const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question,
|
|
256
|
+
const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, ...(typeof args.thread === "string" ? { thread: args.thread } : {}) }, remember);
|
|
224
257
|
return result;
|
|
225
258
|
}));
|
|
226
259
|
register("jarvis_check", {
|
|
@@ -235,14 +268,19 @@ export function registerJarvisTools(server, deps) {
|
|
|
235
268
|
.max(200)
|
|
236
269
|
.optional()
|
|
237
270
|
.describe("The task id JARVIS named when it dispatched. Omit to ask about the recent ones."),
|
|
238
|
-
thread: z
|
|
271
|
+
thread: z
|
|
272
|
+
.string()
|
|
273
|
+
.min(1)
|
|
274
|
+
.max(200)
|
|
275
|
+
.optional()
|
|
276
|
+
.describe("The thread_id of a conversation to continue. Omit for a fresh one."),
|
|
239
277
|
},
|
|
240
278
|
}, async (args) => withSession(deps, async (session) => {
|
|
241
279
|
const task = typeof args.task === "string" ? args.task.trim() : "";
|
|
242
280
|
const question = task.length > 0
|
|
243
281
|
? `Check the coding task ${task} and tell me its status, its branch and its pull request if it has one.`
|
|
244
282
|
: "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,
|
|
283
|
+
const { result } = await takeTurn(deps, session, "jarvis_check", { question, ...(typeof args.thread === "string" ? { thread: args.thread } : {}) }, remember);
|
|
246
284
|
return result;
|
|
247
285
|
}));
|
|
248
286
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
|
|
3
|
+
* `notes_move` MCP tools (BLI-3756 batch 2) — putting a meeting note in,
|
|
4
|
+
* sharing it, taking it back and moving it to another shelf, over the same
|
|
5
|
+
* `/api/notes/**` doors `cockpit notes` calls with the same device token.
|
|
6
|
+
*
|
|
7
|
+
* Reads live in `notes-tools.ts`. Three rules this half keeps.
|
|
8
|
+
*
|
|
9
|
+
* **The local screen runs before the network, on the name first.** A path is
|
|
10
|
+
* refused for a secret-shaped NAME without its bytes ever being read, then for
|
|
11
|
+
* being missing, unreadable, empty or over 20 MB — the same four checks and
|
|
12
|
+
* the same sentences as `commands/notes-file.ts`, because the server's own
|
|
13
|
+
* guard is the authority and this only avoids spending a round trip on a file
|
|
14
|
+
* it is certain to refuse. If the two ever disagree, the server wins.
|
|
15
|
+
*
|
|
16
|
+
* **Sharing is deliberate.** `cockpit notes share` asks before it acts unless
|
|
17
|
+
* `--yes` is passed; a client has no keyboard, so `confirm: true` is the whole
|
|
18
|
+
* of that gate here and a share without it is refused in the CLI's own words.
|
|
19
|
+
* Taking a note BACK narrows who can read it, and nobody needs to be talked
|
|
20
|
+
* out of that — `notes_unshare` asks for nothing, exactly as the CLI does not.
|
|
21
|
+
*
|
|
22
|
+
* **A slow door is named, not hidden.** `/api/notes/upload` reads the whole
|
|
23
|
+
* note with one model call and is budgeted to 300 seconds server-side; this
|
|
24
|
+
* waits slightly longer so the server's named failure wins the race whenever
|
|
25
|
+
* it manages to send one. An MCP client's own deadline may still be shorter —
|
|
26
|
+
* the description says so rather than letting a cut call read as a refusal.
|
|
27
|
+
*/
|
|
28
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
29
|
+
export type NotesWriteDeps = ToolDeps;
|
|
30
|
+
/** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
|
|
31
|
+
export declare const NOTE_FILE_MAX_BYTES: number;
|
|
32
|
+
export declare function registerNotesWriteTools(server: {
|
|
33
|
+
registerTool: (...args: never[]) => unknown;
|
|
34
|
+
}, deps: NotesWriteDeps): void;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
|
|
3
|
+
* `notes_move` MCP tools (BLI-3756 batch 2) — putting a meeting note in,
|
|
4
|
+
* sharing it, taking it back and moving it to another shelf, over the same
|
|
5
|
+
* `/api/notes/**` doors `cockpit notes` calls with the same device token.
|
|
6
|
+
*
|
|
7
|
+
* Reads live in `notes-tools.ts`. Three rules this half keeps.
|
|
8
|
+
*
|
|
9
|
+
* **The local screen runs before the network, on the name first.** A path is
|
|
10
|
+
* refused for a secret-shaped NAME without its bytes ever being read, then for
|
|
11
|
+
* being missing, unreadable, empty or over 20 MB — the same four checks and
|
|
12
|
+
* the same sentences as `commands/notes-file.ts`, because the server's own
|
|
13
|
+
* guard is the authority and this only avoids spending a round trip on a file
|
|
14
|
+
* it is certain to refuse. If the two ever disagree, the server wins.
|
|
15
|
+
*
|
|
16
|
+
* **Sharing is deliberate.** `cockpit notes share` asks before it acts unless
|
|
17
|
+
* `--yes` is passed; a client has no keyboard, so `confirm: true` is the whole
|
|
18
|
+
* of that gate here and a share without it is refused in the CLI's own words.
|
|
19
|
+
* Taking a note BACK narrows who can read it, and nobody needs to be talked
|
|
20
|
+
* out of that — `notes_unshare` asks for nothing, exactly as the CLI does not.
|
|
21
|
+
*
|
|
22
|
+
* **A slow door is named, not hidden.** `/api/notes/upload` reads the whole
|
|
23
|
+
* note with one model call and is budgeted to 300 seconds server-side; this
|
|
24
|
+
* waits slightly longer so the server's named failure wins the race whenever
|
|
25
|
+
* it manages to send one. An MCP client's own deadline may still be shorter —
|
|
26
|
+
* the description says so rather than letting a cut call read as a refusal.
|
|
27
|
+
*/
|
|
28
|
+
import { readFile, stat } from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
33
|
+
import { CONFIRM_INPUT, doorFailureText, errorResult, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
|
|
34
|
+
/** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
|
|
35
|
+
export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
36
|
+
/** The route's own ceiling is 300 s. Wait past it so its sentence wins. */
|
|
37
|
+
const UPLOAD_DEADLINE_MS = 305_000;
|
|
38
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
39
|
+
const PASTE_MAX_CHARS = 2_000_000;
|
|
40
|
+
/** The sentence the CLI prints for a refusal caught locally, kept word for word. */
|
|
41
|
+
function refusalSentence(refusal, filePath) {
|
|
42
|
+
switch (refusal) {
|
|
43
|
+
case "file_not_found":
|
|
44
|
+
return `I could not find that file: ${filePath}`;
|
|
45
|
+
case "file_unreadable":
|
|
46
|
+
return `I could not read that file: ${filePath}`;
|
|
47
|
+
case "file_empty":
|
|
48
|
+
return `There is nothing in that file: ${filePath}`;
|
|
49
|
+
case "file_too_big":
|
|
50
|
+
return "That file is too big to put in as a note — keep it under 20 MB.";
|
|
51
|
+
case "looks_like_a_key_file":
|
|
52
|
+
// The same rule the server's gate applies, said the same way: the name
|
|
53
|
+
// is all it takes to decide, and looking inside to be sure would already
|
|
54
|
+
// be the thing the rule forbids.
|
|
55
|
+
return `That name looks like a key or credential file, so I did not open it: ${filePath}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Reads and locally screens one note file. Never throws. */
|
|
59
|
+
async function readNoteFile(filePath) {
|
|
60
|
+
const fileName = path.basename(filePath);
|
|
61
|
+
const refuse = (refusal) => ({
|
|
62
|
+
ok: false,
|
|
63
|
+
refusal,
|
|
64
|
+
sentence: refusalSentence(refusal, filePath),
|
|
65
|
+
});
|
|
66
|
+
if (isSecretLikePathSegment(fileName))
|
|
67
|
+
return refuse("looks_like_a_key_file");
|
|
68
|
+
let size;
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(filePath);
|
|
71
|
+
if (!info.isFile())
|
|
72
|
+
return refuse("file_unreadable");
|
|
73
|
+
size = info.size;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return refuse(error.code === "ENOENT" ? "file_not_found" : "file_unreadable");
|
|
77
|
+
}
|
|
78
|
+
if (size === 0)
|
|
79
|
+
return refuse("file_empty");
|
|
80
|
+
if (size > NOTE_FILE_MAX_BYTES)
|
|
81
|
+
return refuse("file_too_big");
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, bytes: await readFile(filePath), fileName };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return refuse("file_unreadable");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The route's own words for what happened. Never rephrased here. */
|
|
90
|
+
function saidByTower(body) {
|
|
91
|
+
const said = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])];
|
|
92
|
+
if (body.noteId)
|
|
93
|
+
said.push(`Note id: ${body.noteId}`);
|
|
94
|
+
return said.join("\n");
|
|
95
|
+
}
|
|
96
|
+
async function postForm(deps, session, tool, form) {
|
|
97
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/upload", form, UPLOAD_DEADLINE_MS);
|
|
98
|
+
if (!response.ok)
|
|
99
|
+
return errorResult(doorFailureText(tool, response));
|
|
100
|
+
const body = response.body;
|
|
101
|
+
const text = saidByTower(body);
|
|
102
|
+
// `stored: false` is the door saying it did not keep the note. That is a
|
|
103
|
+
// refusal with a reason, not a success with a caveat.
|
|
104
|
+
return body.stored === true
|
|
105
|
+
? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null })
|
|
106
|
+
: errorResult(text);
|
|
107
|
+
}
|
|
108
|
+
export function registerNotesWriteTools(server, deps) {
|
|
109
|
+
const register = registrarFor(server);
|
|
110
|
+
register("notes_upload", {
|
|
111
|
+
title: "Put a local file in as a Tower meeting note",
|
|
112
|
+
description: "Uploads a transcript or note file from THIS machine's disk — the same door `cockpit notes upload` uses. "
|
|
113
|
+
+ "A name that looks like a key or credential file is refused without being opened; so is a missing, empty or "
|
|
114
|
+
+ "over-20-MB file. Tower reads the whole note with one model call, so a large file can take a couple of "
|
|
115
|
+
+ "minutes and your client may give up first — the upload keeps going; notes_list will show it.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
path: z.string().min(1).max(4096).describe("An absolute path on this machine."),
|
|
118
|
+
exclude: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.max(2000)
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Anything in the note to leave out, in plain words — forwarded to the reader verbatim."),
|
|
124
|
+
},
|
|
125
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
126
|
+
const filePath = String(args.path ?? "");
|
|
127
|
+
const read = await readNoteFile(filePath);
|
|
128
|
+
if (!read.ok)
|
|
129
|
+
return errorResult(`Refused (${read.refusal}): ${read.sentence} Nothing was sent.`);
|
|
130
|
+
const form = new FormData();
|
|
131
|
+
form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
|
|
132
|
+
if (args.exclude)
|
|
133
|
+
form.set("exclusions", String(args.exclude));
|
|
134
|
+
return postForm(deps, session, "notes_upload", form);
|
|
135
|
+
}));
|
|
136
|
+
register("notes_paste", {
|
|
137
|
+
title: "Put text in as a Tower meeting note",
|
|
138
|
+
description: "Stores text you already have as a meeting note — the same door as notes_upload, which is what "
|
|
139
|
+
+ "`cockpit notes paste` posts to. The CLI takes the body on stdin; here it is an argument, so the text "
|
|
140
|
+
+ "travels in the request and is never echoed back to you.",
|
|
141
|
+
inputSchema: {
|
|
142
|
+
text: z.string().min(1).max(PASTE_MAX_CHARS).describe("The note itself."),
|
|
143
|
+
name: z.string().min(1).max(300).optional().describe("What to call it. Tower names it by date if you do not."),
|
|
144
|
+
exclude: z.string().min(1).max(2000).optional().describe("Anything to leave out, in plain words."),
|
|
145
|
+
},
|
|
146
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
147
|
+
const text = String(args.text ?? "");
|
|
148
|
+
if (text.trim() === "") {
|
|
149
|
+
return errorResult("Refused (empty_paste): there was nothing to paste. Nothing was sent.");
|
|
150
|
+
}
|
|
151
|
+
const form = new FormData();
|
|
152
|
+
form.set("text", text);
|
|
153
|
+
if (args.name)
|
|
154
|
+
form.set("name", String(args.name));
|
|
155
|
+
if (args.exclude)
|
|
156
|
+
form.set("exclusions", String(args.exclude));
|
|
157
|
+
return postForm(deps, session, "notes_paste", form);
|
|
158
|
+
}));
|
|
159
|
+
register("notes_share", {
|
|
160
|
+
title: "Share a Tower meeting note with the team",
|
|
161
|
+
description: "Lets everyone signed in read one note. Deliberate: refused without `confirm: true`, the same rule as the "
|
|
162
|
+
+ "CLI's --yes. Use notes_unshare to take it back.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
note_id: z.string().min(1).max(200).describe("A note id, as notes_list or notes_shelf reports it."),
|
|
165
|
+
confirm: CONFIRM_INPUT,
|
|
166
|
+
},
|
|
167
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
168
|
+
const refusal = unconfirmed(args, "Sharing a note lets everyone signed in read it. Pass confirm: true to do it without being asked.");
|
|
169
|
+
if (refusal)
|
|
170
|
+
return refusal;
|
|
171
|
+
return shareCall(deps, session, args, true);
|
|
172
|
+
}));
|
|
173
|
+
register("notes_unshare", {
|
|
174
|
+
title: "Take a shared Tower note back",
|
|
175
|
+
description: "Makes one note yours again. No confirmation: taking a note back narrows who can read it, and nobody needs "
|
|
176
|
+
+ "to be talked out of that — the same asymmetry `cockpit notes unshare` has.",
|
|
177
|
+
inputSchema: { note_id: z.string().min(1).max(200).describe("A note id.") },
|
|
178
|
+
}, async (args) => withSession(deps, async (session) => shareCall(deps, session, args, false)));
|
|
179
|
+
register("notes_move", {
|
|
180
|
+
title: "Move a Tower meeting note to another shelf",
|
|
181
|
+
description: "Puts one note on a different shelf, or clears its shelf with `clear: true` — the empty string the browser's "
|
|
182
|
+
+ "own move box sends. A shelf is free text; notes_shelves lists the ones in use.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
note_id: z.string().min(1).max(200).describe("A note id."),
|
|
185
|
+
to: z.string().min(1).max(200).optional().describe("The shelf to move it to."),
|
|
186
|
+
clear: z.boolean().optional().describe("Take it off every shelf instead."),
|
|
187
|
+
},
|
|
188
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
189
|
+
const clearing = args.clear === true;
|
|
190
|
+
if (!clearing && !args.to) {
|
|
191
|
+
return errorResult("Refused (no_destination): say which shelf with `to`, or pass `clear: true`. Nothing was moved.");
|
|
192
|
+
}
|
|
193
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/move", { note_id: String(args.note_id ?? ""), category: clearing ? "" : String(args.to) }, WRITE_DEADLINE_MS);
|
|
194
|
+
if (!response.ok)
|
|
195
|
+
return errorResult(doorFailureText("notes_move", response));
|
|
196
|
+
const body = response.body;
|
|
197
|
+
const text = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])].join("\n");
|
|
198
|
+
return body.ok === true
|
|
199
|
+
? textResult(text, { ok: true, shelf: body.shelf ?? null })
|
|
200
|
+
: errorResult(text);
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
/** Share and unshare are one door and one body; only the boolean differs. */
|
|
204
|
+
async function shareCall(deps, session, args, share) {
|
|
205
|
+
const tool = share ? "notes_share" : "notes_unshare";
|
|
206
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/share", { note_id: String(args.note_id ?? ""), share }, WRITE_DEADLINE_MS);
|
|
207
|
+
if (!response.ok)
|
|
208
|
+
return errorResult(doorFailureText(tool, response));
|
|
209
|
+
const body = response.body;
|
|
210
|
+
return textResult(saidByTower(body), { ok: true, shared: share, noteId: body.noteId ?? null });
|
|
211
|
+
}
|
package/dist/ops-tools.d.ts
CHANGED
|
@@ -11,16 +11,12 @@
|
|
|
11
11
|
* unhealthy ones so an agent does not have to know the vocabulary to notice a
|
|
12
12
|
* red board.
|
|
13
13
|
*
|
|
14
|
-
* `
|
|
15
|
-
* this
|
|
14
|
+
* `ops_recompile` (BLI-3756 batch 2) is the one write here: it spends a model
|
|
15
|
+
* call, so it is the only tool in this file that can cost money, and the only
|
|
16
|
+
* one whose failure can leave the effect UNKNOWN rather than known.
|
|
16
17
|
*/
|
|
17
18
|
import { type ToolDeps } from "./tool-result.js";
|
|
18
19
|
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
20
|
export declare const UNHEALTHY_VERDICTS: Set<string>;
|
|
25
21
|
export declare function registerOpsTools(server: {
|
|
26
22
|
registerTool: (...args: never[]) => unknown;
|
package/dist/ops-tools.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* unhealthy ones so an agent does not have to know the vocabulary to notice a
|
|
12
12
|
* red board.
|
|
13
13
|
*
|
|
14
|
-
* `
|
|
15
|
-
* this
|
|
14
|
+
* `ops_recompile` (BLI-3756 batch 2) is the one write here: it spends a model
|
|
15
|
+
* call, so it is the only tool in this file that can cost money, and the only
|
|
16
|
+
* one whose failure can leave the effect UNKNOWN rather than known.
|
|
16
17
|
*/
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -22,7 +23,23 @@ import { doorFailureText, errorResult, registrarFor, textResult, queryString, wi
|
|
|
22
23
|
* `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
|
|
23
24
|
* reader's input is CURRENT and its answer is bad.
|
|
24
25
|
*/
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
|
|
28
|
+
* seconds; waiting slightly past it is what lets this tool tell "Tower gave up"
|
|
29
|
+
* from "the network did", instead of hanging up first and blaming the wrong
|
|
30
|
+
* one. A client with a shorter deadline of its own will cut the call first —
|
|
31
|
+
* the description says so.
|
|
32
|
+
*/
|
|
33
|
+
const RECOMPILE_DEADLINE_MS = 830_000;
|
|
34
|
+
const TOWER_RECOMPILE_CEILING_SECONDS = 800;
|
|
35
|
+
export const UNHEALTHY_VERDICTS = new Set([
|
|
36
|
+
"stale",
|
|
37
|
+
"never_produced",
|
|
38
|
+
"unreadable",
|
|
39
|
+
"failing",
|
|
40
|
+
// BLI-3762: a job that wrote less than it owed is not healthy. Amber counts.
|
|
41
|
+
"degraded",
|
|
42
|
+
]);
|
|
26
43
|
function numberOf(value) {
|
|
27
44
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
28
45
|
}
|
|
@@ -148,4 +165,48 @@ export function registerOpsTools(server, deps) {
|
|
|
148
165
|
coverage: read["coverage"] ?? null,
|
|
149
166
|
});
|
|
150
167
|
}));
|
|
168
|
+
register("ops_recompile", {
|
|
169
|
+
title: "Write one person's Tower page again",
|
|
170
|
+
description: "Compiles a daily page on demand — `cockpit ops recompile`, same door. It SPENDS A MODEL CALL and takes "
|
|
171
|
+
+ "several minutes; pass `dry_run: true` to resolve the person and see which page is being served without "
|
|
172
|
+
+ "compiling anything. Your own page always; anybody else's is admin only, decided on the server. Tower's own "
|
|
173
|
+
+ "budget is 800 seconds — if that runs out the effect is UNKNOWN, not failed, and this says so.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
person: z.string().min(1).max(200).describe("An email, a display name, or a person id."),
|
|
176
|
+
dry_run: z.boolean().optional().describe("Resolve the person and read the current page; compile nothing."),
|
|
177
|
+
},
|
|
178
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
179
|
+
const dryRun = args.dry_run === true;
|
|
180
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/recompile", { person: String(args.person ?? ""), dryRun }, RECOMPILE_DEADLINE_MS);
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
// The ceiling is its own outcome, and its effect is UNKNOWN rather
|
|
183
|
+
// than failed: the compile may well have finished after the
|
|
184
|
+
// connection was cut. Said in the CLI's own words, because a caller
|
|
185
|
+
// told "it failed" would reasonably ask for it again and pay twice.
|
|
186
|
+
if (response.transportError) {
|
|
187
|
+
return errorResult(`Refused (recompile_ceiling_exceeded): Tower's ${TOWER_RECOMPILE_CEILING_SECONDS}-second budget for one `
|
|
188
|
+
+ "recompile ran out before it answered. That is not the same as the compile failing: it may have "
|
|
189
|
+
+ "finished after the connection was cut. Call brief_read for that person to see which page is being "
|
|
190
|
+
+ "served now.");
|
|
191
|
+
}
|
|
192
|
+
return errorResult(doorFailureText("ops_recompile", response));
|
|
193
|
+
}
|
|
194
|
+
const body = response.body;
|
|
195
|
+
const status = typeof body["status"] === "string" ? body["status"] : "compiled";
|
|
196
|
+
const displayName = typeof body["displayName"] === "string" ? body["displayName"] : String(args.person ?? "");
|
|
197
|
+
const pageId = typeof body["pageId"] === "string" ? body["pageId"] : null;
|
|
198
|
+
if (status === "dry_run") {
|
|
199
|
+
const latest = typeof body["latestPageId"] === "string" ? body["latestPageId"] : null;
|
|
200
|
+
return textResult(`${displayName} is on the roster.\n`
|
|
201
|
+
+ (latest
|
|
202
|
+
? `The page being served for them right now is ${latest}.`
|
|
203
|
+
: "Nothing has ever been written for them.")
|
|
204
|
+
+ "\nNothing was compiled — this was a dry run.", body);
|
|
205
|
+
}
|
|
206
|
+
return textResult(`Wrote ${displayName}'s page again: ${pageId ?? "(no id returned)"}\n`
|
|
207
|
+
+ (body["isNowLive"] === true
|
|
208
|
+
? "It is the page being served now."
|
|
209
|
+
: "It is NOT the page being served: a scheduled compile is stamped for a later delivery window, so that "
|
|
210
|
+
+ "one still wins. Nothing was lost — this is a new version alongside it."), body);
|
|
211
|
+
}));
|
|
151
212
|
}
|
package/dist/pages-tools.d.ts
CHANGED
|
@@ -14,11 +14,15 @@
|
|
|
14
14
|
* signals and reports the totals in `coverage`; a truncated board and a quiet
|
|
15
15
|
* board must never read alike.
|
|
16
16
|
*
|
|
17
|
-
* Moving a Scout card (`
|
|
18
|
-
*
|
|
17
|
+
* Moving a Scout card (`scout_start`/`scout_dismiss`/`scout_undo`, BLI-3756
|
|
18
|
+
* batch 2) is super_admin server-side, so those three RELAY a decision rather
|
|
19
|
+
* than making one — and each reads the board first, so an id is matched
|
|
20
|
+
* exactly against cards that exist and a refusal still hands back the read.
|
|
19
21
|
*/
|
|
20
22
|
import { type ToolDeps } from "./tool-result.js";
|
|
21
23
|
export type PagesDeps = ToolDeps;
|
|
22
24
|
export declare function registerPagesTools(server: {
|
|
23
25
|
registerTool: (...args: never[]) => unknown;
|
|
24
26
|
}, deps: PagesDeps): void;
|
|
27
|
+
/** The three verbs `cockpit scout` has beyond reading the board. */
|
|
28
|
+
export declare const SCOUT_ACTIONS: readonly ["start", "dismiss", "undo"];
|
package/dist/pages-tools.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* signals and reports the totals in `coverage`; a truncated board and a quiet
|
|
15
15
|
* board must never read alike.
|
|
16
16
|
*
|
|
17
|
-
* Moving a Scout card (`
|
|
18
|
-
*
|
|
17
|
+
* Moving a Scout card (`scout_start`/`scout_dismiss`/`scout_undo`, BLI-3756
|
|
18
|
+
* batch 2) is super_admin server-side, so those three RELAY a decision rather
|
|
19
|
+
* than making one — and each reads the board first, so an id is matched
|
|
20
|
+
* exactly against cards that exist and a refusal still hands back the read.
|
|
19
21
|
*/
|
|
20
22
|
import { z } from "zod";
|
|
21
23
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -120,4 +122,69 @@ export function registerPagesTools(server, deps) {
|
|
|
120
122
|
.join("\n\n");
|
|
121
123
|
return textResult(`WORKBOOK · ${projects.length} project(s) · ${docCount} document(s).${lines ? `\n\n${lines}` : ""}`, { projects });
|
|
122
124
|
}));
|
|
125
|
+
// BLI-3756 batch 2: the three verbs that MOVE a Scout card. Deciding a card
|
|
126
|
+
// is super_admin server-side and reading the board is not, so a refusal here
|
|
127
|
+
// never costs the caller the read it already had — the open cards come back
|
|
128
|
+
// beside the refusal, exactly as `cockpit scout` leaves the board on stdout.
|
|
129
|
+
for (const action of SCOUT_ACTIONS) {
|
|
130
|
+
register(`scout_${action}`, {
|
|
131
|
+
title: SCOUT_TITLES[action],
|
|
132
|
+
description: SCOUT_DESCRIPTIONS[action],
|
|
133
|
+
inputSchema: {
|
|
134
|
+
experiment_id: z
|
|
135
|
+
.string()
|
|
136
|
+
.min(1)
|
|
137
|
+
.max(200)
|
|
138
|
+
.describe("A full experiment id, exactly as scout_board reports it. No prefixes on this surface."),
|
|
139
|
+
},
|
|
140
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
141
|
+
const experimentId = String(args.experiment_id ?? "");
|
|
142
|
+
// The board is read FIRST, as the CLI does: it is what says whether
|
|
143
|
+
// the id names a card that exists, and it is what a refused caller
|
|
144
|
+
// still gets to keep.
|
|
145
|
+
const read = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/cockpit/scout");
|
|
146
|
+
if (!read.ok)
|
|
147
|
+
return errorResult(doorFailureText(`scout_${action}`, read));
|
|
148
|
+
const board = (read.body.board ?? {});
|
|
149
|
+
const open = board.experiments ?? [];
|
|
150
|
+
const card = [...open, ...(board.settled ?? [])].find((one) => one.id === experimentId);
|
|
151
|
+
if (!card) {
|
|
152
|
+
// The exact-match rule, and the list to fix a wrong guess from in
|
|
153
|
+
// one step. Never a prefix: a near-miss that resolved to the wrong
|
|
154
|
+
// card would settle an experiment nobody decided on.
|
|
155
|
+
const known = open.map((one) => ` ${one.id ?? "?"} ${one.title ?? ""}`).join("\n");
|
|
156
|
+
return errorResult(`Refused (unknown_experiment): no card on the Scout board has the id "${experimentId}". `
|
|
157
|
+
+ `Ids come from scout_board and are matched exactly.${known ? `\n\nOpen cards:\n${known}` : ""}`);
|
|
158
|
+
}
|
|
159
|
+
const serverAction = action === "undo" ? "undo_dismiss" : action;
|
|
160
|
+
const applied = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/cockpit/scout", {
|
|
161
|
+
experiment_id: experimentId,
|
|
162
|
+
action: serverAction,
|
|
163
|
+
});
|
|
164
|
+
if (!applied.ok) {
|
|
165
|
+
const gate = applied.status === 403
|
|
166
|
+
? " Deciding a Scout card is a super_admin action; reading the board is not."
|
|
167
|
+
: "";
|
|
168
|
+
return errorResult(`${doorFailureText(`scout_${action}`, applied)}${gate}`);
|
|
169
|
+
}
|
|
170
|
+
return textResult(`${SCOUT_PAST_TENSE[action]} ${experimentId} · ${card.claimSummary ?? card.title ?? "that card"}`, { ok: true, action: serverAction, experiment: applied.body.experiment ?? null });
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
123
173
|
}
|
|
174
|
+
/** The three verbs `cockpit scout` has beyond reading the board. */
|
|
175
|
+
export const SCOUT_ACTIONS = ["start", "dismiss", "undo"];
|
|
176
|
+
const SCOUT_TITLES = {
|
|
177
|
+
start: "Start a Scout experiment",
|
|
178
|
+
dismiss: "Dismiss a Scout card",
|
|
179
|
+
undo: "Restore a dismissed Scout card",
|
|
180
|
+
};
|
|
181
|
+
const SCOUT_DESCRIPTIONS = {
|
|
182
|
+
start: "Marks one experiment card as being run — `cockpit scout start`, same door. super_admin, decided on the server.",
|
|
183
|
+
dismiss: "Sets one experiment card aside — `cockpit scout dismiss`, same door. Reversible with scout_undo. super_admin, decided on the server.",
|
|
184
|
+
undo: "Puts a dismissed card back on the board — `cockpit scout undo`, which the door calls `undo_dismiss`. super_admin, decided on the server.",
|
|
185
|
+
};
|
|
186
|
+
const SCOUT_PAST_TENSE = {
|
|
187
|
+
start: "Started",
|
|
188
|
+
dismiss: "Dismissed",
|
|
189
|
+
undo: "Restored",
|
|
190
|
+
};
|