@clien-ai/mcp 0.4.0 → 0.5.1

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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Render safety — the guards every renderer in this package puts between untrusted
3
+ * text and its own line-structured output.
4
+ *
5
+ * These accreted inside `research.ts` (FUL-247, FUL-252) because that is where the
6
+ * first caller happened to live. FUL-253 then added `safeInline` call sites across
7
+ * six more modules, which made `research.ts` — the `clien_research` tool contract
8
+ * plus the shared `apiCall` HTTP client — the import hub for a concern it has
9
+ * nothing to do with. They live here instead: one module, no dependencies, imported
10
+ * by every renderer.
11
+ *
12
+ * Two of these are named guards rather than open-coded steps precisely because
13
+ * forgetting one on a new field is silent. `collapseWhitespace` stops a newline in
14
+ * an untrusted value from FORGING a line of the renderer's own output; the doc
15
+ * comments below carry the specific instances that were found, the measured digest
16
+ * cost of `RECEIPT_QUOTE_MAX`, and the trap in `locateSpan` where a case-sensitive
17
+ * lookup silently re-creates the bug `clipReceiptQuote` exists to fix.
18
+ *
19
+ * Behaviour here is pinned from the CONSUMER side — `agent-visible-output.test.ts`
20
+ * and `render-coverage.test.ts` drive real handlers and assert on the rendered
21
+ * text — so this module has no tests of its own beyond `clipReceiptQuote`'s
22
+ * windowing arithmetic in `research.test.ts`.
23
+ */
24
+ export function truncate(s, n) {
25
+ return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
26
+ }
27
+ /**
28
+ * How much of a source receipt's verbatim quote reaches agent-visible text (FUL-247).
29
+ *
30
+ * Lives here, beside `truncate`, because TWO surfaces render receipts —
31
+ * `renderPersonaProfile` (`get_persona`) and `renderReceiptPools` (`get_report`'s
32
+ * trust digest) — and they must clip identically. An agent that reads a quote on
33
+ * one surface and the same quote clipped differently on the other has to wonder
34
+ * which one is the source's actual wording; neither is, but only a consistent
35
+ * ellipsis makes that unambiguous.
36
+ *
37
+ * FUL-252 made that "identically" structural instead of conventional: both surfaces
38
+ * now go through `clipReceiptQuote`, which spends this same budget but may WINDOW it
39
+ * around a verified span. `get_persona` passes no spans — `GET /api/personas/{id}`
40
+ * carries no claim spine, so no span is knowable there — and gets the head clip. The
41
+ * asymmetry is in the INPUT, not in two renderers drifting apart, and a windowed
42
+ * excerpt announces itself with a leading `…` so neither surface can be misread as
43
+ * the source's opening words.
44
+ *
45
+ * 200 chars is roughly two sentences: enough to judge whether a quote supports the
46
+ * claim pointing at it, short enough that one long forum post cannot crowd out the
47
+ * other receipts in a pool capped at 40 entries.
48
+ *
49
+ * DIGEST COST, stated because a digest an agent truncates is a digest it does not
50
+ * read. The worst case is BOUNDED BY CONSTRUCTION, not by hope: the persona pool
51
+ * renders at most `CLAIM_RENDER_CAP` (40) receipts, so quotes can add at most
52
+ * 40 × (200 + ~6 for the indent, quotes and newline) ≈ 8.4 KB to `get_report`'s
53
+ * text — measured at +8,360 bytes on a saturated fixture, which is ~14% on the
54
+ * ~58 KB digest FUL-243 shipped. Raising either constant raises that ceiling
55
+ * multiplicatively; do the arithmetic before you do.
56
+ */
57
+ export const RECEIPT_QUOTE_MAX = 200;
58
+ /**
59
+ * Flatten a value's internal whitespace before it is interpolated into a
60
+ * LINE-STRUCTURED render.
61
+ *
62
+ * ⚠️ THIS IS A FORGERY GUARD, not formatting. Every renderer in this package
63
+ * builds its output as lines with meaning — `- RCP-p0-s0 — platform — url`,
64
+ * `Funding: <value> [GROUNDED]`, ` id: <uuid>`. The values interpolated into
65
+ * them include VERBATIM THIRD-PARTY TEXT (forum quotes) and agent-written free
66
+ * text (company facts, strengths). A newline inside one of those values does not
67
+ * merely look untidy — it ends the line early, and everything after it becomes a
68
+ * new line the reader parses as the renderer's own output. Two real instances,
69
+ * both found by adversarial review of FUL-247 rather than reasoned about:
70
+ *
71
+ * - a quote containing `\n- RCP-p9-s9 — reddit — https://attacker.example`
72
+ * FORGES a receipt-pool entry, so a claim can appear to resolve to a source
73
+ * nobody retrieved;
74
+ * - a company fact containing `\nHQ: London` renders `Funding: Series B` with
75
+ * NO grounding state, and moves the `[NO_RECEIPT]` suffix onto what now
76
+ * reads as an HQ line — breaking the one invariant this ticket exists to
77
+ * establish, that a fact never appears without its state.
78
+ *
79
+ * Collapsing rather than escaping or indenting: these are ≤240-char excerpts
80
+ * whose internal paragraph structure carries no meaning, and collapsing is the
81
+ * option a crafted value cannot defeat. `buildEvidencePromptBlock` in the app
82
+ * (`app/lib/ai/persona-generator.ts`) already applies the same `\s+` collapse to
83
+ * this same quote field before it reaches a model, so this matches an existing
84
+ * project decision rather than inventing one.
85
+ *
86
+ * Apply BEFORE `truncate`, so the character budget is spent on content rather
87
+ * than on whitespace that will not survive.
88
+ */
89
+ export function collapseWhitespace(s) {
90
+ return s.replace(/\s+/g, ' ').trim();
91
+ }
92
+ /**
93
+ * A value made safe to interpolate into ONE LINE of a line-structured render:
94
+ * collapsed, bounded, and `null` when there is nothing to say.
95
+ *
96
+ * The two steps are separately load-bearing and both easy to forget on a new
97
+ * field, which is why they get a name instead of being open-coded per call site.
98
+ * `collapseWhitespace` is FUL-247's forgery guard — a newline in ANY untrusted
99
+ * value ends its line early and lets the remainder pose as the renderer's own
100
+ * output. `truncate` bounds a field that a reshaped backend could return
101
+ * arbitrarily long; every caller here renders ids and timestamps, so a value near
102
+ * the bound is already evidence of drift rather than of a long name.
103
+ *
104
+ * `null` for absent, empty, whitespace-only, or non-string — collapsed into ONE
105
+ * answer on purpose, because every one of them means "the endpoint did not say",
106
+ * and a caller must choose its own honest wording for that rather than print an
107
+ * empty pair of quotes that reads as a value.
108
+ */
109
+ export function safeInline(value, max = 120) {
110
+ if (typeof value !== 'string')
111
+ return null;
112
+ const flat = collapseWhitespace(value);
113
+ return flat ? truncate(flat, max) : null;
114
+ }
115
+ /**
116
+ * Clip a receipt quote to `max` characters, choosing WHICH characters by where the
117
+ * verified spans actually are (FUL-252).
118
+ *
119
+ * ⚠️ THIS IS A TRUST-SURFACE FIX, not a formatting one. A claim is `GROUNDED`
120
+ * because its `quoteSpan` was found inside the stored quote. FUL-247 rendered the
121
+ * quote's FIRST `RECEIPT_QUOTE_MAX` characters, so a span sitting past that cutoff
122
+ * left an agent reading `[GROUNDED]` beside an excerpt that did NOT contain the
123
+ * sentence which earned the badge. The badge was true and the visible support was
124
+ * not the support — the trust surface lying, which is the one failure this package
125
+ * exists to prevent. It shipped deliberately, pinned in
126
+ * `agent-visible-output.test.ts`, and this function is that pin being paid off.
127
+ *
128
+ * What this does NOT change: a receipt is still source + quote, and the budget is
129
+ * still `max`. Windowing only picks a BETTER `max` characters — same size, same
130
+ * ellipsis convention, so the two receipt surfaces stay mutually legible.
131
+ *
132
+ * ⚠️ WHITESPACE IS COLLAPSED HERE, not assumed to have been collapsed by the
133
+ * caller — and that redundancy is the point. `collapseWhitespace` is FUL-247's
134
+ * forgery guard and it runs first in both callers, but it also RESHAPES THE
135
+ * STRING: every whitespace run becomes one space and the ends are trimmed, so an
136
+ * offset measured against the raw quote is wrong by however many characters
137
+ * collapsing removed before it. Handed the raw text, a windowing function would cut
138
+ * several characters late and clip the span's own head — the original bug wearing a
139
+ * fix's clothes, and self-consistent enough that the obvious tests still pass.
140
+ * Collapsing here (idempotent, so the callers' own call stays free) makes that
141
+ * whole class of mistake unreachable rather than merely documented. Spans get the
142
+ * same treatment: a span carrying its own line break exists only in the raw text
143
+ * and would never be located otherwise, silently degrading to the head clip.
144
+ *
145
+ * A span that cannot be located steers nothing: the head clip is kept
146
+ * rather than a window invented around a guess. Same for a quote that fits whole,
147
+ * and for spans that already survive the head clip — those keep byte-identical
148
+ * output to FUL-247, so the change is confined to the case that was broken.
149
+ * Locating matches the VERIFIER's leniency, not JavaScript's `===` — see
150
+ * `locateSpan`, which is where this fix most easily goes on lying.
151
+ *
152
+ * With several spans on one receipt (many claims can share a source) the window is
153
+ * the one covering the MOST of them, tie-broken to the earliest start so the output
154
+ * is deterministic. Candidates are drawn from THREE alignments per span (centred,
155
+ * left, right) because a centred-only search misses clusters that genuinely fit —
156
+ * the loop below says why. Spans further apart than `max` cannot all be shown
157
+ * inside one budget; that residue is recorded as a narrower pinned limitation
158
+ * rather than papered over.
159
+ */
160
+ export function clipReceiptQuote(quote, spans, max) {
161
+ const flat = collapseWhitespace(quote);
162
+ if (flat.length <= max)
163
+ return flat;
164
+ // What `truncate` actually leaves visible: it keeps `max - 1` chars and spends
165
+ // the last on `…`. A span ending at or before that index needs no window.
166
+ const headKeep = max - 1;
167
+ const located = [];
168
+ for (const raw of spans) {
169
+ const span = collapseWhitespace(raw);
170
+ if (!span)
171
+ continue;
172
+ const at = locateSpan(flat, span);
173
+ if (at === -1)
174
+ continue;
175
+ located.push({ start: at, end: at + span.length });
176
+ }
177
+ if (located.length === 0 || located.every((s) => s.end <= headKeep))
178
+ return truncate(flat, max);
179
+ // One char of the budget buys the leading `…`. It is unconditional in this
180
+ // branch: every window that reaches here starts past 0 (a window starting at 0
181
+ // IS the head clip, and the early returns above already took that path), so the
182
+ // mark never claims a clip that did not happen.
183
+ const body = Math.max(1, max - 1);
184
+ const lastStart = Math.max(1, flat.length - body);
185
+ const clampStart = (n) => Math.min(Math.max(1, n), lastStart);
186
+ let best = null;
187
+ for (const span of located) {
188
+ // THREE candidate starts per span, not one. A centred window alone is what an
189
+ // adversarial cross-model pass caught: two spans that both fit inside one
190
+ // `body` can each centre on a window excluding the other, so the excerpt shows
191
+ // one and the second claim keeps its `[GROUNDED]` badge with no visible
192
+ // support — the exact failure this function exists to remove, at a separation
193
+ // FAR SMALLER than the budget. Left-aligning on the first span and
194
+ // right-aligning on the last are precisely the boundaries that co-window a
195
+ // cluster, so they must be candidates too.
196
+ const slack = Math.max(0, body - (span.end - span.start));
197
+ const candidates = [
198
+ clampStart(span.start - Math.floor(slack / 2)), // centred — best reading context
199
+ clampStart(span.start), // left-aligned — reaches spans to the RIGHT
200
+ clampStart(span.end - body), // right-aligned — reaches spans to the LEFT
201
+ ];
202
+ for (const from of candidates) {
203
+ // The VISIBLE end, not `from + body`: `truncate` spends its last character on
204
+ // the trailing `…` whenever it drops text on the right, so a span ending on
205
+ // that final character is counted as covered while rendering one char short.
206
+ const visible = flat.length - from <= body ? body : body - 1;
207
+ const to = from + visible;
208
+ const covered = located.filter((o) => o.start >= from && o.end <= to).length;
209
+ if (best === null ||
210
+ covered > best.covered ||
211
+ (covered === best.covered && from < best.from)) {
212
+ best = { from, covered };
213
+ }
214
+ }
215
+ }
216
+ // `truncate` adds the trailing `…` only when text was actually dropped on the
217
+ // right, so a window that runs to the end of the quote does not claim otherwise.
218
+ return `…${truncate(flat.slice(best.from), body)}`;
219
+ }
220
+ /**
221
+ * Where a verified span sits in the collapsed quote — matching the way the span was
222
+ * VERIFIED, not the way JavaScript compares strings by default (FUL-252).
223
+ *
224
+ * ⚠️ THE VERIFIER IS MORE LENIENT THAN `indexOf`, and that asymmetry is a live way
225
+ * for this fix to keep lying. `verifySpan` (agent/src/claim-classifier.ts) accepts a
226
+ * span after `NFC → toLowerCase → collapse whitespace`, and then accepts a FUZZY
227
+ * match at 0.95 similarity on top of that. A case-sensitive `indexOf` therefore
228
+ * fails to locate spans the verifier happily grounded — the claim keeps its
229
+ * `[GROUNDED]` badge, the lookup silently returns "not found", and the excerpt falls
230
+ * back to the head clip. That is FUL-247's bug surviving inside its own fix, for
231
+ * exactly the claims the verifier was most generous about. Found by an adversarial
232
+ * cross-model review, not by the tests.
233
+ *
234
+ * Exact match first (the common case, and the only one with unambiguous offsets),
235
+ * then a case-insensitive retry.
236
+ *
237
+ * ⚠️ THE LENGTH GUARD IS A CORRECTNESS CONDITION, not defensiveness. An index into
238
+ * the normalized string is only an index into `flat` when normalizing moved nothing:
239
+ * `toLowerCase` can EXPAND a character (`İ` → `i̇`) and NFC can CONTRACT one
240
+ * (combining marks composing). Neither can do the opposite — lowercase never
241
+ * contracts, NFC never expands — so equal lengths prove no character moved and the
242
+ * offsets align exactly. When they differ we take the head clip rather than window
243
+ * to an offset we cannot trust: a wrong window is worse than a conservative one.
244
+ *
245
+ * Fuzzy-only matches (the verifier's 0.95 tier, where no exact substring exists at
246
+ * any casing) are NOT located here. The alignment DP that could find them lives in
247
+ * the agent package, which this separately-published package cannot import, and
248
+ * guessing a window would be inventing evidence. They keep the head clip, which is a
249
+ * recorded limitation rather than a silent one — see the pin in
250
+ * `agent-visible-output.test.ts`.
251
+ */
252
+ function locateSpan(flat, span) {
253
+ const exact = flat.indexOf(span);
254
+ if (exact !== -1)
255
+ return exact;
256
+ const foldedFlat = flat.normalize('NFC').toLowerCase();
257
+ if (foldedFlat.length !== flat.length)
258
+ return -1;
259
+ const foldedSpan = span.normalize('NFC').toLowerCase();
260
+ if (foldedSpan.length !== span.length)
261
+ return -1;
262
+ return foldedFlat.indexOf(foldedSpan);
263
+ }
264
+ //# sourceMappingURL=render-safety.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-safety.js","sourceRoot":"","sources":["../../src/tools/render-safety.ts"],"names":[],"mappings":"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"}
@@ -38,7 +38,7 @@
38
38
  * than typed field access: a reshaped field must degrade to "not shown", never
39
39
  * throw and lose the whole digest.
40
40
  */
41
- import { truncate } from './research.js';
41
+ import { collapseWhitespace, safeInline, clipReceiptQuote, RECEIPT_QUOTE_MAX, } from './render-safety.js';
42
42
  /**
43
43
  * Max claims rendered per spine. Above this the digest states how many were
44
44
  * dropped and where the full list lives — never a silent cut. Sized so a typical
@@ -142,22 +142,36 @@ function renderPersonaClaim(raw) {
142
142
  const claim = asRecord(raw);
143
143
  if (!claim)
144
144
  return '- (unreadable claim entry)';
145
- const state = str(claim.state) ?? 'UNKNOWN_STATE';
146
- const id = str(claim.id) ?? '(no id)';
147
- const personaId = str(claim.personaId);
148
- const sourceId = str(claim.sourceId);
149
- const text = str(claim.text);
145
+ // FUL-253: the spine is the sharpest row in this file. Every line is a `- `
146
+ // entry whose leading `[STATE]` is what an agent keys on, so a newline in
147
+ // `text` — the one field here that is model-written prose over scraped input —
148
+ // forges a SIBLING CLAIM carrying `[GROUNDED] … ← RCP-p0-s0`: a receipt minted
149
+ // for a claim no check ever ran on, which is the exact invariant this digest
150
+ // exists to hold. `truncate` never stopped it; it bounds length, and a forged
151
+ // line needs one `\n` well inside the cap. Guarded on every field, not just
152
+ // `text`: they share the line, so any of them can end it early.
153
+ //
154
+ // The GROUNDED gates keep reading the RAW `str()` value: flattening trims, so
155
+ // gating on the flattened one would newly admit `" GROUNDED "` as grounded and
156
+ // put the row's badge at odds with `tallyStates`, which reads raw. Flattening
157
+ // is for the RENDER; it must not widen what counts as a receipt.
158
+ const rawState = str(claim.state);
159
+ const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
160
+ const id = safeInline(claim.id, 60) ?? '(no id)';
161
+ const personaId = safeInline(claim.personaId, 60);
162
+ const sourceId = safeInline(claim.sourceId, 60);
163
+ const text = safeInline(claim.text, CLAIM_TEXT_MAX);
150
164
  // Gated on `state`, not on `sourceId` presence — see `renderReportClaim` for
151
165
  // why a pointer on a non-GROUNDED claim must never render as a receipt.
152
166
  let receipt = '';
153
- if (state === 'GROUNDED') {
167
+ if (rawState === 'GROUNDED') {
154
168
  receipt = sourceId ? ` ← ${sourceId}` : ' · GROUNDED but no receipt id — unresolvable';
155
169
  }
156
170
  else if (sourceId) {
157
171
  receipt = ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
158
172
  }
159
173
  const who = personaId ? ` (${personaId})` : '';
160
- const body = text ? ` — "${truncate(text, CLAIM_TEXT_MAX)}"` : '';
174
+ const body = text ? ` — "${text}"` : '';
161
175
  return `- [${state}] ${id}${who}${receipt}${body}`;
162
176
  }
163
177
  /**
@@ -175,13 +189,16 @@ function renderReportClaim(raw) {
175
189
  const claim = asRecord(raw);
176
190
  if (!claim)
177
191
  return '- (unreadable claim entry)';
178
- const state = str(claim.state) ?? 'UNKNOWN_STATE';
179
- const id = str(claim.id) ?? '(no id)';
180
- const section = str(claim.section);
181
- const sourceId = str(claim.sourceId);
182
- const text = str(claim.text);
192
+ // FUL-253: same guard and same raw-gate split as `renderPersonaClaim` — this
193
+ // spine's rows are the ones a forged `[GROUNDED] … ← RRCP-s0` would hide in.
194
+ const rawState = str(claim.state);
195
+ const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
196
+ const id = safeInline(claim.id, 60) ?? '(no id)';
197
+ const section = safeInline(claim.section, 40);
198
+ const sourceId = safeInline(claim.sourceId, 60);
199
+ const text = safeInline(claim.text, CLAIM_TEXT_MAX);
183
200
  const attestation = asRecord(claim.attestation);
184
- const attestedSourceId = str(attestation?.sourceId);
201
+ const attestedSourceId = safeInline(attestation?.sourceId, 60);
185
202
  // The bare `←` arrow means RECEIPT, so it is gated on `state`, NOT on the mere
186
203
  // presence of a `sourceId`. Keying on presence would render an explicitly
187
204
  // unsupported claim with the same marker a span-verified one gets: the schema
@@ -190,22 +207,22 @@ function renderReportClaim(raw) {
190
207
  // `{ state: 'NO_RECEIPT', sourceId: 'RRCP-s0' }` genuinely reaches this code.
191
208
  // An agent following that arrow would treat a NO_RECEIPT figure as source-checked.
192
209
  let provenance = '';
193
- if (state === 'GROUNDED') {
210
+ if (rawState === 'GROUNDED') {
194
211
  provenance = sourceId ? ` ← ${sourceId}` : ' · GROUNDED but no receipt id — unresolvable';
195
212
  }
196
- else if (state === 'NO_RECEIPT' && attestedSourceId) {
213
+ else if (rawState === 'NO_RECEIPT' && attestedSourceId) {
197
214
  provenance = ` · model-attested → ${attestedSourceId} (NOT a receipt)`;
198
215
  }
199
- else if (state === 'NO_RECEIPT') {
216
+ else if (rawState === 'NO_RECEIPT') {
200
217
  provenance = ' · from model knowledge — unverified';
201
218
  }
202
219
  // A pointer on a non-grounded claim is shown but explicitly disarmed, so it is
203
220
  // neither hidden from the reader nor readable as grounding.
204
- if (state !== 'GROUNDED' && sourceId) {
221
+ if (rawState !== 'GROUNDED' && sourceId) {
205
222
  provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
206
223
  }
207
224
  const where = section ? ` (${section})` : '';
208
- const body = text ? ` — "${truncate(text, CLAIM_TEXT_MAX)}"` : '';
225
+ const body = text ? ` — "${text}"` : '';
209
226
  return `- [${state}] ${id}${where}${provenance}${body}`;
210
227
  }
211
228
  function renderPersonaSpine(reportData, channel) {
@@ -250,33 +267,112 @@ function renderReportSpine(reportData, channel) {
250
267
  `${lines.join('\n')}` +
251
268
  capNote(shown.length, claims.length, channel, 'report_data.reportClaims'));
252
269
  }
270
+ /**
271
+ * The verified spans each persona receipt has to be able to SHOW, keyed by the
272
+ * receipt id its claims point at (FUL-252).
273
+ *
274
+ * Gated on `state === 'GROUNDED'`, matching `renderPersonaClaim`'s own gate and for
275
+ * the same reason: only a GROUNDED claim's span earned a badge, so only a GROUNDED
276
+ * claim's span is what the excerpt owes the reader. Keying on the mere presence of
277
+ * `quoteSpan` would let a SPECULATION claim steer the window — pushing the excerpt
278
+ * away from the sentence a real receipt was verified against, in favour of one no
279
+ * check ever ran on. `getReport` hands the RAW payload through when Zod rejects it,
280
+ * so `{ state: 'SPECULATION', quoteSpan: … }` genuinely reaches this code.
281
+ *
282
+ * Read from the SHOWN slice, not from every claim: past `CLAIM_RENDER_CAP` a claim
283
+ * has no visible badge, so windowing a quote to support it would spend the excerpt
284
+ * on a claim the agent cannot see, at the cost of one it can.
285
+ */
286
+ function collectVerifiedSpans(reportData) {
287
+ const spans = new Map();
288
+ for (const raw of asArray(reportData.claims).slice(0, CLAIM_RENDER_CAP)) {
289
+ const claim = asRecord(raw);
290
+ if (!claim || str(claim.state) !== 'GROUNDED')
291
+ continue;
292
+ const sourceId = str(claim.sourceId);
293
+ const span = str(claim.quoteSpan);
294
+ if (!sourceId || !span)
295
+ continue;
296
+ const existing = spans.get(sourceId);
297
+ if (existing)
298
+ existing.push(span);
299
+ else
300
+ spans.set(sourceId, [span]);
301
+ }
302
+ return spans;
303
+ }
253
304
  /**
254
305
  * The two receipt POOLS, so an agent that reads `← RCP-p0-s2` in a claim line can
255
- * resolve the pointer without the structured channel. Rendered compactly (index,
256
- * platform, url) — the verbatim quotes stay in the structured payload.
306
+ * resolve the pointer without the structured channel.
307
+ *
308
+ * FUL-247: persona receipts now carry the VERBATIM QUOTE, truncated to
309
+ * `RECEIPT_QUOTE_MAX`. This comment used to say "the verbatim quotes stay in the
310
+ * structured payload", and that was the whole problem: `renderPersonaClaim` prints
311
+ * `claim.text`, which is a PARAPHRASE, and the pool printed only platform + url —
312
+ * so an agent could see that a GROUNDED claim had a receipt and where it pointed,
313
+ * but no agent-visible channel anywhere carried the sentence the claim was
314
+ * grounded ON. `structuredContent` is not a fallback for this: `_meta` is dropped
315
+ * by Claude Code outright, and a reader checking a claim against its evidence
316
+ * should not have to leave the text to do it.
317
+ *
318
+ * FUL-252: the quote is now WINDOWED around the spans of the claims pointing at it,
319
+ * within the same budget. Rendering the head was not merely a smaller view of the
320
+ * evidence — when the verified span sat past the cutoff it was a view that excluded
321
+ * the evidence, under a `[GROUNDED]` badge.
322
+ *
323
+ * Evidence receipts (`RRCP-`) are NOT given the same treatment: they point at web
324
+ * pages, whose "quote" would be a page excerpt chosen at fetch time rather than a
325
+ * human's own words, and the pool already carries the one thing a reader needs
326
+ * from them — `publishedDate`, the figure's actual recency (FUL-148).
257
327
  */
258
328
  function renderReceiptPools(reportData, channel) {
259
329
  const personas = asArray(reportData.personas);
260
330
  const reportEvidence = asArray(reportData.reportEvidence);
331
+ const spansByReceipt = collectVerifiedSpans(reportData);
261
332
  const personaReceipts = [];
262
333
  personas.forEach((rawPersona, i) => {
263
334
  const sources = asArray(asRecord(rawPersona)?.sources);
264
335
  sources.forEach((rawSource, j) => {
265
336
  const source = asRecord(rawSource);
266
- const platform = str(source?.platform) ?? 'unknown platform';
267
- const url = str(source?.url) ?? '(no url)';
268
- personaReceipts.push(`- RCP-p${i}-s${j} — ${platform} — ${url}`);
337
+ // FUL-253: the quote below was already flattened — these two were not, and
338
+ // they sit on the SAME `- RCP-…` line, so a newline in either forges the
339
+ // receipt row the comment below says a forged quote would.
340
+ const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
341
+ const url = safeInline(source?.url, 200) ?? '(no url)';
342
+ // Indented under its own receipt line so the pool still scans as a list of
343
+ // pointers; a receipt with no cached quote simply has no second line, which
344
+ // is itself worth seeing — it is a pointer that grounds nothing until fetched.
345
+ //
346
+ // `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
347
+ // list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
348
+ // convincing `- RCP-` prefix would add an entry pointing at a source nobody
349
+ // retrieved. A whitespace-only quote also collapses to '' here and correctly
350
+ // renders as a bare pointer rather than as `""` — "the source said nothing".
351
+ //
352
+ // FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
353
+ // GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
354
+ // the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
355
+ // `renderPersonaClaim` prints, so the two sides of the arrow are built from
356
+ // the same expression and cannot drift into windowing the wrong receipt.
357
+ const receiptId = `RCP-p${i}-s${j}`;
358
+ const quote = collapseWhitespace(str(source?.quote) ?? '');
359
+ const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
360
+ const quoteLine = quote ? `\n "${clipped}"` : '';
361
+ personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}`);
269
362
  });
270
363
  });
271
364
  const evidenceReceipts = reportEvidence.map((raw, n) => {
272
365
  const source = asRecord(raw);
273
- const section = str(source?.section) ?? 'unknown section';
274
- const platform = str(source?.platform) ?? 'unknown platform';
275
- const url = str(source?.url) ?? '(no url)';
366
+ // FUL-253: every field on this row is scraped-page metadata, and the row is
367
+ // what an `RRCP-s{n}` pointer resolves TO — a forged one answers a real
368
+ // claim's arrow with a source nobody fetched.
369
+ const section = safeInline(source?.section, 40) ?? 'unknown section';
370
+ const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
371
+ const url = safeInline(source?.url, 200) ?? '(no url)';
276
372
  // FUL-148: `publishedDate` is the figure's recency; `retrievedAt` is only when
277
373
  // WE fetched it. Rendering retrievedAt as recency would be a lie, so an absent
278
374
  // publication date is shown honestly as unknown rather than substituted.
279
- const published = str(source?.publishedDate) ?? 'publish date unknown';
375
+ const published = safeInline(source?.publishedDate, 40) ?? 'publish date unknown';
280
376
  return `- RRCP-s${n} (${section}) — ${platform} — ${published} — ${url}`;
281
377
  });
282
378
  if (personaReceipts.length === 0 && evidenceReceipts.length === 0) {
@@ -319,19 +415,25 @@ function renderPersonas(reportData, channel) {
319
415
  const shown = personas.slice(0, LIST_RENDER_CAP);
320
416
  const lines = shown.map((raw, i) => {
321
417
  const persona = asRecord(raw);
322
- const name = str(persona?.name) ?? `persona-${i}`;
323
- const role = str(persona?.role);
418
+ // FUL-253: `str()` answers "is this a string", not "is this safe on a line".
419
+ // These rows are `- ` entries carrying a QA-FLAGGED warning, so a newline in a
420
+ // persona name forges a SIBLING PERSONA that renders without the warning —
421
+ // the one field on this line an agent reads to decide whether to trust the
422
+ // rest of it. Applied to every free-text field on the row, not just the name:
423
+ // they share one line, so any of them can end it early.
424
+ const name = safeInline(persona?.name, 100) ?? `persona-${i}`;
425
+ const role = safeInline(persona?.role, 100);
324
426
  const sources = asArray(persona?.sources);
325
427
  const flagged = bool(persona?.qaFlagged);
326
428
  const reasons = asArray(persona?.qaFlagReasons)
327
- .map((r) => str(r))
429
+ .map((r) => safeInline(r, 120))
328
430
  .filter((r) => r !== null);
329
431
  const insufficient = bool(persona?.insufficientEvidence);
330
432
  const sourcesFound = num(persona?.sourcesFound);
331
433
  const priors = asRecord(persona?.identityPriors);
332
- const seniority = str(priors?.seniority);
333
- const careerPath = str(priors?.careerPath);
334
- const provenance = str(persona?.identityProvenance);
434
+ const seniority = safeInline(priors?.seniority, 80);
435
+ const careerPath = safeInline(priors?.careerPath, 80);
436
+ const provenance = safeInline(persona?.identityProvenance, 200);
335
437
  const bits = [];
336
438
  bits.push(`${plural(sources.length, 'receipt')}`);
337
439
  if (insufficient) {
@@ -354,7 +456,7 @@ function renderPersonas(reportData, channel) {
354
456
  ? '⚠️ qaFlagged UNREADABLE (malformed value) — treat this persona as unchecked. '
355
457
  : '';
356
458
  const provenanceLine = provenance
357
- ? `\n identityProvenance: "${truncate(provenance, 200)}" — aggregate calibration, NEVER a receipt; do not cite it as a source.`
459
+ ? `\n identityProvenance: "${provenance}" — aggregate calibration, NEVER a receipt; do not cite it as a source.`
358
460
  : '';
359
461
  return `- **${name}**${role ? ` (${role})` : ''} — ${flag}${bits.join(' · ')}${provenanceLine}`;
360
462
  });
@@ -398,7 +500,10 @@ function renderSycophancy(reportData, channel) {
398
500
  if (lowDiscriminationCount > 0) {
399
501
  const names = personaSignals
400
502
  .filter((raw) => bool(asRecord(raw)?.lowDiscrimination))
401
- .map((raw) => str(asRecord(raw)?.personaName) ?? '(unnamed)');
503
+ // FUL-253: joined into one warning line, so a newline in a name would end
504
+ // the warning early and leave the rest of the flagged personas rendering
505
+ // as ordinary text below it.
506
+ .map((raw) => safeInline(asRecord(raw)?.personaName, 100) ?? '(unnamed)');
402
507
  lines.push(`⚠️ ${lowDiscriminationCount} persona(s) flagged \`lowDiscrimination\` (uniformly supportive AND ` +
403
508
  `rejected nothing — their answers carry little signal): ${names.join(', ')}`);
404
509
  }
@@ -408,7 +513,7 @@ function renderSycophancy(reportData, channel) {
408
513
  shown
409
514
  .map((raw) => {
410
515
  const d = asRecord(raw);
411
- const id = str(d?.hypothesisId) ?? '(no id)';
516
+ const id = safeInline(d?.hypothesisId, 60) ?? '(no id)';
412
517
  const supportive = asArray(d?.supportive).length;
413
518
  const negative = asArray(d?.negative).length;
414
519
  const neutral = asArray(d?.neutral).length;
@@ -443,13 +548,17 @@ function renderRobustness(reportData, channel) {
443
548
  const shown = withRobustness.slice(0, LIST_RENDER_CAP);
444
549
  const lines = shown.map((raw) => {
445
550
  const result = asRecord(raw);
446
- const id = str(result?.hypothesisId) ?? '(no id)';
447
- const status = str(result?.status) ?? 'unknown';
551
+ // FUL-253: a `- ` row whose whole point is the ⚠️ FLIPPED warning. A newline
552
+ // in `status` or `downgradedStatus` forges a sibling row for the same
553
+ // hypothesis id WITHOUT the warning — the reading that says the reported
554
+ // verdict is not the one to act on is exactly what a forgery would drop.
555
+ const id = safeInline(result?.hypothesisId, 60) ?? '(no id)';
556
+ const status = safeInline(result?.status, 40) ?? 'unknown';
448
557
  const robustness = asRecord(result?.robustness);
449
558
  const survived = num(robustness?.survived);
450
559
  const total = num(robustness?.total);
451
560
  const flipped = tribool(robustness?.flipped);
452
- const downgraded = str(robustness?.downgradedStatus);
561
+ const downgraded = safeInline(robustness?.downgradedStatus, 40);
453
562
  const count = survived !== null && total !== null ? `held ${survived}/${total} framings` : 'survival count unavailable';
454
563
  if (flipped === true) {
455
564
  return (`- ${id}: reported \`${status}\` — ⚠️ FLIPPED under rephrasing (${count}). ` +