@bli-cockpit/cli 0.2.64 → 0.2.65

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.
@@ -27,6 +27,25 @@
27
27
  * older `reply` / `thread` / `traceId` / `model` / `trace` / `latency` /
28
28
  * `clientLatency` keys are untouched, because scripts already read them and a
29
29
  * one-contract ticket that broke the contract would be a joke.
30
+ *
31
+ * ## Where `sources` comes from (BLI-3770)
32
+ *
33
+ * From the DOOR, as objects, when the door sends them: `POST /api/jarvis/cli`
34
+ * carries `sources` built from the citations the grounding layer minted, and
35
+ * the `Source:` lines inside `answer` are rendered from that same array. The
36
+ * older derivation — a line-anchored regex over the finished prose — could
37
+ * only find a citation the renderer happened to put on its own line, so QA
38
+ * tick 16's c9, c9b and n9 (all off `searchEverything`) ended *"…Source:
39
+ * issue, at /work/BLI-3706, by …"* INSIDE the final sentence and returned
40
+ * `"sources": []`: the person saw a citation, the machine did not.
41
+ *
42
+ * The prose derivation survives as the FALLBACK, for a dashboard deployed
43
+ * before that door field existed, and it is wider than it was: a whole
44
+ * `Sources:` line (plural, any case, bulleted) counts — that is what the
45
+ * server itself treats as provenance — and so does a citation that begins a
46
+ * sentence inside a paragraph. A `Source:` in the MIDDLE of a sentence ("the
47
+ * Source: field on that row was blank") is prose about a source, not a
48
+ * source, and is deliberately not matched.
30
49
  */
31
50
  /**
32
51
  * Every key of the envelope, in the order it is written. The literal list IS
@@ -43,8 +62,20 @@ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
43
62
  "degraded",
44
63
  "degraded_reasons",
45
64
  ];
46
- /** A `Source:` line, as the grounding gate renders it into the answer. */
47
- const SOURCE_LINE = /^\s*Source:\s*\S/;
65
+ /**
66
+ * A citation line, in every shape the server itself treats as one.
67
+ *
68
+ * BLI-3770 widened this from `/^\s*Source:\s*\S/` to mirror
69
+ * `apps/dashboard/src/lib/jarvis/chat-v2/provenance-lines.ts`, which matches
70
+ * `sources?` case-insensitively with an optional bullet. The narrow version
71
+ * meant a `Sources: …` line the server had already lifted out of the body as
72
+ * provenance, and put straight back under the answer, was not a source to
73
+ * this side: one thing, two definitions, in two packages.
74
+ */
75
+ const SOURCE_LINE = /^\s*(?:[-*\u2022]\s+)?sources?\s*:\s*\S/i;
76
+ /** The receipt's link, as `appendCitations` lays it out: `<url>` after the words. */
77
+ const BRACKETED_LINK = /\s*<(https?:\/\/[^>\s]+)>\s*$/;
78
+ const TRAILING_LINK = /\s+(https?:\/\/\S+)\s*$/;
48
79
  /**
49
80
  * The `Source:` lines inside an answer. Pure string work on what the server
50
81
  * already sent — this side never decides what a source IS, it only finds the
@@ -56,6 +87,90 @@ export function extractSourceLines(answer) {
56
87
  .map((line) => line.trim())
57
88
  .filter((line) => SOURCE_LINE.test(line));
58
89
  }
90
+ /** The words and the link, kept apart, the way every surface renders them. */
91
+ function splitLink(line) {
92
+ const trimmed = line.trim().replace(/^[-*\u2022]\s+/, "");
93
+ const bracketed = trimmed.match(BRACKETED_LINK);
94
+ if (bracketed)
95
+ return { label: trimmed.slice(0, bracketed.index).trim(), href: bracketed[1] ?? null };
96
+ const trailing = trimmed.match(TRAILING_LINK);
97
+ if (trailing)
98
+ return { label: trimmed.slice(0, trailing.index).trim(), href: trailing[1] ?? null };
99
+ return { label: trimmed, href: null };
100
+ }
101
+ /**
102
+ * A citation the model wrote INSIDE a paragraph (BLI-3770).
103
+ *
104
+ * It counts when it begins a sentence, which is the shape QA tick 16 caught
105
+ * three times. It deliberately does not count in the middle of one — "the
106
+ * Source: field on that row was blank" is a sentence about a field, and
107
+ * treating it as a receipt would put prose in a contract that is supposed to
108
+ * carry evidence.
109
+ */
110
+ function inlineSourceFragments(line) {
111
+ return line
112
+ .split(/(?<=[.!?])\s+/)
113
+ .map((part) => part.trim())
114
+ .filter((part) => SOURCE_LINE.test(part));
115
+ }
116
+ /**
117
+ * The fallback derivation: what this side can tell from the answer alone.
118
+ *
119
+ * Only reached when the door sent no structured `sources` — a dashboard
120
+ * deployed before BLI-3770, or a turn whose citation nothing minted. Every
121
+ * row it produces is labelled `kind: "prose"` so a consumer can tell a
122
+ * recovered line from a minted receipt.
123
+ */
124
+ export function sourcesFromAnswer(answer) {
125
+ const found = [];
126
+ const seen = new Set();
127
+ const add = (text) => {
128
+ const { label, href } = splitLink(text);
129
+ if (!label || seen.has(label))
130
+ return;
131
+ seen.add(label);
132
+ found.push({ kind: "prose", label, href, id: null, tool: null });
133
+ };
134
+ for (const raw of answer.split("\n")) {
135
+ const line = raw.trim();
136
+ if (!line)
137
+ continue;
138
+ if (SOURCE_LINE.test(line)) {
139
+ add(line);
140
+ continue;
141
+ }
142
+ for (const fragment of inlineSourceFragments(line))
143
+ add(fragment);
144
+ }
145
+ return found;
146
+ }
147
+ /**
148
+ * The door's own `sources`, checked rather than trusted. A field that arrives
149
+ * as something other than a list of labelled objects is treated as absent, so
150
+ * a malformed body degrades to the prose fallback instead of putting
151
+ * `[object Object]` in front of an agent.
152
+ */
153
+ function doorSources(sent) {
154
+ if (!Array.isArray(sent))
155
+ return [];
156
+ const rows = [];
157
+ for (const entry of sent) {
158
+ if (!entry || typeof entry !== "object")
159
+ continue;
160
+ const row = entry;
161
+ const label = typeof row.label === "string" ? row.label.trim() : "";
162
+ if (!label)
163
+ continue;
164
+ rows.push({
165
+ kind: typeof row.kind === "string" && row.kind ? row.kind : "unknown",
166
+ label,
167
+ href: typeof row.href === "string" ? row.href : null,
168
+ id: typeof row.id === "string" ? row.id : null,
169
+ tool: typeof row.tool === "string" ? row.tool : null,
170
+ });
171
+ }
172
+ return rows;
173
+ }
59
174
  export function buildJarvisAnswerEnvelope(input) {
60
175
  const reasons = [];
61
176
  if (input.modelFallback === true)
@@ -68,10 +183,14 @@ export function buildJarvisAnswerEnvelope(input) {
68
183
  const turnId = input.traceId ?? null;
69
184
  if (!turnId)
70
185
  reasons.push("no_turn_id");
186
+ const sent = doorSources(input.sources);
71
187
  return {
72
188
  ok: true,
73
189
  answer: input.reply,
74
- sources: extractSourceLines(input.reply),
190
+ // BLI-3770: the door's own citations when it sent them, and only then the
191
+ // prose fallback. Never both — a turn's receipts have one origin, and
192
+ // merging the two would double-count the lines the door already described.
193
+ sources: sent.length > 0 ? sent : sourcesFromAnswer(input.reply),
75
194
  turn_id: turnId,
76
195
  thread_id: input.thread ?? null,
77
196
  trace_thread_id: input.traceThread ?? null,
@@ -158,6 +158,10 @@ export async function sendOneTurn(context, prompt, io) {
158
158
  revised: body.revised,
159
159
  trace,
160
160
  proposalId: body.proposalId,
161
+ // BLI-3770: the door's own structured citations. Absent from a
162
+ // dashboard that predates it, and the envelope falls back to
163
+ // reading the answer's own `Source:` lines when it is.
164
+ sources: body.sources,
161
165
  }),
162
166
  reply: body.reply,
163
167
  thread: body.thread ?? context.command.thread,
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.64");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.65");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.64",
3
+ "version": "0.2.65",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.9",
31
- "@bli-cockpit/mcp": "0.1.7",
31
+ "@bli-cockpit/mcp": "0.1.8",
32
32
  "@bli-cockpit/telemetry-core": "0.1.30"
33
33
  }
34
34
  }