@witchpot/gmc 0.1.0

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,1862 @@
1
+ import { COMPARE_FOCUS_SECTIONS, EXIT_API } from "./constants.js";
2
+ import { buildRequestHeaders, createRequestUrl, filterShowcasesByDeadline } from "./requests.js";
3
+ import { writeJsonError } from "./output.js";
4
+
5
+ export async function executeRequestPlan(baseUrl, request, { apiKey }) {
6
+ if (request.kind === "resolve") {
7
+ return executeResolveRequest(baseUrl, request, { apiKey });
8
+ }
9
+ if (request.kind === "benchmark") {
10
+ return executeBenchmarkRequest(baseUrl, request, { apiKey });
11
+ }
12
+ if (request.kind === "compare") {
13
+ return executeCompareRequest(baseUrl, request, { apiKey });
14
+ }
15
+ if (request.kind === "reviewPatterns") {
16
+ return executeReviewPatternsRequest(baseUrl, request, { apiKey });
17
+ }
18
+ if (request.kind === "campaignSignals") {
19
+ return executeCampaignSignalsRequest(baseUrl, request, { apiKey });
20
+ }
21
+ if (request.kind === "cohortEvidence") {
22
+ return executeCohortEvidenceRequest(baseUrl, request, { apiKey });
23
+ }
24
+ if (request.kind === "showcaseFit") {
25
+ return executeShowcaseFitRequest(baseUrl, request, { apiKey });
26
+ }
27
+
28
+ const response = await fetchJson(createRequestUrl(baseUrl, request), { apiKey });
29
+ return request.transform ? request.transform(response) : response;
30
+ }
31
+
32
+ async function executeCohortEvidenceRequest(baseUrl, request, { apiKey }) {
33
+ const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
34
+ const search = mergeSearchPages(sampled.searches, request.sampling);
35
+ const games = Array.isArray(search?.data) ? search.data : [];
36
+ const analysisResults = request.analysisQuery
37
+ ? await Promise.all(games.map(async (game) => {
38
+ const externalId = asString(game?.externalId ?? game?.appid);
39
+ if (!externalId) {
40
+ return {
41
+ game,
42
+ skippedReason: "missing_external_id"
43
+ };
44
+ }
45
+
46
+ const analysisResult = await fetchJsonResult(createRequestUrl(baseUrl, {
47
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/analysis`,
48
+ query: request.analysisQuery
49
+ }), { apiKey });
50
+ if (!analysisResult.ok) {
51
+ return {
52
+ game,
53
+ externalId,
54
+ skippedReason: "analysis_request_failed",
55
+ error: analysisResult.error
56
+ };
57
+ }
58
+
59
+ return {
60
+ game,
61
+ externalId,
62
+ analysis: analysisResult.payload
63
+ };
64
+ }))
65
+ : games.map((game) => ({
66
+ game,
67
+ externalId: asString(game?.externalId ?? game?.appid)
68
+ }));
69
+
70
+ return composeCohortEvidencePayload(request, {
71
+ search,
72
+ searches: sampled.searches,
73
+ searchPageErrors: sampled.pageErrors,
74
+ analysisResults
75
+ });
76
+ }
77
+
78
+ async function executeResolveRequest(baseUrl, request, { apiKey }) {
79
+ const search = await fetchJson(createRequestUrl(baseUrl, request.searchRequest), { apiKey });
80
+ return composeResolvePayload(request, search);
81
+ }
82
+
83
+ async function executeBenchmarkRequest(baseUrl, request, { apiKey }) {
84
+ const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
85
+ return composeBenchmarkPayload(request, sampled);
86
+ }
87
+
88
+ async function executeCompareRequest(baseUrl, request, { apiKey }) {
89
+ const games = await Promise.all(
90
+ request.externalIds.map(async (externalId) => {
91
+ const detailRequest = {
92
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}`,
93
+ query: request.query
94
+ };
95
+ const detail = await fetchJson(createRequestUrl(baseUrl, detailRequest), { apiKey });
96
+ const sections = COMPARE_FOCUS_SECTIONS[request.focus] ?? [];
97
+ const analysis = sections.length
98
+ ? await fetchJson(createRequestUrl(baseUrl, {
99
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/analysis`,
100
+ query: {
101
+ ...request.query,
102
+ sections: sections.join(",")
103
+ }
104
+ }), { apiKey })
105
+ : undefined;
106
+
107
+ return {
108
+ externalId,
109
+ detail,
110
+ analysis
111
+ };
112
+ })
113
+ );
114
+
115
+ return composeComparePayload(request, games);
116
+ }
117
+
118
+ async function executeReviewPatternsRequest(baseUrl, request, { apiKey }) {
119
+ const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
120
+ const search = mergeSearchPages(sampled.searches, request.sampling);
121
+ const games = Array.isArray(search?.data) ? search.data : [];
122
+ const topicResults = await Promise.all(
123
+ games.map(async (game) => {
124
+ const externalId = asString(game?.externalId ?? game?.appid);
125
+ if (!externalId) {
126
+ return {
127
+ game,
128
+ skippedReason: "missing_external_id"
129
+ };
130
+ }
131
+
132
+ const topicsUrl = createRequestUrl(baseUrl, {
133
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/review-topics`,
134
+ query: request.topicQuery
135
+ });
136
+ const topicsResult = await fetchJsonResult(topicsUrl, { apiKey });
137
+ if (!topicsResult.ok) {
138
+ return {
139
+ game,
140
+ externalId,
141
+ skippedReason: "review_topics_request_failed",
142
+ error: topicsResult.error
143
+ };
144
+ }
145
+
146
+ return {
147
+ game,
148
+ externalId,
149
+ topics: topicsResult.payload
150
+ };
151
+ })
152
+ );
153
+
154
+ return composeReviewPatternsPayload(request, {
155
+ search,
156
+ searches: sampled.searches,
157
+ searchPageErrors: sampled.pageErrors,
158
+ topicResults
159
+ });
160
+ }
161
+
162
+ async function executeCampaignSignalsRequest(baseUrl, request, { apiKey }) {
163
+ const detailRequest = {
164
+ path: `/api/v1/steam/games/${encodeURIComponent(request.externalId)}`,
165
+ query: request.detailQuery
166
+ };
167
+ const detail = await fetchJson(createRequestUrl(baseUrl, detailRequest), { apiKey });
168
+ return composeCampaignSignalsPayload(request, { detail });
169
+ }
170
+
171
+ async function executeShowcaseFitRequest(baseUrl, request, { apiKey }) {
172
+ const detailRequest = {
173
+ path: `/api/v1/steam/games/${encodeURIComponent(request.externalId)}`,
174
+ query: request.detailQuery
175
+ };
176
+ const detail = await fetchJson(createRequestUrl(baseUrl, detailRequest), { apiKey });
177
+ const tags = request.tagsOverride?.length
178
+ ? request.tagsOverride
179
+ : getPreferredShowcaseTags(detail.data);
180
+ const showcaseQuery = {
181
+ ...request.showcaseQuery,
182
+ ...(tags.length
183
+ ? {
184
+ tags: tags.join(","),
185
+ ...(request.showcaseQuery.tagMode ? {} : { tagMode: "or" })
186
+ }
187
+ : {})
188
+ };
189
+ const showcasesResponse = await fetchJson(createRequestUrl(baseUrl, {
190
+ path: "/api/v1/showcases",
191
+ query: showcaseQuery
192
+ }), { apiKey });
193
+ const showcases = request.deadlineBefore
194
+ ? filterShowcasesByDeadline(showcasesResponse, request.deadlineBefore)
195
+ : showcasesResponse;
196
+
197
+ return composeShowcaseFitPayload(request, {
198
+ detail,
199
+ showcases,
200
+ showcaseQuery,
201
+ tags
202
+ });
203
+ }
204
+
205
+ async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey }) {
206
+ if (!sampling) {
207
+ return {
208
+ searches: [await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey })],
209
+ pageErrors: []
210
+ };
211
+ }
212
+
213
+ const firstSearch = await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey });
214
+ const searches = [firstSearch];
215
+ const pageErrors = [];
216
+ let nextOffset = firstSearch?.pagination?.nextOffset;
217
+
218
+ while (nextOffset != null && shouldFetchNextSamplePage(sampling, searches)) {
219
+ const pageRequest = {
220
+ ...searchRequest,
221
+ query: {
222
+ ...searchRequest.query,
223
+ limit: sampling.pageLimit,
224
+ offset: nextOffset
225
+ }
226
+ };
227
+ const pageResult = await fetchJsonResult(createRequestUrl(baseUrl, pageRequest), { apiKey });
228
+ if (!pageResult.ok) {
229
+ pageErrors.push({
230
+ offset: nextOffset,
231
+ error: pageResult.error
232
+ });
233
+ break;
234
+ }
235
+
236
+ searches.push(pageResult.payload);
237
+ nextOffset = pageResult.payload?.pagination?.nextOffset;
238
+ }
239
+
240
+ return {
241
+ searches,
242
+ pageErrors
243
+ };
244
+ }
245
+
246
+ function shouldFetchNextSamplePage(sampling, searches) {
247
+ if (sampling.mode === "sample-pages") return searches.length < sampling.requestedPages;
248
+ const fetchedRows = searches.reduce((sum, search) => sum + asArray(search?.data).length, 0);
249
+ return fetchedRows < sampling.requestedSize;
250
+ }
251
+
252
+ export function composeComparePayload(request, games, options = {}) {
253
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
254
+ const requestId = options.requestId ?? createCliRequestId();
255
+ const warnings = uniqueStrings(games.flatMap((game) => [
256
+ ...safeWarnings(game.detail),
257
+ ...safeWarnings(game.analysis)
258
+ ]));
259
+
260
+ return {
261
+ data: {
262
+ kind: "evidence_pack",
263
+ source: request.source,
264
+ workflow: "gamesCompare",
265
+ inputs: {
266
+ externalIds: request.externalIds,
267
+ focus: request.focus,
268
+ sections: COMPARE_FOCUS_SECTIONS[request.focus] ?? []
269
+ },
270
+ focus: request.focus,
271
+ externalIds: request.externalIds,
272
+ cohort: {
273
+ included: games.map((game) => ({
274
+ source: request.source,
275
+ externalId: game.externalId,
276
+ title: game.detail?.data?.title,
277
+ evidence: {
278
+ detail: game.detail?.data,
279
+ ...(game.analysis ? { analysis: game.analysis.data } : {})
280
+ },
281
+ warnings: uniqueStrings([
282
+ ...safeWarnings(game.detail),
283
+ ...safeWarnings(game.analysis)
284
+ ])
285
+ })),
286
+ skipped: []
287
+ },
288
+ summary: withSummaryBasis({
289
+ targetCount: games.length,
290
+ analysisSections: COMPARE_FOCUS_SECTIONS[request.focus] ?? [],
291
+ comparedExternalIds: request.externalIds,
292
+ warningCount: warnings.length,
293
+ caveats: [
294
+ "Compare output is normalized evidence for selected titles, not a generated verdict.",
295
+ "Use locked metadata and warnings before making comparative claims.",
296
+ "Resolve ambiguous titles before calling compare so each target is source-backed."
297
+ ]
298
+ }, "returned_page"),
299
+ games: games.map((game) => ({
300
+ source: request.source,
301
+ externalId: game.externalId,
302
+ detail: game.detail?.data,
303
+ detailMeta: game.detail?.meta,
304
+ ...(game.analysis
305
+ ? {
306
+ analysis: game.analysis.data,
307
+ analysisMeta: game.analysis.meta
308
+ }
309
+ : {}),
310
+ warnings: uniqueStrings([
311
+ ...safeWarnings(game.detail),
312
+ ...safeWarnings(game.analysis)
313
+ ])
314
+ }))
315
+ },
316
+ meta: {
317
+ generatedAt,
318
+ requestId,
319
+ composedBy: "gmc-cli",
320
+ upstreamRequests: games.flatMap((game) => [
321
+ {
322
+ externalId: game.externalId,
323
+ kind: "detail",
324
+ requestId: game.detail?.meta?.requestId,
325
+ source: game.detail?.meta?.source,
326
+ entitlements: game.detail?.meta?.entitlements
327
+ },
328
+ ...(game.analysis
329
+ ? [{
330
+ externalId: game.externalId,
331
+ kind: "analysis",
332
+ requestId: game.analysis?.meta?.requestId,
333
+ source: game.analysis?.meta?.source,
334
+ entitlements: game.analysis?.meta?.entitlements
335
+ }]
336
+ : [])
337
+ ])
338
+ },
339
+ ...(warnings.length ? { warnings } : {})
340
+ };
341
+ }
342
+
343
+ export function composeResolvePayload(request, search, options = {}) {
344
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
345
+ const requestId = options.requestId ?? createCliRequestId();
346
+ const candidates = Array.isArray(search?.data)
347
+ ? search.data.map((game, index) => buildResolveCandidate(request, game, index))
348
+ : [];
349
+ const rankedCandidates = candidates.sort((a, b) => b.score - a.score);
350
+
351
+ return {
352
+ data: {
353
+ source: request.source,
354
+ query: request.query,
355
+ resolution: summarizeResolution(rankedCandidates),
356
+ candidates: rankedCandidates
357
+ },
358
+ meta: {
359
+ generatedAt,
360
+ requestId,
361
+ composedBy: "gmc-cli",
362
+ upstreamRequests: [
363
+ {
364
+ kind: "search",
365
+ requestId: search?.meta?.requestId,
366
+ source: search?.meta?.source,
367
+ entitlements: search?.meta?.entitlements,
368
+ pagination: search?.pagination
369
+ }
370
+ ]
371
+ },
372
+ ...(Array.isArray(search?.warnings) && search.warnings.length ? { warnings: search.warnings } : {})
373
+ };
374
+ }
375
+
376
+ export function composeBenchmarkPayload(request, search, options = {}) {
377
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
378
+ const requestId = options.requestId ?? createCliRequestId();
379
+ const searches = Array.isArray(search?.searches) ? search.searches : [search];
380
+ const pageErrors = Array.isArray(search?.pageErrors) ? search.pageErrors : [];
381
+ const mergedSearch = Array.isArray(search?.searches)
382
+ ? mergeSearchPages(search.searches, request.sampling)
383
+ : search;
384
+ const games = Array.isArray(mergedSearch?.data) ? mergedSearch.data : [];
385
+ const sample = buildSampleMetadata(request, searches, games, pageErrors);
386
+
387
+ return {
388
+ data: {
389
+ kind: "evidence_pack",
390
+ source: request.source,
391
+ workflow: "gamesBenchmark",
392
+ inputs: {
393
+ filters: request.query,
394
+ ...(sample ? { sample: buildEvidencePackSample(sample, games.length) } : {
395
+ sample: buildReturnedPageSample(mergedSearch?.pagination, games.length)
396
+ })
397
+ },
398
+ query: request.query,
399
+ summary: withSummaryBasis(
400
+ buildBenchmarkSummary(games, mergedSearch?.pagination, request.source, { sampled: Boolean(sample) }),
401
+ sample ? "bounded_sample" : "returned_page"
402
+ ),
403
+ ...(sample
404
+ ? {
405
+ sampledSummary: withSummaryBasis(
406
+ buildBenchmarkSummary(games, mergedSearch?.pagination, request.source, { sampled: true }),
407
+ "bounded_sample"
408
+ )
409
+ }
410
+ : {}),
411
+ cohort: {
412
+ included: games.map((game) => ({
413
+ source: request.source,
414
+ externalId: asString(game?.externalId ?? game?.appid),
415
+ title: game?.title ?? game?.name,
416
+ evidence: game
417
+ })),
418
+ skipped: []
419
+ },
420
+ games
421
+ },
422
+ meta: {
423
+ generatedAt,
424
+ requestId,
425
+ composedBy: "gmc-cli",
426
+ ...(sample ? { sample } : {}),
427
+ upstreamRequests: buildSearchUpstreamRequests(searches, pageErrors)
428
+ },
429
+ ...(mergedSearch?.pagination ? { pagination: mergedSearch.pagination } : {}),
430
+ warnings: uniqueStrings([
431
+ ...searches.flatMap((entry) => safeWarnings(entry)),
432
+ ...pageErrors.map((entry) => `benchmark sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
433
+ sample
434
+ ? "sampledSummary is computed from bounded sampled pages; it is not a full market-size estimate"
435
+ : "benchmark summary is computed from the returned page only; it is not a full market-size estimate"
436
+ ])
437
+ };
438
+ }
439
+
440
+ export function composeReviewPatternsPayload(request, result, options = {}) {
441
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
442
+ const requestId = options.requestId ?? createCliRequestId();
443
+ const games = Array.isArray(result.search?.data) ? result.search.data : [];
444
+ const searches = Array.isArray(result.searches) ? result.searches : [result.search];
445
+ const searchPageErrors = Array.isArray(result.searchPageErrors) ? result.searchPageErrors : [];
446
+ const topicResults = Array.isArray(result.topicResults) ? result.topicResults : [];
447
+ const normalizedGames = topicResults.map((entry) => buildReviewPatternGame(request, entry));
448
+ const includedGames = normalizedGames.filter((game) => !game.skippedReason);
449
+ const usableGames = includedGames.filter((game) => game.topics.length > 0);
450
+ const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
451
+ const skipped = normalizedGames
452
+ .filter((game) => game.skippedReason)
453
+ .map((game) => ({
454
+ source: request.source,
455
+ externalId: game.externalId,
456
+ appid: game.appid,
457
+ title: game.title,
458
+ reason: game.skippedReason,
459
+ ...(game.error ? { error: game.error } : {})
460
+ }));
461
+ const warnings = uniqueStrings([
462
+ ...safeWarnings(result.search),
463
+ ...searches.flatMap((entry) => safeWarnings(entry)),
464
+ ...searchPageErrors.map((entry) => `review pattern sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
465
+ ...topicResults.flatMap((entry) => safeWarnings(entry.topics)),
466
+ ...topicResults.flatMap((entry) => reviewPatternErrorWarning(entry)),
467
+ "review patterns are computed from the returned cohort page only; they are not full-market review clusters",
468
+ ...(usableGames.length < request.minGames
469
+ ? [`usable game count ${usableGames.length} is below --min-games ${request.minGames}`]
470
+ : [])
471
+ ]);
472
+
473
+ return {
474
+ data: {
475
+ kind: "evidence_pack",
476
+ source: request.source,
477
+ workflow: request.compareBy ? "reviewPatternsCompare" : "reviewPatterns",
478
+ inputs: {
479
+ filters: request.query,
480
+ sections: ["review-topics"],
481
+ ...(request.sentiment ? { sentiment: request.sentiment } : {}),
482
+ ...(request.topic ? { topic: request.topic } : {}),
483
+ ...(request.compareBy ? { compareBy: request.compareBy } : {}),
484
+ ...(sample
485
+ ? { sample: buildEvidencePackSample(sample, usableGames.length) }
486
+ : { sample: buildReturnedPageSample(result.search?.pagination, usableGames.length) })
487
+ },
488
+ cohort: {
489
+ query: request.query,
490
+ minGames: request.minGames,
491
+ maxTopicsPerGame: request.maxTopicsPerGame,
492
+ sentiment: request.sentiment,
493
+ topic: request.topic,
494
+ ...(request.compareBy ? { compareBy: request.compareBy } : {}),
495
+ returnedGames: games.length,
496
+ usableGames: usableGames.length,
497
+ skippedGames: skipped.length,
498
+ totalMatches: result.search?.pagination?.total,
499
+ included: usableGames.map((game) => ({
500
+ source: request.source,
501
+ externalId: game.externalId,
502
+ title: game.title,
503
+ evidence: {
504
+ reviews: game.reviews,
505
+ rating: game.rating,
506
+ releaseDate: game.releaseDate,
507
+ coverage: game.coverage,
508
+ topics: game.topics
509
+ }
510
+ })),
511
+ skipped
512
+ },
513
+ summary: withSummaryBasis(
514
+ buildReviewPatternsSummary(request, usableGames, skipped, result.search?.pagination),
515
+ sample ? "bounded_sample" : "returned_page"
516
+ ),
517
+ patterns: buildReviewPatterns(usableGames),
518
+ ...(request.compareBy ? { segments: buildReviewPatternSegments(usableGames, request.compareBy) } : {}),
519
+ games: includedGames.map((game) => ({
520
+ source: request.source,
521
+ externalId: game.externalId,
522
+ appid: game.appid,
523
+ title: game.title,
524
+ reviews: game.reviews,
525
+ rating: game.rating,
526
+ releaseDate: game.releaseDate,
527
+ coverage: game.coverage,
528
+ topicCount: game.topics.length,
529
+ topics: game.topics
530
+ })),
531
+ skipped
532
+ },
533
+ meta: {
534
+ generatedAt,
535
+ requestId,
536
+ composedBy: "gmc-cli",
537
+ ...(sample ? { sample } : {}),
538
+ upstreamRequests: [
539
+ ...buildSearchUpstreamRequests(searches, searchPageErrors),
540
+ ...topicResults
541
+ .filter((entry) => entry.topics || entry.error)
542
+ .map((entry) => ({
543
+ externalId: entry.externalId,
544
+ kind: "review-topics",
545
+ requestId: entry.topics?.meta?.requestId,
546
+ source: entry.topics?.meta?.source,
547
+ entitlements: entry.topics?.meta?.entitlements,
548
+ ...(entry.error ? { error: entry.error } : {})
549
+ }))
550
+ ]
551
+ },
552
+ ...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
553
+ ...(warnings.length ? { warnings } : {})
554
+ };
555
+ }
556
+
557
+ export function composeCampaignSignalsPayload(request, result, options = {}) {
558
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
559
+ const requestId = options.requestId ?? createCliRequestId();
560
+ const detail = result.detail ?? {};
561
+ const game = detail.data ?? {};
562
+ const allSignals = asArray(game?.insights?.marketing?.signals);
563
+ const signals = allSignals.slice(0, request.limit);
564
+ const locked = asArray(detail?.meta?.entitlements?.locked);
565
+ const warnings = uniqueStrings([
566
+ ...safeWarnings(detail),
567
+ ...(locked.some((key) => String(key).startsWith("marketing."))
568
+ ? ["Marketing campaign evidence is partially locked by the caller entitlement."]
569
+ : []),
570
+ "Campaign signals are observed source-backed evidence, not causal attribution or lift measurement."
571
+ ]);
572
+
573
+ return {
574
+ data: {
575
+ kind: "evidence_pack",
576
+ source: request.source,
577
+ workflow: "campaignSignals",
578
+ inputs: {
579
+ externalIds: [request.externalId],
580
+ sections: ["marketing"],
581
+ limit: request.limit
582
+ },
583
+ game: {
584
+ source: game.source,
585
+ externalId: asString(game.externalId ?? game.appid),
586
+ appid: game.appid,
587
+ title: game.title,
588
+ releaseDate: game.releaseDate,
589
+ coverage: game.coverage
590
+ },
591
+ summary: withSummaryBasis({
592
+ signalCount: signals.length,
593
+ totalSignalsReturnedByDetail: allSignals.length,
594
+ topSignalSources: topValues(signals.map((signal) => signal?.source)),
595
+ topCampaignTypes: topValues(signals.map((signal) => signal?.campaignType)),
596
+ locked: locked.filter((key) => String(key).startsWith("marketing.")),
597
+ caveats: [
598
+ "This pack uses visible fields from the Steam detail API only.",
599
+ "sourceUrl may be absent when the caller only has preview access.",
600
+ "Use future lift workflows before making before/after movement claims."
601
+ ]
602
+ }, "returned_detail"),
603
+ signals,
604
+ cohort: {
605
+ included: signals.length
606
+ ? [{
607
+ source: request.source,
608
+ externalId: request.externalId,
609
+ title: game.title,
610
+ evidence: {
611
+ signals
612
+ }
613
+ }]
614
+ : [],
615
+ skipped: signals.length
616
+ ? []
617
+ : [{
618
+ source: request.source,
619
+ externalId: request.externalId,
620
+ title: game.title,
621
+ reason: locked.some((key) => String(key).startsWith("marketing."))
622
+ ? "campaign_signals_locked_or_redacted"
623
+ : "no_campaign_signals"
624
+ }]
625
+ }
626
+ },
627
+ meta: {
628
+ generatedAt,
629
+ requestId,
630
+ composedBy: "gmc-cli",
631
+ upstreamRequests: [{
632
+ externalId: request.externalId,
633
+ kind: "detail",
634
+ requestId: detail?.meta?.requestId,
635
+ source: detail?.meta?.source,
636
+ entitlements: detail?.meta?.entitlements
637
+ }]
638
+ },
639
+ ...(warnings.length ? { warnings } : {})
640
+ };
641
+ }
642
+
643
+ export function composeCohortEvidencePayload(request, result, options = {}) {
644
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
645
+ const requestId = options.requestId ?? createCliRequestId();
646
+ const games = Array.isArray(result.search?.data) ? result.search.data : [];
647
+ const searches = Array.isArray(result.searches) ? result.searches : [result.search];
648
+ const searchPageErrors = Array.isArray(result.searchPageErrors) ? result.searchPageErrors : [];
649
+ const analysisResults = Array.isArray(result.analysisResults) ? result.analysisResults : [];
650
+ const entries = analysisResults.map((entry) => buildCohortEvidenceGame(request, entry));
651
+ const included = entries.filter((entry) => !entry.skippedReason);
652
+ const skipped = entries
653
+ .filter((entry) => entry.skippedReason)
654
+ .map((entry) => ({
655
+ source: request.source,
656
+ externalId: entry.externalId,
657
+ appid: entry.appid,
658
+ title: entry.title,
659
+ reason: entry.skippedReason,
660
+ ...(entry.error ? { error: entry.error } : {})
661
+ }));
662
+ const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
663
+ const warnings = uniqueStrings([
664
+ ...safeWarnings(result.search),
665
+ ...searches.flatMap((entry) => safeWarnings(entry)),
666
+ ...searchPageErrors.map((entry) => `${request.workflow} sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
667
+ ...analysisResults.flatMap((entry) => safeWarnings(entry.analysis)),
668
+ ...analysisResults.flatMap((entry) => cohortEvidenceErrorWarning(entry, request.workflow)),
669
+ `${request.workflow} is a CLI-composed evidence pack over bounded visible API data; treat it as sampled evidence, not causal proof or full-market measurement`
670
+ ]);
671
+
672
+ return {
673
+ data: {
674
+ kind: "evidence_pack",
675
+ source: request.source,
676
+ workflow: request.workflow,
677
+ evidenceType: request.evidenceType,
678
+ inputs: {
679
+ filters: request.query,
680
+ sections: request.sections,
681
+ ...(sample ? { sample: buildEvidencePackSample(sample, included.length) } : {
682
+ sample: buildReturnedPageSample(result.search?.pagination, included.length)
683
+ })
684
+ },
685
+ cohort: {
686
+ query: request.query,
687
+ returnedGames: games.length,
688
+ usableGames: included.length,
689
+ skippedGames: skipped.length,
690
+ totalMatches: result.search?.pagination?.total,
691
+ included: included.map((entry) => ({
692
+ source: request.source,
693
+ externalId: entry.externalId,
694
+ title: entry.title,
695
+ evidence: {
696
+ reviews: entry.reviews,
697
+ rating: entry.rating,
698
+ price: entry.price,
699
+ releaseDate: entry.releaseDate,
700
+ coverage: entry.coverage,
701
+ tags: entry.tags,
702
+ patternEvidence: entry.patternEvidence
703
+ }
704
+ })),
705
+ skipped
706
+ },
707
+ summary: withSummaryBasis(
708
+ buildCohortEvidenceSummary(request, included, result.search?.pagination),
709
+ sample ? "bounded_sample" : "returned_page"
710
+ ),
711
+ patterns: buildCohortEvidencePatterns(request, included),
712
+ ...(request.evidenceType === "pricing" ? { segments: buildPricingSegments(included) } : {}),
713
+ ...(request.evidenceType === "trends" || request.evidenceType === "brief" ? { trends: buildTrendSummary(included, result.search?.pagination, request.source) } : {}),
714
+ games: included.map((entry) => ({
715
+ source: request.source,
716
+ externalId: entry.externalId,
717
+ appid: entry.appid,
718
+ title: entry.title,
719
+ reviews: entry.reviews,
720
+ rating: entry.rating,
721
+ price: entry.price,
722
+ releaseDate: entry.releaseDate,
723
+ coverage: entry.coverage,
724
+ tags: entry.tags,
725
+ patternEvidence: entry.patternEvidence
726
+ })),
727
+ skipped
728
+ },
729
+ meta: {
730
+ generatedAt,
731
+ requestId,
732
+ composedBy: "gmc-cli",
733
+ ...(sample ? { sample } : {}),
734
+ upstreamRequests: [
735
+ ...buildSearchUpstreamRequests(searches, searchPageErrors),
736
+ ...analysisResults
737
+ .filter((entry) => entry.analysis || entry.error)
738
+ .map((entry) => ({
739
+ externalId: entry.externalId,
740
+ kind: "analysis",
741
+ requestId: entry.analysis?.meta?.requestId,
742
+ source: entry.analysis?.meta?.source,
743
+ entitlements: entry.analysis?.meta?.entitlements,
744
+ ...(entry.error ? { error: entry.error } : {})
745
+ }))
746
+ ]
747
+ },
748
+ ...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
749
+ ...(warnings.length ? { warnings } : {})
750
+ };
751
+ }
752
+
753
+ export function composeShowcaseFitPayload(request, result, options = {}) {
754
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
755
+ const requestId = options.requestId ?? createCliRequestId();
756
+ const warnings = uniqueStrings([
757
+ ...safeWarnings(result.detail),
758
+ ...safeWarnings(result.showcases)
759
+ ]);
760
+
761
+ return {
762
+ data: {
763
+ kind: "evidence_pack",
764
+ source: request.source,
765
+ workflow: "showcaseFit",
766
+ inputs: {
767
+ externalIds: [request.externalId],
768
+ filters: result.showcaseQuery,
769
+ sample: buildReturnedPageSample(result.showcases?.pagination, asArray(result.showcases?.data).length)
770
+ },
771
+ externalId: request.externalId,
772
+ game: result.detail?.data,
773
+ showcaseQuery: result.showcaseQuery,
774
+ matchBasis: {
775
+ tags: result.tags,
776
+ status: result.showcaseQuery.status,
777
+ format: result.showcaseQuery.format,
778
+ country: result.showcaseQuery.country,
779
+ deadlineBefore: request.deadlineBefore
780
+ },
781
+ summary: withSummaryBasis(buildShowcaseFitSummary(request, result), "returned_page"),
782
+ matches: result.showcases?.data ?? [],
783
+ showcases: result.showcases?.data ?? [],
784
+ cohort: {
785
+ included: asArray(result.showcases?.data).map((showcase) => ({
786
+ source: "showcase_catalog",
787
+ externalId: asString(showcase?.id),
788
+ title: showcase?.name,
789
+ evidence: showcase
790
+ })),
791
+ skipped: []
792
+ }
793
+ },
794
+ meta: {
795
+ generatedAt,
796
+ requestId,
797
+ composedBy: "gmc-cli",
798
+ upstreamRequests: [
799
+ {
800
+ externalId: request.externalId,
801
+ kind: "detail",
802
+ requestId: result.detail?.meta?.requestId,
803
+ source: result.detail?.meta?.source,
804
+ entitlements: result.detail?.meta?.entitlements
805
+ },
806
+ {
807
+ kind: "showcases",
808
+ requestId: result.showcases?.meta?.requestId,
809
+ source: result.showcases?.meta?.source,
810
+ pagination: result.showcases?.pagination
811
+ }
812
+ ]
813
+ },
814
+ ...(warnings.length ? { warnings } : {})
815
+ };
816
+ }
817
+
818
+ async function fetchJson(url, { apiKey }) {
819
+ let response;
820
+ try {
821
+ response = await fetch(url, {
822
+ headers: buildRequestHeaders(apiKey)
823
+ });
824
+ } catch (error) {
825
+ writeJsonError({
826
+ code: "CLI_API_ERROR",
827
+ message: `Failed to reach GMC API at ${url.origin}`,
828
+ cause: error instanceof Error ? error.message : "Unknown network failure"
829
+ });
830
+ process.exit(EXIT_API);
831
+ }
832
+
833
+ const payload = await response.json().catch(() => null);
834
+
835
+ if (!response.ok) {
836
+ process.stdout.write(`${JSON.stringify(payload ?? {
837
+ error: buildHttpApiError(url, response)
838
+ }, null, 2)}\n`);
839
+ process.exit(EXIT_API);
840
+ }
841
+
842
+ if (payload == null) {
843
+ writeJsonError(buildNonJsonApiError(url, response));
844
+ process.exit(EXIT_API);
845
+ }
846
+
847
+ return payload;
848
+ }
849
+
850
+ function getPreferredShowcaseTags(game) {
851
+ const rawTags = [
852
+ ...getRankedTagNames(game),
853
+ ...(Array.isArray(game?.tags) ? game.tags : []),
854
+ ...(Array.isArray(game?.genres) ? game.genres : [])
855
+ ];
856
+ return uniqueStrings(rawTags.map((tag) => normalizeTagSlug(tag)).filter(Boolean)).slice(0, 6);
857
+ }
858
+
859
+ function buildResolveCandidate(request, game, index) {
860
+ const title = asString(game?.title ?? game?.name);
861
+ const appid = game?.appid;
862
+ const externalId = asString(game?.externalId ?? appid);
863
+ const normalizedQuery = normalizeResolveText(request.query);
864
+ const normalizedTitle = normalizeResolveText(title);
865
+ const queryTokens = tokenizeResolveText(request.query);
866
+ const titleTokens = tokenizeResolveText(title);
867
+ const tokenOverlap = calculateTokenOverlap(queryTokens, titleTokens);
868
+ const scoreInputs = {
869
+ titleExact: title === request.query,
870
+ normalizedTitleExact: normalizedTitle.length > 0 && normalizedTitle === normalizedQuery,
871
+ titleContainsQuery: normalizedQuery.length > 0 && normalizedTitle.includes(normalizedQuery),
872
+ queryContainsTitle: normalizedTitle.length > 0 && normalizedQuery.includes(normalizedTitle),
873
+ tokenOverlap,
874
+ reviewCount: typeof game?.reviews === "number" ? game.reviews : undefined,
875
+ rating: typeof game?.rating === "number" ? game.rating : undefined,
876
+ coverage: game?.coverage
877
+ };
878
+ const score = calculateResolveScore(scoreInputs, index);
879
+
880
+ return {
881
+ source: request.source,
882
+ externalId,
883
+ appid,
884
+ title,
885
+ developer: game?.developer,
886
+ releaseDate: game?.releaseDate,
887
+ reviews: game?.reviews,
888
+ rating: game?.rating,
889
+ price: game?.price,
890
+ tags: game?.tags,
891
+ tagRanks: game?.tagRanks,
892
+ genres: game?.genres,
893
+ categories: game?.categories,
894
+ facets: game?.facets,
895
+ coverage: game?.coverage,
896
+ score,
897
+ confidence: classifyResolveConfidence(score),
898
+ scoreInputs
899
+ };
900
+ }
901
+
902
+ function buildBenchmarkSummary(games, pagination, source, options = {}) {
903
+ return {
904
+ sampleSize: games.length,
905
+ totalMatches: pagination?.total,
906
+ reviews: numericSummary(games.map((game) => game?.reviews)),
907
+ rating: numericSummary(games.map((game) => game?.rating)),
908
+ price: numericSummary(games.map((game) => game?.price)),
909
+ releaseYears: countReleaseYears(games),
910
+ priceBuckets: countPriceBuckets(games),
911
+ topTags: topRankedTags(games),
912
+ topGenres: topValues(games.flatMap((game) => asArray(game?.genres))),
913
+ topPlayModes: topValues(games.flatMap((game) => asArray(game?.facets?.playModes))),
914
+ topFeatures: topValues(games.flatMap((game) => asArray(game?.facets?.features))),
915
+ coverage: countCoverage(games),
916
+ topGames: games
917
+ .slice()
918
+ .sort((a, b) => sortableNumber(b?.reviews) - sortableNumber(a?.reviews))
919
+ .slice(0, 5)
920
+ .map((game) => ({
921
+ source,
922
+ externalId: asString(game?.externalId ?? game?.appid),
923
+ appid: game?.appid,
924
+ title: game?.title ?? game?.name,
925
+ reviews: game?.reviews,
926
+ rating: game?.rating,
927
+ releaseDate: game?.releaseDate
928
+ })),
929
+ caveats: options.sampled
930
+ ? [
931
+ "Summary uses only the fetched sample rows, not every matching Steam title.",
932
+ "Use larger bounded samples or a future server-side aggregate endpoint before making broad cohort claims.",
933
+ "Visible review volume, rating, tags, price, and coverage are evidence inputs, not causal explanations."
934
+ ]
935
+ : [
936
+ "Summary uses only the returned page, not every matching Steam title.",
937
+ "Use pagination.nextOffset for additional pages before making broad cohort claims.",
938
+ "Visible review volume, rating, tags, price, and coverage are evidence inputs, not causal explanations."
939
+ ]
940
+ };
941
+ }
942
+
943
+ function getRankedTagNames(game) {
944
+ const ranked = asArray(game?.tagRanks)
945
+ .slice()
946
+ .sort((a, b) => sortableNumber(a?.rank) - sortableNumber(b?.rank))
947
+ .map((tag) => asString(tag?.name))
948
+ .filter(Boolean);
949
+ return ranked.length ? ranked : asArray(game?.tags);
950
+ }
951
+
952
+ function topRankedTags(games, limit = 10) {
953
+ const counts = new Map();
954
+ for (const game of games) {
955
+ const rankedTags = asArray(game?.tagRanks)
956
+ .map((tag, index) => {
957
+ const rank = numericValue(tag?.rank);
958
+ return {
959
+ value: asString(tag?.name),
960
+ rank: Number.isFinite(rank) ? rank : index + 1
961
+ };
962
+ })
963
+ .filter((tag) => tag.value);
964
+ const tags = rankedTags.length
965
+ ? rankedTags
966
+ : asArray(game?.tags).map((tag, index) => ({
967
+ value: asString(tag),
968
+ rank: index + 1
969
+ }));
970
+
971
+ const seen = new Set();
972
+ for (const tag of tags) {
973
+ if (seen.has(tag.value)) continue;
974
+ seen.add(tag.value);
975
+ const current = counts.get(tag.value) ?? {
976
+ value: tag.value,
977
+ count: 0,
978
+ rankTotal: 0,
979
+ minRank: tag.rank
980
+ };
981
+ current.count += 1;
982
+ current.rankTotal += tag.rank;
983
+ current.minRank = Math.min(current.minRank, tag.rank);
984
+ counts.set(tag.value, current);
985
+ }
986
+ }
987
+
988
+ return Array.from(counts.values())
989
+ .map((item) => ({
990
+ value: item.value,
991
+ count: item.count,
992
+ averageRank: roundNumber(item.rankTotal / item.count),
993
+ minRank: item.minRank
994
+ }))
995
+ .sort((a, b) => b.count - a.count || a.averageRank - b.averageRank || a.value.localeCompare(b.value))
996
+ .slice(0, limit);
997
+ }
998
+
999
+ function mergeSearchPages(searches, sampling) {
1000
+ const data = searches.flatMap((search) => asArray(search?.data));
1001
+ const sampledData = sampling?.mode === "sample-size"
1002
+ ? data.slice(0, sampling.requestedSize)
1003
+ : data;
1004
+ const first = searches[0] ?? {};
1005
+ const last = searches[searches.length - 1] ?? {};
1006
+ return {
1007
+ ...first,
1008
+ data: sampledData,
1009
+ pagination: {
1010
+ ...(first.pagination ?? {}),
1011
+ limit: first.pagination?.limit,
1012
+ offset: first.pagination?.offset,
1013
+ total: first.pagination?.total,
1014
+ nextOffset: last.pagination?.nextOffset
1015
+ },
1016
+ warnings: uniqueStrings(searches.flatMap((search) => safeWarnings(search)))
1017
+ };
1018
+ }
1019
+
1020
+ function buildSampleMetadata(request, searches, games, pageErrors) {
1021
+ if (!request.sampling) return undefined;
1022
+ return {
1023
+ mode: request.sampling.mode,
1024
+ requestedSize: request.sampling.requestedSize,
1025
+ requestedPages: request.sampling.requestedPages,
1026
+ pageLimit: request.sampling.pageLimit,
1027
+ fetchedPages: searches.length,
1028
+ fetchedGames: games.length,
1029
+ skippedPages: pageErrors.length,
1030
+ totalMatches: searches[0]?.pagination?.total,
1031
+ quotaCaveat: "Sampled workflows issue one search request per fetched page, plus downstream per-game requests when the command needs detail evidence."
1032
+ };
1033
+ }
1034
+
1035
+ function buildEvidencePackSample(sample, returnedRows) {
1036
+ return {
1037
+ mode: normalizeEvidencePackSampleMode(sample.mode),
1038
+ requestedRows: sample.requestedSize,
1039
+ requestedPages: sample.requestedPages,
1040
+ returnedRows,
1041
+ fetchedPages: sample.fetchedPages,
1042
+ skippedPages: sample.skippedPages,
1043
+ totalMatches: sample.totalMatches
1044
+ };
1045
+ }
1046
+
1047
+ function buildReturnedPageSample(pagination, returnedRows) {
1048
+ return {
1049
+ mode: "returned_page",
1050
+ requestedRows: pagination?.limit,
1051
+ returnedRows,
1052
+ totalMatches: pagination?.total
1053
+ };
1054
+ }
1055
+
1056
+ function normalizeEvidencePackSampleMode(mode) {
1057
+ if (mode === "sample-size") return "sample_size";
1058
+ if (mode === "sample-pages") return "sample_pages";
1059
+ return mode;
1060
+ }
1061
+
1062
+ function withSummaryBasis(summary, basis) {
1063
+ return {
1064
+ basis,
1065
+ ...summary
1066
+ };
1067
+ }
1068
+
1069
+ function buildSearchUpstreamRequests(searches, pageErrors = []) {
1070
+ return [
1071
+ ...searches.map((search, index) => ({
1072
+ kind: "search",
1073
+ page: index + 1,
1074
+ requestId: search?.meta?.requestId,
1075
+ source: search?.meta?.source,
1076
+ entitlements: search?.meta?.entitlements,
1077
+ pagination: search?.pagination
1078
+ })),
1079
+ ...pageErrors.map((entry, index) => ({
1080
+ kind: "search",
1081
+ page: searches.length + index + 1,
1082
+ offset: entry.offset,
1083
+ error: entry.error
1084
+ }))
1085
+ ];
1086
+ }
1087
+
1088
+ function buildReviewPatternGame(request, entry) {
1089
+ const game = entry.game ?? {};
1090
+ const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
1091
+ const topics = extractReviewTopics(entry.topics)
1092
+ .slice(0, request.maxTopicsPerGame)
1093
+ .map(normalizeReviewTopic)
1094
+ .filter((topic) => matchesReviewPatternFilters(topic, request));
1095
+ const skippedReason = entry.skippedReason ?? (topics.length === 0 ? "no_matching_review_topics" : undefined);
1096
+
1097
+ return {
1098
+ source: request.source,
1099
+ externalId,
1100
+ appid: game?.appid,
1101
+ title: game?.title ?? game?.name,
1102
+ reviews: game?.reviews,
1103
+ rating: game?.rating,
1104
+ releaseDate: game?.releaseDate,
1105
+ coverage: game?.coverage,
1106
+ topics,
1107
+ skippedReason,
1108
+ error: entry.error
1109
+ };
1110
+ }
1111
+
1112
+ function buildReviewPatterns(games) {
1113
+ const totalGames = games.length;
1114
+ const totalReviewVolume = games.reduce((sum, game) => sum + Math.max(0, numericValue(game.reviews) || 0), 0);
1115
+ const patternMap = new Map();
1116
+
1117
+ for (const game of games) {
1118
+ const seenInGame = new Set();
1119
+ for (const topic of game.topics) {
1120
+ const key = normalizeReviewPatternKey(topic.topic);
1121
+ if (!key || seenInGame.has(key)) continue;
1122
+ seenInGame.add(key);
1123
+
1124
+ const existing = patternMap.get(key) ?? {
1125
+ key,
1126
+ topic: topic.topic,
1127
+ appearsInGames: 0,
1128
+ matchedReviewVolume: 0,
1129
+ sentiments: {},
1130
+ prevalenceValues: [],
1131
+ exampleGames: []
1132
+ };
1133
+ existing.appearsInGames += 1;
1134
+ existing.matchedReviewVolume += Math.max(0, numericValue(game.reviews) || 0);
1135
+ if (topic.sentiment) {
1136
+ existing.sentiments[topic.sentiment] = (existing.sentiments[topic.sentiment] ?? 0) + 1;
1137
+ }
1138
+ const prevalence = numericValue(topic.prevalence);
1139
+ if (Number.isFinite(prevalence)) existing.prevalenceValues.push(prevalence);
1140
+ if (existing.exampleGames.length < 5) {
1141
+ existing.exampleGames.push({
1142
+ source: game.source,
1143
+ externalId: game.externalId,
1144
+ appid: game.appid,
1145
+ title: game.title,
1146
+ reviews: game.reviews,
1147
+ rating: game.rating,
1148
+ topic: topic.topic,
1149
+ clusterId: topic.clusterId,
1150
+ displayGroup: topic.displayGroup,
1151
+ sentiment: topic.sentiment,
1152
+ summary: topic.summary,
1153
+ prevalence: topic.prevalence,
1154
+ impactScore: topic.impactScore,
1155
+ representativeQuotes: topic.representativeQuotes
1156
+ });
1157
+ }
1158
+ patternMap.set(key, existing);
1159
+ }
1160
+ }
1161
+
1162
+ return Array.from(patternMap.values())
1163
+ .map((pattern) => ({
1164
+ key: pattern.key,
1165
+ topic: pattern.topic,
1166
+ appearsInGames: pattern.appearsInGames,
1167
+ shareOfGames: totalGames > 0 ? roundNumber(pattern.appearsInGames / totalGames) : 0,
1168
+ reviewVolumeWeightedShare: totalReviewVolume > 0
1169
+ ? roundNumber(pattern.matchedReviewVolume / totalReviewVolume)
1170
+ : undefined,
1171
+ matchedReviewVolume: pattern.matchedReviewVolume,
1172
+ sentiments: sortCountObject(pattern.sentiments),
1173
+ averageTopicPrevalence: pattern.prevalenceValues.length > 0
1174
+ ? roundNumber(pattern.prevalenceValues.reduce((sum, value) => sum + value, 0) / pattern.prevalenceValues.length)
1175
+ : undefined,
1176
+ exampleGames: pattern.exampleGames
1177
+ }))
1178
+ .sort((a, b) => (
1179
+ b.appearsInGames - a.appearsInGames
1180
+ || sortableNumber(b.matchedReviewVolume) - sortableNumber(a.matchedReviewVolume)
1181
+ || a.key.localeCompare(b.key)
1182
+ ));
1183
+ }
1184
+
1185
+ function buildReviewPatternsSummary(request, usableGames, skipped, pagination) {
1186
+ const patterns = buildReviewPatterns(usableGames);
1187
+ return {
1188
+ sampleSize: usableGames.length,
1189
+ totalMatches: pagination?.total,
1190
+ skippedGames: skipped.length,
1191
+ topicEvidenceItems: usableGames.reduce((sum, game) => sum + game.topics.length, 0),
1192
+ patternCount: patterns.length,
1193
+ topSignals: patterns.slice(0, 10).map((pattern) => ({
1194
+ type: "reviewTopic",
1195
+ value: pattern.topic,
1196
+ count: pattern.appearsInGames,
1197
+ evidenceRefs: pattern.exampleGames.map((game) => `${game.source}:${game.externalId}`)
1198
+ })),
1199
+ caveats: [
1200
+ "Review patterns are repeated visible topics across the bounded cohort, not full-market clusters.",
1201
+ "Representative quotes are evidence; locked severity or fixability fields must remain unavailable when locked.",
1202
+ ...(usableGames.length < request.minGames
1203
+ ? [`Usable game count ${usableGames.length} is below --min-games ${request.minGames}.`]
1204
+ : [])
1205
+ ]
1206
+ };
1207
+ }
1208
+
1209
+ function extractReviewTopics(payload) {
1210
+ if (Array.isArray(payload?.data)) return payload.data;
1211
+ if (Array.isArray(payload?.data?.topics)) return payload.data.topics;
1212
+ if (Array.isArray(payload?.data?.sections?.reviews?.topics)) return payload.data.sections.reviews.topics;
1213
+ return [];
1214
+ }
1215
+
1216
+ function normalizeReviewTopic(topic) {
1217
+ const displayGroup = asString(topic?.displayGroup);
1218
+ const quote = asString(topic?.quote);
1219
+ const representativeQuotes = asArray(topic?.representativeQuotes);
1220
+ if (representativeQuotes.length === 0 && quote) {
1221
+ representativeQuotes.push({ text: quote });
1222
+ }
1223
+ const clusterLabel = topic?.clusterId == null
1224
+ ? undefined
1225
+ : `${displayGroup ? `${displayGroup} ` : ""}cluster ${topic.clusterId}`;
1226
+
1227
+ return {
1228
+ topic: asString(topic?.topic ?? topic?.label ?? clusterLabel ?? displayGroup),
1229
+ clusterId: topic?.clusterId,
1230
+ displayGroup: displayGroup || undefined,
1231
+ sentiment: topic?.sentiment ?? sentimentFromReviewDisplayGroup(displayGroup),
1232
+ summary: topic?.summary ?? (quote || undefined),
1233
+ prevalence: topic?.prevalence,
1234
+ impactScore: topic?.impactScore,
1235
+ severity: topic?.severity,
1236
+ fixability: topic?.fixability,
1237
+ representativeQuotes: representativeQuotes.slice(0, 2)
1238
+ };
1239
+ }
1240
+
1241
+ function matchesReviewPatternFilters(topic, request) {
1242
+ if (!topic.topic) return false;
1243
+ if (request.sentiment && topic.sentiment !== request.sentiment) return false;
1244
+ if (request.topic) {
1245
+ const needle = asString(request.topic).trim().toLowerCase();
1246
+ const haystack = `${topic.topic} ${topic.summary ?? ""}`.toLowerCase();
1247
+ if (needle && !haystack.includes(needle)) return false;
1248
+ }
1249
+ return true;
1250
+ }
1251
+
1252
+ function normalizeReviewPatternKey(value) {
1253
+ return asString(value)
1254
+ .trim()
1255
+ .toLowerCase()
1256
+ .replace(/&/g, " and ")
1257
+ .replace(/[^a-z0-9]+/g, " ")
1258
+ .replace(/\s+/g, " ")
1259
+ .trim();
1260
+ }
1261
+
1262
+ function sentimentFromReviewDisplayGroup(value) {
1263
+ if (value === "love") return "positive";
1264
+ if (value === "actionable") return "negative";
1265
+ if (value === "mixed") return "mixed";
1266
+ return undefined;
1267
+ }
1268
+
1269
+ function reviewPatternErrorWarning(entry) {
1270
+ if (!entry?.error) return [];
1271
+ const code = entry.error.code ?? "CLI_API_ERROR";
1272
+ const suffix = entry.externalId ? ` for ${entry.externalId}` : "";
1273
+ return [`review topics request failed${suffix}: ${code}`];
1274
+ }
1275
+
1276
+ function buildReviewPatternSegments(games, segment) {
1277
+ const groups = new Map();
1278
+ for (const game of games) {
1279
+ const key = classifyGameSegment(game, segment);
1280
+ const current = groups.get(key) ?? [];
1281
+ current.push(game);
1282
+ groups.set(key, current);
1283
+ }
1284
+
1285
+ return Array.from(groups.entries())
1286
+ .sort(([a], [b]) => a.localeCompare(b))
1287
+ .map(([segmentValue, segmentGames]) => ({
1288
+ segment,
1289
+ value: segmentValue,
1290
+ gameCount: segmentGames.length,
1291
+ patterns: buildReviewPatterns(segmentGames).slice(0, 10)
1292
+ }));
1293
+ }
1294
+
1295
+ function buildCohortEvidenceGame(request, entry) {
1296
+ const game = entry.game ?? {};
1297
+ const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
1298
+ const sections = getAnalysisSections(entry.analysis);
1299
+ const patternEvidence = extractCohortPatternEvidence(request.evidenceType, game, sections);
1300
+ const skippedReason = entry.skippedReason
1301
+ ?? (request.analysisQuery && patternEvidence.length === 0 ? "no_matching_analysis_evidence" : undefined);
1302
+
1303
+ return {
1304
+ source: request.source,
1305
+ externalId,
1306
+ appid: game?.appid,
1307
+ title: game?.title ?? game?.name,
1308
+ reviews: game?.reviews,
1309
+ rating: game?.rating,
1310
+ price: game?.price,
1311
+ discount: game?.discount,
1312
+ releaseDate: game?.releaseDate,
1313
+ coverage: game?.coverage,
1314
+ tags: getRankedTagNames(game).slice(0, 8),
1315
+ genres: game?.genres,
1316
+ analysis: entry.analysis,
1317
+ sections,
1318
+ patternEvidence,
1319
+ skippedReason,
1320
+ error: entry.error
1321
+ };
1322
+ }
1323
+
1324
+ function buildCohortEvidenceSummary(request, entries, pagination) {
1325
+ return {
1326
+ sampleSize: entries.length,
1327
+ totalMatches: pagination?.total,
1328
+ reviews: numericSummary(entries.map((entry) => entry.reviews)),
1329
+ rating: numericSummary(entries.map((entry) => entry.rating)),
1330
+ price: numericSummary(entries.map((entry) => entry.price)),
1331
+ releaseYears: countReleaseYears(entries),
1332
+ priceBuckets: countPriceBuckets(entries),
1333
+ topTags: topRankedTags(entries),
1334
+ topGenres: topValues(entries.flatMap((entry) => asArray(entry.genres))),
1335
+ coverage: countCoverage(entries),
1336
+ evidenceItems: entries.reduce((sum, entry) => sum + entry.patternEvidence.length, 0),
1337
+ caveats: [
1338
+ "This summary uses bounded visible API rows and optional per-game analysis sections.",
1339
+ "Observed patterns are evidence inputs, not recommendations, causal claims, or full-market estimates.",
1340
+ "Locked, missing, partial, and failed downstream analysis requests must remain visible when interpreting the output."
1341
+ ]
1342
+ };
1343
+ }
1344
+
1345
+ function buildShowcaseFitSummary(request, result) {
1346
+ const showcases = asArray(result.showcases?.data);
1347
+ const tags = asArray(result.tags);
1348
+ return {
1349
+ sampleSize: showcases.length,
1350
+ totalMatches: result.showcases?.pagination?.total,
1351
+ target: {
1352
+ source: request.source,
1353
+ externalId: request.externalId,
1354
+ title: result.detail?.data?.title
1355
+ },
1356
+ topSignals: [
1357
+ ...tags.slice(0, 8).map((tag) => ({
1358
+ type: "gameTag",
1359
+ value: tag,
1360
+ count: showcases.filter((showcase) => asArray(showcase?.tags).includes(tag)).length || undefined
1361
+ })),
1362
+ ...(request.deadlineBefore
1363
+ ? [{
1364
+ type: "deadlineBefore",
1365
+ value: request.deadlineBefore,
1366
+ count: showcases.length
1367
+ }]
1368
+ : [])
1369
+ ],
1370
+ caveats: [
1371
+ "Showcase fit is evidence for submission planning, not a final recommendation or guaranteed eligibility.",
1372
+ "Unknown demo, Steam page, fee, or format fields require manual follow-up.",
1373
+ "Current matching is based on visible catalog filters and game tags unless a future scoring method is documented."
1374
+ ]
1375
+ };
1376
+ }
1377
+
1378
+ function buildCohortEvidencePatterns(request, entries) {
1379
+ if (request.evidenceType === "trends") {
1380
+ return [
1381
+ ...topRankedTags(entries).map((item) => ({ type: "tag", ...item })),
1382
+ ...topValues(entries.flatMap((entry) => asArray(entry.genres))).map((item) => ({ type: "genre", ...item }))
1383
+ ];
1384
+ }
1385
+
1386
+ if (request.evidenceType === "pricing") {
1387
+ return Object.entries(countPriceBuckets(entries)).map(([value, count]) => ({
1388
+ type: "priceBucket",
1389
+ value,
1390
+ count
1391
+ }));
1392
+ }
1393
+
1394
+ const patternMap = new Map();
1395
+ for (const entry of entries) {
1396
+ const seen = new Set();
1397
+ for (const evidence of entry.patternEvidence) {
1398
+ const key = `${evidence.type}:${normalizeReviewPatternKey(evidence.value)}`;
1399
+ if (!evidence.value || seen.has(key)) continue;
1400
+ seen.add(key);
1401
+ const current = patternMap.get(key) ?? {
1402
+ type: evidence.type,
1403
+ value: evidence.value,
1404
+ count: 0,
1405
+ reviewVolume: 0,
1406
+ exampleGames: []
1407
+ };
1408
+ current.count += 1;
1409
+ current.reviewVolume += Math.max(0, numericValue(entry.reviews) || 0);
1410
+ if (current.exampleGames.length < 5) {
1411
+ current.exampleGames.push({
1412
+ source: entry.source,
1413
+ externalId: entry.externalId,
1414
+ appid: entry.appid,
1415
+ title: entry.title,
1416
+ reviews: entry.reviews,
1417
+ rating: entry.rating,
1418
+ evidence: evidence.raw
1419
+ });
1420
+ }
1421
+ patternMap.set(key, current);
1422
+ }
1423
+ }
1424
+
1425
+ return Array.from(patternMap.values())
1426
+ .map((pattern) => ({
1427
+ ...pattern,
1428
+ shareOfGames: entries.length > 0 ? roundNumber(pattern.count / entries.length) : 0
1429
+ }))
1430
+ .sort((a, b) => b.count - a.count || b.reviewVolume - a.reviewVolume || a.value.localeCompare(b.value))
1431
+ .slice(0, 30);
1432
+ }
1433
+
1434
+ function buildPricingSegments(entries) {
1435
+ const buckets = new Map();
1436
+ for (const entry of entries) {
1437
+ const bucket = classifyPriceBucket(entry.price);
1438
+ const current = buckets.get(bucket) ?? [];
1439
+ current.push(entry);
1440
+ buckets.set(bucket, current);
1441
+ }
1442
+ return Array.from(buckets.entries())
1443
+ .sort(([a], [b]) => a.localeCompare(b))
1444
+ .map(([value, games]) => ({
1445
+ segment: "price",
1446
+ value,
1447
+ gameCount: games.length,
1448
+ reviews: numericSummary(games.map((game) => game.reviews)),
1449
+ rating: numericSummary(games.map((game) => game.rating)),
1450
+ topTags: topRankedTags(games, 5)
1451
+ }));
1452
+ }
1453
+
1454
+ function buildTrendSummary(entries, pagination, source) {
1455
+ return buildBenchmarkSummary(entries, pagination, source, { sampled: true });
1456
+ }
1457
+
1458
+ function extractCohortPatternEvidence(evidenceType, game, sections) {
1459
+ if (evidenceType === "trends" || evidenceType === "pricing") return [];
1460
+ if (evidenceType === "brief") {
1461
+ return [
1462
+ ...extractCohortPatternEvidence("marketing", game, sections),
1463
+ ...extractCohortPatternEvidence("creators", game, sections),
1464
+ ...extractCohortPatternEvidence("showcases", game, sections)
1465
+ ];
1466
+ }
1467
+ if (evidenceType === "marketing" || evidenceType === "marketingTiming") {
1468
+ const marketing = sections.marketing ?? {};
1469
+ const media = sections.media ?? {};
1470
+ const creators = sections.creators ?? {};
1471
+ const events = asArray(marketing.events).map((event) => ({
1472
+ type: evidenceType === "marketingTiming" ? "marketingEventTiming" : "marketingEvent",
1473
+ value: evidenceType === "marketingTiming"
1474
+ ? classifyRelativeTiming(game?.releaseDate, event?.date)
1475
+ : asString(event?.category ?? event?.event),
1476
+ raw: pickCompactEvidence(event, ["date", "event", "category", "description", "marketScope", "previewUrl"])
1477
+ }));
1478
+ if (evidenceType === "marketingTiming") return events.filter((item) => item.value);
1479
+ return [
1480
+ ...events,
1481
+ ...asArray(media.articles).map((article) => ({
1482
+ type: "mediaAngle",
1483
+ value: asString(article?.articleType ?? article?.outlet),
1484
+ raw: pickCompactEvidence(article, ["title", "outlet", "articleType", "date", "summary", "previewUrl"])
1485
+ })),
1486
+ ...asArray(creators.creators ?? creators).map((creator) => ({
1487
+ type: "creatorPlatform",
1488
+ value: asString(creator?.platform),
1489
+ raw: pickCompactEvidence(creator, ["platform", "channelTitle", "subscriberCount", "totalViewsOnGame"])
1490
+ }))
1491
+ ].filter((item) => item.value);
1492
+ }
1493
+ if (evidenceType === "creators") {
1494
+ const creators = sections.creators ?? {};
1495
+ return asArray(creators.creators ?? creators).flatMap((creator) => [
1496
+ {
1497
+ type: "creatorPlatform",
1498
+ value: asString(creator?.platform),
1499
+ raw: pickCompactEvidence(creator, ["platform", "channelTitle", "subscriberCount", "totalViewsOnGame", "totalVideosOnGame", "firstCoverageDate"])
1500
+ },
1501
+ {
1502
+ type: "creatorScale",
1503
+ value: classifyCreatorScale(creator?.subscriberCount, creator?.totalViewsOnGame),
1504
+ raw: pickCompactEvidence(creator, ["platform", "channelTitle", "subscriberCount", "totalViewsOnGame"])
1505
+ }
1506
+ ]).filter((item) => item.value);
1507
+ }
1508
+ if (evidenceType === "showcases") {
1509
+ const showcases = sections.showcases ?? [];
1510
+ return asArray(showcases).flatMap((showcase) => [
1511
+ {
1512
+ type: "showcaseEvent",
1513
+ value: asString(showcase?.eventName),
1514
+ raw: pickCompactEvidence(showcase, ["eventName", "eventDate", "eventType", "marketScope", "description", "previewUrl"])
1515
+ },
1516
+ {
1517
+ type: "showcaseType",
1518
+ value: asString(showcase?.eventType),
1519
+ raw: pickCompactEvidence(showcase, ["eventName", "eventDate", "eventType", "marketScope"])
1520
+ }
1521
+ ]).filter((item) => item.value);
1522
+ }
1523
+ return [];
1524
+ }
1525
+
1526
+ function getAnalysisSections(payload) {
1527
+ return payload?.data?.sections && typeof payload.data.sections === "object"
1528
+ ? payload.data.sections
1529
+ : {};
1530
+ }
1531
+
1532
+ function cohortEvidenceErrorWarning(entry, workflow) {
1533
+ if (!entry?.error) return [];
1534
+ const code = entry.error.code ?? "CLI_API_ERROR";
1535
+ const suffix = entry.externalId ? ` for ${entry.externalId}` : "";
1536
+ return [`${workflow} analysis request failed${suffix}: ${code}`];
1537
+ }
1538
+
1539
+ function classifyGameSegment(game, segment) {
1540
+ if (segment === "coverage") return asString(game.coverage) || "unknown";
1541
+ if (segment === "releaseYear") {
1542
+ const year = typeof game.releaseDate === "string" ? game.releaseDate.slice(0, 4) : "";
1543
+ return /^\d{4}$/.test(year) ? year : "unknown";
1544
+ }
1545
+ return classifyPriceBucket(game.price);
1546
+ }
1547
+
1548
+ function classifyPriceBucket(priceValue) {
1549
+ const price = numericValue(priceValue);
1550
+ if (!Number.isFinite(price)) return "unknown";
1551
+ if (price <= 0) return "free";
1552
+ if (price < 10) return "under10";
1553
+ if (price < 20) return "under20";
1554
+ if (price < 40) return "under40";
1555
+ return "fortyPlus";
1556
+ }
1557
+
1558
+ function classifyRelativeTiming(releaseDate, eventDate) {
1559
+ if (!isIsoDate(releaseDate) || !isIsoDate(eventDate)) return "unknown";
1560
+ const release = Date.parse(`${releaseDate}T00:00:00.000Z`);
1561
+ const event = Date.parse(`${eventDate}T00:00:00.000Z`);
1562
+ const days = Math.round((event - release) / 86400000);
1563
+ if (days < -180) return "prelaunch_6m_plus";
1564
+ if (days < -90) return "prelaunch_3_6m";
1565
+ if (days < -30) return "prelaunch_1_3m";
1566
+ if (days < 0) return "prelaunch_0_30d";
1567
+ if (days <= 30) return "launch_window_0_30d";
1568
+ if (days <= 90) return "postlaunch_1_3m";
1569
+ if (days <= 180) return "postlaunch_3_6m";
1570
+ return "postlaunch_6m_plus";
1571
+ }
1572
+
1573
+ function isIsoDate(value) {
1574
+ return /^\d{4}-\d{2}-\d{2}$/.test(asString(value));
1575
+ }
1576
+
1577
+ function classifyCreatorScale(subscriberCount, totalViewsOnGame) {
1578
+ const subscribers = numericValue(subscriberCount);
1579
+ const views = numericValue(totalViewsOnGame);
1580
+ const size = Number.isFinite(subscribers) ? subscribers : views;
1581
+ if (!Number.isFinite(size)) return "unknown";
1582
+ if (size >= 1000000) return "mega";
1583
+ if (size >= 100000) return "large";
1584
+ if (size >= 10000) return "mid";
1585
+ return "small";
1586
+ }
1587
+
1588
+ function pickCompactEvidence(value, keys) {
1589
+ if (!value || typeof value !== "object") return value;
1590
+ const picked = {};
1591
+ for (const key of keys) {
1592
+ if (value[key] != null && value[key] !== "") picked[key] = value[key];
1593
+ }
1594
+ return picked;
1595
+ }
1596
+
1597
+ async function fetchJsonResult(url, { apiKey }) {
1598
+ let response;
1599
+ try {
1600
+ response = await fetch(url, {
1601
+ headers: buildRequestHeaders(apiKey)
1602
+ });
1603
+ } catch (error) {
1604
+ return {
1605
+ ok: false,
1606
+ error: {
1607
+ code: "CLI_API_ERROR",
1608
+ message: `Failed to reach GMC API at ${url.origin}`,
1609
+ cause: error instanceof Error ? error.message : "Unknown network failure"
1610
+ }
1611
+ };
1612
+ }
1613
+
1614
+ const payload = await response.json().catch(() => null);
1615
+ if (!response.ok) {
1616
+ return {
1617
+ ok: false,
1618
+ payload,
1619
+ error: payload?.error ?? buildHttpApiError(url, response)
1620
+ };
1621
+ }
1622
+
1623
+ if (payload == null) {
1624
+ return {
1625
+ ok: false,
1626
+ error: buildNonJsonApiError(url, response)
1627
+ };
1628
+ }
1629
+
1630
+ return {
1631
+ ok: true,
1632
+ payload
1633
+ };
1634
+ }
1635
+
1636
+ export function buildHttpApiError(url, response) {
1637
+ return {
1638
+ code: "CLI_API_ERROR",
1639
+ message: `API request failed with HTTP ${response.status}`,
1640
+ status: response.status,
1641
+ statusText: response.statusText || undefined,
1642
+ path: `${url.pathname}${url.search}`,
1643
+ contentType: response.headers.get("content-type") || undefined
1644
+ };
1645
+ }
1646
+
1647
+ export function buildNonJsonApiError(url, response) {
1648
+ return {
1649
+ code: "CLI_API_ERROR",
1650
+ message: `GMC API returned a non-JSON response from ${url.origin}`,
1651
+ status: response.status,
1652
+ statusText: response.statusText || undefined,
1653
+ path: `${url.pathname}${url.search}`,
1654
+ contentType: response.headers.get("content-type") || undefined
1655
+ };
1656
+ }
1657
+
1658
+ function numericSummary(values) {
1659
+ const numbers = values
1660
+ .map(numericValue)
1661
+ .filter((value) => Number.isFinite(value))
1662
+ .sort((a, b) => a - b);
1663
+ if (numbers.length === 0) {
1664
+ return {
1665
+ count: 0
1666
+ };
1667
+ }
1668
+
1669
+ return {
1670
+ count: numbers.length,
1671
+ min: numbers[0],
1672
+ median: median(numbers),
1673
+ max: numbers[numbers.length - 1],
1674
+ average: roundNumber(numbers.reduce((sum, value) => sum + value, 0) / numbers.length)
1675
+ };
1676
+ }
1677
+
1678
+ function median(sortedNumbers) {
1679
+ const middle = Math.floor(sortedNumbers.length / 2);
1680
+ if (sortedNumbers.length % 2 === 1) return sortedNumbers[middle];
1681
+ return roundNumber((sortedNumbers[middle - 1] + sortedNumbers[middle]) / 2);
1682
+ }
1683
+
1684
+ function countReleaseYears(games) {
1685
+ const counts = {};
1686
+ for (const game of games) {
1687
+ const year = typeof game?.releaseDate === "string" ? game.releaseDate.slice(0, 4) : undefined;
1688
+ const key = /^\d{4}$/.test(year ?? "") ? year : "unknown";
1689
+ counts[key] = (counts[key] ?? 0) + 1;
1690
+ }
1691
+ return sortCountObject(counts);
1692
+ }
1693
+
1694
+ function countPriceBuckets(games) {
1695
+ const counts = {
1696
+ free: 0,
1697
+ under10: 0,
1698
+ under20: 0,
1699
+ under40: 0,
1700
+ fortyPlus: 0,
1701
+ unknown: 0
1702
+ };
1703
+ for (const game of games) {
1704
+ const price = numericValue(game?.price);
1705
+ if (!Number.isFinite(price)) counts.unknown += 1;
1706
+ else if (price <= 0) counts.free += 1;
1707
+ else if (price < 10) counts.under10 += 1;
1708
+ else if (price < 20) counts.under20 += 1;
1709
+ else if (price < 40) counts.under40 += 1;
1710
+ else counts.fortyPlus += 1;
1711
+ }
1712
+ return counts;
1713
+ }
1714
+
1715
+ function countCoverage(games) {
1716
+ const counts = {
1717
+ basic: 0,
1718
+ partial: 0,
1719
+ full: 0,
1720
+ unknown: 0
1721
+ };
1722
+ for (const game of games) {
1723
+ const coverage = game?.coverage;
1724
+ if (coverage === "basic" || coverage === "partial" || coverage === "full") counts[coverage] += 1;
1725
+ else counts.unknown += 1;
1726
+ }
1727
+ return counts;
1728
+ }
1729
+
1730
+ function topValues(values, limit = 10) {
1731
+ const counts = {};
1732
+ for (const rawValue of values) {
1733
+ const value = asString(rawValue).trim();
1734
+ if (!value) continue;
1735
+ counts[value] = (counts[value] ?? 0) + 1;
1736
+ }
1737
+ return Object.entries(counts)
1738
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1739
+ .slice(0, limit)
1740
+ .map(([value, count]) => ({ value, count }));
1741
+ }
1742
+
1743
+ function sortCountObject(counts) {
1744
+ return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)));
1745
+ }
1746
+
1747
+ function asArray(value) {
1748
+ return Array.isArray(value) ? value : [];
1749
+ }
1750
+
1751
+ function numericValue(value) {
1752
+ const number = typeof value === "number" ? value : Number(value);
1753
+ return Number.isFinite(number) ? number : Number.NaN;
1754
+ }
1755
+
1756
+ function sortableNumber(value) {
1757
+ const number = numericValue(value);
1758
+ return Number.isFinite(number) ? number : Number.NEGATIVE_INFINITY;
1759
+ }
1760
+
1761
+ function roundNumber(value) {
1762
+ return Math.round(value * 100) / 100;
1763
+ }
1764
+
1765
+ function summarizeResolution(candidates) {
1766
+ if (candidates.length === 0) {
1767
+ return {
1768
+ status: "no_candidates",
1769
+ reason: "No candidates were returned by the game search API."
1770
+ };
1771
+ }
1772
+
1773
+ const exactCandidates = candidates.filter((candidate) => candidate.scoreInputs.normalizedTitleExact);
1774
+ if (exactCandidates.length === 1) {
1775
+ return {
1776
+ status: "single_confident_candidate",
1777
+ recommendedExternalId: exactCandidates[0].externalId,
1778
+ reason: "One candidate exactly matches the normalized query."
1779
+ };
1780
+ }
1781
+
1782
+ const [first, second] = candidates;
1783
+ if (first && second && first.score >= 80 && first.score - second.score >= 20) {
1784
+ return {
1785
+ status: "single_likely_candidate",
1786
+ recommendedExternalId: first.externalId,
1787
+ reason: "The top candidate is clearly separated from the next candidate."
1788
+ };
1789
+ }
1790
+
1791
+ return {
1792
+ status: "ambiguous",
1793
+ reason: "Multiple candidates may match. Ask the user to choose an externalId before detail, compare, or fit commands."
1794
+ };
1795
+ }
1796
+
1797
+ function calculateResolveScore(inputs, index) {
1798
+ let score = 0;
1799
+ if (inputs.normalizedTitleExact) score += 70;
1800
+ else if (inputs.titleContainsQuery || inputs.queryContainsTitle) score += 35;
1801
+ score += Math.round(inputs.tokenOverlap * 25);
1802
+ if (typeof inputs.reviewCount === "number") score += Math.min(10, Math.floor(Math.log10(Math.max(1, inputs.reviewCount))));
1803
+ if (typeof inputs.rating === "number" && inputs.rating >= 80) score += 5;
1804
+ if (inputs.coverage === "full") score += 5;
1805
+ else if (inputs.coverage === "partial") score += 3;
1806
+ score -= Math.min(index, 10);
1807
+ return Math.max(0, Math.min(100, score));
1808
+ }
1809
+
1810
+ function classifyResolveConfidence(score) {
1811
+ if (score >= 90) return "exact";
1812
+ if (score >= 75) return "strong";
1813
+ if (score >= 45) return "possible";
1814
+ return "weak";
1815
+ }
1816
+
1817
+ function calculateTokenOverlap(queryTokens, titleTokens) {
1818
+ if (queryTokens.length === 0 || titleTokens.length === 0) return 0;
1819
+ const titleSet = new Set(titleTokens);
1820
+ const matched = queryTokens.filter((token) => titleSet.has(token)).length;
1821
+ return matched / queryTokens.length;
1822
+ }
1823
+
1824
+ function tokenizeResolveText(value) {
1825
+ return normalizeResolveText(value).split(" ").filter(Boolean);
1826
+ }
1827
+
1828
+ function normalizeResolveText(value) {
1829
+ return asString(value)
1830
+ .trim()
1831
+ .toLowerCase()
1832
+ .replace(/&/g, " and ")
1833
+ .replace(/[^a-z0-9]+/g, " ")
1834
+ .replace(/\s+/g, " ")
1835
+ .trim();
1836
+ }
1837
+
1838
+ function asString(value) {
1839
+ return value == null ? "" : String(value);
1840
+ }
1841
+
1842
+ function normalizeTagSlug(value) {
1843
+ return String(value)
1844
+ .trim()
1845
+ .toLowerCase()
1846
+ .replace(/&/g, "and")
1847
+ .replace(/[^a-z0-9]+/g, "-")
1848
+ .replace(/^-+|-+$/g, "");
1849
+ }
1850
+
1851
+ function safeWarnings(payload) {
1852
+ return Array.isArray(payload?.warnings) ? payload.warnings : [];
1853
+ }
1854
+
1855
+ function uniqueStrings(values) {
1856
+ return Array.from(new Set(values.filter((value) => typeof value === "string" && value.length > 0)));
1857
+ }
1858
+
1859
+ function createCliRequestId() {
1860
+ const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
1861
+ return `cli_${id.replaceAll("-", "")}`;
1862
+ }