@bli-cockpit/mcp 0.1.0 → 0.1.2

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 CHANGED
@@ -3,20 +3,76 @@
3
3
  Two, deliberately separate, tool families over one MCP server (bin
4
4
  `bli-cockpit-mcp`, server id `bli-tower`):
5
5
 
6
- 1. **`docs_*`/`msg_*` (BLI-3706)** — the SAME `/api/docs/**` and `/api/msg/**`
7
- agent doors `cockpit docs`/`cockpit msg` call, authenticated with this
8
- machine's collector device token (`~/.config/bli-cockpit/session.json`,
9
- written by `cockpit login`). This is the live, actively-developed half.
6
+ 1. **The agent doors** — the SAME routes the `cockpit` CLI and the browser
7
+ call, authenticated with this machine's collector device token
8
+ (`~/.config/bli-cockpit/session.json`, written by `cockpit login`). This is
9
+ the live, actively-developed half: `docs_*`/`msg_*` (BLI-3706),
10
+ `work_*` (BLI-3716) and `jarvis_*` (BLI-3732).
10
11
  2. **`emit_event`/`get_ticket_timeline`/`get_active_tickets`** — the legacy
11
12
  event-stream write path, authenticated with `BLI_OPERATOR_TOKEN` or the
12
13
  `bli-event` session helper. See "Legacy event-stream tools" below.
13
14
 
14
15
  The two halves use DIFFERENT credentials and DIFFERENT base URLs on purpose
15
- (`docs-msg-tools.ts` talks to the production Tower dashboard; the event-stream
16
+ (the agent doors talk to the production Tower dashboard; the event-stream
16
17
  tools talk to `BLI_API_BASE_URL`, historically a local worker) — see
17
- `docs/architecture/cli-caller-identity.md` for why `docs_*`/`msg_*` needed no
18
- new server-side door: every `/api/docs/**`/`/api/msg/**` route already accepts
19
- a collector device token.
18
+ `docs/architecture/cli-caller-identity.md` for why they needed no new
19
+ server-side door: every one of those routes already accepts a collector device
20
+ token.
21
+
22
+ **Every `cockpit <noun> <verb>` that talks to Tower is meant to have a twin
23
+ here** (BLI-3706: "a surface an agent cannot reach conveniently is as good as
24
+ dead"). `src/verb-census.test.ts` is that rule as a test — it reads the CLI's
25
+ own decision tables and fails when a verb is in neither the twin map, the
26
+ terminal-only list, nor the written follow-up queue.
27
+
28
+ ## `jarvis_*` tools (BLI-3732)
29
+
30
+ JARVIS itself — the same assistant Tower web chat, the Slack DM and
31
+ `cockpit jarvis` reach, over the same two doors with the same device token.
32
+
33
+ | Tool | Wraps | Notes |
34
+ | --- | --- | --- |
35
+ | `jarvis_ask` | `POST /api/jarvis/cli` | One turn. Returns the answer, its `Source:` lines, `turn_id` and `thread_id`. |
36
+ | `jarvis_trace` | `GET /api/ops/trace/[id]` | That turn's step tree — model steps, tool calls, timings, failures. Metadata only. Takes a `turn_id` or `"last"`. |
37
+ | `jarvis_dispatch` | `POST /api/jarvis/cli` | The coding arm (BLI-2981). Two calls: once for the plan, once with the person's approval code. |
38
+ | `jarvis_check` | `POST /api/jarvis/cli` | Where a dispatched coding task got to. Spends no approval. |
39
+
40
+ `jarvis_ask` answers with the SAME object `cockpit jarvis --json` prints —
41
+ `answer`, `sources`, `turn_id`, `thread_id`, `trace_thread_id`, `degraded`,
42
+ `degraded_reasons` — so a script and an agent read one contract. A worked
43
+ example:
44
+
45
+ ```
46
+ jarvis_ask { "question": "what did Brandon ship yesterday?" }
47
+ -> { "answer": "Two pull requests went in…\n\nSource: BLI-3654 — …",
48
+ "sources": ["Source: BLI-3654 — …"],
49
+ "turn_id": "0f3c…", "thread_id": "main",
50
+ "degraded": false, "degraded_reasons": [] }
51
+
52
+ jarvis_trace { "turn_id": "0f3c…" } # or "last"
53
+ -> Turn 0f3c…
54
+ 6 steps · 4.2 s · 1 failed
55
+ jarvis-chat 4.2s openai:gpt-5.6-terra
56
+ readIssues 0.2s
57
+ searchMemory 1.1s
58
+ readScout 0.3s FAILED
59
+ ```
60
+
61
+ `degraded` is never a failure — the answer is still returned. It names how the
62
+ turn fell short: `model_fallback` (a fallback model answered),
63
+ `answer_revised` (the grounding gate took words back), `tool_step_failed`, or
64
+ `no_turn_id` (no trace id came back, so the tree cannot be opened later).
65
+
66
+ **The coding arm's approval code.** `jarvis_dispatch` takes `approval_code` as
67
+ a parameter and never derives, guesses or fabricates one. Call it once with no
68
+ code to get the plan and its 8-character code, show both to the person, then
69
+ call it again with the code THEY hand back. The gate's first lock (an HMAC
70
+ over the exact plan, this account and a server secret) is untouched by this
71
+ surface — this server holds no secret and cannot compute a code. Its second
72
+ lock ("the code came from the human's own message") is weaker here than in a
73
+ terminal, because nothing on the wire can tell a person-relayed code from one
74
+ the model lifted out of the previous answer; `src/jarvis-tools.ts` says so in
75
+ full at the top of the file.
20
76
 
21
77
  ## `docs_*` / `msg_*` tools (BLI-3706)
22
78
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The ONE shape a JARVIS answer wears when nobody is reading it with their
3
+ * eyes (BLI-3732).
4
+ *
5
+ * `cockpit jarvis --json` and the `bli-tower` MCP server's `jarvis_ask` reach
6
+ * the same door (`POST /api/jarvis/cli`) with the same device token and get
7
+ * the same reply body back. Before this file they described that body two
8
+ * different ways, so a script and an agent read two contracts for one turn.
9
+ * Now both build this envelope, and the five fields a consumer actually asks
10
+ * for — the answer, where it came from, which turn it was, which conversation
11
+ * it belongs to, and whether anything about it was second-rate — have one
12
+ * spelling each.
13
+ *
14
+ * ## Where the other copy is, and why
15
+ *
16
+ * This file is a deliberate copy of
17
+ * `packages/cockpit-local-collector/src/commands/jarvis-answer-envelope.ts`,
18
+ * which is the original. This package declares no dependency on that one — see
19
+ * `agent-door-session.ts` next door, whose own header makes the same call for
20
+ * the same reason — so the shape is duplicated rather than imported. Both
21
+ * copies are pinned by a test on the same literal key list
22
+ * (`JARVIS_ANSWER_ENVELOPE_KEYS` below), so a field added on one side and not
23
+ * the other fails a suite instead of drifting quietly.
24
+ *
25
+ * ## What `jarvis_ask` returns beside it
26
+ *
27
+ * The MCP tool returns exactly this envelope as its `structuredContent`, plus
28
+ * a human-readable text block. It does NOT carry the `clientLatency` /
29
+ * `latency` / `model` keys `cockpit jarvis --json` also prints: those describe
30
+ * a terminal process's own stopwatch, and this server has no such stopwatch to
31
+ * report honestly.
32
+ */
33
+ /**
34
+ * Every key of the envelope, in the order it is written. The literal list IS
35
+ * the contract: both copies assert against it, so a drift is a red suite.
36
+ */
37
+ export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "degraded", "degraded_reasons"];
38
+ /**
39
+ * The closed set of ways an answer can be second-rate without being a failure.
40
+ * A degraded answer is still an answer — it is printed, returned and counted —
41
+ * but the consumer is told which way it fell short rather than being left to
42
+ * guess from a shorter reply.
43
+ */
44
+ export type JarvisDegradedReason =
45
+ /** The requested model refused or failed and a fallback answered instead. */
46
+ "model_fallback"
47
+ /** The grounding gate took back words the model had already produced. */
48
+ | "answer_revised"
49
+ /** At least one tool the turn reached for came back failed. */
50
+ | "tool_step_failed"
51
+ /** No trace id came back, so this turn's step tree cannot be opened later. */
52
+ | "no_turn_id";
53
+ export interface JarvisAnswerEnvelope {
54
+ ok: true;
55
+ /**
56
+ * The answer verbatim, exactly as the terminal prints it — `Source:` lines
57
+ * included. Nothing is stripped: `sources` below is an index into this
58
+ * string, not a slice taken out of it.
59
+ */
60
+ answer: string;
61
+ /** Every `Source: …` line found in `answer`, in the order it appears. */
62
+ sources: string[];
63
+ /**
64
+ * This turn's trace id — what `cockpit jarvis --trace <id>` and the
65
+ * `jarvis_trace` MCP tool open. `null` against a dashboard that predates
66
+ * BLI-3560, which is also a `no_turn_id` degradation.
67
+ */
68
+ turn_id: string | null;
69
+ /**
70
+ * The conversation NAME to hand back to continue this thread (`--thread`,
71
+ * or `jarvis_ask`'s `thread`). Not a uuid — `main` is the usual value.
72
+ */
73
+ thread_id: string | null;
74
+ /**
75
+ * The Mastra thread id the turn's spans were recorded under, which is what
76
+ * `GET /api/ops/trace?thread=` takes. A different identifier from
77
+ * `thread_id` above and deliberately named apart from it.
78
+ */
79
+ trace_thread_id: string | null;
80
+ degraded: boolean;
81
+ degraded_reasons: JarvisDegradedReason[];
82
+ }
83
+ /**
84
+ * The `Source:` lines inside an answer. Pure string work on what the server
85
+ * already sent — this side never decides what a source IS, it only finds the
86
+ * lines the server wrote, so a new citation kind needs no change here.
87
+ */
88
+ export declare function extractSourceLines(answer: string): string[];
89
+ export interface JarvisAnswerEnvelopeInput {
90
+ /** The reply text the door returned. */
91
+ reply: string;
92
+ /** `body.thread`, or the thread the caller asked for when the door omitted it. */
93
+ thread?: string | null;
94
+ /** `body.traceId` — BLI-3560's per-turn id. */
95
+ traceId?: string | null;
96
+ /** `body.traceThread` — the Mastra thread the spans landed in. */
97
+ traceThread?: string | null;
98
+ /** `body.model.fallback` — whether a fallback model answered. */
99
+ modelFallback?: boolean;
100
+ /** `body.revised` — whether the grounding gate took back streamed words. */
101
+ revised?: boolean;
102
+ /** The settled trace, for the failed-step check. */
103
+ trace?: ReadonlyArray<{
104
+ status?: string;
105
+ }> | null;
106
+ }
107
+ export declare function buildJarvisAnswerEnvelope(input: JarvisAnswerEnvelopeInput): JarvisAnswerEnvelope;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The ONE shape a JARVIS answer wears when nobody is reading it with their
3
+ * eyes (BLI-3732).
4
+ *
5
+ * `cockpit jarvis --json` and the `bli-tower` MCP server's `jarvis_ask` reach
6
+ * the same door (`POST /api/jarvis/cli`) with the same device token and get
7
+ * the same reply body back. Before this file they described that body two
8
+ * different ways, so a script and an agent read two contracts for one turn.
9
+ * Now both build this envelope, and the five fields a consumer actually asks
10
+ * for — the answer, where it came from, which turn it was, which conversation
11
+ * it belongs to, and whether anything about it was second-rate — have one
12
+ * spelling each.
13
+ *
14
+ * ## Where the other copy is, and why
15
+ *
16
+ * This file is a deliberate copy of
17
+ * `packages/cockpit-local-collector/src/commands/jarvis-answer-envelope.ts`,
18
+ * which is the original. This package declares no dependency on that one — see
19
+ * `agent-door-session.ts` next door, whose own header makes the same call for
20
+ * the same reason — so the shape is duplicated rather than imported. Both
21
+ * copies are pinned by a test on the same literal key list
22
+ * (`JARVIS_ANSWER_ENVELOPE_KEYS` below), so a field added on one side and not
23
+ * the other fails a suite instead of drifting quietly.
24
+ *
25
+ * ## What `jarvis_ask` returns beside it
26
+ *
27
+ * The MCP tool returns exactly this envelope as its `structuredContent`, plus
28
+ * a human-readable text block. It does NOT carry the `clientLatency` /
29
+ * `latency` / `model` keys `cockpit jarvis --json` also prints: those describe
30
+ * a terminal process's own stopwatch, and this server has no such stopwatch to
31
+ * report honestly.
32
+ */
33
+ /**
34
+ * Every key of the envelope, in the order it is written. The literal list IS
35
+ * the contract: both copies assert against it, so a drift is a red suite.
36
+ */
37
+ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
38
+ "ok",
39
+ "answer",
40
+ "sources",
41
+ "turn_id",
42
+ "thread_id",
43
+ "trace_thread_id",
44
+ "degraded",
45
+ "degraded_reasons",
46
+ ];
47
+ /** A `Source:` line, as the grounding gate renders it into the answer. */
48
+ const SOURCE_LINE = /^\s*Source:\s*\S/;
49
+ /**
50
+ * The `Source:` lines inside an answer. Pure string work on what the server
51
+ * already sent — this side never decides what a source IS, it only finds the
52
+ * lines the server wrote, so a new citation kind needs no change here.
53
+ */
54
+ export function extractSourceLines(answer) {
55
+ return answer
56
+ .split("\n")
57
+ .map((line) => line.trim())
58
+ .filter((line) => SOURCE_LINE.test(line));
59
+ }
60
+ export function buildJarvisAnswerEnvelope(input) {
61
+ const reasons = [];
62
+ if (input.modelFallback === true)
63
+ reasons.push("model_fallback");
64
+ if (input.revised === true)
65
+ reasons.push("answer_revised");
66
+ if ((input.trace ?? []).some((step) => step?.status === "failed")) {
67
+ reasons.push("tool_step_failed");
68
+ }
69
+ const turnId = input.traceId ?? null;
70
+ if (!turnId)
71
+ reasons.push("no_turn_id");
72
+ return {
73
+ ok: true,
74
+ answer: input.reply,
75
+ sources: extractSourceLines(input.reply),
76
+ turn_id: turnId,
77
+ thread_id: input.thread ?? null,
78
+ trace_thread_id: input.traceThread ?? null,
79
+ degraded: reasons.length > 0,
80
+ degraded_reasons: reasons,
81
+ };
82
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * One JARVIS turn, and one trace read, for the `jarvis_*` MCP tools
3
+ * (BLI-3732). Everything the four tools in `jarvis-tools.ts` share lives here
4
+ * and nothing else does: the session gate, the request the dashboard actually
5
+ * receives, the envelope it becomes, and what `last` resolves to.
6
+ *
7
+ * The split exists so `jarvis-tools.ts` reads as four tool descriptions —
8
+ * which is the part a person and a model both have to understand — and the
9
+ * wire mechanics have one owner. Both halves talk to the same doors
10
+ * `cockpit jarvis` calls, with this machine's device token.
11
+ */
12
+ import { loadAgentDoorSession } from "./agent-door-session.js";
13
+ import { type FetchImpl } from "./agent-door.js";
14
+ import { type JarvisAnswerEnvelope } from "./jarvis-answer-envelope.js";
15
+ import { readCollectorTurnBookmark } from "./jarvis-turn-bookmark.js";
16
+ /** The dashboard's own turn ceiling is 120 s; this is `TURN_DEADLINE_MS` from the CLI, verbatim. */
17
+ export declare const TURN_TIMEOUT_MS = 125000;
18
+ /** A trace read touches no model. Same 30 s the CLI gives it. */
19
+ export declare const READ_TIMEOUT_MS = 30000;
20
+ /**
21
+ * Hex, 8 characters — `CONFIRMATION_CODE_PATTERN` in
22
+ * `apps/dashboard/src/lib/jarvis/dispatch/proposal.ts`, anchored here because
23
+ * this side validates a whole argument rather than scanning a sentence.
24
+ */
25
+ export declare const APPROVAL_CODE: RegExp;
26
+ export interface JarvisDeps {
27
+ fetchImpl: FetchImpl;
28
+ /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
29
+ loadSession?: typeof loadAgentDoorSession;
30
+ /** Injectable for tests; defaults to the collector's own `--trace last` bookmark. */
31
+ readBookmark?: typeof readCollectorTurnBookmark;
32
+ /** Metadata-only, stderr. stdout belongs to the MCP transport. */
33
+ log?: (line: string) => void;
34
+ }
35
+ export declare const TAG = "[bli-cockpit-mcp jarvis]";
36
+ export declare function defaultLog(line: string): void;
37
+ export declare function textResult(text: string, structured?: Record<string, unknown>): {
38
+ structuredContent?: Record<string, unknown> | undefined;
39
+ content: {
40
+ type: "text";
41
+ text: string;
42
+ }[];
43
+ };
44
+ export declare function errorResult(text: string): {
45
+ isError: boolean;
46
+ content: {
47
+ type: "text";
48
+ text: string;
49
+ }[];
50
+ };
51
+ export type ToolResult = ReturnType<typeof textResult> | ReturnType<typeof errorResult>;
52
+ export declare function withSession(deps: JarvisDeps, run: (session: {
53
+ dashboardUrl: string;
54
+ deviceToken: string;
55
+ }) => Promise<ToolResult>): Promise<ToolResult>;
56
+ export declare function doorFailureText(door: string, response: {
57
+ transportError: string | null;
58
+ status: number;
59
+ body: Record<string, unknown>;
60
+ }): string;
61
+ /** What the last successful turn on THIS server was, for `jarvis_trace last`. */
62
+ export interface LastTurn {
63
+ turnId: string | null;
64
+ traceThreadId: string | null;
65
+ }
66
+ /**
67
+ * One turn, and the envelope it becomes. Every `jarvis_*` tool that talks to a
68
+ * model goes through here, so the ask, the dispatch and the check cannot drift
69
+ * into three different request shapes.
70
+ */
71
+ export declare function takeTurn(deps: JarvisDeps, session: {
72
+ dashboardUrl: string;
73
+ deviceToken: string;
74
+ }, door: string, body: Record<string, unknown>, remember: (turn: LastTurn) => void): Promise<{
75
+ result: ToolResult;
76
+ envelope: JarvisAnswerEnvelope | null;
77
+ }>;
78
+ /**
79
+ * Which turn `last` means. Three sources, newest knowledge first: this
80
+ * server's own memory of the turn it just took, then the bookmark
81
+ * `cockpit jarvis` writes on the same machine, then — when the bookmark knew
82
+ * only the conversation — that conversation's newest recorded turn.
83
+ */
84
+ export declare function resolveLastTurn(deps: JarvisDeps, session: {
85
+ dashboardUrl: string;
86
+ deviceToken: string;
87
+ }, lastTurn: LastTurn): Promise<{
88
+ ok: true;
89
+ traceId: string;
90
+ from: "server_memory" | "bookmark" | "thread";
91
+ } | {
92
+ ok: false;
93
+ reason: string;
94
+ message: string;
95
+ }>;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * One JARVIS turn, and one trace read, for the `jarvis_*` MCP tools
3
+ * (BLI-3732). Everything the four tools in `jarvis-tools.ts` share lives here
4
+ * and nothing else does: the session gate, the request the dashboard actually
5
+ * receives, the envelope it becomes, and what `last` resolves to.
6
+ *
7
+ * The split exists so `jarvis-tools.ts` reads as four tool descriptions —
8
+ * which is the part a person and a model both have to understand — and the
9
+ * wire mechanics have one owner. Both halves talk to the same doors
10
+ * `cockpit jarvis` calls, with this machine's device token.
11
+ */
12
+ import { loadAgentDoorSession } from "./agent-door-session.js";
13
+ import { callAgentDoor } from "./agent-door.js";
14
+ import { buildJarvisAnswerEnvelope, } from "./jarvis-answer-envelope.js";
15
+ import { readCollectorTurnBookmark } from "./jarvis-turn-bookmark.js";
16
+ /** The dashboard's own turn ceiling is 120 s; this is `TURN_DEADLINE_MS` from the CLI, verbatim. */
17
+ export const TURN_TIMEOUT_MS = 125_000;
18
+ /** A trace read touches no model. Same 30 s the CLI gives it. */
19
+ export const READ_TIMEOUT_MS = 30_000;
20
+ /**
21
+ * Hex, 8 characters — `CONFIRMATION_CODE_PATTERN` in
22
+ * `apps/dashboard/src/lib/jarvis/dispatch/proposal.ts`, anchored here because
23
+ * this side validates a whole argument rather than scanning a sentence.
24
+ */
25
+ export const APPROVAL_CODE = /^[0-9a-f]{8}$/i;
26
+ export const TAG = "[bli-cockpit-mcp jarvis]";
27
+ export function defaultLog(line) {
28
+ process.stderr.write(`${line}\n`);
29
+ }
30
+ export function textResult(text, structured) {
31
+ return {
32
+ content: [{ type: "text", text }],
33
+ ...(structured ? { structuredContent: structured } : {}),
34
+ };
35
+ }
36
+ export function errorResult(text) {
37
+ return { isError: true, content: [{ type: "text", text }] };
38
+ }
39
+ export async function withSession(deps, run) {
40
+ const loadSession = deps.loadSession ?? loadAgentDoorSession;
41
+ const loaded = loadSession();
42
+ if (!loaded.ok) {
43
+ (deps.log ?? defaultLog)(`${TAG} refused ${JSON.stringify({ reason: loaded.reason })}`);
44
+ return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
45
+ }
46
+ return run(loaded.session);
47
+ }
48
+ export function doorFailureText(door, response) {
49
+ if (response.transportError) {
50
+ return `Tower could not be reached for ${door} (${response.transportError}). Nothing was asked or written.`;
51
+ }
52
+ const reason = typeof response.body.error === "string"
53
+ ? response.body.error
54
+ : typeof response.body.reason === "string"
55
+ ? response.body.reason
56
+ : "unknown_error";
57
+ const message = typeof response.body.reply === "string"
58
+ ? response.body.reply
59
+ : typeof response.body.message === "string"
60
+ ? response.body.message
61
+ : `Tower answered ${response.status}.`;
62
+ return `Tower refused ${door} (${reason}): ${message}`;
63
+ }
64
+ /**
65
+ * One turn, and the envelope it becomes. Every `jarvis_*` tool that talks to a
66
+ * model goes through here, so the ask, the dispatch and the check cannot drift
67
+ * into three different request shapes.
68
+ */
69
+ export async function takeTurn(deps, session, door, body, remember) {
70
+ const log = deps.log ?? defaultLog;
71
+ const startedAt = Date.now();
72
+ // `accept: application/json` (which `callAgentDoor` always sends) is what
73
+ // makes the dashboard answer one JSON body instead of the NDJSON stream the
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);
76
+ if (!response.ok) {
77
+ log(`${TAG} turn failed ${JSON.stringify({
78
+ door,
79
+ status: response.status,
80
+ transport: response.transportError !== null,
81
+ elapsed_ms: Date.now() - startedAt,
82
+ })}`);
83
+ return { result: errorResult(doorFailureText(door, response)), envelope: null };
84
+ }
85
+ const reply = response.body;
86
+ if (reply.ok === false || typeof reply.reply !== "string" || reply.reply.length === 0) {
87
+ log(`${TAG} turn empty ${JSON.stringify({
88
+ door,
89
+ reason: reply.error ?? "no_reply",
90
+ elapsed_ms: Date.now() - startedAt,
91
+ })}`);
92
+ return {
93
+ result: errorResult(`JARVIS did not answer ${door} (${reply.error ?? "no_reply"}). ${reply.reply ?? "Tower returned no text."}`),
94
+ envelope: null,
95
+ };
96
+ }
97
+ const envelope = buildJarvisAnswerEnvelope({
98
+ reply: reply.reply,
99
+ thread: reply.thread ?? (typeof body.thread === "string" ? body.thread : null),
100
+ traceId: reply.traceId,
101
+ traceThread: reply.traceThread,
102
+ modelFallback: reply.model?.fallback,
103
+ revised: reply.revised,
104
+ trace: reply.trace,
105
+ });
106
+ remember({ turnId: envelope.turn_id, traceThreadId: envelope.trace_thread_id });
107
+ log(`${TAG} answered ${JSON.stringify({
108
+ door,
109
+ reply_chars: envelope.answer.length,
110
+ sources: envelope.sources.length,
111
+ trace_steps: reply.trace?.length ?? 0,
112
+ degraded: envelope.degraded,
113
+ degraded_reasons: envelope.degraded_reasons,
114
+ has_turn_id: envelope.turn_id !== null,
115
+ elapsed_ms: Date.now() - startedAt,
116
+ })}`);
117
+ const footer = [
118
+ envelope.turn_id ? `turn_id: ${envelope.turn_id} (open it with jarvis_trace)` : null,
119
+ envelope.thread_id ? `thread_id: ${envelope.thread_id}` : null,
120
+ envelope.degraded ? `degraded: ${envelope.degraded_reasons.join(", ")}` : null,
121
+ ]
122
+ .filter((line) => line !== null)
123
+ .join("\n");
124
+ return {
125
+ result: textResult(`${envelope.answer}\n\n---\n${footer}`, { ...envelope }),
126
+ envelope,
127
+ };
128
+ }
129
+ /**
130
+ * Which turn `last` means. Three sources, newest knowledge first: this
131
+ * server's own memory of the turn it just took, then the bookmark
132
+ * `cockpit jarvis` writes on the same machine, then — when the bookmark knew
133
+ * only the conversation — that conversation's newest recorded turn.
134
+ */
135
+ export async function resolveLastTurn(deps, session, lastTurn) {
136
+ if (lastTurn.turnId)
137
+ return { ok: true, traceId: lastTurn.turnId, from: "server_memory" };
138
+ const bookmark = await (deps.readBookmark ?? readCollectorTurnBookmark)();
139
+ const threadId = lastTurn.traceThreadId ?? bookmark?.threadId ?? null;
140
+ if (bookmark?.traceId)
141
+ return { ok: true, traceId: bookmark.traceId, from: "bookmark" };
142
+ if (threadId) {
143
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/trace?thread=${encodeURIComponent(threadId)}&limit=1`, undefined, READ_TIMEOUT_MS);
144
+ const traces = response.ok
145
+ ? (response.body.traces ?? [])
146
+ : [];
147
+ const newest = traces[0]?.traceId;
148
+ if (newest)
149
+ return { ok: true, traceId: newest, from: "thread" };
150
+ return {
151
+ ok: false,
152
+ reason: "no_traces_in_thread",
153
+ message: "That conversation has no recorded turns inside the 30-day retention. Ask something with "
154
+ + "jarvis_ask and trace that turn, or pass a turn_id.",
155
+ };
156
+ }
157
+ return {
158
+ ok: false,
159
+ reason: "no_remembered_turn",
160
+ message: 'Nothing has been asked on this machine yet, so "last" names no turn. Call jarvis_ask first, '
161
+ + "then pass the turn_id it returns.",
162
+ };
163
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `jarvis_*` MCP tools (BLI-3732) — JARVIS on the `bli-tower` server, over the
3
+ * exact doors `cockpit jarvis` calls with this machine's device token.
4
+ *
5
+ * The point of this file in one sentence: an agent on Codex or Claude Code
6
+ * should be able to ask JARVIS a question, read how it answered, and spend a
7
+ * human's coding-arm approval, without a browser and without a second
8
+ * vocabulary. Four tools, three doors, no new server-side surface:
9
+ *
10
+ * jarvis_ask POST /api/jarvis/cli one turn, the same one
11
+ * `cockpit jarvis` takes
12
+ * jarvis_trace GET /api/ops/trace/<id> that turn's step tree, rendered
13
+ * SERVER-side and printed verbatim
14
+ * jarvis_dispatch POST /api/jarvis/cli a coding-arm turn, carrying the
15
+ * jarvis_check person's own approval code
16
+ *
17
+ * The wire mechanics they share — the session gate, the request the dashboard
18
+ * actually receives, the envelope it becomes, and what `last` resolves to —
19
+ * live in `jarvis-door.ts`, so this file reads as four tool descriptions,
20
+ * which is the part a person and a model both have to understand.
21
+ *
22
+ * ## The one contract
23
+ *
24
+ * `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
25
+ * `cockpit jarvis --json` prints. A script and an agent read one shape.
26
+ *
27
+ * ## The approval code, and what this surface can and cannot prove
28
+ *
29
+ * The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
30
+ * exact plan folded with the asking account, and it must have come out of the
31
+ * HUMAN's own message. Lock 1 is untouched here — this server holds no secret
32
+ * and cannot compute a code for any plan, so an invented code is refused by
33
+ * the dashboard exactly as it always was.
34
+ *
35
+ * Lock 2 is weaker on THIS surface than it is in a terminal, and saying so is
36
+ * the honest thing to do. In a terminal the person types the code themselves.
37
+ * Through MCP the code arrives as a tool argument, and the dashboard cannot
38
+ * tell a code a person handed their agent from one the agent lifted out of the
39
+ * previous answer by itself. So: this tool takes the code as a parameter, it
40
+ * NEVER derives, guesses or fabricates one, its description tells the model in
41
+ * plain words that the code must come from the person, and every relay is
42
+ * logged (presence and length only, never the code). Anything stronger — a
43
+ * per-code single use, an out-of-band confirmation — is a server-side change
44
+ * to the gate itself and belongs with the gate, not here.
45
+ */
46
+ import { type JarvisDeps } from "./jarvis-door.js";
47
+ export type { JarvisDeps } from "./jarvis-door.js";
48
+ export declare function registerJarvisTools(server: {
49
+ registerTool: (...args: never[]) => unknown;
50
+ }, deps: JarvisDeps): void;