@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.
- package/README.md +41 -1
- package/dist/auth/errors.js +10 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth/oauth.js +2 -8
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/storage.js +1 -1
- package/dist/auth/storage.js.map +1 -1
- package/dist/server.js +4 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/cancel-research.js +260 -0
- package/dist/tools/cancel-research.js.map +1 -0
- package/dist/tools/collections.js +183 -68
- package/dist/tools/collections.js.map +1 -1
- package/dist/tools/errors.js +2 -0
- package/dist/tools/errors.js.map +1 -1
- package/dist/tools/market-signals.js +121 -0
- package/dist/tools/market-signals.js.map +1 -0
- package/dist/tools/market-sizing-proof.js +9 -3
- package/dist/tools/market-sizing-proof.js.map +1 -1
- package/dist/tools/output-schemas.js +78 -11
- package/dist/tools/output-schemas.js.map +1 -1
- package/dist/tools/permanent-failure.js +75 -0
- package/dist/tools/permanent-failure.js.map +1 -0
- package/dist/tools/registry.js +102 -34
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/report-digest.js +355 -45
- package/dist/tools/report-digest.js.map +1 -1
- package/dist/tools/research.js +42 -3
- package/dist/tools/research.js.map +1 -1
- package/dist/tools/scoped-research.js +96 -55
- package/dist/tools/scoped-research.js.map +1 -1
- package/dist/types/report.js +385 -21
- package/dist/types/report.js.map +1 -1
- package/dist/types/semantic-theme.js +43 -0
- package/dist/types/semantic-theme.js.map +1 -0
- package/package.json +5 -1
package/dist/types/report.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
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('
|
|
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"
|
|
1353
|
-
keyTrends: z.array(z.string()).optional().describe('
|
|
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('
|
|
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
|
|
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('
|
|
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
|