@clien-ai/mcp 0.12.0 → 0.12.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.
@@ -50,8 +50,10 @@ import { collapseWhitespace, safeInline, safeId, clipReceiptQuote, containsSpan,
50
50
  import { canonicalContentType, contentTypeDeviationLine, contentTypeProvenanceLine, } from './content-type-display.js';
51
51
  import { readFinalHypothesisState } from './hypothesis-final.js';
52
52
  import { readRobustnessCounts } from '../hypothesis-semantics.js';
53
- import { resolveSourceReceipt } from './receipt-children.js';
54
- import { resolveSupportedMarketSizing } from './market-sizing-proof.js';
53
+ import { parseReceiptId, resolveSourceReceipt } from './receipt-children.js';
54
+ import { resolveMarketSignals } from './market-signals.js';
55
+ import { MCP_OVERVIEW_REF_CAP, MCP_OVERVIEW_SPAN_CAP, REPORT_PORTRAIT_PATHS, sanitizeMcpMarkdownTitle, sanitizeMcpReportTitle, } from '../types/report.js';
56
+ import { readSemanticTheme } from '../types/semantic-theme.js';
55
57
  /**
56
58
  * Max claims rendered per spine. Above this the digest states how many were
57
59
  * dropped and where the full list lives — never a silent cut. Sized so a typical
@@ -307,12 +309,18 @@ function capNote(shown, total, channel, path) {
307
309
  return '';
308
310
  return `\n_(showing ${shown} of ${total} — the remaining ${total - shown} are in \`${channel}.${path}\`)_`;
309
311
  }
312
+ /** Honest overflow note for overview entries omitted before their public allowlist is evaluated. */
313
+ function overviewCapNote(truncated, cap, noun) {
314
+ if (!truncated)
315
+ return '';
316
+ return `\n_(showing at most ${cap} stored ${noun} — additional entries were omitted before public allowlisting)_`;
317
+ }
310
318
  /** Return the normalized URL only when it is a safe, non-credentialed web receipt. */
311
319
  function safeReceiptUrl(raw) {
312
- if (typeof raw !== 'string' || !raw.trim())
320
+ if (typeof raw !== 'string' || !/^https?:\/\//i.test(raw))
313
321
  return null;
314
322
  try {
315
- const url = new URL(raw.trim());
323
+ const url = new URL(raw);
316
324
  if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
317
325
  return null;
318
326
  }
@@ -322,6 +330,112 @@ function safeReceiptUrl(raw) {
322
330
  return null;
323
331
  }
324
332
  }
333
+ const OVERVIEW_ASSERTION_MAX = 320;
334
+ function overviewReceiptSupportsClaim(claim, receipt) {
335
+ if (claim.length > OVERVIEW_ASSERTION_MAX || receipt.length > OVERVIEW_ASSERTION_MAX)
336
+ return false;
337
+ const normalize = (value) => value
338
+ .normalize('NFC')
339
+ .replace(/\s+/g, ' ')
340
+ .trim();
341
+ const normalizedClaim = normalize(claim);
342
+ return normalizedClaim !== '' && normalizedClaim === normalize(receipt);
343
+ }
344
+ function resolveOverviewRef(ref, claimSpan, reportEvidence, forumThreads) {
345
+ const record = asRecord(ref);
346
+ const kind = str(record?.kind);
347
+ if (kind === 'report_evidence') {
348
+ const sourceId = str(record?.sourceId);
349
+ const match = sourceId ? /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId) : null;
350
+ if (!match)
351
+ return null;
352
+ const source = asRecord(reportEvidence[Number(match[1])]);
353
+ const href = safeReceiptUrl(source?.url);
354
+ const quote = str(source?.quote);
355
+ if (!source || !href || !quote || !overviewReceiptSupportsClaim(claimSpan, quote))
356
+ return null;
357
+ return `${sourceId} -> ${href}`;
358
+ }
359
+ if (kind === 'forum_receipt') {
360
+ const receiptId = safeId(record?.receiptId);
361
+ if (!receiptId || !parseReceiptId(receiptId))
362
+ return null;
363
+ const matches = forumThreads.flatMap((rawThread) => {
364
+ const thread = asRecord(rawThread);
365
+ return asArray(thread?.receipts).flatMap((rawReceipt) => {
366
+ const receipt = asRecord(rawReceipt);
367
+ const excerpt = str(receipt?.excerpt);
368
+ if (str(receipt?.receiptId) !== receiptId ||
369
+ !excerpt ||
370
+ !hasVisibleContent(excerpt) ||
371
+ !overviewReceiptSupportsClaim(claimSpan, excerpt))
372
+ return [];
373
+ const href = safeReceiptUrl(thread?.url);
374
+ return [`${receiptId}${href
375
+ ? ` -> ${href}`
376
+ : ` -> cached excerpt: "${safeInline(excerpt, OVERVIEW_ASSERTION_MAX) ?? '[unavailable]'}"`}`];
377
+ });
378
+ });
379
+ return matches.length === 1 ? (matches[0] ?? null) : null;
380
+ }
381
+ // A hypothesis is reasoning context, not an external receipt. Interview refs are private.
382
+ return null;
383
+ }
384
+ function renderOverviewTrust(data) {
385
+ const reportEvidence = asArray(data.reportEvidence);
386
+ const forum = asRecord(data.forumResearch);
387
+ const forumThreads = asArray(forum?.threads);
388
+ const sections = ['marketOverview', 'communityOverview'].flatMap((key) => {
389
+ const overview = asRecord(data[key]);
390
+ const overviewText = str(overview?.text);
391
+ if (!overview || !overviewText || !hasVisibleContent(overviewText))
392
+ return [];
393
+ const rawClaims = asArray(overview.claimSpans);
394
+ const claimsTruncated = overview.claimSpansTruncated === true || rawClaims.length > MCP_OVERVIEW_SPAN_CAP;
395
+ const ranges = rawClaims.slice(0, MCP_OVERVIEW_SPAN_CAP).flatMap((rawClaim, claimIndex) => {
396
+ const claim = asRecord(rawClaim);
397
+ const span = str(claim?.span);
398
+ if (!span)
399
+ return [];
400
+ const start = overviewText.indexOf(span);
401
+ if (start < 0 || overviewText.indexOf(span, start + 1) >= 0)
402
+ return [];
403
+ return [{ start, end: start + span.length, span, claim, claimIndex }];
404
+ }).sort((a, b) => a.start - b.start);
405
+ const lines = [];
406
+ let cursor = 0;
407
+ for (const range of ranges) {
408
+ if (range.start < cursor)
409
+ continue;
410
+ const uncovered = overviewText.slice(cursor, range.start);
411
+ if (/[\p{L}\p{N}]/u.test(uncovered))
412
+ lines.push(`- Unsourced: "${safeInline(uncovered)}"`);
413
+ if (str(range.claim?.claimType) === 'inference') {
414
+ lines.push(`- Inferred: "${safeInline(range.span)}"`);
415
+ cursor = range.end;
416
+ continue;
417
+ }
418
+ const rawRefs = asArray(range.claim?.evidenceRefs);
419
+ const refsTruncated = range.claim?.evidenceRefsTruncated === true || rawRefs.length > MCP_OVERVIEW_REF_CAP;
420
+ const refs = [...new Set(rawRefs.slice(0, MCP_OVERVIEW_REF_CAP)
421
+ .map((ref) => resolveOverviewRef(ref, range.span, reportEvidence, forumThreads))
422
+ .filter((ref) => Boolean(ref)))];
423
+ const line = refs.length > 0
424
+ ? `- Cited (${refs.length}): "${safeInline(range.span, OVERVIEW_ASSERTION_MAX)}" — ${refs.join('; ')}`
425
+ : `- Unsourced: "${safeInline(range.span)}"`;
426
+ lines.push(line + overviewCapNote(refsTruncated, MCP_OVERVIEW_REF_CAP, 'evidence-reference positions'));
427
+ cursor = range.end;
428
+ }
429
+ const trailing = overviewText.slice(cursor);
430
+ if (/[\p{L}\p{N}]/u.test(trailing))
431
+ lines.push(`- Unsourced: "${safeInline(trailing)}"`);
432
+ if (lines.length === 0)
433
+ return [];
434
+ const heading = key === 'marketOverview' ? 'Market overview trust' : 'Community overview trust';
435
+ return [`### ${heading}\n${lines.join('\n')}${overviewCapNote(claimsTruncated, MCP_OVERVIEW_SPAN_CAP, 'claim-span positions')}`];
436
+ });
437
+ return sections.join('\n\n');
438
+ }
325
439
  /**
326
440
  * Resolve the single, persona-owned presentation grant behind a GROUNDED claim.
327
441
  *
@@ -1012,8 +1126,14 @@ function renderPersonas(reportData, channel) {
1012
1126
  const seniority = safeInline(priors?.seniority, 80);
1013
1127
  const careerPath = safeInline(priors?.careerPath, 80);
1014
1128
  const provenance = safeInline(persona?.identityProvenance, 200);
1129
+ const hasFrozenPortrait = REPORT_PORTRAIT_PATHS.includes(persona?.portrait);
1015
1130
  const bits = [];
1016
1131
  bits.push(`${plural(sources.length, 'receipt')}`);
1132
+ // State availability, not the path: a raw drift payload must never interpolate an arbitrary
1133
+ // remote URL or an identifier-bearing decoration into agent-visible report text.
1134
+ bits.push(hasFrozenPortrait
1135
+ ? 'frozen portrait decoration available'
1136
+ : 'initials fallback');
1017
1137
  if (insufficient) {
1018
1138
  bits.push(`⚠️ insufficientEvidence${sourcesFound !== null ? ` (only ${sourcesFound} real posts found)` : ''}`);
1019
1139
  }
@@ -1051,6 +1171,58 @@ function renderPersonas(reportData, channel) {
1051
1171
  return (`${header}\n${lines.join('\n')}` +
1052
1172
  capNote(shown.length, personas.length, channel, 'report_data.personas'));
1053
1173
  }
1174
+ /**
1175
+ * A literal full-report Community summary. Internal pain-point counts/examples stay available in
1176
+ * structured report_data; they are deliberately not promoted into a user-facing `Threads` column.
1177
+ */
1178
+ function renderCommunityPatterns(reportData, channel) {
1179
+ const threads = asArray(asRecord(reportData.forumResearch)?.threads).filter((rawThread) => safeInline(asRecord(rawThread)?.title, MODEL_FRAMING_TEXT_MAX) !== null);
1180
+ if (threads.length === 0)
1181
+ return '';
1182
+ const shown = threads.slice(0, LIST_RENDER_CAP);
1183
+ const lines = shown.map((rawThread) => {
1184
+ const thread = asRecord(rawThread);
1185
+ const sentiment = safeInline(thread?.sentimentSummary, MODEL_FRAMING_TEXT_MAX);
1186
+ const title = safeInline(thread?.title, MODEL_FRAMING_TEXT_MAX) ?? 'Untitled discussion';
1187
+ const theme = readSemanticTheme(thread?.theme, title);
1188
+ const href = safeReceiptUrl(thread?.url);
1189
+ return [
1190
+ `- Theme: ${theme ?? 'not recorded'}`,
1191
+ ` Sentiment: ${sentiment ?? 'not recorded'}`,
1192
+ ` Discussion: ${title}${href ? ` — ${href}` : ''}`,
1193
+ ].join('\n');
1194
+ });
1195
+ return (`### Community patterns — ${plural(threads.length, 'discussion')}\n` +
1196
+ 'Themes are producer-authored semantic labels; Discussion preserves the original source title.\n' +
1197
+ lines.join('\n') +
1198
+ capNote(shown.length, threads.length, channel, 'report_data.forumResearch.threads'));
1199
+ }
1200
+ /**
1201
+ * Current agent Markdown already owns the literal Community rows. Detect only its code-owned,
1202
+ * three-line row grammar so full-report tools do not append the same evidence a second time.
1203
+ * Legacy Markdown lacks this grammar and still receives the compatibility projection above.
1204
+ */
1205
+ function hasCurrentCommunityPatternRows(reportMarkdown, reportData) {
1206
+ const expectedRows = asArray(asRecord(reportData.forumResearch)?.threads).filter((rawThread) => safeInline(asRecord(rawThread)?.title, MODEL_FRAMING_TEXT_MAX) !== null).length;
1207
+ if (expectedRows === 0)
1208
+ return false;
1209
+ const lines = reportMarkdown.split('\n');
1210
+ const heading = lines.indexOf('### Relevant Discussions');
1211
+ if (heading < 0)
1212
+ return false;
1213
+ const nextSection = lines.findIndex((line, index) => index > heading && /^#{2,3}\s/u.test(line));
1214
+ const end = nextSection < 0 ? lines.length : nextSection;
1215
+ let foundRows = 0;
1216
+ for (let index = heading + 1; index + 2 < end; index += 1) {
1217
+ if (lines[index]?.startsWith('- **Theme:** ') &&
1218
+ lines[index + 1]?.startsWith(' - **Sentiment:** ') &&
1219
+ lines[index + 2]?.startsWith(' - **Discussion:** ')) {
1220
+ foundRows += 1;
1221
+ index += 2;
1222
+ }
1223
+ }
1224
+ return foundRows === expectedRows;
1225
+ }
1054
1226
  /**
1055
1227
  * The anti-sycophancy readout — deterministically computed from the interviews,
1056
1228
  * not model-opined, which is exactly why it belongs in the text: it is the one
@@ -1593,6 +1765,109 @@ function renderInsightCrossReferences(reportData) {
1593
1765
  return ('## Insight source cross-references — model-authored findings, code-resolved destinations\n\n' +
1594
1766
  rows.join('\n'));
1595
1767
  }
1768
+ function resolveCrossCuttingEvidence(reportData, ref) {
1769
+ if (ref.kind === 'report_evidence') {
1770
+ const sourceId = safeId(ref.sourceId);
1771
+ const match = sourceId ? /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId) : null;
1772
+ const source = match ? asRecord(asArray(reportData.reportEvidence)[Number(match[1])]) : null;
1773
+ if (!source)
1774
+ return null;
1775
+ const label = safeInline(source.platform, MODEL_FRAMING_TEXT_MAX) || 'Web source';
1776
+ const href = safeReceiptUrl(source.url);
1777
+ const quote = safeInline(source.quote, RECEIPT_QUOTE_MAX);
1778
+ if (!href)
1779
+ return null;
1780
+ return `${label}${href ? ` — ${href}` : ''}${quote ? `\n > ${quote}` : ''}`;
1781
+ }
1782
+ if (ref.kind === 'forum_receipt') {
1783
+ const receiptId = safeId(ref.receiptId);
1784
+ if (!receiptId)
1785
+ return null;
1786
+ const matches = asArray(asRecord(reportData.forumResearch)?.threads).flatMap((threadRaw) => {
1787
+ const thread = asRecord(threadRaw);
1788
+ return asArray(thread?.receipts).flatMap((receiptRaw) => {
1789
+ const receipt = asRecord(receiptRaw);
1790
+ return safeId(receipt?.receiptId) === receiptId ? [{ thread, receipt }] : [];
1791
+ });
1792
+ });
1793
+ if (matches.length !== 1)
1794
+ return null;
1795
+ const match = matches[0];
1796
+ const label = safeInline(match.thread?.platform, MODEL_FRAMING_TEXT_MAX) || 'Community source';
1797
+ const href = safeReceiptUrl(match.thread?.url);
1798
+ const quote = safeInline(match.receipt?.excerpt, RECEIPT_QUOTE_MAX);
1799
+ if (!quote)
1800
+ return null;
1801
+ return `${label}${href ? ` — ${href}` : ''}${quote ? `\n > ${quote}` : ''}`;
1802
+ }
1803
+ if (ref.kind === 'interview_quote_v2') {
1804
+ const interviewId = safeId(ref.interviewId);
1805
+ const quoteId = safeId(ref.quoteId);
1806
+ if (!interviewId || !quoteId)
1807
+ return null;
1808
+ const matches = asArray(reportData.interviewHighlights).flatMap((highlightRaw) => {
1809
+ const highlight = asRecord(highlightRaw);
1810
+ const evidence = asRecord(highlight?.evidenceReference);
1811
+ if (safeId(evidence?.interviewId) !== interviewId)
1812
+ return [];
1813
+ return asArray(evidence?.quotes).flatMap((quoteRaw) => {
1814
+ const quote = asRecord(quoteRaw);
1815
+ return safeId(quote?.quoteId) === quoteId ? [{ highlight, quote }] : [];
1816
+ });
1817
+ });
1818
+ if (matches.length !== 1)
1819
+ return null;
1820
+ const match = matches[0];
1821
+ const persona = safeInline(match.highlight?.personaName, MODEL_FRAMING_TEXT_MAX) || 'Persona';
1822
+ const quote = safeInline(match.quote?.text, RECEIPT_QUOTE_MAX);
1823
+ return quote ? `Synthetic interview · ${persona}\n > ${quote}` : null;
1824
+ }
1825
+ return null;
1826
+ }
1827
+ function renderCrossCuttingFindings(reportData) {
1828
+ if (!Object.prototype.hasOwnProperty.call(reportData, 'crossCuttingFindings'))
1829
+ return '';
1830
+ const rows = asArray(reportData.crossCuttingFindings).slice(0, 5).flatMap((rawFinding) => {
1831
+ const finding = asRecord(rawFinding);
1832
+ const claim = safeInline(finding?.claim, MODEL_FRAMING_TEXT_MAX);
1833
+ const why = safeInline(finding?.whyItMatters, MODEL_FRAMING_TEXT_MAX);
1834
+ const confidence = safeInline(finding?.confidence, 12);
1835
+ if (!claim || !why || !confidence || !['low', 'medium', 'high'].includes(confidence))
1836
+ return [];
1837
+ const rawRefs = asArray(finding?.evidenceRefs).slice(0, 8);
1838
+ const resolvedRefs = rawRefs.flatMap((rawRef) => {
1839
+ const ref = asRecord(rawRef);
1840
+ const rendered = ref ? resolveCrossCuttingEvidence(reportData, ref) : null;
1841
+ return ref && rendered ? [{ ref, rendered }] : [];
1842
+ });
1843
+ const methods = new Set(resolvedRefs.flatMap(({ ref }) => ref.kind === 'report_evidence' ? ['web']
1844
+ : ref.kind === 'forum_receipt' ? ['forum']
1845
+ : ref.kind === 'interview_quote_v2' ? ['interview'] : []));
1846
+ const uniqueRefs = new Set(resolvedRefs.map(({ ref }) => JSON.stringify(ref)));
1847
+ if (rawRefs.length < 2 || resolvedRefs.length !== rawRefs.length || uniqueRefs.size < 2 || methods.size < 2)
1848
+ return [];
1849
+ const evidence = resolvedRefs.map(({ rendered }) => ` - Evidence: ${rendered}`);
1850
+ return [[
1851
+ `- ${claim}`,
1852
+ ` - Why it matters: ${why}`,
1853
+ ` - Confidence: ${confidence}`,
1854
+ ...evidence,
1855
+ ].join('\n')];
1856
+ });
1857
+ return rows.length > 0
1858
+ ? `## Cross-cutting findings\n\n${rows.join('\n')}`
1859
+ : '## Cross-cutting findings\n\nNo cross-cutting finding met the evidence bar.';
1860
+ }
1861
+ function removeLegacyInsightOutput(markdown) {
1862
+ return markdown
1863
+ .replace(/^## (?:Key )?Insights(?: \(legacy\))?\s*\n[\s\S]*?(?=^## |$(?![\s\S]))/gm, '')
1864
+ .replace(/^## Cross-cutting findings\s*\n[\s\S]*?(?=^## |$(?![\s\S]))/gm, '')
1865
+ .replace(/\n{3,}/g, '\n\n')
1866
+ .trimEnd();
1867
+ }
1868
+ function labelLegacyInsightOutput(markdown) {
1869
+ return markdown.replace(/^## (?:Key )?Insights$/gm, '## Insights (legacy)');
1870
+ }
1596
1871
  /**
1597
1872
  * The approved model-authored context block.
1598
1873
  *
@@ -1755,48 +2030,26 @@ function renderMarketSizing(reportData) {
1755
2030
  const market = asRecord(asRecord(reportData.webResearch)?.marketData);
1756
2031
  if (!market)
1757
2032
  return '';
1758
- const raw = asArray(market.marketSizing);
1759
2033
  const reportClaims = asArray(reportData.reportClaims).map(asRecord).filter((v) => v !== null);
1760
2034
  const reportEvidence = asArray(reportData.reportEvidence);
1761
- const supported = resolveSupportedMarketSizing(raw, reportClaims, reportEvidence);
1762
- const labels = { tam: 'TAM', sam: 'SAM', som: 'SOM' };
1763
- const scopes = ['tam', 'sam', 'som'];
1764
- const lines = scopes.map((scope) => {
1765
- const contenders = supported.map(asRecord).filter((entry) => entry?.scope === scope);
1766
- if (contenders.length !== 1)
1767
- return `- ${labels[scope]}: — (Not established)`;
1768
- const entry = contenders[0];
1769
- const value = num(entry.value);
1770
- const currency = safeInline(entry.currency, 3);
1771
- const period = safeInline(entry.period, 60);
1772
- const geography = safeInline(entry.geography, 120);
1773
- const audience = safeInline(entry.audience, 160);
1774
- if (!value || !currency?.match(/^[A-Z]{3}$/) || !period || !geography || !audience) {
1775
- return `- ${labels[scope]}: — (Not established)`;
1776
- }
1777
- if (entry.evidenceState === 'source_stated') {
1778
- return `- ${labels[scope]}: ${currency} ${value} [Cited] — ${geography}; ${audience}; ${period}`;
1779
- }
1780
- const derivation = asRecord(entry.derivation);
1781
- const inputs = asArray(derivation?.inputs).map(asRecord);
1782
- const formula = safeInline(derivation?.formula, 240);
1783
- if (entry.evidenceState !== 'derived' || derivation?.operation !== 'multiply' || !formula || inputs.length < 2 || inputs.some((input) => !input)) {
1784
- return `- ${labels[scope]}: — (Not established)`;
1785
- }
1786
- const validInputs = inputs.filter((input) => input !== null);
1787
- const sameBoundary = validInputs.every((input) => input.kind === 'ratio_assumption' || (input.period === entry.period && input.geography === entry.geography && input.audience === entry.audience));
1788
- const inputValues = validInputs.map((input) => num(input.value));
1789
- const oneCurrency = validInputs.filter((input) => input.kind === 'currency' && input.currency === currency).length === 1;
1790
- const product = inputValues.every((input) => input !== null)
1791
- ? inputValues.reduce((total, input) => total * input, 1)
1792
- : Number.NaN;
1793
- if (!sameBoundary || !oneCurrency || !Number.isFinite(product) || Math.abs(product - value) > Math.max(0.01, value * 1e-9)) {
1794
- return `- ${labels[scope]}: — (Not established)`;
1795
- }
1796
- const assumptions = asArray(derivation.assumptions).map((item) => safeInline(item, 160)).filter(Boolean);
1797
- return `- ${labels[scope]}: ${currency} ${value} [Estimate / Inferred] — ${geography}; ${audience}; ${period}; method: ${formula}${assumptions.length ? `; assumptions: ${assumptions.join('; ')}` : ''}`;
2035
+ const signals = resolveMarketSignals(market.marketSignals, asArray(market.marketSizing), reportClaims, reportEvidence);
2036
+ if (signals.length === 0)
2037
+ return '### Market signals\nNo cited market signals were established.';
2038
+ const lines = signals.map((signal) => {
2039
+ const context = safeInline(signal.context, 360) ?? 'Context unavailable';
2040
+ const sources = (signal.sources ?? (signal.source ? [signal.source] : []))
2041
+ .map((source) => safeInline(source.url, 240))
2042
+ .filter((url) => Boolean(url));
2043
+ const citations = sources.length > 0 ? ` ← ${sources.join(', ')}` : '';
2044
+ if (signal.kind !== 'size')
2045
+ return `- ${signal.label}: ${context} [Cited]${citations}`;
2046
+ const formula = safeInline(signal.formula, 240);
2047
+ const assumptions = (signal.assumptions ?? []).map((item) => safeInline(item, 160)).filter(Boolean);
2048
+ return `- ${signal.label}: ${signal.currency} ${signal.value} — ${context}` +
2049
+ (formula ? ` [Estimate / Inferred]; method: ${formula}${assumptions.length ? `; assumptions: ${assumptions.join('; ')}` : ''}` : ' [Cited]') +
2050
+ citations;
1798
2051
  });
1799
- return `### TAM / SAM / SOM — typed sizing\n${lines.join('\n')}`;
2052
+ return `### Market signals\n${lines.join('\n')}`;
1800
2053
  }
1801
2054
  /**
1802
2055
  * The Reddit community-evidence rail's typed outcome (FUL-685 / T8, D6).
@@ -2011,6 +2264,7 @@ export function buildTrustDigest(reportData, channel) {
2011
2264
  const sections = [
2012
2265
  renderPersonaSpine(data, channel),
2013
2266
  renderReportSpine(data, channel),
2267
+ renderOverviewTrust(data),
2014
2268
  renderMarketSizing(data),
2015
2269
  renderCompetitorSourceQuality(data),
2016
2270
  // FUL-685: immediately above the pool the accepted rows land in, so a reader meets the
@@ -2103,11 +2357,67 @@ export function composeReportText(reportMarkdown, reportData, channel) {
2103
2357
  const rationale = renderVerdictRationale(reportData);
2104
2358
  const framing = renderModelFraming(reportData);
2105
2359
  const data = asRecord(reportData);
2106
- const insightReferences = data ? renderInsightCrossReferences(data) : '';
2360
+ const markdownTitle = reportMarkdown.startsWith('# ')
2361
+ ? reportMarkdown.slice(2, reportMarkdown.indexOf('\n') >= 0 ? reportMarkdown.indexOf('\n') : undefined)
2362
+ : undefined;
2363
+ const safeTitle = data?.reportTitle === undefined
2364
+ ? normalizeLegacyMcpHeading(markdownTitle)
2365
+ : escapeMcpMarkdownHeading(sanitizeMcpReportTitle(data.reportTitle));
2366
+ const titledReportMarkdown = reportMarkdown.startsWith('# ')
2367
+ ? `# ${safeTitle}${reportMarkdown.includes('\n') ? reportMarkdown.slice(reportMarkdown.indexOf('\n')) : ''}`
2368
+ : reportMarkdown;
2369
+ const hasCrossCutting = Boolean(data && Object.prototype.hasOwnProperty.call(data, 'crossCuttingFindings'));
2370
+ const safeReportMarkdown = hasCrossCutting
2371
+ ? removeLegacyInsightOutput(titledReportMarkdown)
2372
+ : labelLegacyInsightOutput(titledReportMarkdown);
2373
+ const insightReferences = data && !hasCrossCutting ? renderInsightCrossReferences(data) : '';
2374
+ const crossCutting = data && hasCrossCutting ? renderCrossCuttingFindings(data) : '';
2375
+ // Community themes, sentiment, and source titles are producer/source-authored framing, not
2376
+ // machine-checked claims. Keep them above the authority separator while preserving the literal
2377
+ // Theme → Sentiment → Discussion contract for every full-report caller.
2378
+ const communityPatterns = data && !hasCurrentCommunityPatternRows(safeReportMarkdown, data)
2379
+ ? renderCommunityPatterns(data, channel)
2380
+ : '';
2107
2381
  // FUL-586: the completeness banner goes FIRST — before the prose it is a caveat about.
2108
2382
  const gaps = renderReportGaps(reportData);
2109
2383
  return (`${gaps ? `${gaps}\n\n---\n\n` : ''}` +
2110
- `${reportMarkdown}${insightReferences ? `\n\n${insightReferences}` : ''}${framing ? `\n\n${framing}` : ''}${rationale ? `\n\n${rationale}` : ''}` +
2384
+ `${safeReportMarkdown}${insightReferences ? `\n\n${insightReferences}` : ''}${crossCutting ? `\n\n${crossCutting}` : ''}${communityPatterns ? `\n\n${communityPatterns}` : ''}${framing ? `\n\n${framing}` : ''}${rationale ? `\n\n${rationale}` : ''}` +
2111
2385
  `\n\n---\n\n${buildTrustDigest(reportData, channel)}`);
2112
2386
  }
2387
+ /** Keep a model-authored structured title inert when replacing the report Markdown H1. */
2388
+ function escapeMcpMarkdownHeading(value) {
2389
+ if (/(?:\b[a-z][a-z0-9+.-]{1,31}:\/\/|\bwww\.|[^\s@]+@[^\s@]+)/i.test(value)) {
2390
+ const longestRun = (value.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0);
2391
+ const fence = '`'.repeat(longestRun + 1);
2392
+ const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
2393
+ return `${fence}${pad}${value}${pad}${fence}`;
2394
+ }
2395
+ return value
2396
+ .replace(/[\\`*_[\]~&]/g, '\\$&')
2397
+ .replace(/<(?=[a-zA-Z/?!])/g, '\\<')
2398
+ .replace(/#+$/, (run) => run.replace(/#/g, '\\#'));
2399
+ }
2400
+ /** Normalize producer-escaped legacy H1 text before applying the current inert-heading rules. */
2401
+ function normalizeLegacyMcpHeading(value) {
2402
+ if (typeof value !== 'string')
2403
+ return 'Report';
2404
+ const serialized = value
2405
+ .replace(/[\p{Cc}\p{Cf}]/gu, ' ')
2406
+ .replace(/\s+/g, ' ')
2407
+ .trim();
2408
+ if (!serialized)
2409
+ return 'Report';
2410
+ const openingFence = /^`+/.exec(serialized)?.[0];
2411
+ const closingFence = /`+$/.exec(serialized)?.[0];
2412
+ if (openingFence && closingFence?.length === openingFence.length && serialized.length >= openingFence.length * 2) {
2413
+ const inner = serialized.slice(openingFence.length, -openingFence.length);
2414
+ const innerRuns = inner.match(/`+/g) ?? [];
2415
+ if (innerRuns.every((run) => run.length < openingFence.length)) {
2416
+ const visible = /^ .* $/.test(inner) && /\S/.test(inner) ? inner.slice(1, -1) : inner;
2417
+ return sanitizeMcpMarkdownTitle(visible) === visible ? serialized : 'Report';
2418
+ }
2419
+ }
2420
+ const plain = serialized.replace(/\\([\\`*_[\]~&<#])/g, '$1');
2421
+ return escapeMcpMarkdownHeading(sanitizeMcpMarkdownTitle(plain));
2422
+ }
2113
2423
  //# sourceMappingURL=report-digest.js.map