@clien-ai/mcp 0.12.0 → 0.12.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.
Files changed (36) hide show
  1. package/README.md +41 -1
  2. package/dist/auth/errors.js +10 -0
  3. package/dist/auth/errors.js.map +1 -0
  4. package/dist/auth/oauth.js +2 -8
  5. package/dist/auth/oauth.js.map +1 -1
  6. package/dist/auth/storage.js +1 -1
  7. package/dist/auth/storage.js.map +1 -1
  8. package/dist/server.js +4 -1
  9. package/dist/server.js.map +1 -1
  10. package/dist/tools/cancel-research.js +260 -0
  11. package/dist/tools/cancel-research.js.map +1 -0
  12. package/dist/tools/collections.js +183 -68
  13. package/dist/tools/collections.js.map +1 -1
  14. package/dist/tools/errors.js +2 -0
  15. package/dist/tools/errors.js.map +1 -1
  16. package/dist/tools/market-signals.js +121 -0
  17. package/dist/tools/market-signals.js.map +1 -0
  18. package/dist/tools/market-sizing-proof.js +9 -3
  19. package/dist/tools/market-sizing-proof.js.map +1 -1
  20. package/dist/tools/output-schemas.js +78 -11
  21. package/dist/tools/output-schemas.js.map +1 -1
  22. package/dist/tools/permanent-failure.js +75 -0
  23. package/dist/tools/permanent-failure.js.map +1 -0
  24. package/dist/tools/registry.js +102 -34
  25. package/dist/tools/registry.js.map +1 -1
  26. package/dist/tools/report-digest.js +355 -45
  27. package/dist/tools/report-digest.js.map +1 -1
  28. package/dist/tools/research.js +42 -3
  29. package/dist/tools/research.js.map +1 -1
  30. package/dist/tools/scoped-research.js +96 -55
  31. package/dist/tools/scoped-research.js.map +1 -1
  32. package/dist/types/report.js +385 -21
  33. package/dist/types/report.js.map +1 -1
  34. package/dist/types/semantic-theme.js +43 -0
  35. package/dist/types/semantic-theme.js.map +1 -0
  36. package/package.json +5 -1
@@ -19,6 +19,8 @@ import { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOT
19
19
  // test parses `CANONICAL_CONTENT_TYPES` out of that exact path as TEXT. Moving the file to break
20
20
  // the layering would cost more than the layering does.
21
21
  import { CONTENT_TYPE_EXCLUSIONS, CONTENT_TYPE_STATES } from '../tools/content-type-display.js';
22
+ import { parseReceiptId } from '../tools/receipt-children.js';
23
+ import { readSemanticTheme } from './semantic-theme.js';
22
24
  export { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, } from '../hypothesis-semantics.js';
23
25
  /**
24
26
  * A competitor discovered during the research run.
@@ -500,6 +502,19 @@ export const ReportIdentityPriorsSchema = z
500
502
  .describe('How the persona got to this role and where they are heading — a short trajectory arc, e.g. "engineer → engineering manager".'),
501
503
  })
502
504
  .passthrough();
505
+ /**
506
+ * Frozen, decorative persona portraits a report may carry (FUL-771).
507
+ *
508
+ * This is deliberately a closed list of bundled Clien assets, not a URL schema.
509
+ * Treating any syntactically valid URL as a portrait would turn report_data into
510
+ * an arbitrary remote-image channel and could also leak a live persona identity.
511
+ */
512
+ export const REPORT_PORTRAIT_PATHS = [
513
+ '/avatars/maya-chen.png',
514
+ '/avatars/james-rodriguez.png',
515
+ '/avatars/sarah-thompson.png',
516
+ ];
517
+ export const ReportPortraitSchema = z.enum(REPORT_PORTRAIT_PATHS);
503
518
  /**
504
519
  * A report persona carrying its source RECEIPTS. Lean surface — `.passthrough()`
505
520
  * keeps the full persona profile (goals, painPoints, psychographic, tools) the
@@ -511,6 +526,9 @@ export const ReportPersonaSchema = z
511
526
  .object({
512
527
  name: z.string(),
513
528
  role: z.string().optional(),
529
+ portrait: ReportPortraitSchema.optional()
530
+ .catch(undefined)
531
+ .describe('Frozen decorative portrait selected from Clien bundled assets. Never a live persona join or an arbitrary remote URL.'),
514
532
  sources: z.array(ReportSourceSchema).optional().describe('The real forum posts this persona is composited from — the receipts a claim\'s sourceId points at (may be empty when evidence was sparse).'),
515
533
  insufficientEvidence: z.boolean().optional().describe('True when the forum pre-pass found too few real posts to ground this persona.'),
516
534
  sourcesFound: z.number().optional().describe('Count of unique real posts the pre-pass surfaced for this persona.'),
@@ -835,13 +853,64 @@ export const MethodologyNoteSchema = z
835
853
  * ⚠️ DELIBERATELY NOT `.passthrough()`. The producer also carries
836
854
  * `researchTaskId`, an internal `validation_research_tasks.id` that is not an
837
855
  * interview-link contract. Zod's default strip behaviour is the privacy boundary:
838
- * only these four fields cross into MCP's structured report payload.
856
+ * only these fields cross into MCP's structured report payload. The content-addressed evidence
857
+ * block is public report identity; the internal research-task id remains excluded.
839
858
  */
840
859
  export const InterviewHighlightSchema = z.object({
841
860
  personaName: z.string(),
842
861
  personaRole: z.string(),
843
862
  overallImpression: z.string(),
844
863
  quotesByHypothesis: z.record(z.string(), z.array(z.string())),
864
+ evidenceReference: z.object({
865
+ interviewId: z.string().min(1),
866
+ quotes: z.array(z.object({
867
+ quoteId: z.string().min(1),
868
+ hypothesisId: z.string().min(1),
869
+ text: z.string(),
870
+ })),
871
+ }).optional(),
872
+ });
873
+ export const EvidenceRefSchema = z.discriminatedUnion('kind', [
874
+ z.object({ kind: z.literal('report_evidence'), sourceId: z.string().min(1) }),
875
+ z.object({
876
+ kind: z.literal('forum_receipt'),
877
+ receiptId: z.string().refine((value) => parseReceiptId(value) !== null, {
878
+ message: 'Invalid receipt id',
879
+ }),
880
+ }),
881
+ // Kept in the parser for compatibility, but stripped by projectReportDataForMcp because the
882
+ // researchTaskId is private and this surface has no external interview-receipt contract.
883
+ z.object({ kind: z.literal('interview_quote'), researchTaskId: z.string(), quoteId: z.string() }),
884
+ z.object({ kind: z.literal('interview_quote_v2'), interviewId: z.string().min(1), quoteId: z.string().min(1) }),
885
+ z.object({ kind: z.literal('hypothesis'), hypothesisId: z.string().min(1) }),
886
+ ]);
887
+ export const CrossCuttingEvidenceRefSchema = z.discriminatedUnion('kind', [
888
+ z.object({ kind: z.literal('report_evidence'), sourceId: z.string().min(1) }),
889
+ z.object({
890
+ kind: z.literal('forum_receipt'),
891
+ receiptId: z.string().refine((value) => parseReceiptId(value) !== null, {
892
+ message: 'Invalid receipt id',
893
+ }),
894
+ }),
895
+ z.object({ kind: z.literal('interview_quote_v2'), interviewId: z.string().min(1), quoteId: z.string().min(1) }),
896
+ ]);
897
+ export const CrossCuttingFindingSchema = z.object({
898
+ claim: z.string().min(1),
899
+ whyItMatters: z.string().min(1),
900
+ confidence: z.enum(['low', 'medium', 'high']),
901
+ evidenceRefs: z.array(CrossCuttingEvidenceRefSchema),
902
+ });
903
+ export const MCP_OVERVIEW_SPAN_CAP = 8;
904
+ export const MCP_OVERVIEW_REF_CAP = 4;
905
+ export const ReportOverviewSchema = z.object({
906
+ text: z.string(),
907
+ claimSpansTruncated: z.boolean().optional(),
908
+ claimSpans: z.array(z.object({
909
+ span: z.string(),
910
+ claimType: z.enum(['externally_checkable', 'inference']),
911
+ evidenceRefsTruncated: z.boolean().optional(),
912
+ evidenceRefs: z.array(EvidenceRefSchema).transform((refs) => refs.slice(0, MCP_OVERVIEW_REF_CAP)),
913
+ })).transform((spans) => spans.slice(0, MCP_OVERVIEW_SPAN_CAP)),
845
914
  });
846
915
  /**
847
916
  * The MCP-exposed shape of `_meta.report_data`. All fields optional —
@@ -873,9 +942,17 @@ export const ResearchReportDataSchema = z
873
942
  .array(InsightSchema)
874
943
  .optional()
875
944
  .describe('Typed insight findings. Fresh source rows carry exact RRCP ids; legacy string rows remain inert text.'),
945
+ crossCuttingFindings: z
946
+ .array(CrossCuttingFindingSchema.optional().catch(undefined))
947
+ .optional()
948
+ .catch(undefined)
949
+ .describe('Evidence-gated cross-method findings. Present empty means the gate ran and no candidate qualified.'),
876
950
  keyFindings: z.array(z.string()).optional(),
877
951
  recommendations: z.array(z.unknown()).optional(),
878
952
  executiveSummary: z.string().optional(),
953
+ reportTitle: z.string().optional(),
954
+ marketOverview: ReportOverviewSchema.optional().catch(undefined),
955
+ communityOverview: ReportOverviewSchema.optional().catch(undefined),
879
956
  personasSynthesis: PersonasSynthesisSchema
880
957
  .optional()
881
958
  .catch(undefined)
@@ -1005,20 +1082,279 @@ export const ResearchReportDataSchema = z
1005
1082
  * rather than represented as empty; absence says "unreadable", while `[]` would
1006
1083
  * falsely say the run recorded no highlights.
1007
1084
  */
1085
+ const MCP_REPORT_TITLE_MAX_CODE_POINTS = 160;
1086
+ export function sanitizeMcpMarkdownTitle(value) {
1087
+ if (typeof value !== 'string')
1088
+ return 'Report';
1089
+ const title = value
1090
+ .replace(/[\p{Cc}\p{Cf}]/gu, ' ')
1091
+ .replace(/\s+/g, ' ')
1092
+ .trim();
1093
+ return title && Array.from(title).length <= MCP_REPORT_TITLE_MAX_CODE_POINTS ? title : 'Report';
1094
+ }
1095
+ export function sanitizeMcpReportTitle(value) {
1096
+ const title = sanitizeMcpMarkdownTitle(value);
1097
+ const latinWords = title.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? [];
1098
+ const cjkCharacters = title.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? [];
1099
+ const words = latinWords.filter((word) => !/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(word)).length + cjkCharacters.length;
1100
+ return words >= 6 && words <= 12 ? title : 'Report';
1101
+ }
1008
1102
  export function projectReportDataForMcp(raw) {
1009
1103
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1010
1104
  return raw;
1011
1105
  const record = raw;
1106
+ const asProjectionRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
1107
+ ? value
1108
+ : null;
1109
+ const safeProjectionUrl = (value) => {
1110
+ if (typeof value !== 'string' || !/^https?:\/\//i.test(value))
1111
+ return false;
1112
+ try {
1113
+ const url = new URL(value);
1114
+ return (url.protocol === 'http:' || url.protocol === 'https:') &&
1115
+ !url.username && !url.password;
1116
+ }
1117
+ catch {
1118
+ return false;
1119
+ }
1120
+ };
1121
+ const hasProjectionVisibleContent = (value) => typeof value === 'string' &&
1122
+ value.replace(/[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}]/gu, '').length > 0;
1012
1123
  const hasHighlights = Object.prototype.hasOwnProperty.call(record, 'interviewHighlights');
1013
1124
  const hasHypothesisResults = Object.prototype.hasOwnProperty.call(record, 'hypothesisResults');
1125
+ const hasReportTitle = Object.prototype.hasOwnProperty.call(record, 'reportTitle');
1126
+ const hasOverview = Object.prototype.hasOwnProperty.call(record, 'marketOverview') ||
1127
+ Object.prototype.hasOwnProperty.call(record, 'communityOverview');
1128
+ const hasCrossCutting = Object.prototype.hasOwnProperty.call(record, 'crossCuttingFindings');
1129
+ const hasPersonas = Object.prototype.hasOwnProperty.call(record, 'personas');
1130
+ const hasForumResearch = Object.prototype.hasOwnProperty.call(record, 'forumResearch');
1014
1131
  const methodology = record.methodology;
1015
1132
  const hasRedditNote = methodology !== null &&
1016
1133
  typeof methodology === 'object' &&
1017
1134
  !Array.isArray(methodology) &&
1018
1135
  Object.prototype.hasOwnProperty.call(methodology, 'redditRetrieval');
1019
- if (!hasHighlights && !hasRedditNote && !hasHypothesisResults)
1136
+ if (!hasHighlights && !hasRedditNote && !hasHypothesisResults && !hasOverview && !hasReportTitle && !hasCrossCutting && !hasPersonas && !hasForumResearch)
1020
1137
  return raw;
1021
1138
  const projected = { ...record };
1139
+ if (hasReportTitle)
1140
+ projected.reportTitle = sanitizeMcpReportTitle(record.reportTitle);
1141
+ // Validate the frozen decoration before either the typed parse or raw schema-drift exit.
1142
+ // Rebuilding only this field preserves the full forward-compatible persona object while an
1143
+ // object, identifier-bearing value or arbitrary URL fails closed to the normal initials path.
1144
+ if (hasPersonas && Array.isArray(record.personas)) {
1145
+ projected.personas = record.personas.map((rawPersona) => {
1146
+ const persona = asProjectionRecord(rawPersona);
1147
+ if (!persona)
1148
+ return rawPersona;
1149
+ const nextPersona = { ...persona };
1150
+ const portrait = ReportPortraitSchema.safeParse(persona.portrait);
1151
+ if (portrait.success)
1152
+ nextPersona.portrait = portrait.data;
1153
+ else
1154
+ delete nextPersona.portrait;
1155
+ return nextPersona;
1156
+ });
1157
+ }
1158
+ else if (hasPersonas) {
1159
+ // A malformed container must not survive the raw schema-drift exit with an arbitrary nested
1160
+ // URL or identifier. Absence is the only honest forward-compatible projection here.
1161
+ delete projected.personas;
1162
+ }
1163
+ // `forumResearch` is intentionally forward-compatible and currently crosses the full-report
1164
+ // schema through `.passthrough()`. Scrub its optional display label before both the typed and
1165
+ // raw-drift exits so a source-title copy cannot survive in `_meta.report_data` even when the
1166
+ // prose renderer correctly withholds it.
1167
+ if (hasForumResearch) {
1168
+ const forum = asProjectionRecord(record.forumResearch);
1169
+ if (forum) {
1170
+ const nextForum = { ...forum };
1171
+ if (Array.isArray(forum.threads)) {
1172
+ nextForum.threads = forum.threads.map((rawThread) => {
1173
+ const thread = asProjectionRecord(rawThread);
1174
+ if (!thread)
1175
+ return rawThread;
1176
+ const nextThread = { ...thread };
1177
+ const theme = typeof thread.title === 'string'
1178
+ ? readSemanticTheme(thread.theme, thread.title)
1179
+ : null;
1180
+ if (theme === null) {
1181
+ delete nextThread.theme;
1182
+ }
1183
+ else {
1184
+ nextThread.theme = theme;
1185
+ }
1186
+ return nextThread;
1187
+ });
1188
+ }
1189
+ else {
1190
+ delete nextForum.threads;
1191
+ }
1192
+ projected.forumResearch = nextForum;
1193
+ }
1194
+ else {
1195
+ delete projected.forumResearch;
1196
+ }
1197
+ }
1198
+ const projectOverviewRef = (rawRef) => {
1199
+ if (rawRef === null || typeof rawRef !== 'object' || Array.isArray(rawRef))
1200
+ return null;
1201
+ const ref = rawRef;
1202
+ if (ref.kind === 'report_evidence' && typeof ref.sourceId === 'string' && ref.sourceId.length > 0) {
1203
+ return { kind: 'report_evidence', sourceId: ref.sourceId };
1204
+ }
1205
+ if (ref.kind === 'forum_receipt' &&
1206
+ parseReceiptId(ref.receiptId)) {
1207
+ return { kind: 'forum_receipt', receiptId: ref.receiptId };
1208
+ }
1209
+ if (ref.kind === 'interview_quote_v2' &&
1210
+ typeof ref.interviewId === 'string' && ref.interviewId.length > 0 &&
1211
+ typeof ref.quoteId === 'string' && ref.quoteId.length > 0) {
1212
+ return { kind: 'interview_quote_v2', interviewId: ref.interviewId, quoteId: ref.quoteId };
1213
+ }
1214
+ if (ref.kind === 'hypothesis' && typeof ref.hypothesisId === 'string' && ref.hypothesisId.length > 0) {
1215
+ return { kind: 'hypothesis', hypothesisId: ref.hypothesisId };
1216
+ }
1217
+ // Interview references and unknown future variants are private by default. Rebuilding every
1218
+ // surviving object from an allowlist prevents schema-drift fallback from carrying opaque ids.
1219
+ return null;
1220
+ };
1221
+ for (const key of ['marketOverview', 'communityOverview']) {
1222
+ const overview = record[key];
1223
+ if (overview === null || typeof overview !== 'object' || Array.isArray(overview)) {
1224
+ delete projected[key];
1225
+ continue;
1226
+ }
1227
+ const overviewRecord = overview;
1228
+ if (typeof overviewRecord.text !== 'string' || !Array.isArray(overviewRecord.claimSpans)) {
1229
+ delete projected[key];
1230
+ continue;
1231
+ }
1232
+ const claimSpans = overviewRecord.claimSpans.slice(0, MCP_OVERVIEW_SPAN_CAP).flatMap((rawClaim) => {
1233
+ if (rawClaim === null || typeof rawClaim !== 'object' || Array.isArray(rawClaim))
1234
+ return [];
1235
+ const claim = rawClaim;
1236
+ if (typeof claim.span !== 'string' ||
1237
+ (claim.claimType !== 'externally_checkable' && claim.claimType !== 'inference'))
1238
+ return [];
1239
+ const rawEvidenceRefs = Array.isArray(claim.evidenceRefs) ? claim.evidenceRefs : [];
1240
+ const evidenceRefs = [];
1241
+ const seenRefs = new Set();
1242
+ for (const rawRef of rawEvidenceRefs.slice(0, MCP_OVERVIEW_REF_CAP)) {
1243
+ const safeRef = projectOverviewRef(rawRef);
1244
+ if (!safeRef)
1245
+ continue;
1246
+ const refKey = safeRef.kind === 'report_evidence'
1247
+ ? `${safeRef.kind}:${safeRef.sourceId}`
1248
+ : safeRef.kind === 'forum_receipt'
1249
+ ? `${safeRef.kind}:${safeRef.receiptId}`
1250
+ : safeRef.kind === 'interview_quote_v2'
1251
+ ? `${safeRef.kind}:${safeRef.interviewId}:${safeRef.quoteId}`
1252
+ : `${safeRef.kind}:${safeRef.hypothesisId}`;
1253
+ if (seenRefs.has(refKey))
1254
+ continue;
1255
+ seenRefs.add(refKey);
1256
+ evidenceRefs.push(safeRef);
1257
+ }
1258
+ return [{
1259
+ span: claim.span,
1260
+ claimType: claim.claimType,
1261
+ evidenceRefsTruncated: rawEvidenceRefs.length > MCP_OVERVIEW_REF_CAP,
1262
+ evidenceRefs,
1263
+ }];
1264
+ });
1265
+ projected[key] = {
1266
+ text: overviewRecord.text,
1267
+ claimSpansTruncated: overviewRecord.claimSpans.length > MCP_OVERVIEW_SPAN_CAP,
1268
+ claimSpans,
1269
+ };
1270
+ }
1271
+ // Cross-cutting refs must resolve against the exact public projection. A malformed sibling
1272
+ // withdraws the all-or-nothing highlight block below, so validating against the raw array would
1273
+ // retain a finding whose cited quote is absent from structured output and rendered evidence.
1274
+ if (hasHighlights) {
1275
+ const highlights = z.array(InterviewHighlightSchema).safeParse(record.interviewHighlights);
1276
+ if (highlights.success)
1277
+ projected.interviewHighlights = highlights.data;
1278
+ else
1279
+ delete projected.interviewHighlights;
1280
+ }
1281
+ if (hasCrossCutting) {
1282
+ // Current reports replace the legacy surface. The projection is itself externally visible
1283
+ // under `_meta.report_data`, so suppressing only the composed prose would still leak it.
1284
+ delete projected.insights;
1285
+ const resolvesCrossCuttingRef = (ref) => {
1286
+ if (ref.kind === 'report_evidence') {
1287
+ const match = /^RRCP-s(0|[1-9]\d*)$/.exec(ref.sourceId ?? '');
1288
+ const source = match && asProjectionRecord(Array.isArray(record.reportEvidence)
1289
+ ? record.reportEvidence[Number(match[1])]
1290
+ : undefined);
1291
+ return Boolean(source &&
1292
+ safeProjectionUrl(source.url) &&
1293
+ (source.section === 'market' || source.section === 'competitor' || source.section === 'summary'));
1294
+ }
1295
+ if (ref.kind === 'forum_receipt') {
1296
+ const matches = (Array.isArray(asProjectionRecord(record.forumResearch)?.threads)
1297
+ ? asProjectionRecord(record.forumResearch).threads
1298
+ : []).flatMap((rawThread) => {
1299
+ const thread = asProjectionRecord(rawThread);
1300
+ return (Array.isArray(thread?.receipts) ? thread.receipts : []).filter((rawReceipt) => {
1301
+ const receipt = asProjectionRecord(rawReceipt);
1302
+ return Boolean(receipt && receipt.receiptId === ref.receiptId &&
1303
+ hasProjectionVisibleContent(receipt.excerpt));
1304
+ });
1305
+ });
1306
+ return matches.length === 1;
1307
+ }
1308
+ if (ref.kind === 'interview_quote_v2') {
1309
+ const matches = (Array.isArray(projected.interviewHighlights) ? projected.interviewHighlights : [])
1310
+ .flatMap((rawHighlight) => {
1311
+ const evidence = asProjectionRecord(asProjectionRecord(rawHighlight)?.evidenceReference);
1312
+ if (!evidence || evidence.interviewId !== ref.interviewId)
1313
+ return [];
1314
+ return (Array.isArray(evidence.quotes) ? evidence.quotes : []).filter((rawQuote) => {
1315
+ const quote = asProjectionRecord(rawQuote);
1316
+ return Boolean(quote && quote.quoteId === ref.quoteId &&
1317
+ hasProjectionVisibleContent(quote.text));
1318
+ });
1319
+ });
1320
+ return matches.length === 1;
1321
+ }
1322
+ return false;
1323
+ };
1324
+ projected.crossCuttingFindings = Array.isArray(record.crossCuttingFindings)
1325
+ ? record.crossCuttingFindings.slice(0, 5).flatMap((rawFinding) => {
1326
+ if (rawFinding === null || typeof rawFinding !== 'object' || Array.isArray(rawFinding))
1327
+ return [];
1328
+ const finding = rawFinding;
1329
+ if (!hasProjectionVisibleContent(finding.claim) ||
1330
+ !hasProjectionVisibleContent(finding.whyItMatters) ||
1331
+ !['low', 'medium', 'high'].includes(String(finding.confidence)))
1332
+ return [];
1333
+ const rawEvidenceRefs = Array.isArray(finding.evidenceRefs) ? finding.evidenceRefs : [];
1334
+ const evidenceRefs = rawEvidenceRefs
1335
+ .slice(0, 8)
1336
+ .flatMap((rawRef) => {
1337
+ const ref = projectOverviewRef(rawRef);
1338
+ return ref ? [ref] : [];
1339
+ });
1340
+ const methods = new Set(evidenceRefs.map((ref) => ref.kind));
1341
+ const refKeys = new Set(evidenceRefs.map((ref) => JSON.stringify(ref)));
1342
+ if (rawEvidenceRefs.length < 2 ||
1343
+ rawEvidenceRefs.length > 8 ||
1344
+ evidenceRefs.length !== rawEvidenceRefs.length ||
1345
+ refKeys.size < 2 ||
1346
+ methods.size < 2 ||
1347
+ !evidenceRefs.every(resolvesCrossCuttingRef))
1348
+ return [];
1349
+ return [{
1350
+ claim: finding.claim,
1351
+ whyItMatters: finding.whyItMatters,
1352
+ confidence: finding.confidence,
1353
+ evidenceRefs,
1354
+ }];
1355
+ })
1356
+ : [];
1357
+ }
1022
1358
  if (hasHypothesisResults && Array.isArray(record.hypothesisResults)) {
1023
1359
  projected.hypothesisResults = record.hypothesisResults.map((rawRow) => {
1024
1360
  if (rawRow === null || typeof rawRow !== 'object' || Array.isArray(rawRow))
@@ -1079,13 +1415,6 @@ export function projectReportDataForMcp(raw) {
1079
1415
  return safeRow;
1080
1416
  });
1081
1417
  }
1082
- if (hasHighlights) {
1083
- const highlights = z.array(InterviewHighlightSchema).safeParse(record.interviewHighlights);
1084
- if (highlights.success)
1085
- projected.interviewHighlights = highlights.data;
1086
- else
1087
- delete projected.interviewHighlights;
1088
- }
1089
1418
  // ⚠️ FUL-685 — THE REDDIT NOTE IS PROJECTED HERE FOR THE SAME REASON `researchTaskId` IS:
1090
1419
  // `prepareReportDataForMcp` returns this projection UNPARSED when the full schema drifts, so a
1091
1420
  // field stripped only by `RedditRetrievalNoteSchema` would come straight back on that path — and
@@ -1323,6 +1652,14 @@ export const MarketSizingLineSchema = z
1323
1652
  }
1324
1653
  }
1325
1654
  });
1655
+ export const MarketSignalReferenceSchema = z.discriminatedUnion('kind', [
1656
+ z.object({ kind: z.literal('size'), scope: z.enum(['tam', 'sam', 'som']) }),
1657
+ z.object({
1658
+ kind: z.enum(['growth', 'buying_behavior', 'category_dynamics']),
1659
+ claimId: z.string().regex(/^RCLM-m(?:0|[1-9]\d*)$/),
1660
+ sourceId: z.string().regex(/^RRCP-s(?:0|[1-9]\d*)$/).optional(),
1661
+ }),
1662
+ ]);
1326
1663
  /**
1327
1664
  * The market block a run established — the shape `scan_market` (FUL-479) surfaces to the caller
1328
1665
  * and the server promotes into the Research → Market dossier.
@@ -1333,28 +1670,28 @@ export const MarketSizingLineSchema = z
1333
1670
  * benefit, not an agent's. `extractMarket` does the flattening so this mirror describes what the
1334
1671
  * agent is actually being told, and every field here maps 1:1 onto a `project_market` column.
1335
1672
  *
1336
- * ⚠️ THESE FIGURES ARE UNGRADED ON THIS SURFACE, exactly as `get_market`'s are. Per-claim trust
1337
- * state is reconstructed app-side from the run's claim spine and does not cross the package
1338
- * boundary, so nothing here carries a `[GROUNDED]`-style bracket. Silence is not a clean bill of
1339
- * health, and the tool description says so beside the figures.
1673
+ * `marketSignals` below are producer selectors, not proof on their own. `extractMarket` resolves
1674
+ * them against exact claims, receipts, and derivations and exposes only survivors as
1675
+ * `resolvedSignals`. The other fields remain legacy recorded context and never inherit a
1676
+ * selector's authority.
1340
1677
  */
1341
1678
  export const MarketFindingsSchema = z
1342
1679
  .object({
1343
1680
  estimatedMarketSize: z
1344
1681
  .string()
1345
1682
  .optional()
1346
- .describe('The run\'s headline market SIZE, in the source\'s own words. Derived by the producer as ' +
1683
+ .describe('Legacy raw recorded market-size context, in the source\'s own words. Derived by the producer as ' +
1347
1684
  'the first claim that is a size (never a rate). Absent or empty when the run established ' +
1348
- 'no size — which is a result, not a zero.'),
1685
+ 'no size — which is a result, not a zero. Neutral unless emitted separately as a strict resolved signal.'),
1349
1686
  growthTrend: z
1350
1687
  .string()
1351
1688
  .optional()
1352
- .describe('"growing" | "stable" | "declining" | "unknown". "unknown" is a real finding: the sources disagreed or said nothing.'),
1353
- keyTrends: z.array(z.string()).optional().describe('Industry trends this run found moving the market.'),
1689
+ .describe('Raw reported direction: "growing" | "stable" | "declining" | "unknown". Neutral recorded context, never cited growth; "unknown" means the sources disagreed or said nothing.'),
1690
+ keyTrends: z.array(z.string()).optional().describe('Neutral legacy context: industry trends this run recorded as moving the market.'),
1354
1691
  claims: z
1355
1692
  .array(MarketClaimLineSchema)
1356
1693
  .optional()
1357
- .describe('Every atomic market figure the run recorded, one figure per entry, each with its own scope label where the source named one.'),
1694
+ .describe('Raw recorded atomic market figures, one per entry, with source-named scope where present. Neutral legacy context, not independently citable.'),
1358
1695
  marketSizing: z
1359
1696
  .array(z.unknown())
1360
1697
  .transform((items) => items.flatMap((item) => {
@@ -1362,11 +1699,19 @@ export const MarketFindingsSchema = z
1362
1699
  return parsed.success ? [parsed.data] : [];
1363
1700
  }))
1364
1701
  .optional()
1365
- .describe('Typed TAM/SAM/SOM entries. Malformed siblings are omitted; never infer a replacement.'),
1702
+ .describe('Typed TAM/SAM/SOM proof candidates. Malformed siblings are omitted; only entries selected and proven in resolvedSignals are citable.'),
1703
+ marketSignals: z
1704
+ .array(z.unknown())
1705
+ .transform((items) => items.slice(0, 3).flatMap((item) => {
1706
+ const parsed = MarketSignalReferenceSchema.safeParse(item);
1707
+ return parsed.success ? [parsed.data] : [];
1708
+ }))
1709
+ .optional()
1710
+ .describe('Zero-to-three ordered producer selectors, not independently citable. extractMarket emits a selector in resolvedSignals only after strict sizing proof or an exact grounded claim, non-empty contained quote span, and receipt. Empty resolvedSignals means no selector survived proof, not that the raw context or market is absent, small, or zero.'),
1366
1711
  positioningGaps: z
1367
1712
  .array(z.string())
1368
1713
  .optional()
1369
- .describe('Segments the category under-serves and needs available tools do not meet, as the run\'s sources describe them — market-side observations, not a competitor comparison.'),
1714
+ .describe('Neutral legacy context: segments the category under-serves and needs available tools do not meet, as recorded by the run; not independently citable or a competitor comparison.'),
1370
1715
  })
1371
1716
  .passthrough();
1372
1717
  /**
@@ -1377,6 +1722,14 @@ export const MarketFindingsSchema = z
1377
1722
  export const ForumThreadSchema = z
1378
1723
  .object({
1379
1724
  title: z.string(),
1725
+ theme: z
1726
+ .string()
1727
+ .trim()
1728
+ .max(48)
1729
+ .regex(/^[\p{L}\p{N}]+(?:[\p{Pd}'’][\p{L}\p{N}]+)*(?: [\p{L}\p{N}]+(?:[\p{Pd}'’][\p{L}\p{N}]+)*)?$/u)
1730
+ .optional()
1731
+ .catch(undefined)
1732
+ .describe('Producer-authored 1-2-word semantic theme. Distinct from title, which remains source metadata and the discussion link label.'),
1380
1733
  url: z.string().optional(),
1381
1734
  platform: z.string().optional(),
1382
1735
  /**
@@ -1451,5 +1804,16 @@ export const ForumThreadSchema = z
1451
1804
  .describe('Receipt children of this discussion, when it yielded several bounded excerpts. Each names ' +
1452
1805
  'one exact excerpt in `relevantQuotes`; absent means no rail minted per-excerpt ids for it.'),
1453
1806
  })
1454
- .passthrough();
1807
+ .passthrough()
1808
+ .overwrite((thread) => {
1809
+ if (!thread.theme)
1810
+ return thread;
1811
+ const theme = readSemanticTheme(thread.theme, thread.title);
1812
+ if (theme === null) {
1813
+ const withoutTheme = { ...thread };
1814
+ delete withoutTheme.theme;
1815
+ return withoutTheme;
1816
+ }
1817
+ return theme === thread.theme ? thread : { ...thread, theme };
1818
+ });
1455
1819
  //# sourceMappingURL=report.js.map