@bli-cockpit/mcp 0.1.7 → 0.1.8

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.
@@ -29,12 +29,55 @@
29
29
  * `latency` / `model` keys `cockpit jarvis --json` also prints: those describe
30
30
  * a terminal process's own stopwatch, and this server has no such stopwatch to
31
31
  * report honestly.
32
+ *
33
+ * ## Where `sources` comes from (BLI-3770)
34
+ *
35
+ * From the DOOR, as objects, when the door sends them: `POST /api/jarvis/cli`
36
+ * carries `sources` built from the citations the grounding layer minted, and
37
+ * the `Source:` lines inside `answer` are rendered from that same array. The
38
+ * older derivation — a line-anchored regex over the finished prose — could
39
+ * only find a citation the renderer happened to put on its own line, so QA
40
+ * tick 16's c9, c9b and n9 (all off `searchEverything`) ended *"…Source:
41
+ * issue, at /work/BLI-3706, by …"* INSIDE the final sentence and returned
42
+ * `"sources": []`: the person saw a citation, the machine did not.
43
+ *
44
+ * The prose derivation survives as the FALLBACK, for a dashboard deployed
45
+ * before that door field existed, and it is wider than it was: a whole
46
+ * `Sources:` line (plural, any case, bulleted) counts — that is what the
47
+ * server itself treats as provenance — and so does a citation that begins a
48
+ * sentence inside a paragraph. A `Source:` in the MIDDLE of a sentence ("the
49
+ * Source: field on that row was blank") is prose about a source, not a
50
+ * source, and is deliberately not matched.
32
51
  */
33
52
  /**
34
53
  * Every key of the envelope, in the order it is written. The literal list IS
35
54
  * the contract: both copies assert against it, so a drift is a red suite.
36
55
  */
37
56
  export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "proposal_id", "degraded", "degraded_reasons"];
57
+ /**
58
+ * One thing that backed the answer (BLI-3770).
59
+ *
60
+ * Minted by the grounding layer with its `kind` already decided, so a caller
61
+ * can tell an issue from a document from a live tool read without parsing the
62
+ * words back apart. `kind: "prose"` means this envelope recovered the line
63
+ * from the answer itself because the door sent no structured sources — an
64
+ * older dashboard, or a citation the model wrote that nothing minted.
65
+ *
66
+ * The same shape is declared by `apps/dashboard/src/lib/jarvis/chat-v2/
67
+ * answer-sources.ts`, which is where the array is built.
68
+ */
69
+ export interface JarvisAnswerSource {
70
+ /** `issue`, `doc`, `channel`, `memory`, `session`, `note`, `linear`, `web`, `live_read`, `conversation`, `brief`, `correction`, or `prose`. */
71
+ kind: string;
72
+ /** The words the citation carries, without its link. */
73
+ label: string;
74
+ /** The durable link, when the receipt has one. */
75
+ href: string | null;
76
+ /** The thing cited, when it has an identifier a caller could fetch again. */
77
+ id: string | null;
78
+ /** The belt tool the evidence came off, when one did. */
79
+ tool: string | null;
80
+ }
38
81
  /**
39
82
  * The closed set of ways an answer can be second-rate without being a failure.
40
83
  * A degraded answer is still an answer — it is printed, returned and counted —
@@ -54,12 +97,18 @@ export interface JarvisAnswerEnvelope {
54
97
  ok: true;
55
98
  /**
56
99
  * 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.
100
+ * included. Nothing is stripped, and nothing is derived from it either:
101
+ * since BLI-3770 `sources` below is the array those lines were RENDERED
102
+ * FROM, not a reading taken back out of this string.
59
103
  */
60
104
  answer: string;
61
- /** Every `Source: …` line found in `answer`, in the order it appears. */
62
- sources: string[];
105
+ /**
106
+ * What backed the answer, in the order the grounding layer minted it.
107
+ *
108
+ * BLI-3770: these are objects, not lines. Empty means this turn cited
109
+ * nothing — never "the citation was on a line this side could not parse".
110
+ */
111
+ sources: JarvisAnswerSource[];
63
112
  /**
64
113
  * This turn's trace id — what `cockpit jarvis --trace <id>` and the
65
114
  * `jarvis_trace` MCP tool open. `null` against a dashboard that predates
@@ -96,6 +145,15 @@ export interface JarvisAnswerEnvelope {
96
145
  * lines the server wrote, so a new citation kind needs no change here.
97
146
  */
98
147
  export declare function extractSourceLines(answer: string): string[];
148
+ /**
149
+ * The fallback derivation: what this side can tell from the answer alone.
150
+ *
151
+ * Only reached when the door sent no structured `sources` — a dashboard
152
+ * deployed before BLI-3770, or a turn whose citation nothing minted. Every
153
+ * row it produces is labelled `kind: "prose"` so a consumer can tell a
154
+ * recovered line from a minted receipt.
155
+ */
156
+ export declare function sourcesFromAnswer(answer: string): JarvisAnswerSource[];
99
157
  export interface JarvisAnswerEnvelopeInput {
100
158
  /** The reply text the door returned. */
101
159
  reply: string;
@@ -115,5 +173,11 @@ export interface JarvisAnswerEnvelopeInput {
115
173
  }> | null;
116
174
  /** `body.proposalId` — BLI-3755's coding-arm proposal id, when the turn made one. */
117
175
  proposalId?: string | null;
176
+ /**
177
+ * `body.sources` — BLI-3770's structured citations, straight from the door.
178
+ * Typed as `unknown` on purpose: this is another process's JSON, and the
179
+ * one place it is shaped is `doorSources` above.
180
+ */
181
+ sources?: unknown;
118
182
  }
119
183
  export declare function buildJarvisAnswerEnvelope(input: JarvisAnswerEnvelopeInput): JarvisAnswerEnvelope;
@@ -29,6 +29,25 @@
29
29
  * `latency` / `model` keys `cockpit jarvis --json` also prints: those describe
30
30
  * a terminal process's own stopwatch, and this server has no such stopwatch to
31
31
  * report honestly.
32
+ *
33
+ * ## Where `sources` comes from (BLI-3770)
34
+ *
35
+ * From the DOOR, as objects, when the door sends them: `POST /api/jarvis/cli`
36
+ * carries `sources` built from the citations the grounding layer minted, and
37
+ * the `Source:` lines inside `answer` are rendered from that same array. The
38
+ * older derivation — a line-anchored regex over the finished prose — could
39
+ * only find a citation the renderer happened to put on its own line, so QA
40
+ * tick 16's c9, c9b and n9 (all off `searchEverything`) ended *"…Source:
41
+ * issue, at /work/BLI-3706, by …"* INSIDE the final sentence and returned
42
+ * `"sources": []`: the person saw a citation, the machine did not.
43
+ *
44
+ * The prose derivation survives as the FALLBACK, for a dashboard deployed
45
+ * before that door field existed, and it is wider than it was: a whole
46
+ * `Sources:` line (plural, any case, bulleted) counts — that is what the
47
+ * server itself treats as provenance — and so does a citation that begins a
48
+ * sentence inside a paragraph. A `Source:` in the MIDDLE of a sentence ("the
49
+ * Source: field on that row was blank") is prose about a source, not a
50
+ * source, and is deliberately not matched.
32
51
  */
33
52
  /**
34
53
  * Every key of the envelope, in the order it is written. The literal list IS
@@ -45,8 +64,20 @@ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
45
64
  "degraded",
46
65
  "degraded_reasons",
47
66
  ];
48
- /** A `Source:` line, as the grounding gate renders it into the answer. */
49
- const SOURCE_LINE = /^\s*Source:\s*\S/;
67
+ /**
68
+ * A citation line, in every shape the server itself treats as one.
69
+ *
70
+ * BLI-3770 widened this from `/^\s*Source:\s*\S/` to mirror
71
+ * `apps/dashboard/src/lib/jarvis/chat-v2/provenance-lines.ts`, which matches
72
+ * `sources?` case-insensitively with an optional bullet. The narrow version
73
+ * meant a `Sources: …` line the server had already lifted out of the body as
74
+ * provenance, and put straight back under the answer, was not a source to
75
+ * this side: one thing, two definitions, in two packages.
76
+ */
77
+ const SOURCE_LINE = /^\s*(?:[-*\u2022]\s+)?sources?\s*:\s*\S/i;
78
+ /** The receipt's link, as `appendCitations` lays it out: `<url>` after the words. */
79
+ const BRACKETED_LINK = /\s*<(https?:\/\/[^>\s]+)>\s*$/;
80
+ const TRAILING_LINK = /\s+(https?:\/\/\S+)\s*$/;
50
81
  /**
51
82
  * The `Source:` lines inside an answer. Pure string work on what the server
52
83
  * already sent — this side never decides what a source IS, it only finds the
@@ -58,6 +89,90 @@ export function extractSourceLines(answer) {
58
89
  .map((line) => line.trim())
59
90
  .filter((line) => SOURCE_LINE.test(line));
60
91
  }
92
+ /** The words and the link, kept apart, the way every surface renders them. */
93
+ function splitLink(line) {
94
+ const trimmed = line.trim().replace(/^[-*\u2022]\s+/, "");
95
+ const bracketed = trimmed.match(BRACKETED_LINK);
96
+ if (bracketed)
97
+ return { label: trimmed.slice(0, bracketed.index).trim(), href: bracketed[1] ?? null };
98
+ const trailing = trimmed.match(TRAILING_LINK);
99
+ if (trailing)
100
+ return { label: trimmed.slice(0, trailing.index).trim(), href: trailing[1] ?? null };
101
+ return { label: trimmed, href: null };
102
+ }
103
+ /**
104
+ * A citation the model wrote INSIDE a paragraph (BLI-3770).
105
+ *
106
+ * It counts when it begins a sentence, which is the shape QA tick 16 caught
107
+ * three times. It deliberately does not count in the middle of one — "the
108
+ * Source: field on that row was blank" is a sentence about a field, and
109
+ * treating it as a receipt would put prose in a contract that is supposed to
110
+ * carry evidence.
111
+ */
112
+ function inlineSourceFragments(line) {
113
+ return line
114
+ .split(/(?<=[.!?])\s+/)
115
+ .map((part) => part.trim())
116
+ .filter((part) => SOURCE_LINE.test(part));
117
+ }
118
+ /**
119
+ * The fallback derivation: what this side can tell from the answer alone.
120
+ *
121
+ * Only reached when the door sent no structured `sources` — a dashboard
122
+ * deployed before BLI-3770, or a turn whose citation nothing minted. Every
123
+ * row it produces is labelled `kind: "prose"` so a consumer can tell a
124
+ * recovered line from a minted receipt.
125
+ */
126
+ export function sourcesFromAnswer(answer) {
127
+ const found = [];
128
+ const seen = new Set();
129
+ const add = (text) => {
130
+ const { label, href } = splitLink(text);
131
+ if (!label || seen.has(label))
132
+ return;
133
+ seen.add(label);
134
+ found.push({ kind: "prose", label, href, id: null, tool: null });
135
+ };
136
+ for (const raw of answer.split("\n")) {
137
+ const line = raw.trim();
138
+ if (!line)
139
+ continue;
140
+ if (SOURCE_LINE.test(line)) {
141
+ add(line);
142
+ continue;
143
+ }
144
+ for (const fragment of inlineSourceFragments(line))
145
+ add(fragment);
146
+ }
147
+ return found;
148
+ }
149
+ /**
150
+ * The door's own `sources`, checked rather than trusted. A field that arrives
151
+ * as something other than a list of labelled objects is treated as absent, so
152
+ * a malformed body degrades to the prose fallback instead of putting
153
+ * `[object Object]` in front of an agent.
154
+ */
155
+ function doorSources(sent) {
156
+ if (!Array.isArray(sent))
157
+ return [];
158
+ const rows = [];
159
+ for (const entry of sent) {
160
+ if (!entry || typeof entry !== "object")
161
+ continue;
162
+ const row = entry;
163
+ const label = typeof row.label === "string" ? row.label.trim() : "";
164
+ if (!label)
165
+ continue;
166
+ rows.push({
167
+ kind: typeof row.kind === "string" && row.kind ? row.kind : "unknown",
168
+ label,
169
+ href: typeof row.href === "string" ? row.href : null,
170
+ id: typeof row.id === "string" ? row.id : null,
171
+ tool: typeof row.tool === "string" ? row.tool : null,
172
+ });
173
+ }
174
+ return rows;
175
+ }
61
176
  export function buildJarvisAnswerEnvelope(input) {
62
177
  const reasons = [];
63
178
  if (input.modelFallback === true)
@@ -70,10 +185,14 @@ export function buildJarvisAnswerEnvelope(input) {
70
185
  const turnId = input.traceId ?? null;
71
186
  if (!turnId)
72
187
  reasons.push("no_turn_id");
188
+ const sent = doorSources(input.sources);
73
189
  return {
74
190
  ok: true,
75
191
  answer: input.reply,
76
- sources: extractSourceLines(input.reply),
192
+ // BLI-3770: the door's own citations when it sent them, and only then the
193
+ // prose fallback. Never both — a turn's receipts have one origin, and
194
+ // merging the two would double-count the lines the door already described.
195
+ sources: sent.length > 0 ? sent : sourcesFromAnswer(input.reply),
77
196
  turn_id: turnId,
78
197
  thread_id: input.thread ?? null,
79
198
  trace_thread_id: input.traceThread ?? null,
@@ -110,6 +110,8 @@ export async function takeTurn(deps, session, door, body, remember) {
110
110
  revised: reply.revised,
111
111
  trace: reply.trace,
112
112
  proposalId: reply.proposalId,
113
+ // BLI-3770: the door's own structured citations, when it sent them.
114
+ sources: reply.sources,
113
115
  });
114
116
  remember({ turnId: envelope.turn_id, traceThreadId: envelope.trace_thread_id });
115
117
  log(`${TAG} answered ${JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "description": "bli-tower — an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",